UNPKG

@ably/cli

Version:

Ably CLI for Pub/Sub, Chat and Spaces

241 lines (240 loc) 12.3 kB
import { RoomStatus } from "@ably/chat"; import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; // The heartbeats are throttled to one every 10 seconds. There's a 2 second // leeway to send a keystroke/heartbeat after the 10 second mark so the // typing indicator won't flicker for others. The 2 second leeway is at the // recipient side, we have 2 second window to publish the heartbeat and it // should also arrive within this interval. // // The best thing to do to keep the indicator on is to keystroke() often. const KEYSTROKE_INTERVAL = 450; // ms export default class TypingKeystroke extends ChatBaseCommand { static args = { roomId: Args.string({ description: "The room ID to start typing in", required: true, }), }; static description = "Send a typing indicator in an Ably Chat room (use --autoType to keep typing automatically until terminated)"; static examples = [ "$ ably rooms typing keystroke my-room", "$ ably rooms typing keystroke my-room --autoType", '$ ably rooms typing keystroke --api-key "YOUR_API_KEY" my-room', "$ ably rooms typing keystroke my-room --json", "$ ably rooms typing keystroke my-room --pretty-json", ]; static flags = { ...ChatBaseCommand.globalFlags, autoType: Flags.boolean({ description: "Automatically keep typing indicator active", default: false, }), }; chatClient = null; ablyClient = null; typingIntervalId = null; unsubscribeStatusFn = 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.typingIntervalId) { clearInterval(this.typingIntervalId); this.typingIntervalId = null; } if (this.unsubscribeStatusFn) { try { this.unsubscribeStatusFn(); } catch { /* ignore */ } } // Proper cleanup sequence try { // Release room if we have one 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(TypingKeystroke); try { // Create Chat client this.chatClient = await this.createChatClient(flags); this.ablyClient = this._chatRealtimeClient; if (!this.chatClient || !this.ablyClient) { this.error("Failed to initialize clients"); return; } const { roomId } = args; this.roomId = roomId; // Set up connection state logging this.setupConnectionStateLogging(this.ablyClient, flags, { includeUserFriendlyMessages: true }); // Get the room with typing enabled this.logCliEvent(flags, "room", "gettingRoom", `Getting room handle for ${roomId}`); const room = await this.chatClient.rooms.get(roomId, {}); this.logCliEvent(flags, "room", "gotRoom", `Got room handle for ${roomId}`); // Subscribe to room status changes this.logCliEvent(flags, "room", "subscribingToStatus", "Subscribing to room status changes"); const { off: unsubscribeStatus } = room.onStatusChange((statusChange) => { let reason; if (statusChange.current === RoomStatus.Failed) { reason = room.error; // Get reason from room.error on failure } const reasonMsg = reason instanceof Error ? reason.message : reason; this.logCliEvent(flags, "room", `status-${statusChange.current}`, `Room status changed to ${statusChange.current}`, { reason: reasonMsg }); if (statusChange.current === RoomStatus.Attached) { if (!this.shouldOutputJson(flags)) { this.log(`${chalk.green("Connected to room:")} ${chalk.bold(roomId)}`); } // Start typing immediately this.logCliEvent(flags, "typing", "startAttempt", "Attempting to start typing..."); room.typing .keystroke() .then(() => { this.logCliEvent(flags, "typing", "started", "Successfully started typing"); if (!this.shouldOutputJson(flags)) { this.log(`${chalk.green("Started typing in room.")}`); if (flags.autoType) { this.log(`${chalk.dim("Will automatically remain typing until this command is terminated. Press Ctrl+C to exit.")}`); } else { this.log(`${chalk.dim("Sent a single typing indicator. Use --autoType flag to keep typing automatically. Press Ctrl+C to exit.")}`); } } // Keep typing active by calling keystroke() periodically if autoType is enabled if (this.typingIntervalId) clearInterval(this.typingIntervalId); if (flags.autoType) { this.typingIntervalId = setInterval(() => { room.typing.keystroke().catch((error) => { this.logCliEvent(flags, "typing", "startErrorPeriodic", `Error refreshing typing state: ${error.message}`, { error: error.message }); }); }, KEYSTROKE_INTERVAL); } }) .catch((error) => { this.logCliEvent(flags, "typing", "startErrorInitial", `Failed to start typing initially: ${error.message}`, { error: error.message }); if (!this.shouldOutputJson(flags)) { this.error(`Failed to start typing: ${error.message}`); } }); } else if (statusChange.current === RoomStatus.Failed && !this.shouldOutputJson(flags)) { this.error(`Failed to attach to room: ${reasonMsg || "Unknown error"}`); } }); this.unsubscribeStatusFn = unsubscribeStatus; this.logCliEvent(flags, "room", "subscribedToStatus", "Successfully subscribed to room status changes"); // Attach to the room this.logCliEvent(flags, "room", "attaching", `Attaching to room ${roomId}`); await room.attach(); // Successful attach and initial typing start logged by onStatusChange handler this.logCliEvent(flags, "typing", "listening", "Maintaining typing status..."); // Keep the process running until Ctrl+C await new Promise((resolve) => { // This promise intentionally never resolves process.on("SIGINT", async () => { this.logCliEvent(flags, "typing", "cleanupInitiated", "Cleanup initiated (Ctrl+C pressed)"); if (!this.shouldOutputJson(flags)) { this.log(""); this.log(`${chalk.yellow("Stopping typing and disconnecting from room...")}`); } // Clear the typing interval if (this.typingIntervalId) { this.logCliEvent(flags, "typing", "clearingInterval", "Clearing typing refresh interval"); clearInterval(this.typingIntervalId); this.typingIntervalId = null; } // Clean up subscriptions if (this.unsubscribeStatusFn) { try { this.logCliEvent(flags, "room", "unsubscribingStatus", "Unsubscribing from room status"); this.unsubscribeStatusFn(); this.logCliEvent(flags, "room", "unsubscribedStatus", "Unsubscribed from room status"); } catch (error) { this.logCliEvent(flags, "room", "unsubscribeStatusError", "Error unsubscribing status", { error: error instanceof Error ? error.message : String(error), }); } } // Stop typing explicitly (optional, but good practice) try { this.logCliEvent(flags, "typing", "stopAttempt", "Attempting to stop typing indicator"); await room.typing.stop(); this.logCliEvent(flags, "typing", "stopped", "Stopped typing indicator"); } catch (error) { this.logCliEvent(flags, "typing", "stopError", "Error stopping typing", { error: error instanceof Error ? error.message : String(error) }); } // Release the room and close connection try { this.logCliEvent(flags, "room", "releasing", `Releasing room ${roomId}`); await this.chatClient?.rooms.release(roomId); this.logCliEvent(flags, "room", "released", `Room ${roomId} released`); } catch (error) { this.logCliEvent(flags, "room", "releaseError", "Error releasing room", { error: error instanceof Error ? error.message : String(error) }); } if (this.ablyClient) { this.logCliEvent(flags, "connection", "closing", "Closing Realtime connection"); this.ablyClient.connection.off(); // unsubscribe connection events this.ablyClient.close(); // close client this.logCliEvent(flags, "connection", "closed", "Realtime connection closed"); } if (!this.shouldOutputJson(flags)) { this.log(`${chalk.green("Successfully disconnected.")}`); } resolve(); }); }); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); this.logCliEvent(flags, "typing", "fatalError", `Failed to start typing: ${errorMsg}`, { error: errorMsg, roomId: args.roomId }); // Close the connection in case of error if (this.ablyClient) { this.ablyClient.close(); } if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ error: errorMsg, roomId: args.roomId, success: false }, flags)); } else { this.error(`Failed to start typing: ${errorMsg}`); } } } }