UNPKG

@ably/cli

Version:

Ably CLI for Pub/Sub, Chat and Spaces

318 lines (317 loc) 15.2 kB
import { Args, Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../../chat-base-command.js"; export default class MessagesSend extends ChatBaseCommand { static args = { roomId: Args.string({ description: "The room ID to send the message to", required: true, }), text: Args.string({ description: "The message text to send", required: true, }), }; static description = "Send a message to an Ably Chat room"; static examples = [ '$ ably rooms messages send my-room "Hello World!"', '$ ably rooms messages send --api-key "YOUR_API_KEY" my-room "Welcome to the chat!"', '$ ably rooms messages send --metadata \'{"isImportant":true}\' my-room "Attention please!"', '$ ably rooms messages send --count 5 my-room "Message number {{.Count}}"', '$ ably rooms messages send --count 10 --delay 1000 my-room "Message at {{.Timestamp}}"', '$ ably rooms messages send my-room "Hello World!" --json', '$ ably rooms messages send my-room "Hello World!" --pretty-json', ]; static flags = { ...ChatBaseCommand.globalFlags, count: Flags.integer({ char: "c", default: 1, description: "Number of messages to send", }), delay: Flags.integer({ char: "d", default: 0, description: "Delay between messages in milliseconds (min 10ms when count > 1)", }), metadata: Flags.string({ description: "Additional metadata for the message (JSON format)", }), }; ablyClient = null; progressIntervalId = null; chatClient = null; roomId = null; async properlyCloseAblyClient() { if (!this.ablyClient || this.ablyClient.connection.state === 'closed') { return; } return new Promise((resolve) => { const timeout = setTimeout(() => { console.warn('Ably client cleanup timed out after 3 seconds'); resolve(); }, 3000); const onClosed = () => { clearTimeout(timeout); resolve(); }; // Listen for both closed and failed states this.ablyClient.connection.once('closed', onClosed); this.ablyClient.connection.once('failed', onClosed); // Close the client this.ablyClient.close(); }); } // Override finally to ensure resources are cleaned up async finally(err) { if (this.progressIntervalId) { clearInterval(this.progressIntervalId); this.progressIntervalId = null; } // Proper cleanup sequence try { // Release room if we haven't already if (this.chatClient && this.roomId) { await this.chatClient.rooms.release(this.roomId); } } catch { // Ignore release errors in cleanup } // Close Ably client properly await this.properlyCloseAblyClient(); return super.finally(err); } async run() { const { args, flags } = await this.parse(MessagesSend); this.roomId = args.roomId; // Store for cleanup try { // Create Chat client this.chatClient = await this.createChatClient(flags); // Get the underlying Ably client for cleanup and state listeners this.ablyClient = this._chatRealtimeClient; if (!this.chatClient) { this.error("Failed to create Chat client"); return; } if (!this.ablyClient) { this.error("Failed to create Ably client"); // Should not happen if chatClient created return; } // Add listeners for connection state changes this.ablyClient.connection.on((stateChange) => { this.logCliEvent(flags, "connection", stateChange.current, `Realtime connection state changed to ${stateChange.current}`, { reason: stateChange.reason }); }); // Parse metadata if provided let metadata; if (flags.metadata) { try { metadata = JSON.parse(flags.metadata); this.logCliEvent(flags, "message", "metadataParsed", "Message metadata parsed successfully", { metadata }); } catch (error) { const errorMsg = `Invalid metadata JSON: ${error instanceof Error ? error.message : String(error)}`; this.logCliEvent(flags, "message", "metadataParseError", errorMsg, { error: errorMsg, }); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ error: errorMsg, success: false }, flags)); } else { this.error(errorMsg); } return; } } // Get the room with default options this.logCliEvent(flags, "room", "gettingRoom", `Getting room handle for ${args.roomId}`); const room = await this.chatClient.rooms.get(args.roomId, {}); this.logCliEvent(flags, "room", "gotRoom", `Got room handle for ${args.roomId}`); // Attach to the room this.logCliEvent(flags, "room", "attaching", `Attaching to room ${args.roomId}`); await room.attach(); this.logCliEvent(flags, "room", "attached", `Successfully attached to room ${args.roomId}`); // Validate count and delay const count = Math.max(1, flags.count); let { delay } = flags; // Enforce minimum delay when sending multiple messages if (count > 1 && delay < 10) { delay = 10; this.logCliEvent(flags, "message", "minDelayEnforced", "Using minimum delay of 10ms for multiple messages", { delay }); } // If sending multiple messages, show a progress indication this.logCliEvent(flags, "message", "startingSend", `Sending ${count} messages with ${delay}ms delay...`, { count, delay }); if (count > 1 && !this.shouldOutputJson(flags)) { this.log(`Sending ${count} messages with ${delay}ms delay...`); } // Track send progress let sentCount = 0; let errorCount = 0; const results = []; // Send messages if (count > 1) { // Sending multiple messages this.progressIntervalId = this.shouldOutputJson(flags) ? setInterval(() => { this.logCliEvent(flags, "message", "progress", "Sending messages", { errors: errorCount, sent: sentCount, total: count, }); }, 2000) : setInterval(() => { this.log(`Progress: ${sentCount}/${count} messages sent (${errorCount} errors)`); }, 1000); for (let i = 0; i < count; i++) { // Apply interpolation to the message const interpolatedText = this.interpolateMessage(args.text, i + 1); const messageToSend = { text: interpolatedText, ...(metadata ? { metadata } : {}), }; this.logCliEvent(flags, "message", "sending", `Attempting to send message ${i + 1}`, { index: i + 1, message: messageToSend }); // Send the message without awaiting room.messages .send(messageToSend) .then(() => { sentCount++; const result = { index: i + 1, message: messageToSend, roomId: args.roomId, success: true, }; results.push(result); this.logCliEvent(flags, "message", "sentSuccess", `Message ${i + 1} sent successfully`, { index: i + 1 }); if (!this.shouldSuppressOutput(flags) && !this.shouldOutputJson(flags)) { // Logged implicitly by progress interval } }) .catch((error) => { errorCount++; const errorMsg = error instanceof Error ? error.message : String(error); const result = { error: errorMsg, index: i + 1, roomId: args.roomId, success: false, }; results.push(result); this.logCliEvent(flags, "message", "sendError", `Error sending message ${i + 1}: ${errorMsg}`, { error: errorMsg, index: i + 1 }); if (!this.shouldSuppressOutput(flags) && !this.shouldOutputJson(flags)) { // Logged implicitly by progress interval } }); // Delay before sending next message if not the last one if (i < count - 1 && delay > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); } } // Wait for all sends to complete (or timeout after a reasonable period) const maxWaitTime = Math.max(5000, count * delay * 2); // At least 5 seconds or twice the expected duration const startWaitTime = Date.now(); await new Promise((resolve) => { const checkInterval = setInterval(() => { if (sentCount + errorCount >= count || Date.now() - startWaitTime > maxWaitTime) { if (this.progressIntervalId) clearInterval(this.progressIntervalId); clearInterval(checkInterval); resolve(); } }, 100); }); const finalResult = { errors: errorCount, results, sent: sentCount, success: errorCount === 0, total: count, }; this.logCliEvent(flags, "message", "multiSendComplete", `Finished sending ${count} messages`, finalResult); if (!this.shouldSuppressOutput(flags)) { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput(finalResult, flags)); } else { // Clear the last progress line before final summary in an interactive // terminal. Avoid this in test mode or non-TTY environments as it // makes captured output hard to read. if (this.shouldUseTerminalUpdates()) { process.stdout.write("\r" + " ".repeat(process.stdout.columns) + "\r"); } this.log(`${sentCount}/${count} messages sent successfully (${errorCount} errors).`); } } } else { // Single message try { // Apply interpolation to the message const interpolatedText = this.interpolateMessage(args.text, 1); const messageToSend = { text: interpolatedText, ...(metadata ? { metadata } : {}), }; this.logCliEvent(flags, "message", "sending", "Attempting to send single message", { message: messageToSend }); // Send the message await room.messages.send(messageToSend); const result = { message: messageToSend, roomId: args.roomId, success: true, }; this.logCliEvent(flags, "message", "singleSendComplete", "Message sent successfully", result); if (!this.shouldSuppressOutput(flags)) { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput(result, flags)); } else { this.log("Message sent successfully."); } } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); const result = { error: errorMsg, roomId: args.roomId, success: false, }; this.logCliEvent(flags, "message", "singleSendError", `Failed to send message: ${errorMsg}`, { error: errorMsg }); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput(result, flags)); } else { this.error(`Failed to send message: ${errorMsg}`); } } } // Release the room this.logCliEvent(flags, "room", "releasing", `Releasing room ${args.roomId}`); await this.chatClient.rooms.release(args.roomId); this.logCliEvent(flags, "room", "released", `Room ${args.roomId} released`); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); this.logCliEvent(flags, "message", "fatalError", `Failed to send message: ${errorMsg}`, { error: errorMsg }); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ error: errorMsg, success: false }, flags)); } else { this.error(`Failed to send message: ${errorMsg}`); } } finally { // Cleanup is handled in the finally() override method to avoid duplication } } interpolateMessage(message, count) { // Replace {{.Count}} with the current count let result = message.replaceAll("{{.Count}}", count.toString()); // Replace {{.Timestamp}} with the current timestamp result = result.replaceAll("{{.Timestamp}}", Date.now().toString()); return result; } }