UNPKG

ccremote

Version:

Claude Code Remote: approve prompts from Discord, auto-continue sessions after quota resets, and schedule quota windows around your workday.

1,217 lines (1,211 loc) 47.5 kB
import { promises } from "node:fs"; import { dirname, join } from "node:path"; import { ChannelType, Client, Events, GatewayIntentBits, PermissionFlagsBits } from "discord.js"; import { randomBytes } from "node:crypto"; import { homedir } from "node:os"; import { exec } from "node:child_process"; import { promisify } from "node:util"; //#region src/utils/discord-error-handling.ts /** * Check if an error is retryable (network/connection issues) */ function isRetryableDiscordError(error) { const message = error.message.toLowerCase(); const isNetworkError = message.includes("opening handshake has timed out") || message.includes("connection timeout") || message.includes("network error") || message.includes("enotfound") || message.includes("econnreset") || message.includes("econnrefused"); const isRateLimit = message.includes("rate limit") || message.includes("too many requests") || message.includes("429"); return isNetworkError || isRateLimit; } /** * Check if an error is permanent (invalid token, permissions, etc.) */ function isPermanentDiscordError(error) { const message = error.message.toLowerCase(); return message.includes("token") || message.includes("unauthorized") || message.includes("forbidden") || message.includes("invalid") || message.includes("missing access"); } /** * Execute a Discord operation with retry logic */ async function withDiscordRetry(operation, options = {}) { const { maxRetries = 3, baseDelayMs = 1e3, maxDelayMs = 3e4, onRetry } = options; let lastError; for (let attempt = 0; attempt <= maxRetries; attempt++) try { return { success: true, result: await operation(), attempts: attempt + 1 }; } catch (error) { lastError = error; if (isPermanentDiscordError(lastError)) return { success: false, error: lastError, attempts: attempt + 1 }; if (attempt === maxRetries) break; if (!isRetryableDiscordError(lastError)) return { success: false, error: lastError, attempts: attempt + 1 }; const delayMs = Math.min(baseDelayMs * 2 ** attempt + Math.random() * 1e3, maxDelayMs); onRetry?.(lastError, attempt + 1); await new Promise((resolve$1) => setTimeout(resolve$1, delayMs)); } return { success: false, error: lastError, attempts: maxRetries + 1 }; } /** * Safe wrapper for Discord operations that logs errors but doesn't throw */ async function safeDiscordOperation(operation, operationName, logger, retryOptions) { const result = await withDiscordRetry(operation, { ...retryOptions, onRetry: (error, attempt) => { logger.warn?.(`${operationName} failed (attempt ${attempt}): ${error.message}. Retrying...`); retryOptions?.onRetry?.(error, attempt); } }); if (result.success) { if (result.attempts > 1) logger.debug?.(`${operationName} succeeded after ${result.attempts} attempts`); return result.result; } logger.warn(`${operationName} failed after ${result.attempts} attempts: ${result.error?.message}`); } if (import.meta.vitest) { const { describe, it, expect, vi } = import.meta.vitest; describe("Discord Error Handling", () => { describe("isRetryableDiscordError", () => { it("should identify handshake timeout as retryable", () => { expect(isRetryableDiscordError(/* @__PURE__ */ new Error("Opening handshake has timed out"))).toBe(true); }); it("should identify rate limit as retryable", () => { expect(isRetryableDiscordError(/* @__PURE__ */ new Error("Rate limit exceeded"))).toBe(true); }); it("should not identify token error as retryable", () => { expect(isRetryableDiscordError(/* @__PURE__ */ new Error("An invalid token was provided"))).toBe(false); }); }); describe("isPermanentDiscordError", () => { it("should identify token errors as permanent", () => { expect(isPermanentDiscordError(/* @__PURE__ */ new Error("An invalid token was provided"))).toBe(true); }); it("should identify unauthorized as permanent", () => { expect(isPermanentDiscordError(/* @__PURE__ */ new Error("Unauthorized"))).toBe(true); }); it("should not identify network errors as permanent", () => { expect(isPermanentDiscordError(/* @__PURE__ */ new Error("Opening handshake has timed out"))).toBe(false); }); }); describe("withDiscordRetry", () => { it("should succeed on first try", async () => { const operation = vi.fn().mockResolvedValue("success"); const result = await withDiscordRetry(operation); expect(result.success).toBe(true); expect(result.result).toBe("success"); expect(result.attempts).toBe(1); expect(operation).toHaveBeenCalledTimes(1); }); it("should retry retryable errors", async () => { const operation = vi.fn().mockRejectedValueOnce(/* @__PURE__ */ new Error("Opening handshake has timed out")).mockResolvedValue("success"); const result = await withDiscordRetry(operation, { maxRetries: 2, baseDelayMs: 1 }); expect(result.success).toBe(true); expect(result.result).toBe("success"); expect(result.attempts).toBe(2); expect(operation).toHaveBeenCalledTimes(2); }); it("should not retry permanent errors", async () => { const operation = vi.fn().mockRejectedValue(/* @__PURE__ */ new Error("An invalid token was provided")); const result = await withDiscordRetry(operation, { maxRetries: 2 }); expect(result.success).toBe(false); expect(result.attempts).toBe(1); expect(operation).toHaveBeenCalledTimes(1); }); it("should respect max retries", async () => { const operation = vi.fn().mockRejectedValue(/* @__PURE__ */ new Error("Opening handshake has timed out")); const result = await withDiscordRetry(operation, { maxRetries: 2, baseDelayMs: 1 }); expect(result.success).toBe(false); expect(result.attempts).toBe(3); expect(operation).toHaveBeenCalledTimes(3); }); }); describe("safeDiscordOperation", () => { it("should return result on success", async () => { const operation = vi.fn().mockResolvedValue("success"); const logger = { warn: vi.fn(), debug: vi.fn() }; expect(await safeDiscordOperation(operation, "test op", logger)).toBe("success"); expect(logger.warn).not.toHaveBeenCalled(); }); it("should return undefined on failure and log warning", async () => { const operation = vi.fn().mockRejectedValue(/* @__PURE__ */ new Error("test error")); const logger = { warn: vi.fn(), debug: vi.fn() }; expect(await safeDiscordOperation(operation, "test op", logger)).toBe(void 0); expect(logger.warn).toHaveBeenCalledWith("test op failed after 1 attempts: test error"); }); it("should log retry attempts", async () => { const operation = vi.fn().mockRejectedValueOnce(/* @__PURE__ */ new Error("Opening handshake has timed out")).mockResolvedValue("success"); const logger = { warn: vi.fn(), debug: vi.fn() }; expect(await safeDiscordOperation(operation, "test op", logger, { maxRetries: 2, baseDelayMs: 1 })).toBe("success"); expect(logger.warn).toHaveBeenCalledWith("test op failed (attempt 1): Opening handshake has timed out. Retrying..."); expect(logger.debug).toHaveBeenCalledWith("test op succeeded after 2 attempts"); }); }); }); } //#endregion //#region src/core/discord.ts var DiscordBot = class { client; authorizedUsers = []; ownerId = ""; sessionChannelMap = /* @__PURE__ */ new Map(); channelSessionMap = /* @__PURE__ */ new Map(); guildId = null; isReady = false; token = ""; healthCheckInterval = null; lastHealthCheckTime = /* @__PURE__ */ new Date(); sessionManager; tmuxManager; isShuttingDown = false; readyTimestamp = 0; constructor(sessionManager, tmuxManager) { this.sessionManager = sessionManager; this.tmuxManager = tmuxManager; this.client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages ], ws: { handshakeTimeout: 6e4, helloTimeout: 12e4, readyTimeout: 3e4 } }); this.setupEventHandlers(); this.setupErrorSuppression(); } /** * Setup error suppression for expected Discord.js shutdown errors */ setupErrorSuppression() {} async start(token, ownerId, authorizedUsers = [], healthCheckInterval) { console.info("[DISCORD] start() method called"); console.info(`[DISCORD] Starting Discord bot with owner: ${ownerId}, authorized users: ${authorizedUsers.join(", ")}`); this.token = token; this.ownerId = ownerId; this.authorizedUsers = [ownerId, ...authorizedUsers]; const result = await withDiscordRetry(async () => this.performLogin(token), { maxRetries: 3, baseDelayMs: 2e3, maxDelayMs: 3e4, onRetry: (error, attempt) => { console.warn(`[DISCORD] Login attempt ${attempt} failed: ${error.message}. Retrying...`); } }); if (!result.success) throw result.error || /* @__PURE__ */ new Error("Discord login failed after retries"); if (result.attempts > 1) console.info(`[DISCORD] Successfully connected after ${result.attempts} attempts`); this.startHealthCheck(healthCheckInterval); } async performLogin(token) { return new Promise((resolve$1, reject) => { const timeout = setTimeout(() => { const error = /* @__PURE__ */ new Error("Opening handshake has timed out"); console.error("[DISCORD] Discord bot login timed out after 30 seconds"); reject(error); }, 3e4); this.client.once(Events.ClientReady, () => { console.info("[DISCORD] ClientReady event fired!"); clearTimeout(timeout); this.isReady = true; this.readyTimestamp = Date.now(); const guild = this.client.guilds.cache.first(); if (guild) { this.guildId = guild.id; console.info(`[DISCORD] Discord bot logged in as ${this.client.user?.tag} in guild: ${guild.name}`); } else console.warn("[DISCORD] Discord bot not in any guilds - cannot create channels"); console.info("[DISCORD] Bot startup complete, resolving promise"); resolve$1(); }); this.client.once(Events.Error, (error) => { console.error("[DISCORD] Discord client error during startup:", error); clearTimeout(timeout); reject(error); }); console.info("[DISCORD] Calling client.login()..."); this.client.login(token).then(() => { console.info("[DISCORD] client.login() resolved successfully"); }).catch((error) => { console.error("[DISCORD] Login failed:", error); clearTimeout(timeout); reject(error); }); }); } setupEventHandlers() { this.client.on(Events.MessageCreate, (message) => { if (message.author.bot) return; this.handleMessage(message); }); this.client.on(Events.Error, (error) => { console.error("Discord client error:", error); }); } async handleMessage(message) { if (!this.isAuthorized(message.author.id)) return; const content = message.content.toLowerCase().trim(); const sessionId = this.channelSessionMap.get(message.channel.id); if (!sessionId) return; try { const numericMatch = content.match(/^(\d+)$/); if (numericMatch) { const optionNumber = Number.parseInt(numericMatch[1], 10); await this.handleOptionSelection(sessionId, optionNumber); await message.reply(`✅ Selected option ${optionNumber}`); } else if (content === "approve") { await this.handleOptionSelection(sessionId, 1); await message.reply("✅ Approved (option 1)"); } else if (content === "deny") { await this.handleOptionSelection(sessionId, 2); await message.reply("❌ Denied (option 2)"); } else if (content === "status") await this.handleStatus(sessionId, message); else if (content === "output" || content === "/output") await this.handleOutput(sessionId, message); } catch (error) { console.error("Error handling Discord message:", error); await message.reply("❌ Error processing command"); } } isAuthorized(userId) { return this.authorizedUsers.includes(userId); } async sendNotification(sessionId, notification) { if (!this.isReady) { console.warn("Discord bot not ready, skipping notification"); return; } const channelId = this.sessionChannelMap.get(sessionId); if (!channelId) { console.warn(`No Discord channel found for session ${sessionId}`); return; } await safeDiscordOperation(async () => { const channel = await this.client.channels.fetch(channelId); if (!channel) throw new Error(`Discord channel ${channelId} not found`); const message = this.formatNotification(notification); await channel.send(message); if (notification.type === "session_ended") setTimeout(() => { this.cleanupSessionChannel(sessionId); }, 2e3); }, "send Discord notification", { warn: console.warn, debug: console.info }, { maxRetries: 2, baseDelayMs: 1e3 }); } formatNotification(notification) { const { type, sessionName, message, metadata } = notification; switch (type) { case "limit": return `🚫 **${sessionName}** - Usage limit reached\n📅 Resets: ${metadata?.resetTime || "unknown"}\n\n${message}`; case "continued": return `✅ **${sessionName}** - Session resumed\n\n${message}`; case "approval": { const toolName = metadata?.toolName || "unknown"; const command = metadata?.command || ""; return `⚠️ **${sessionName}** - Approval Required\n🔧 Tool: ${toolName}\n\n${message}\n\n${command ? `\`\`\`${command}\`\`\`` : ""}`; } case "error": return `❌ **${sessionName}** - Error\n\n${message}`; case "session_ended": return `🏁 **${sessionName}** - Session Ended\n\n${message}`; case "task_completed": return `✅ **${sessionName}** - Task completed\n⏱️ Idle for: ${metadata?.idleDurationSeconds || 0}s\n\n${message}`; default: return `📝 **${sessionName}**\n\n${message}`; } } async createOrGetChannel(sessionId, sessionName) { console.info(`[DISCORD] createOrGetChannel called for session: ${sessionId}, name: ${sessionName}`); const existingChannelId = this.sessionChannelMap.get(sessionId); if (existingChannelId) { console.info(`[DISCORD] Found existing channel mapping: ${existingChannelId}`); try { if (await this.client.channels.fetch(existingChannelId)) { console.info(`[DISCORD] Existing channel confirmed, returning: ${existingChannelId}`); return existingChannelId; } } catch { console.warn(`[DISCORD] Existing channel ${existingChannelId} no longer exists, will create new one`); } } if (!this.guildId) { console.warn("[DISCORD] No guild available, falling back to DM"); return this.createDMChannel(sessionId, sessionName); } const channelName = await this.generateChannelName(sessionName); console.info(`[DISCORD] Checking for existing channel with name: ${channelName}`); try { const guild = await this.client.guilds.fetch(this.guildId); if (guild) { const existingChannel = guild.channels.cache.find((channel) => channel.name === channelName && channel.isTextBased() && !channel.name.startsWith("_archived-")); if (existingChannel) { console.info(`[DISCORD] Found existing channel by name: ${existingChannel.id} (${channelName})`); this.sessionChannelMap.set(sessionId, existingChannel.id); this.channelSessionMap.set(existingChannel.id, sessionId); if (existingChannel.isTextBased()) await existingChannel.send(`🔄 **ccremote Session Resumed**\nSession: ${sessionName} (${sessionId})\n\nReusing existing channel for this session.`); return existingChannel.id; } } } catch (error) { console.warn(`[DISCORD] Error checking for existing channel: ${error}`); } console.info(`[DISCORD] Creating new channel in guild: ${this.guildId}`); const channelId = await safeDiscordOperation(async () => { console.info(`[DISCORD] Fetching guild: ${this.guildId}`); const guild = await this.client.guilds.fetch(this.guildId); if (!guild) throw new Error(`Guild ${this.guildId} not found`); console.info(`[DISCORD] Guild fetched successfully: ${guild.name}`); console.info(`[DISCORD] Will create channel with name: ${channelName}`); console.info(`[DISCORD] Creating channel with bot access ensured...`); const botMember = guild.members.me; const initialPermissions = []; initialPermissions.push({ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.ViewChannel] }); if (botMember) initialPermissions.push({ id: botMember.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory ] }); const channel = await guild.channels.create({ name: channelName, type: ChannelType.GuildText, permissionOverwrites: initialPermissions }); console.info(`[DISCORD] Channel created, attempting to add user permissions...`); let permissionsSet = false; for (const userId of this.authorizedUsers) try { console.info(`[DISCORD] Fetching and adding permissions for user: ${userId}`); const user = await this.client.users.fetch(userId); await channel.permissionOverwrites.create(user, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true }); console.info(`[DISCORD] Successfully added permissions for user: ${userId}`); permissionsSet = true; } catch (permError) { console.warn(`Failed to add permissions for user ${userId}:`, permError); console.warn("This is usually due to Discord permission hierarchy - continuing without user-specific permissions"); } if (!permissionsSet) console.warn(`[DISCORD] Could not set user-specific permissions - channel will rely on server permissions`); console.info(`[DISCORD] Channel created with initial permissions (hidden from @everyone, bot has access)`); this.sessionChannelMap.set(sessionId, channel.id); this.channelSessionMap.set(channel.id, sessionId); await channel.send(`🚀 **ccremote Session Started**\nSession: ${sessionName} (${sessionId})\n\nI'll send notifications for this session here. This channel is private and only visible to authorized users.`); return channel.id; }, "create Discord channel", { warn: console.warn, debug: console.info }, { maxRetries: 2, baseDelayMs: 1500 }); if (channelId) return channelId; console.warn(`[DISCORD] Failed to create channel in guild, falling back to DM`); return this.createDMChannel(sessionId, sessionName); } async createDMChannel(sessionId, sessionName) { const channelId = await safeDiscordOperation(async () => { const dmChannel = await (await this.client.users.fetch(this.ownerId)).createDM(); this.sessionChannelMap.set(sessionId, dmChannel.id); this.channelSessionMap.set(dmChannel.id, sessionId); await dmChannel.send(`🚀 **ccremote Session Started**\nSession: ${sessionName} (${sessionId})\n\nI'll send notifications for this session here. (Using DM as fallback - no guild available)`); return dmChannel.id; }, "create Discord DM channel", { warn: console.warn, debug: console.info }, { maxRetries: 2, baseDelayMs: 1e3 }); if (!channelId) throw new Error("Failed to create DM channel after retries"); return channelId; } async assignChannelToSession(sessionId, channelId) { this.sessionChannelMap.set(sessionId, channelId); this.channelSessionMap.set(channelId, sessionId); } /** * Generate a Discord channel name for a session * Matches Discord's naming rules: lowercase, alphanumeric + hyphens only */ async generateChannelName(sessionName) { return `${(await import("node:path")).basename(process.cwd())}-${sessionName}`.toLowerCase().replace(/[^a-z0-9-]/g, "-"); } /** * Delete a Discord channel and send a final message before deletion */ async deleteChannel(channelId, finalMessage, deleteReason) { const channel = await this.client.channels.fetch(channelId); if (!channel || channel.type !== ChannelType.GuildText) return; const botMember = channel.guild.members.me; if (botMember) try { await channel.permissionOverwrites.edit(botMember, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true }); } catch (permError) { console.warn(`Failed to ensure bot permissions for channel ${channelId}:`, permError); } await safeDiscordOperation(async () => { await channel.send(finalMessage); }, "send deletion notification", { warn: console.warn, debug: console.info }, { maxRetries: 1, baseDelayMs: 1e3 }); await new Promise((resolve$1) => setTimeout(resolve$1, 2e3)); await channel.delete(deleteReason); } async cleanupSessionChannel(sessionId) { const channelId = this.sessionChannelMap.get(sessionId); if (!channelId) return; try { await this.deleteChannel(channelId, `🏁 Session ${sessionId} ended. This channel will be deleted.`, "Session ended - cleaning up channel"); } catch (error) { console.error(`Error cleaning up channel for session ${sessionId}:`, error); } finally { this.sessionChannelMap.delete(sessionId); this.channelSessionMap.delete(channelId); } } async handleOptionSelection(sessionId, optionNumber) { this.client.emit("ccremote:option_selected", { sessionId, optionNumber }); } async handleStatus(sessionId, message) { await message.reply(`📊 Session status for ${sessionId} - implementation pending`); } async handleOutput(sessionId, message) { if (!this.sessionManager || !this.tmuxManager) { await message.reply("❌ Output display not available - missing dependencies"); return; } try { const session = await this.sessionManager.getSession(sessionId); if (!session) { await message.reply(`❌ Session ${sessionId} not found`); return; } const output = await this.tmuxManager.capturePane(session.tmuxSession); if (!output || output.trim().length === 0) { await message.reply("📺 Session output is empty"); return; } const formattedOutput = this.formatOutputForDiscord(output); for (const chunk of formattedOutput) await message.reply(chunk); } catch (error) { console.error("Error fetching session output:", error); await message.reply("❌ Failed to fetch session output"); } } /** * Format tmux output for Discord display with proper code blocks and chunking */ formatOutputForDiscord(output) { const MAX_CONTENT_LENGTH = 1992; const cleanedOutput = output.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ").split("\n").slice(-50).join("\n").trim(); if (cleanedOutput.length === 0) return ["```\n(empty output)\n```"]; if (cleanedOutput.length <= MAX_CONTENT_LENGTH) return [`\`\`\`\n${cleanedOutput}\n\`\`\``]; const chunks = []; const lines = cleanedOutput.split("\n"); let currentChunk = ""; for (const line of lines) { const lineWithNewline = `${line}\n`; if (currentChunk.length + lineWithNewline.length > MAX_CONTENT_LENGTH) { if (currentChunk.trim().length > 0) chunks.push(`\`\`\`\n${currentChunk.trim()}\n\`\`\``); currentChunk = lineWithNewline; } else currentChunk += lineWithNewline; } if (currentChunk.trim().length > 0) chunks.push(`\`\`\`\n${currentChunk.trim()}\n\`\`\``); if (chunks.length > 1) return chunks.map((chunk, index) => `📺 **Session Output (${index + 1}/${chunks.length})**\n${chunk}`); return chunks.length > 0 ? [`📺 **Session Output**\n${chunks[0]}`] : ["```\n(no output to display)\n```"]; } onOptionSelected(handler) { this.client.on("ccremote:option_selected", ({ sessionId, optionNumber }) => { handler(sessionId, optionNumber); }); } /** * Find orphaned ccremote channels that exist but aren't connected to any active session * * IMPORTANT: Only examines channels belonging to the current project to avoid * cross-project interference when multiple projects share the same Discord guild. */ async findOrphanedChannels(activeSessions) { try { if (!this.client.guilds.cache.size) return []; const orphanedChannels = []; const guild = this.client.guilds.cache.first(); if (!guild) return []; const projectPrefix = `${(await import("node:path")).basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-")}-`; const expectedChannelNames = new Set(await Promise.all(activeSessions.map(async (session) => this.generateChannelName(session.name)))); const projectChannels = guild.channels.cache.filter((channel) => channel.name.startsWith(projectPrefix) && !channel.name.startsWith("_archived-") && channel.type === ChannelType.GuildText); console.info(`[DISCORD] Checking ${projectChannels.size} channels with prefix '${projectPrefix}' for orphans`); for (const [channelId, channel] of projectChannels) { const mappedSessionId = this.channelSessionMap.get(channelId); const activeSessionIds = activeSessions.map((s) => s.id); if (!mappedSessionId && !expectedChannelNames.has(channel.name) || mappedSessionId && !activeSessionIds.includes(mappedSessionId)) { orphanedChannels.push(channelId); console.info(`[DISCORD] Found orphaned channel: ${channel.name} (${channelId}) - mapped to: ${mappedSessionId || "none"}, expected names: [${Array.from(expectedChannelNames).join(", ")}]`); } } return orphanedChannels; } catch (error) { console.warn("[DISCORD] Error finding orphaned channels:", error); return []; } } /** * Find archived channels from previous runs (channels starting with _archived-) */ async findArchivedChannels() { try { if (!this.client.guilds.cache.size) return []; const guild = this.client.guilds.cache.first(); if (!guild) return []; const archivedChannels = guild.channels.cache.filter((channel) => channel.name.startsWith("_archived-") && channel.type === ChannelType.GuildText).map((channel) => channel.id); return Array.from(archivedChannels); } catch (error) { console.warn("[DISCORD] Error finding archived channels:", error); return []; } } /** * Delete an orphaned channel by ID */ async deleteOrphanedChannel(channelId) { try { await this.deleteChannel(channelId, "🏁 Orphaned channel detected during cleanup. This channel will be deleted.", "Orphaned channel cleanup"); this.channelSessionMap.delete(channelId); for (const [sessionId, mappedChannelId] of this.sessionChannelMap.entries()) if (mappedChannelId === channelId) { this.sessionChannelMap.delete(sessionId); break; } console.info(`[DISCORD] Deleted orphaned channel`); return true; } catch (error) { console.warn(`[DISCORD] Failed to delete orphaned channel ${channelId}:`, error); return false; } } async shutdown() { try { this.isShuttingDown = true; if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); this.healthCheckInterval = null; } this.isReady = false; if (this.client) this.client = null; } catch (error) { console.warn("[DISCORD] Error during shutdown:", error); } finally { this.isShuttingDown = false; } } /** * Start periodic health check (every hour by default) */ startHealthCheck(intervalMs = 3600 * 1e3) { console.info(`[DISCORD] Starting health check with ${intervalMs / 6e4} minute interval`); this.stopHealthCheck(); this.healthCheckInterval = setInterval(() => { this.performHealthCheck(); }, intervalMs); this.lastHealthCheckTime = /* @__PURE__ */ new Date(); } /** * Stop the periodic health check */ stopHealthCheck() { if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); this.healthCheckInterval = null; } } /** * Perform a health check and attempt reconnection if needed */ async performHealthCheck() { try { this.lastHealthCheckTime = /* @__PURE__ */ new Date(); if (!this.isHealthy()) { console.warn("[DISCORD] Health check failed - bot appears disconnected"); await this.attemptReconnection(); } else console.info("[DISCORD] Health check passed - bot is connected"); } catch (error) { console.error(`[DISCORD] Health check error: ${error instanceof Error ? error.message : String(error)}`); await this.attemptReconnection(); } } /** * Check if Discord bot is healthy (connected and ready) */ isHealthy() { return this.isReady && this.client && this.client.readyTimestamp !== null && this.client.ws.status === 0; } /** * Attempt to reconnect Discord bot */ async attemptReconnection() { console.info("[DISCORD] Attempting to reconnect..."); try { this.isReady = false; if (this.client && typeof this.client.destroy === "function") { console.info("[DISCORD] Destroying existing client"); await this.client.destroy(); } this.client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages ], ws: { handshakeTimeout: 6e4, helloTimeout: 12e4, readyTimeout: 3e4 } }); this.setupEventHandlers(); if ((await withDiscordRetry(async () => this.performLogin(this.token), { maxRetries: 3, baseDelayMs: 2e3, maxDelayMs: 3e4, onRetry: (error, attempt) => { console.warn(`[DISCORD] Reconnection attempt ${attempt} failed: ${error.message}. Retrying...`); } })).success) console.info("[DISCORD] Successfully reconnected to Discord"); else console.error("[DISCORD] Failed to reconnect after all retries"); } catch (error) { console.error(`[DISCORD] Reconnection failed: ${error instanceof Error ? error.message : String(error)}`); } } async stop() { await this.shutdown(); } }; if (import.meta.vitest) { const { beforeEach, describe, it, expect, vi } = await import("./dist-bz1tWxsS.js"); describe("DiscordBot", () => { let discordBot; let mockSessionManager; let mockTmuxManager; let mockMessage; beforeEach(() => { mockSessionManager = { getSession: vi.fn() }; mockTmuxManager = { capturePane: vi.fn() }; mockMessage = { reply: vi.fn(), author: { id: "test-user" }, channel: { id: "test-channel" } }; discordBot = new DiscordBot(mockSessionManager, mockTmuxManager); discordBot.sessionManager = mockSessionManager; discordBot.tmuxManager = mockTmuxManager; discordBot.channelSessionMap.set("test-channel", "test-session"); discordBot.authorizedUsers = ["test-user"]; }); describe("formatOutputForDiscord", () => { it("should handle empty output", () => { expect(discordBot.formatOutputForDiscord("")).toEqual(["```\n(empty output)\n```"]); }); it("should handle short output within message limit", () => { const result = discordBot.formatOutputForDiscord("Hello world\nThis is a test"); expect(result).toHaveLength(1); expect(result[0]).toBe("```\nHello world\nThis is a test\n```"); }); it("should clean and normalize output correctly", () => { expect(discordBot.formatOutputForDiscord("Line 1\r\nLine 2\rLine 3 Tabbed content")[0]).toContain("Line 1\nLine 2\nLine 3 Tabbed content"); }); it("should limit to last 50 lines for long output", () => { const longOutput = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`).join("\n"); const content = discordBot.formatOutputForDiscord(longOutput)[0]; expect(content).toContain("Line 51"); expect(content).toContain("Line 100"); expect(content).not.toContain("Line 50"); }); it("should split long output into multiple chunks", () => { const longLine = "A".repeat(1900); const longOutput = `${longLine}\n${longLine}\n${longLine}`; const result = discordBot.formatOutputForDiscord(longOutput); expect(result.length).toBeGreaterThan(1); expect(result[0]).toContain("📺 **Session Output (1/"); expect(result[1]).toContain("📺 **Session Output (2/"); }); it("should properly wrap chunks in code blocks", () => { expect(discordBot.formatOutputForDiscord("Some test output")[0]).toMatch(/```\n[\s\S]*\n```/); }); }); describe("handleOutput", () => { it("should reply with error when dependencies are missing", async () => { await new DiscordBot().handleOutput("test-session", mockMessage); expect(mockMessage.reply).toHaveBeenCalledWith("❌ Output display not available - missing dependencies"); }); it("should reply with error when session not found", async () => { mockSessionManager.getSession = vi.fn().mockResolvedValue(null); await discordBot.handleOutput("test-session", mockMessage); expect(mockMessage.reply).toHaveBeenCalledWith("❌ Session test-session not found"); }); it("should reply when output is empty", async () => { mockSessionManager.getSession = vi.fn().mockResolvedValue({ id: "test-session", tmuxSession: "test-tmux" }); mockTmuxManager.capturePane = vi.fn().mockResolvedValue(""); await discordBot.handleOutput("test-session", mockMessage); expect(mockMessage.reply).toHaveBeenCalledWith("📺 Session output is empty"); }); it("should send formatted output when successful", async () => { const session = { id: "test-session", tmuxSession: "test-tmux" }; const tmuxOutput = "Test output\nAnother line"; mockSessionManager.getSession = vi.fn().mockResolvedValue(session); mockTmuxManager.capturePane = vi.fn().mockResolvedValue(tmuxOutput); await discordBot.handleOutput("test-session", mockMessage); expect(mockMessage.reply).toHaveBeenCalledWith("```\nTest output\nAnother line\n```"); }); it("should send multiple chunks for long output", async () => { const session = { id: "test-session", tmuxSession: "test-tmux" }; const longOutput = `${"A".repeat(1900)}\n${"B".repeat(1900)}\n${"C".repeat(1900)}`; mockSessionManager.getSession = vi.fn().mockResolvedValue(session); mockTmuxManager.capturePane = vi.fn().mockResolvedValue(longOutput); await discordBot.handleOutput("test-session", mockMessage); expect(mockMessage.reply.mock.calls.length).toBeGreaterThan(1); const calls = mockMessage.reply.mock.calls; expect(calls[0][0]).toContain("(1/"); expect(calls[1][0]).toContain("(2/"); }); it("should handle errors gracefully", async () => { mockSessionManager.getSession = vi.fn().mockResolvedValue({ id: "test-session", tmuxSession: "test-tmux" }); mockTmuxManager.capturePane = vi.fn().mockRejectedValue(/* @__PURE__ */ new Error("Tmux error")); await discordBot.handleOutput("test-session", mockMessage); expect(mockMessage.reply).toHaveBeenCalledWith("❌ Failed to fetch session output"); }); }); describe("findOrphanedChannels", () => { it("should return empty array when no guilds", async () => { discordBot.client = { guilds: { cache: { size: 0 } } }; expect(await discordBot.findOrphanedChannels([])).toEqual([]); }); it("should identify orphaned channels correctly", async () => { const mockChannelCache = new Map([["channel-1", { id: "channel-1", name: "ccremote-session-1", type: 0 }], ["channel-2", { id: "channel-2", name: "ccremote-session-2", type: 0 }]]); mockChannelCache.filter = vi.fn().mockReturnValue(mockChannelCache); const mockGuild = { channels: { cache: mockChannelCache } }; discordBot.client = { guilds: { cache: { size: 1, first: () => mockGuild } } }; const result = await discordBot.findOrphanedChannels([{ id: "ccremote-1", name: "session-1" }]); expect(result).toContain("channel-2"); expect(result).not.toContain("channel-1"); }); it("should handle errors gracefully", async () => { discordBot.client = { guilds: { cache: { size: 1, first: () => { throw new Error("Guild error"); } } } }; expect(await discordBot.findOrphanedChannels([])).toEqual([]); }); }); }); } //#endregion //#region src/utils/quota.ts /** * Utility functions for quota scheduling */ /** * Generate the quota message for a given execution time */ function generateQuotaMessage(executeAt) { return `🕕 This message will be sent at ${executeAt.toLocaleString()} to ensure the quota window starts at that time.`; } //#endregion //#region src/core/session.ts var SessionManager = class { globalConfigDir; sessionsFile; sessions = /* @__PURE__ */ new Map(); lockFile; writeLock = Promise.resolve(); constructor() { this.globalConfigDir = join(homedir(), ".ccremote"); this.sessionsFile = join(this.globalConfigDir, "sessions.json"); this.lockFile = join(this.globalConfigDir, "sessions.lock"); } async initialize() { await this.ensureConfigDir(); await this.loadSessions(); } async ensureConfigDir() { try { await promises.access(this.globalConfigDir); } catch { await promises.mkdir(this.globalConfigDir, { recursive: true }); } } async loadSessions() { await this.withWriteLock(async () => { try { const data = await promises.readFile(this.sessionsFile, "utf-8"); const sessionData = JSON.parse(data); this.sessions.clear(); let needsMigration = false; for (const [id, sessionRaw] of Object.entries(sessionData)) { const session = sessionRaw; if (!session.projectPath || !session.workingDirectory) { session.projectPath = session.projectPath || "/Users/fredrik.wollsen/Dev/ccremote"; session.workingDirectory = session.workingDirectory || "/Users/fredrik.wollsen/Dev/ccremote"; needsMigration = true; } this.sessions.set(id, session); } if (needsMigration) await this.writeSessionsToFile(); } catch { this.sessions.clear(); } }); } async saveSessions() { await this.withWriteLock(async () => { await this.writeSessionsToFile(); }); } async writeSessionsToFile() { const sessionData = {}; for (const [id, session] of this.sessions) sessionData[id] = session; const dir = dirname(this.sessionsFile); await promises.mkdir(dir, { recursive: true }); const tempFile = `${this.sessionsFile}.tmp.${randomBytes(4).toString("hex")}`; await promises.writeFile(tempFile, JSON.stringify(sessionData, null, 2)); await promises.rename(tempFile, this.sessionsFile); } async loadSessionsFromDisk() { try { const data = await promises.readFile(this.sessionsFile, "utf-8"); const sessionData = JSON.parse(data); this.sessions.clear(); for (const [id, session] of Object.entries(sessionData)) this.sessions.set(id, session); } catch {} } async mergeFromDisk() { try { const data = await promises.readFile(this.sessionsFile, "utf-8"); const sessionData = JSON.parse(data); for (const [id, diskSession] of Object.entries(sessionData)) { const currentSession = this.sessions.get(id); if (currentSession) { const diskState = diskSession; if (new Date(diskState.lastActivity) > new Date(currentSession.lastActivity)) this.sessions.set(id, diskState); } else this.sessions.set(id, diskSession); } } catch {} } async withWriteLock(fn) { const currentLock = this.writeLock; let resolve$1; let reject; this.writeLock = new Promise((res, rej) => { currentLock.then(async () => { try { const result = await fn(); resolve$1(result); res(); } catch (error) { reject(error); rej(error); } }).catch(rej); }); return new Promise((res, rej) => { resolve$1 = res; reject = rej; }); } async createSession(name, channelId) { const sessionId = this.generateSessionId(); const session = { id: sessionId, name: name || `session-${sessionId.split("-")[1]}`, tmuxSession: sessionId, channelId: channelId || "", status: "active", created: (/* @__PURE__ */ new Date()).toISOString(), lastActivity: (/* @__PURE__ */ new Date()).toISOString(), projectPath: process.cwd(), workingDirectory: process.cwd() }; this.sessions.set(sessionId, session); await this.saveSessions(); return session; } async listSessions() { return Array.from(this.sessions.values()); } async listSessionsForProject(projectPath) { const targetPath = projectPath || process.cwd(); return Array.from(this.sessions.values()).filter((session) => session.projectPath === targetPath); } async getSession(id) { return this.sessions.get(id) || null; } async getSessionByName(name) { for (const session of this.sessions.values()) if (session.name === name) return session; return null; } async updateSession(id, updates) { await this.withWriteLock(async () => { await this.mergeFromDisk(); const session = this.sessions.get(id); if (!session) throw new Error(`Session not found: ${id}`); Object.assign(session, updates, { lastActivity: (/* @__PURE__ */ new Date()).toISOString() }); await this.writeSessionsToFile(); }); } async deleteSession(id) { await this.withWriteLock(async () => { await this.mergeFromDisk(); if (!this.sessions.has(id)) throw new Error(`Session not found: ${id}`); this.sessions.delete(id); await this.writeSessionsToFile(); }); } generateSessionId() { const existingNumbers = Array.from(this.sessions.keys()).map((id) => { const match = id.match(/^ccremote-(\d+)$/); return match ? Number.parseInt(match[1], 10) : 0; }).filter((n) => n > 0); return `ccremote-${existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1}`; } }; if (import.meta.vitest) { const { beforeEach, afterEach, describe, it, expect } = await import("./dist-bz1tWxsS.js"); describe("SessionManager", () => { const testDir = ".ccremote-test"; beforeEach(async () => { try { await promises.rm(testDir, { recursive: true, force: true }); } catch {} }); afterEach(async () => { try { await promises.rm(testDir, { recursive: true, force: true }); } catch {} }); it("should create sessions with auto-generated IDs", async () => { const sessionManager = new SessionManager(); sessionManager.sessionsFile = `${testDir}/sessions.json`; await sessionManager.initialize(); const session1 = await sessionManager.createSession(); expect(session1.id).toBe("ccremote-1"); expect(session1.name).toBe("session-1"); const session2 = await sessionManager.createSession("my-session"); expect(session2.id).toBe("ccremote-2"); expect(session2.name).toBe("my-session"); }); it("should persist sessions across instances", async () => { const sessionManager1 = new SessionManager(); sessionManager1.sessionsFile = `${testDir}/sessions.json`; await sessionManager1.initialize(); await sessionManager1.createSession("test-persistence"); const sessionManager2 = new SessionManager(); sessionManager2.sessionsFile = `${testDir}/sessions.json`; await sessionManager2.initialize(); const testSession = (await sessionManager2.listSessions()).find((s) => s.name === "test-persistence"); expect(testSession).toBeDefined(); expect(testSession?.id).toBe("ccremote-1"); }); it("should handle CRUD operations correctly", async () => { const sessionManager = new SessionManager(); sessionManager.sessionsFile = `${testDir}/sessions.json`; await sessionManager.initialize(); const session = await sessionManager.createSession("crud-test"); expect(session.status).toBe("active"); expect((await sessionManager.getSession(session.id))?.name).toBe("crud-test"); expect((await sessionManager.getSessionByName("crud-test"))?.id).toBe(session.id); await sessionManager.updateSession(session.id, { status: "waiting", channelId: "12345" }); const updated = await sessionManager.getSession(session.id); expect(updated?.status).toBe("waiting"); expect(updated?.channelId).toBe("12345"); await sessionManager.deleteSession(session.id); expect(await sessionManager.getSession(session.id)).toBe(null); }); }); } //#endregion //#region src/core/tmux.ts const execAsync = promisify(exec); var TmuxManager = class { async isTmuxAvailable() { try { await execAsync("tmux -V"); return true; } catch { return false; } } async createSession(sessionName) { try { const ccremoteConfig = `${process.env.HOME}/.ccremote/tmux.conf`; const hasConfig = (await import("node:fs")).existsSync(ccremoteConfig); await execAsync(hasConfig ? `tmux new-session -d -s "${sessionName}" -c "${process.cwd()}"` : `tmux new-session -d -s "${sessionName}" -c "${process.cwd()}" \\; set -g mouse on`); if (hasConfig) await execAsync(`tmux source-file "${ccremoteConfig}"`); await execAsync(`tmux send-keys -t "${sessionName}" "claude" Enter`); } catch (error) { throw new Error(`Failed to create tmux session: ${error instanceof Error ? error.message : error}`); } } async capturePane(sessionName) { try { const { stdout } = await execAsync(`tmux capture-pane -t "${sessionName}" -p`); return stdout; } catch (error) { throw new Error(`Failed to capture tmux pane: ${error instanceof Error ? error.message : error}`); } } async capturePaneWithColors(sessionName) { try { const { stdout } = await execAsync(`tmux capture-pane -t "${sessionName}" -p -e`); return stdout; } catch (error) { throw new Error(`Failed to capture tmux pane with colors: ${error instanceof Error ? error.message : error}`); } } async sendKeys(sessionName, keys) { try { await execAsync(`tmux send-keys -t "${sessionName}" "${keys}" Enter`); } catch (error) { throw new Error(`Failed to send keys to tmux: ${error instanceof Error ? error.message : error}`); } } async sendRawKeys(sessionName, keys) { try { await execAsync(`tmux send-keys -t "${sessionName}" "${keys}"`); } catch (error) { throw new Error(`Failed to send raw keys to tmux: ${error instanceof Error ? error.message : error}`); } } async clearInput(sessionName) { try { await execAsync(`tmux send-keys -t "${sessionName}" C-u`); } catch (error) { throw new Error(`Failed to clear tmux input: ${error instanceof Error ? error.message : error}`); } } async sessionExists(sessionName) { try { const command = `tmux has-session -t "${sessionName}"`; await Promise.race([execAsync(command), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("Timeout")), 5e3))]); return true; } catch { return false; } } async killSession(sessionName) { try { await execAsync(`tmux kill-session -t "${sessionName}"`); } catch (error) { if (!error || !String(error).includes("session not found")) throw new Error(`Failed to kill tmux session: ${error instanceof Error ? error.message : error}`); } } async listSessions() { try { const { stdout } = await execAsync("tmux list-sessions -F \"#{session_name},#{session_created},#{session_windows}\""); return stdout.trim().split("\n").filter((line) => line.length > 0).map((line) => { const [name, created, windows] = line.split(","); return { name, created: (/* @__PURE__ */ new Date(Number(created) * 1e3)).toISOString(), windows: Number.parseInt(windows, 10) }; }); } catch { return []; } } async sendContinueCommand(sessionName) { await this.clearInput(sessionName); await new Promise((resolve$1) => setTimeout(resolve$1, 200)); await this.sendRawKeys(sessionName, "continue"); await new Promise((resolve$1) => setTimeout(resolve$1, 200)); await this.sendRawKeys(sessionName, "Enter"); } async sendOptionSelection(sessionName, optionNumber) { const response = String(optionNumber); await this.sendRawKeys(sessionName, response); } }; //#endregion export { DiscordBot as i, SessionManager as n, generateQuotaMessage as r, TmuxManager as t };