@geminixiang/mama 0.2.0-beta.3 → 0.2.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -422
- package/dist/adapter.d.ts +9 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js.map +1 -1
- package/dist/adapters/discord/bot.d.ts +1 -0
- package/dist/adapters/discord/bot.d.ts.map +1 -1
- package/dist/adapters/discord/bot.js +62 -73
- package/dist/adapters/discord/bot.js.map +1 -1
- package/dist/adapters/discord/context.d.ts.map +1 -1
- package/dist/adapters/discord/context.js +9 -2
- package/dist/adapters/discord/context.js.map +1 -1
- package/dist/adapters/shared.d.ts +48 -0
- package/dist/adapters/shared.d.ts.map +1 -1
- package/dist/adapters/shared.js +111 -0
- package/dist/adapters/shared.js.map +1 -1
- package/dist/adapters/slack/bot.d.ts +3 -19
- package/dist/adapters/slack/bot.d.ts.map +1 -1
- package/dist/adapters/slack/bot.js +58 -188
- package/dist/adapters/slack/bot.js.map +1 -1
- package/dist/adapters/slack/context.d.ts.map +1 -1
- package/dist/adapters/slack/context.js +13 -3
- package/dist/adapters/slack/context.js.map +1 -1
- package/dist/adapters/telegram/bot.d.ts.map +1 -1
- package/dist/adapters/telegram/bot.js +78 -100
- package/dist/adapters/telegram/bot.js.map +1 -1
- package/dist/adapters/telegram/context.d.ts.map +1 -1
- package/dist/adapters/telegram/context.js +9 -2
- package/dist/adapters/telegram/context.js.map +1 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +15 -5
- package/dist/agent.js.map +1 -1
- package/dist/bindings.d.ts +2 -1
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +3 -2
- package/dist/bindings.js.map +1 -1
- package/dist/commands/index.d.ts +5 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +8 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/login.d.ts +5 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +37 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/registry.d.ts +7 -0
- package/dist/commands/registry.d.ts.map +1 -0
- package/dist/commands/registry.js +14 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/commands/session-view.d.ts +5 -0
- package/dist/commands/session-view.d.ts.map +1 -0
- package/dist/commands/session-view.js +38 -0
- package/dist/commands/session-view.js.map +1 -0
- package/dist/commands/types.d.ts +41 -0
- package/dist/commands/types.d.ts.map +1 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/types.js.map +1 -0
- package/dist/commands/utils.d.ts +5 -0
- package/dist/commands/utils.d.ts.map +1 -0
- package/dist/commands/utils.js +9 -0
- package/dist/commands/utils.js.map +1 -0
- package/dist/config.d.ts +4 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +37 -42
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +74 -68
- package/dist/context.js.map +1 -1
- package/dist/execution-resolver.d.ts +6 -3
- package/dist/execution-resolver.d.ts.map +1 -1
- package/dist/execution-resolver.js +47 -14
- package/dist/execution-resolver.js.map +1 -1
- package/dist/fs-atomic.d.ts +10 -0
- package/dist/fs-atomic.d.ts.map +1 -0
- package/dist/fs-atomic.js +45 -0
- package/dist/fs-atomic.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/instrument.d.ts.map +1 -1
- package/dist/instrument.js +2 -3
- package/dist/instrument.js.map +1 -1
- package/dist/login/index.d.ts.map +1 -1
- package/dist/login/index.js +19 -8
- package/dist/login/index.js.map +1 -1
- package/dist/login/portal.d.ts.map +1 -1
- package/dist/login/portal.js +7 -7
- package/dist/login/portal.js.map +1 -1
- package/dist/login/session.d.ts +3 -2
- package/dist/login/session.d.ts.map +1 -1
- package/dist/login/session.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +63 -389
- package/dist/main.js.map +1 -1
- package/dist/provisioner.d.ts +11 -9
- package/dist/provisioner.d.ts.map +1 -1
- package/dist/provisioner.js +125 -87
- package/dist/provisioner.js.map +1 -1
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/session-runtime.d.ts +26 -0
- package/dist/runtime/session-runtime.d.ts.map +1 -0
- package/dist/runtime/session-runtime.js +285 -0
- package/dist/runtime/session-runtime.js.map +1 -0
- package/dist/sandbox/cloudflare.d.ts +14 -0
- package/dist/sandbox/cloudflare.d.ts.map +1 -0
- package/dist/sandbox/cloudflare.js +131 -0
- package/dist/sandbox/cloudflare.js.map +1 -0
- package/dist/sandbox/index.d.ts +6 -4
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/index.js +6 -3
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sandbox/types.d.ts +5 -1
- package/dist/sandbox/types.d.ts.map +1 -1
- package/dist/sandbox/types.js.map +1 -1
- package/dist/session-store.d.ts +5 -1
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +14 -9
- package/dist/session-store.js.map +1 -1
- package/dist/session-view/portal.d.ts +2 -0
- package/dist/session-view/portal.d.ts.map +1 -1
- package/dist/session-view/portal.js +45 -7
- package/dist/session-view/portal.js.map +1 -1
- package/dist/session-view/service.d.ts.map +1 -1
- package/dist/session-view/service.js +94 -48
- package/dist/session-view/service.js.map +1 -1
- package/dist/session-view/store.d.ts +3 -2
- package/dist/session-view/store.d.ts.map +1 -1
- package/dist/session-view/store.js.map +1 -1
- package/dist/vault-routing.d.ts +3 -5
- package/dist/vault-routing.d.ts.map +1 -1
- package/dist/vault-routing.js +8 -20
- package/dist/vault-routing.js.map +1 -1
- package/dist/vault.d.ts +7 -5
- package/dist/vault.d.ts.map +1 -1
- package/dist/vault.js +111 -104
- package/dist/vault.js.map +1 -1
- package/package.json +7 -9
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../../../src/adapters/slack/bot.ts"],"names":[],"mappings":"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","sourcesContent":["import { SocketModeClient } from \"@slack/socket-mode\";\nimport { WebClient } from \"@slack/web-api\";\nimport { appendFileSync, existsSync, mkdirSync, readFileSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport type {\n Bot,\n BotAdapters,\n BotEvent,\n BotHandler,\n ChatMessage,\n ChatResponseContext,\n ChatToolResult,\n ConversationKind,\n PlatformInfo,\n} from \"../../adapter.js\";\nimport type { EventsWatcher } from \"../../events.js\";\nimport * as log from \"../../log.js\";\nimport type { Attachment, ChannelStore } from \"../../store.js\";\nimport { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from \"../../ui-copy.js\";\nimport { createSlackAdapters } from \"./context.js\";\nimport { hasMaterializedSlackBranchSession } from \"./branch-manager.js\";\nimport { resolveSlackSessionKey } from \"./session.js\";\n\n// ============================================================================\n// Exponential backoff utility for Slack API calls\n// ============================================================================\n\n/**\n * Retry a function with exponential backoff on rate limit errors.\n */\nasync function withRetry<T>(\n fn: () => Promise<T>,\n maxRetries: number = 3,\n baseDelayMs: number = 1000,\n): Promise<T> {\n let lastError: Error | undefined;\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n try {\n return await fn();\n } catch (err) {\n lastError = err instanceof Error ? err : new Error(String(err));\n\n // Check for rate limit errors\n let isRateLimited = false;\n\n // Check for rate_limited error code (Slack SDK)\n if (\"code\" in lastError && lastError.code === \"rate_limited\") {\n isRateLimited = true;\n }\n\n // Check for rate_limited in error response\n if (\"data\" in lastError) {\n const data = (lastError as { data?: { error?: string; response?: { status?: number } } })\n .data;\n if (data?.error === \"rate_limited\" || data?.response?.status === 429) {\n isRateLimited = true;\n }\n }\n\n if (isRateLimited) {\n const delay = baseDelayMs * Math.pow(2, attempt);\n log.logWarning(\n `Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`,\n );\n await new Promise((resolve) => setTimeout(resolve, delay));\n continue;\n }\n\n // Non-retryable error\n throw lastError;\n }\n }\n throw lastError;\n}\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n type: \"mention\" | \"dm\";\n conversationId: string;\n conversationKind: ConversationKind;\n channel: string;\n ts: string;\n thread_ts?: string;\n user: string;\n text: string;\n files?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;\n /** Processed attachments with local paths (populated after logUserMessage) */\n attachments?: Attachment[];\n /** Session key passed through to BotEvent so handleEvent uses the correct persistent session */\n sessionKey?: string;\n}\n\nexport interface SlackUser {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackChannel {\n id: string;\n name: string;\n}\n\n// Types used by agent.ts\nexport interface ChannelInfo {\n id: string;\n name: string;\n}\n\nexport interface UserInfo {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackContext {\n message: {\n text: string;\n rawText: string;\n user: string;\n userName?: string;\n channel: string;\n ts: string;\n attachments: Array<{ localPath: string }>;\n };\n channelName?: string;\n channels: ChannelInfo[];\n users: UserInfo[];\n respond: (text: string, shouldLog?: boolean) => Promise<void>;\n replaceMessage: (text: string) => Promise<void>;\n respondDiagnostic: (text: string) => Promise<void>;\n setTyping: (isTyping: boolean) => Promise<void>;\n uploadFile: (filePath: string, title?: string) => Promise<void>;\n setWorking: (working: boolean) => Promise<void>;\n deleteMessage: () => Promise<void>;\n}\n\n// ============================================================================\n// Per-channel queue for sequential processing\n// ============================================================================\n\ntype QueuedWork = () => Promise<void>;\n\nclass ChannelQueue {\n private queue: QueuedWork[] = [];\n private processing = false;\n\n enqueue(work: QueuedWork): void {\n this.queue.push(work);\n this.processNext();\n }\n\n size(): number {\n return this.queue.length;\n }\n\n private async processNext(): Promise<void> {\n if (this.processing || this.queue.length === 0) return;\n this.processing = true;\n const work = this.queue.shift()!;\n try {\n await work();\n } catch (err) {\n log.logWarning(\"Queue error\", err instanceof Error ? err.message : String(err));\n }\n this.processing = false;\n this.processNext();\n }\n}\n\n// ============================================================================\n// SlackBot\n// ============================================================================\n\nexport class SlackBot implements Bot {\n private socketClient: SocketModeClient;\n private webClient: WebClient;\n private handler: BotHandler;\n private workingDir: string;\n private store: ChannelStore;\n private botUserId: string | null = null;\n private startupTs: string | null = null; // Messages older than this are just logged, not processed\n\n private users = new Map<string, SlackUser>();\n private channels = new Map<string, SlackChannel>();\n private queues = new Map<string, ChannelQueue>();\n private eventsWatcher: EventsWatcher | null = null;\n\n constructor(\n handler: BotHandler,\n config: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },\n ) {\n this.handler = handler;\n this.workingDir = config.workingDir;\n this.store = config.store;\n this.socketClient = new SocketModeClient({ appToken: config.appToken });\n this.webClient = new WebClient(config.botToken);\n }\n\n setEventsWatcher(watcher: EventsWatcher): void {\n this.eventsWatcher = watcher;\n }\n\n // ==========================================================================\n // Public API\n // ==========================================================================\n\n async start(): Promise<void> {\n const auth = await this.webClient.auth.test();\n this.botUserId = auth.user_id as string;\n\n await Promise.all([this.fetchUsers(), this.fetchChannels()]);\n log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);\n\n await this.backfillAllChannels();\n\n this.setupEventHandlers();\n await this.socketClient.start();\n\n // Record startup time - messages older than this are just logged, not processed\n this.startupTs = (Date.now() / 1000).toFixed(6);\n\n log.logConnected();\n }\n\n getUser(userId: string): SlackUser | undefined {\n return this.users.get(userId);\n }\n\n getChannel(channelId: string): SlackChannel | undefined {\n return this.channels.get(channelId);\n }\n\n getAllUsers(): SlackUser[] {\n return Array.from(this.users.values());\n }\n\n getAllChannels(): SlackChannel[] {\n return Array.from(this.channels.values());\n }\n\n async postMessage(channel: string, text: string): Promise<string> {\n return withRetry(async () => {\n const result = await this.webClient.chat.postMessage({ channel, text });\n return result.ts as string;\n });\n }\n\n async postEphemeral(channel: string, user: string, text: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.chat.postEphemeral({ channel, user, text });\n });\n }\n\n async openDirectMessage(userId: string): Promise<string> {\n return withRetry(async () => {\n const result = await this.webClient.conversations.open({ users: userId });\n const channelId = result.channel?.id;\n if (!channelId) {\n throw new Error(`Failed to open DM for user ${userId}`);\n }\n return channelId;\n });\n }\n\n async updateMessage(channel: string, ts: string, text: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.chat.update({ channel, ts, text });\n });\n }\n\n async deleteMessage(channel: string, ts: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.chat.delete({ channel, ts });\n });\n }\n\n // ==========================================================================\n // Slack Assistant API (AI assistant experience)\n // ==========================================================================\n\n /** Set the status for an assistant thread (shows \"thinking\" state) */\n async setAssistantStatus(channel: string, threadTs: string, status: string): Promise<void> {\n return withRetry(async () => {\n await this.webClient.assistant.threads.setStatus({\n channel_id: channel,\n thread_ts: threadTs,\n status,\n });\n });\n }\n\n async postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n return withRetry(async () => {\n // Use Block Kit section for long messages to trigger Slack's \"Show more\" collapsing (~700 chars)\n const SECTION_TEXT_LIMIT = 3000;\n if (text.length > 500) {\n const blockText =\n text.length > SECTION_TEXT_LIMIT\n ? text.substring(0, SECTION_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : text;\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // full text as notification fallback\n blocks: [{ type: \"section\", text: { type: \"mrkdwn\", text: blockText } }],\n });\n return result.ts as string;\n }\n const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n return result.ts as string;\n });\n }\n\n async postInThreadBlocks(\n channel: string,\n threadTs: string,\n text: string,\n blocks: object[],\n ): Promise<string> {\n return withRetry(async () => {\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // fallback for notifications\n blocks: blocks as any,\n });\n return result.ts as string;\n });\n }\n\n async uploadFile(\n channel: string,\n filePath: string,\n title?: string,\n threadTs?: string,\n ): Promise<void> {\n return withRetry(async () => {\n const fileName = title || basename(filePath);\n const fileContent = readFileSync(filePath);\n await this.webClient.files.uploadV2({\n channel_id: channel,\n file: fileContent,\n filename: fileName,\n title: fileName,\n ...(threadTs ? { thread_ts: threadTs } : {}),\n } as Parameters<typeof this.webClient.files.uploadV2>[0]);\n });\n }\n\n /**\n * Log a message to log.jsonl (SYNC)\n * This is the ONLY place messages are written to log.jsonl\n */\n logToFile(channel: string, entry: object): void {\n const dir = join(this.workingDir, channel);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n appendFileSync(join(dir, \"log.jsonl\"), `${JSON.stringify(entry)}\\n`);\n }\n\n /**\n * Log a bot response to log.jsonl\n */\n logBotResponse(channel: string, text: string, ts: string, threadTs?: string): void {\n this.logToFile(channel, {\n date: new Date().toISOString(),\n ts,\n threadTs,\n user: \"bot\",\n text,\n attachments: [],\n isBot: true,\n });\n }\n\n getPlatformInfo(): PlatformInfo {\n return {\n name: \"slack\",\n formattingGuide:\n \"## Slack Formatting (mrkdwn, NOT Markdown)\\nBold: *text*, Italic: _text_, Code: `code`, Block: ```code```, Links: <url|text>\\nDo NOT use **double asterisks** or [markdown](links).\",\n channels: this.getAllChannels().map((c) => ({ id: c.id, name: c.name })),\n users: this.getAllUsers().map((u) => ({\n id: u.id,\n userName: u.userName,\n displayName: u.displayName,\n })),\n diagnostics: {\n showUsageSummary: true,\n },\n };\n }\n\n // ==========================================================================\n // Events Integration\n // ==========================================================================\n\n /**\n * Enqueue an event for processing. Always queues (no \"already working\" rejection).\n * Returns true if enqueued, false if queue is full (max 5).\n */\n enqueueEvent(event: BotEvent): boolean {\n const conversationId = event.conversationId;\n const queue = this.getQueue(conversationId);\n if (queue.size() >= 5) {\n log.logWarning(\n `Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`,\n );\n return false;\n }\n log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);\n queue.enqueue(() => {\n const slackEvent: SlackEvent = {\n type: event.type as SlackEvent[\"type\"],\n conversationId,\n conversationKind: event.conversationKind,\n channel: conversationId,\n ts: event.ts,\n thread_ts: event.thread_ts,\n user: event.user,\n text: event.text,\n attachments: event.attachments?.map((attachment) => ({\n original: attachment.name,\n localPath: attachment.localPath,\n })),\n sessionKey: event.sessionKey,\n };\n const adapters = createSlackAdapters(slackEvent, this, true);\n return this.handler.handleEvent(event, this, adapters, true);\n });\n return true;\n }\n\n // ==========================================================================\n // Private - Event Handlers\n // ==========================================================================\n\n private getQueue(channelId: string): ChannelQueue {\n let queue = this.queues.get(channelId);\n if (!queue) {\n queue = new ChannelQueue();\n this.queues.set(channelId, queue);\n }\n return queue;\n }\n\n private resolveQueueKey(conversationId: string, sessionKey: string): string {\n if (!sessionKey.includes(\":\")) return sessionKey;\n\n return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey)\n ? sessionKey\n : conversationId;\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private buildHomeView(): { type: \"home\"; blocks: any[] } {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const blocks: any[] = [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${PRODUCT_NAME}*\\nStart a new task or check on running work.`,\n },\n accessory: {\n type: \"image\",\n image_url: \"https://media1.tenor.com/m/lfDATg4Bhc0AAAAC/happy-cat.gif\",\n alt_text: PRODUCT_NAME,\n },\n },\n ];\n\n // --- Running tasks ---\n const runningSessions = this.handler.getRunningSessions();\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Running Tasks (${runningSessions.length})`,\n emoji: true,\n },\n },\n );\n\n if (runningSessions.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No tasks running right now._\" }],\n });\n } else {\n // Threshold for \"stuck\" detection (10 minutes)\n const STUCK_THRESHOLD_MS = 10 * 60 * 1000;\n\n for (const session of runningSessions) {\n const channelId = session.sessionKey.split(\":\")[0];\n const channel = this.channels.get(channelId);\n const channelName = channel ? `#${channel.name}` : channelId;\n const elapsed = Math.floor((Date.now() - session.startedAt) / 60000);\n const elapsedStr = elapsed < 1 ? \"<1 min\" : `${elapsed} min`;\n\n // Check if task might be stuck\n const lastActivity = session.lastActivityAt ? Date.now() - session.lastActivityAt : 0;\n const isStuck = lastActivity > STUCK_THRESHOLD_MS;\n const statusText = isStuck ? \"_stuck_\" : \"_running_\";\n\n // Build status line: channel · status · time · step\n let statusLine = `${statusText} · ${elapsedStr}`;\n if (session.currentTool) {\n statusLine += ` · ${session.currentTool}`;\n }\n if (isStuck && lastActivity > 0) {\n const inactiveMin = Math.floor(lastActivity / 60000);\n statusLine += ` · idle ${inactiveMin}m`;\n }\n\n // Use context block for gray small text (like \"No scheduled jobs.\")\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: `*${channelName}* · ${statusLine}`,\n },\n ],\n });\n\n // Add Force Stop button as separate element if stuck\n if (isStuck) {\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: \" \",\n },\n {\n type: \"button\",\n text: { type: \"plain_text\", text: \"Force Stop\", emoji: true },\n action_id: `force_stop_${session.sessionKey.replace(/:/g, \"_\")}`,\n style: \"danger\",\n },\n ],\n });\n }\n }\n }\n\n // --- Cron jobs ---\n const periodicEvents = this.eventsWatcher?.getPeriodicEvents() ?? [];\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Scheduled Jobs (${periodicEvents.length})`,\n emoji: true,\n },\n },\n );\n\n if (periodicEvents.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No scheduled jobs._\" }],\n });\n } else {\n for (const ev of periodicEvents) {\n const channelLabel =\n ev.platform === \"slack\"\n ? (() => {\n const channel = this.channels.get(ev.conversationId);\n const channelName = channel ? `#${channel.name}` : ev.conversationId;\n return `${ev.platform}:${channelName}`;\n })()\n : `${ev.platform}:${ev.conversationId}`;\n const nextStr = ev.nextRun\n ? new Date(ev.nextRun).toLocaleString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"—\";\n blocks.push({\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${ev.text}*\\n└ \\`${ev.schedule}\\` · ${channelLabel} · Next: ${nextStr}`,\n },\n });\n }\n }\n\n // --- Footer ---\n blocks.push(\n { type: \"divider\" },\n {\n type: \"context\",\n elements: [\n { type: \"mrkdwn\", text: \"💡 @mention in a channel or send a DM to start a new task\" },\n ],\n },\n );\n\n return { type: \"home\", blocks };\n }\n\n /**\n * Resolve which session key to stop.\n * When stop is called from a thread, the thread session (channelId:thread_ts) might\n * not be running — but the channel session (channelId) might be, because the bot's\n * reply to a top-level mention creates a thread. Check both, prefer thread first.\n *\n * For top-level stop requests, fall back to the only running scoped thread session in\n * this conversation when there is exactly one candidate. This keeps DM/channel stop\n * useful after thread sessions were introduced without guessing between multiple threads.\n */\n private resolveStopTarget(channelId: string, threadTs?: string): string | null {\n if (threadTs) {\n const threadKey = resolveSlackSessionKey(channelId, threadTs);\n if (this.handler.isRunning(threadKey)) return threadKey;\n if (this.handler.isRunning(channelId)) return channelId;\n return null;\n }\n\n if (this.handler.isRunning(channelId)) return channelId;\n\n const runningInConversation = this.handler\n .getRunningSessions()\n .map((session) => session.sessionKey)\n .filter((sessionKey) => sessionKey.startsWith(`${channelId}:`));\n\n return runningInConversation.length === 1 ? runningInConversation[0] : null;\n }\n\n private createDirectCommandAdapters(\n conversationId: string,\n userId: string,\n userName: string | undefined,\n text: string,\n ts: string,\n ): BotAdapters {\n const message: ChatMessage = {\n id: ts,\n sessionKey: conversationId,\n conversationKind: \"direct\",\n userId,\n userName,\n text,\n attachments: [],\n };\n\n const responseCtx: ChatResponseContext = {\n respond: async (responseText: string) => {\n const messageTs = await this.postMessage(conversationId, responseText);\n this.logBotResponse(conversationId, responseText, messageTs);\n },\n replaceResponse: async (responseText: string) => {\n const messageTs = await this.postMessage(conversationId, responseText);\n this.logBotResponse(conversationId, responseText, messageTs);\n },\n respondDiagnostic: async (responseText: string) => {\n const messageTs = await this.postMessage(conversationId, responseText);\n this.logBotResponse(conversationId, responseText, messageTs);\n },\n respondToolResult: async (result: ChatToolResult) => {\n const duration = (result.durationMs / 1000).toFixed(1);\n const text = `${result.isError ? \"Error\" : \"Done\"} ${result.toolName} (${duration}s)\\n${result.result}`;\n const messageTs = await this.postMessage(conversationId, text);\n this.logBotResponse(conversationId, text, messageTs);\n },\n setTyping: async () => {},\n setWorking: async () => {},\n uploadFile: async (filePath: string, title?: string) => {\n await this.uploadFile(conversationId, filePath, title);\n },\n deleteResponse: async () => {},\n };\n\n return {\n message,\n responseCtx,\n platform: this.getPlatformInfo(),\n };\n }\n\n private createPrivateSessionCommandAdapters(\n conversationId: string,\n userId: string,\n userName: string | undefined,\n text: string,\n ts: string,\n options: { ephemeralChannelId?: string },\n ): BotAdapters {\n const message: ChatMessage = {\n id: ts,\n sessionKey: conversationId,\n conversationKind: options.ephemeralChannelId ? \"shared\" : \"direct\",\n userId,\n userName,\n text,\n attachments: [],\n };\n\n let hasResponded = false;\n\n const respondPrivately = async (responseText: string) => {\n if (options.ephemeralChannelId) {\n await this.postEphemeral(options.ephemeralChannelId, userId, responseText);\n return;\n }\n\n const messageTs = await this.postMessage(conversationId, responseText);\n this.logBotResponse(conversationId, responseText, messageTs);\n };\n\n const responseCtx: ChatResponseContext = {\n respond: async (responseText: string) => {\n hasResponded = true;\n await respondPrivately(responseText);\n },\n replaceResponse: async (responseText: string) => {\n if (!hasResponded) {\n hasResponded = true;\n }\n await respondPrivately(responseText);\n },\n respondDiagnostic: async (responseText: string) => {\n await respondPrivately(responseText);\n },\n respondToolResult: async (result: ChatToolResult) => {\n const duration = (result.durationMs / 1000).toFixed(1);\n const formatted = `${result.isError ? \"Error\" : \"Done\"} ${result.toolName} (${duration}s)\\n${result.result}`;\n await respondPrivately(formatted);\n },\n setTyping: async () => {},\n setWorking: async () => {},\n uploadFile: async (filePath: string, title?: string) => {\n await this.uploadFile(conversationId, filePath, title);\n },\n deleteResponse: async () => {},\n };\n\n return {\n message,\n responseCtx,\n platform: this.getPlatformInfo(),\n };\n }\n\n private createSlashCommandBot(conversationId: string, threadTs?: string): Bot {\n return {\n start: async () => {},\n postMessage: async (_channel: string, text: string) => {\n if (threadTs) {\n return this.postInThread(conversationId, threadTs, text);\n }\n return this.postMessage(conversationId, text);\n },\n updateMessage: async (channel: string, ts: string, text: string) => {\n await this.updateMessage(channel, ts, text);\n },\n enqueueEvent: (event: BotEvent) => this.enqueueEvent(event),\n getPlatformInfo: () => this.getPlatformInfo(),\n };\n }\n\n private async routeSlashLoginCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const commandSuffix = payload.text?.trim();\n const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const sourceChannelId = payload.channel_id;\n const isDirectMessage = sourceChannelId.startsWith(\"D\");\n const targetChannelId = isDirectMessage\n ? sourceChannelId\n : await this.openDirectMessage(payload.user_id);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n\n this.logToFile(targetChannelId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n if (!isDirectMessage) {\n await this.postEphemeral(\n sourceChannelId,\n payload.user_id,\n `我已私訊你 ${PRODUCT_NAME} 的登入連結,請到私訊完成設定。`,\n );\n }\n\n const event: BotEvent = {\n type: \"dm\",\n conversationId: targetChannelId,\n conversationKind: \"direct\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n sessionKey: targetChannelId,\n };\n\n const adapters = this.createDirectCommandAdapters(\n targetChannelId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n );\n\n await this.handler.handleEvent(event, this, adapters, false);\n }\n\n private async routeSlashNewCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n if (!conversationId.startsWith(\"D\")) {\n await this.postEphemeral(\n conversationId,\n payload.user_id,\n `為了避免誤清除共享上下文,${payload.command} 目前只能在與 ${PRODUCT_NAME} 的私訊中使用。`,\n );\n return;\n }\n\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: payload.command,\n attachments: [],\n isBot: false,\n });\n\n const commandBot = this.createSlashCommandBot(conversationId);\n await this.handler.handleNew(conversationId, conversationId, commandBot);\n }\n\n private async routeSlashSessionCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n const isDirectMessage = conversationId.startsWith(\"D\");\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n const commandText = payload.command;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n const sessionKey = conversationId;\n const event: BotEvent = {\n type: \"dm\",\n conversationId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n sessionKey,\n };\n\n const adapters = this.createPrivateSessionCommandAdapters(\n conversationId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage ? {} : { ephemeralChannelId: conversationId },\n );\n\n await this.handler.handleEvent(event, this, adapters, false);\n }\n\n private setupEventHandlers(): void {\n // Channel @mentions\n this.socketClient.on(\"app_mention\", ({ event, ack }) => {\n const e = event as {\n text: string;\n channel: string;\n user: string;\n ts: string;\n thread_ts?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip DMs (handled by message event)\n if (e.channel.startsWith(\"D\")) {\n ack();\n return;\n }\n\n // Top-level mentions use a persistent channel session.\n // Thread replies get their own isolated session (channelId:thread_ts).\n const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);\n\n const slackEvent: SlackEvent = {\n type: \"mention\",\n conversationId: e.channel,\n conversationKind: \"shared\",\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: e.text.replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n files: e.files,\n sessionKey,\n };\n\n const attachmentsPromise = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,\n );\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Check for stop command - execute immediately, don't queue!\n if (slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {\n slackEvent.attachments = await attachmentsPromise;\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n false,\n );\n });\n\n ack();\n });\n\n // All messages (for logging) + DMs (for triggering)\n this.socketClient.on(\"message\", ({ event, ack }) => {\n const e = event as {\n text?: string;\n channel: string;\n user?: string;\n ts: string;\n thread_ts?: string;\n channel_type?: string;\n subtype?: string;\n bot_id?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip bot messages, edits, etc.\n if (e.bot_id || !e.user || e.user === this.botUserId) {\n ack();\n return;\n }\n if (e.subtype !== undefined && e.subtype !== \"file_share\") {\n ack();\n return;\n }\n if (!e.text && (!e.files || e.files.length === 0)) {\n ack();\n return;\n }\n\n const isDM = e.channel_type === \"im\";\n const conversationKind: ConversationKind = isDM ? \"direct\" : \"shared\";\n const isBotMention = e.text?.includes(`<@${this.botUserId}>`);\n\n // Skip channel @mentions - already handled by app_mention event\n if (!isDM && isBotMention) {\n ack();\n return;\n }\n\n const isSharedThreadReply = !isDM && !!e.thread_ts;\n const sessionKey =\n isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;\n\n const slackEvent: SlackEvent = {\n type: isDM ? \"dm\" : \"mention\",\n conversationId: e.channel,\n conversationKind,\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: (e.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n files: e.files,\n sessionKey,\n };\n\n const attachmentsPromise = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`,\n );\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Check for stop command in channel threads (without @mention)\n // app_mention handles \"@mama stop\", but bare \"stop\" in a thread comes here\n if (!isDM && e.thread_ts && slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Trigger handler for DMs and bare replies inside shared-channel threads.\n if (isDM || isSharedThreadReply) {\n const activeSessionKey =\n slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);\n // Check for stop command - execute immediately, don't queue!\n if (slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this); // Don't await, don't queue\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {\n slackEvent.attachments = await attachmentsPromise;\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n false,\n );\n });\n } else {\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n }\n\n ack();\n });\n\n this.socketClient.on(\"slash_commands\", async ({ body, ack }) => {\n const payload = body as {\n command?: string;\n text?: string;\n channel_id?: string;\n user_id?: string;\n user_name?: string;\n };\n\n await ack();\n\n if (!payload.command || !payload.channel_id || !payload.user_id) {\n return;\n }\n\n const handlerPromise =\n payload.command === \"/pi-login\"\n ? this.routeSlashLoginCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-new\"\n ? this.routeSlashNewCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-session\"\n ? this.routeSlashSessionCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : null;\n\n if (!handlerPromise) {\n return;\n }\n\n handlerPromise.catch((err) => {\n log.logWarning(\n \"Slack slash command error\",\n err instanceof Error ? err.message : String(err),\n );\n });\n });\n\n // App Home tab\n this.socketClient.on(\"app_home_opened\", ({ event, ack }) => {\n const e = event as { user: string; tab: string };\n ack();\n if (e.tab !== \"home\") return;\n\n this.webClient.views\n .publish({\n user_id: e.user,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to publish App Home view`, String(err));\n });\n });\n\n // Handle button clicks (Force Stop)\n this.socketClient.on(\"block_actions\", async ({ body, ack }) => {\n const action = body.actions?.[0];\n if (!action || !action.action_id?.startsWith(\"force_stop_\")) {\n ack();\n return;\n }\n\n ack();\n const sessionKey = action.action_id.replace(\"force_stop_\", \"\").replace(/_/g, \":\");\n const userId = body.user?.id;\n const channelId = body.container?.channel_id || sessionKey.split(\":\")[0];\n\n log.logInfo(`[Force Stop] User ${userId} requested force stop for ${sessionKey}`);\n\n // Use handler's forceStop method\n this.handler.forceStop(sessionKey);\n\n // Notify in channel\n await this.postMessage(channelId, formatForceStopped(\"slack\", userId ?? \"unknown\"));\n\n // Refresh home tab\n if (userId) {\n this.webClient.views\n .publish({\n user_id: userId,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to refresh App Home view`, String(err));\n });\n }\n });\n }\n\n /**\n * Log a user message to log.jsonl after attachments are ready.\n */\n private async logUserMessage(event: SlackEvent): Promise<Attachment[]> {\n const user = this.users.get(event.user);\n const attachments = event.files\n ? await this.store.processAttachments(event.channel, event.files, event.ts)\n : [];\n this.logToFile(event.channel, {\n date: new Date(parseFloat(event.ts) * 1000).toISOString(),\n ts: event.ts,\n threadTs: event.thread_ts,\n user: event.user,\n userName: user?.userName,\n displayName: user?.displayName,\n text: event.text,\n attachments,\n isBot: false,\n });\n return attachments;\n }\n\n // ==========================================================================\n // Private - Backfill\n // ==========================================================================\n\n private async getExistingTimestamps(channelId: string): Promise<Set<string>> {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const timestamps = new Set<string>();\n if (!existsSync(logPath)) return timestamps;\n\n const content = await readFile(logPath, \"utf-8\");\n const lines = content.trim().split(\"\\n\").filter(Boolean);\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.ts) timestamps.add(entry.ts);\n } catch {}\n }\n return timestamps;\n }\n\n private async backfillChannel(channelId: string): Promise<number> {\n const existingTs = await this.getExistingTimestamps(channelId);\n\n // Find the biggest ts in log.jsonl\n let latestTs: string | undefined;\n for (const ts of existingTs) {\n if (!latestTs || parseFloat(ts) > parseFloat(latestTs)) latestTs = ts;\n }\n\n type Message = {\n user?: string;\n bot_id?: string;\n text?: string;\n ts?: string;\n thread_ts?: string;\n subtype?: string;\n files?: Array<{ name: string }>;\n };\n const allMessages: Message[] = [];\n\n let cursor: string | undefined;\n let pageCount = 0;\n const maxPages = 3;\n\n do {\n const result = await this.webClient.conversations.history({\n channel: channelId,\n oldest: latestTs, // Only fetch messages newer than what we have\n inclusive: false,\n limit: 1000,\n cursor,\n });\n if (result.messages) {\n allMessages.push(...(result.messages as Message[]));\n }\n cursor = result.response_metadata?.next_cursor;\n pageCount++;\n } while (cursor && pageCount < maxPages);\n\n // Filter: include mama's messages, exclude other bots, skip already logged\n const relevantMessages = allMessages.filter((msg) => {\n if (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates\n if (msg.user === this.botUserId) return true;\n if (msg.bot_id) return false;\n if (msg.subtype !== undefined && msg.subtype !== \"file_share\") return false;\n if (!msg.user) return false;\n if (!msg.text && (!msg.files || msg.files.length === 0)) return false;\n return true;\n });\n\n // Reverse to chronological order\n relevantMessages.reverse();\n\n // Log each message to log.jsonl\n for (const msg of relevantMessages) {\n const isMamaMessage = msg.user === this.botUserId;\n const user = this.users.get(msg.user!);\n // Strip @mentions from text (same as live messages)\n const text = (msg.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim();\n const attachments = msg.files\n ? await this.store.processAttachments(channelId, msg.files, msg.ts!)\n : [];\n\n this.logToFile(channelId, {\n date: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n ts: msg.ts!,\n threadTs: msg.thread_ts,\n user: isMamaMessage ? \"bot\" : msg.user!,\n userName: isMamaMessage ? undefined : user?.userName,\n displayName: isMamaMessage ? undefined : user?.displayName,\n text,\n attachments,\n isBot: isMamaMessage,\n });\n }\n\n return relevantMessages.length;\n }\n\n private async backfillAllChannels(): Promise<void> {\n const startTime = Date.now();\n\n // Only backfill channels that already have a log.jsonl (mama has interacted with them before)\n const channelsToBackfill: Array<[string, SlackChannel]> = [];\n for (const [channelId, channel] of this.channels) {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n if (existsSync(logPath)) {\n channelsToBackfill.push([channelId, channel]);\n }\n }\n\n log.logBackfillStart(channelsToBackfill.length);\n\n let totalMessages = 0;\n for (const [channelId, channel] of channelsToBackfill) {\n try {\n const count = await this.backfillChannel(channelId);\n if (count > 0) log.logBackfillChannel(channel.name, count);\n totalMessages += count;\n } catch (error) {\n log.logWarning(`Failed to backfill #${channel.name}`, String(error));\n }\n\n // Add delay between channels to avoid hitting Slack rate limits\n if (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n }\n\n const durationMs = Date.now() - startTime;\n log.logBackfillComplete(totalMessages, durationMs);\n }\n\n // ==========================================================================\n // Private - Fetch Users/Channels\n // ==========================================================================\n\n private async fetchUsers(): Promise<void> {\n let cursor: string | undefined;\n do {\n const result = await this.webClient.users.list({ limit: 200, cursor });\n const members = result.members as\n | Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n | undefined;\n if (members) {\n for (const u of members) {\n if (u.id && u.name && !u.deleted) {\n this.users.set(u.id, {\n id: u.id,\n userName: u.name,\n displayName: u.real_name || u.name,\n });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n\n private async fetchChannels(): Promise<void> {\n // Fetch public/private channels\n let cursor: string | undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"public_channel,private_channel\",\n exclude_archived: true,\n limit: 200,\n cursor,\n });\n const channels = result.channels as\n | Array<{ id?: string; name?: string; is_member?: boolean }>\n | undefined;\n if (channels) {\n for (const c of channels) {\n if (c.id && c.name && c.is_member) {\n this.channels.set(c.id, { id: c.id, name: c.name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n\n // Also fetch DM channels (IMs)\n cursor = undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"im\",\n limit: 200,\n cursor,\n });\n const ims = result.channels as Array<{ id?: string; user?: string }> | undefined;\n if (ims) {\n for (const im of ims) {\n if (im.id) {\n // Use user's name as channel name for DMs\n const user = im.user ? this.users.get(im.user) : undefined;\n const name = user ? `DM:${user.userName}` : `DM:${im.id}`;\n this.channels.set(im.id, { id: im.id, name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"bot.d.ts","sourceRoot":"","sources":["../../../src/adapters/slack/bot.ts"],"names":[],"mappings":"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","sourcesContent":["import { SocketModeClient } from \"@slack/socket-mode\";\nimport { WebClient } from \"@slack/web-api\";\nimport { existsSync, readFileSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { basename, join } from \"path\";\nimport type {\n Bot,\n BotAdapters,\n BotEvent,\n BotHandler,\n ChatMessage,\n ChatResponseContext,\n ChatToolResult,\n ConversationKind,\n PlatformInfo,\n} from \"../../adapter.js\";\nimport type { EventsWatcher } from \"../../events.js\";\nimport * as log from \"../../log.js\";\nimport type { Attachment, ChannelStore } from \"../../store.js\";\nimport { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from \"../../ui-copy.js\";\nimport {\n appendBotResponseLog,\n appendChannelLog,\n ChannelQueue,\n resolveOnlyScopedStopTarget,\n resolveStopTarget,\n withRetry,\n} from \"../shared.js\";\n\n// Slack WebClient errors carry either `code: \"rate_limited\"` (retry-after) or\n// the legacy `data.error === \"rate_limited\"` / 429 status shape.\nfunction slackIsRateLimited(err: Error): boolean {\n if ((err as { code?: unknown }).code === \"rate_limited\") return true;\n const data = (err as { data?: { error?: string; response?: { status?: number } } }).data;\n return data?.error === \"rate_limited\" || data?.response?.status === 429;\n}\n\nconst slackRetry = <T>(fn: () => Promise<T>): Promise<T> =>\n withRetry(fn, { isRateLimited: slackIsRateLimited });\nimport { createSlackAdapters } from \"./context.js\";\nimport { hasMaterializedSlackBranchSession } from \"./branch-manager.js\";\nimport { resolveSlackSessionKey } from \"./session.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n type: \"mention\" | \"dm\";\n conversationId: string;\n conversationKind: ConversationKind;\n channel: string;\n ts: string;\n thread_ts?: string;\n user: string;\n text: string;\n files?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;\n /** Processed attachments with local paths (populated after logUserMessage) */\n attachments?: Attachment[];\n /** Session key passed through to BotEvent so handleEvent uses the correct persistent session */\n sessionKey?: string;\n}\n\nexport interface SlackUser {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackChannel {\n id: string;\n name: string;\n}\n\n// Types used by agent.ts\nexport interface ChannelInfo {\n id: string;\n name: string;\n}\n\nexport interface UserInfo {\n id: string;\n userName: string;\n displayName: string;\n}\n\nexport interface SlackContext {\n message: {\n text: string;\n rawText: string;\n user: string;\n userName?: string;\n channel: string;\n ts: string;\n attachments: Array<{ localPath: string }>;\n };\n channelName?: string;\n channels: ChannelInfo[];\n users: UserInfo[];\n respond: (text: string, shouldLog?: boolean) => Promise<void>;\n replaceMessage: (text: string) => Promise<void>;\n respondDiagnostic: (text: string) => Promise<void>;\n setTyping: (isTyping: boolean) => Promise<void>;\n uploadFile: (filePath: string, title?: string) => Promise<void>;\n setWorking: (working: boolean) => Promise<void>;\n deleteMessage: () => Promise<void>;\n}\n\n// ============================================================================\n// SlackBot\n// ============================================================================\n\nexport class SlackBot implements Bot {\n private socketClient: SocketModeClient;\n private webClient: WebClient;\n private handler: BotHandler;\n private workingDir: string;\n private store: ChannelStore;\n private botUserId: string | null = null;\n private startupTs: string | null = null; // Messages older than this are just logged, not processed\n\n private users = new Map<string, SlackUser>();\n private channels = new Map<string, SlackChannel>();\n private queues = new Map<string, ChannelQueue>();\n private eventsWatcher: EventsWatcher | null = null;\n\n constructor(\n handler: BotHandler,\n config: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },\n ) {\n this.handler = handler;\n this.workingDir = config.workingDir;\n this.store = config.store;\n this.socketClient = new SocketModeClient({ appToken: config.appToken });\n this.webClient = new WebClient(config.botToken);\n }\n\n setEventsWatcher(watcher: EventsWatcher): void {\n this.eventsWatcher = watcher;\n }\n\n // ==========================================================================\n // Public API\n // ==========================================================================\n\n async start(): Promise<void> {\n const auth = await this.webClient.auth.test();\n this.botUserId = auth.user_id as string;\n\n await Promise.all([this.fetchUsers(), this.fetchChannels()]);\n log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);\n\n await this.backfillAllChannels();\n\n this.setupEventHandlers();\n await this.socketClient.start();\n\n // Record startup time - messages older than this are just logged, not processed\n this.startupTs = (Date.now() / 1000).toFixed(6);\n\n log.logConnected();\n }\n\n getUser(userId: string): SlackUser | undefined {\n return this.users.get(userId);\n }\n\n getChannel(channelId: string): SlackChannel | undefined {\n return this.channels.get(channelId);\n }\n\n getAllUsers(): SlackUser[] {\n return Array.from(this.users.values());\n }\n\n getAllChannels(): SlackChannel[] {\n return Array.from(this.channels.values());\n }\n\n async postMessage(channel: string, text: string): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.chat.postMessage({ channel, text });\n return result.ts as string;\n });\n }\n\n async postEphemeral(channel: string, user: string, text: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.postEphemeral({ channel, user, text });\n });\n }\n\n async postPrivate(conversationId: string, userId: string, text: string): Promise<void> {\n await this.postEphemeral(conversationId, userId, text);\n }\n\n async openDirectMessage(userId: string): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.conversations.open({ users: userId });\n const channelId = result.channel?.id;\n if (!channelId) {\n throw new Error(`Failed to open DM for user ${userId}`);\n }\n return channelId;\n });\n }\n\n async updateMessage(channel: string, ts: string, text: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.update({ channel, ts, text });\n });\n }\n\n async deleteMessage(channel: string, ts: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.chat.delete({ channel, ts });\n });\n }\n\n // ==========================================================================\n // Slack Assistant API (AI assistant experience)\n // ==========================================================================\n\n /** Set the status for an assistant thread (shows \"thinking\" state) */\n async setAssistantStatus(channel: string, threadTs: string, status: string): Promise<void> {\n return slackRetry(async () => {\n await this.webClient.assistant.threads.setStatus({\n channel_id: channel,\n thread_ts: threadTs,\n status,\n });\n });\n }\n\n async postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n return slackRetry(async () => {\n // Use Block Kit section for long messages to trigger Slack's \"Show more\" collapsing (~700 chars)\n const SECTION_TEXT_LIMIT = 3000;\n if (text.length > 500) {\n const blockText =\n text.length > SECTION_TEXT_LIMIT\n ? text.substring(0, SECTION_TEXT_LIMIT - 20) + \"\\n_(truncated)_\"\n : text;\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // full text as notification fallback\n blocks: [{ type: \"section\", text: { type: \"mrkdwn\", text: blockText } }],\n });\n return result.ts as string;\n }\n const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n return result.ts as string;\n });\n }\n\n async postInThreadBlocks(\n channel: string,\n threadTs: string,\n text: string,\n blocks: object[],\n ): Promise<string> {\n return slackRetry(async () => {\n const result = await this.webClient.chat.postMessage({\n channel,\n thread_ts: threadTs,\n text, // fallback for notifications\n blocks: blocks as any,\n });\n return result.ts as string;\n });\n }\n\n async uploadFile(\n channel: string,\n filePath: string,\n title?: string,\n threadTs?: string,\n ): Promise<void> {\n return slackRetry(async () => {\n const fileName = title || basename(filePath);\n const fileContent = readFileSync(filePath);\n await this.webClient.files.uploadV2({\n channel_id: channel,\n file: fileContent,\n filename: fileName,\n title: fileName,\n ...(threadTs ? { thread_ts: threadTs } : {}),\n } as Parameters<typeof this.webClient.files.uploadV2>[0]);\n });\n }\n\n logToFile(channel: string, entry: object): void {\n appendChannelLog(this.workingDir, channel, entry);\n }\n\n logBotResponse(channel: string, text: string, ts: string, threadTs?: string): void {\n appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);\n }\n\n getPlatformInfo(): PlatformInfo {\n return {\n name: \"slack\",\n formattingGuide:\n \"## Slack Formatting (mrkdwn, NOT Markdown)\\nBold: *text*, Italic: _text_, Code: `code`, Block: ```code```, Links: <url|text>\\nDo NOT use **double asterisks** or [markdown](links).\",\n channels: this.getAllChannels().map((c) => ({ id: c.id, name: c.name })),\n users: this.getAllUsers().map((u) => ({\n id: u.id,\n userName: u.userName,\n displayName: u.displayName,\n })),\n diagnostics: {\n showUsageSummary: true,\n },\n };\n }\n\n // ==========================================================================\n // Events Integration\n // ==========================================================================\n\n /**\n * Enqueue an event for processing. Always queues (no \"already working\" rejection).\n * Returns true if enqueued, false if queue is full (max 5).\n */\n enqueueEvent(event: BotEvent): boolean {\n const conversationId = event.conversationId;\n const queue = this.getQueue(conversationId);\n if (queue.size() >= 5) {\n log.logWarning(\n `Event queue full for ${conversationId}, discarding: ${event.text.substring(0, 50)}`,\n );\n return false;\n }\n log.logInfo(`Enqueueing event for ${conversationId}: ${event.text.substring(0, 50)}`);\n queue.enqueue(() => {\n const slackEvent: SlackEvent = {\n type: event.type as SlackEvent[\"type\"],\n conversationId,\n conversationKind: event.conversationKind,\n channel: conversationId,\n ts: event.ts,\n thread_ts: event.thread_ts,\n user: event.user,\n text: event.text,\n attachments: event.attachments?.map((attachment) => ({\n original: attachment.name,\n localPath: attachment.localPath,\n })),\n sessionKey: event.sessionKey,\n };\n const adapters = createSlackAdapters(slackEvent, this, true);\n return this.handler.handleEvent(event, this, adapters, true);\n });\n return true;\n }\n\n // ==========================================================================\n // Private - Event Handlers\n // ==========================================================================\n\n private getQueue(channelId: string): ChannelQueue {\n let queue = this.queues.get(channelId);\n if (!queue) {\n queue = new ChannelQueue(\"Slack\");\n this.queues.set(channelId, queue);\n }\n return queue;\n }\n\n private resolveQueueKey(conversationId: string, sessionKey: string): string {\n if (!sessionKey.includes(\":\")) return sessionKey;\n\n return hasMaterializedSlackBranchSession(join(this.workingDir, conversationId), sessionKey)\n ? sessionKey\n : conversationId;\n }\n\n private shouldTriggerSharedThreadReply(channelId: string, threadTs?: string): boolean {\n if (!threadTs) return false;\n\n const sessionKey = resolveSlackSessionKey(channelId, threadTs);\n if (this.handler.isRunning(sessionKey)) return true;\n\n return hasMaterializedSlackBranchSession(join(this.workingDir, channelId), sessionKey);\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private buildHomeView(): { type: \"home\"; blocks: any[] } {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const blocks: any[] = [\n {\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${PRODUCT_NAME}*\\nStart a new task or check on running work.`,\n },\n accessory: {\n type: \"image\",\n image_url: \"https://media1.tenor.com/m/lfDATg4Bhc0AAAAC/happy-cat.gif\",\n alt_text: PRODUCT_NAME,\n },\n },\n ];\n\n // --- Running tasks ---\n const runningSessions = this.handler.getRunningSessions();\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Running Tasks (${runningSessions.length})`,\n emoji: true,\n },\n },\n );\n\n if (runningSessions.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No tasks running right now._\" }],\n });\n } else {\n // Threshold for \"stuck\" detection (10 minutes)\n const STUCK_THRESHOLD_MS = 10 * 60 * 1000;\n\n for (const session of runningSessions) {\n const channelId = session.sessionKey.split(\":\")[0];\n const channel = this.channels.get(channelId);\n const channelName = channel ? `#${channel.name}` : channelId;\n const elapsed = Math.floor((Date.now() - session.startedAt) / 60000);\n const elapsedStr = elapsed < 1 ? \"<1 min\" : `${elapsed} min`;\n\n // Check if task might be stuck\n const lastActivity = session.lastActivityAt ? Date.now() - session.lastActivityAt : 0;\n const isStuck = lastActivity > STUCK_THRESHOLD_MS;\n const statusText = isStuck ? \"_stuck_\" : \"_running_\";\n\n // Build status line: channel · status · time · step\n let statusLine = `${statusText} · ${elapsedStr}`;\n if (session.currentTool) {\n statusLine += ` · ${session.currentTool}`;\n }\n if (isStuck && lastActivity > 0) {\n const inactiveMin = Math.floor(lastActivity / 60000);\n statusLine += ` · idle ${inactiveMin}m`;\n }\n\n // Use context block for gray small text (like \"No scheduled jobs.\")\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: `*${channelName}* · ${statusLine}`,\n },\n ],\n });\n\n // Add Force Stop button as separate element if stuck\n if (isStuck) {\n blocks.push({\n type: \"context\",\n elements: [\n {\n type: \"mrkdwn\",\n text: \" \",\n },\n {\n type: \"button\",\n text: { type: \"plain_text\", text: \"Force Stop\", emoji: true },\n action_id: `force_stop_${session.sessionKey.replace(/:/g, \"_\")}`,\n style: \"danger\",\n },\n ],\n });\n }\n }\n }\n\n // --- Cron jobs ---\n const periodicEvents = this.eventsWatcher?.getPeriodicEvents() ?? [];\n\n blocks.push(\n { type: \"divider\" },\n {\n type: \"header\",\n text: {\n type: \"plain_text\",\n text: `Scheduled Jobs (${periodicEvents.length})`,\n emoji: true,\n },\n },\n );\n\n if (periodicEvents.length === 0) {\n blocks.push({\n type: \"context\",\n elements: [{ type: \"mrkdwn\", text: \"_No scheduled jobs._\" }],\n });\n } else {\n for (const ev of periodicEvents) {\n const channelLabel =\n ev.platform === \"slack\"\n ? (() => {\n const channel = this.channels.get(ev.conversationId);\n const channelName = channel ? `#${channel.name}` : ev.conversationId;\n return `${ev.platform}:${channelName}`;\n })()\n : `${ev.platform}:${ev.conversationId}`;\n const nextStr = ev.nextRun\n ? new Date(ev.nextRun).toLocaleString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n : \"—\";\n blocks.push({\n type: \"section\",\n text: {\n type: \"mrkdwn\",\n text: `*${ev.text}*\\n└ \\`${ev.schedule}\\` · ${channelLabel} · Next: ${nextStr}`,\n },\n });\n }\n }\n\n // --- Footer ---\n blocks.push(\n { type: \"divider\" },\n {\n type: \"context\",\n elements: [\n { type: \"mrkdwn\", text: \"💡 @mention in a channel or send a DM to start a new task\" },\n ],\n },\n );\n\n return { type: \"home\", blocks };\n }\n\n private resolveStopTarget(channelId: string, threadTs?: string): string | null {\n const directTarget = resolveStopTarget({\n handler: this.handler,\n conversationId: channelId,\n sessionKey: threadTs ? resolveSlackSessionKey(channelId, threadTs) : undefined,\n });\n if (directTarget) return directTarget;\n if (threadTs) return null;\n return resolveOnlyScopedStopTarget(this.handler, channelId);\n }\n\n private createCommandAdapters(\n conversationId: string,\n userId: string,\n userName: string | undefined,\n text: string,\n ts: string,\n options: { ephemeralChannelId?: string } = {},\n ): BotAdapters {\n const message: ChatMessage = {\n id: ts,\n sessionKey: conversationId,\n conversationKind: options.ephemeralChannelId ? \"shared\" : \"direct\",\n userId,\n userName,\n text,\n attachments: [],\n };\n\n const respond = async (responseText: string) => {\n if (options.ephemeralChannelId) {\n await this.postEphemeral(options.ephemeralChannelId, userId, responseText);\n return;\n }\n const messageTs = await this.postMessage(conversationId, responseText);\n this.logBotResponse(conversationId, responseText, messageTs);\n };\n\n const responseCtx: ChatResponseContext = {\n respond,\n replaceResponse: respond,\n respondDiagnostic: respond,\n respondToolResult: async (result: ChatToolResult) => {\n const duration = (result.durationMs / 1000).toFixed(1);\n await respond(\n `${result.isError ? \"Error\" : \"Done\"} ${result.toolName} (${duration}s)\\n${result.result}`,\n );\n },\n setTyping: async () => {},\n setWorking: async () => {},\n uploadFile: async (filePath: string, title?: string) => {\n await this.uploadFile(conversationId, filePath, title);\n },\n deleteResponse: async () => {},\n };\n\n return {\n message,\n responseCtx,\n platform: this.getPlatformInfo(),\n };\n }\n\n private createSlashCommandBot(conversationId: string, threadTs?: string): Bot {\n return {\n start: async () => {},\n postMessage: async (_channel: string, text: string) => {\n if (threadTs) {\n return this.postInThread(conversationId, threadTs, text);\n }\n return this.postMessage(conversationId, text);\n },\n updateMessage: async (channel: string, ts: string, text: string) => {\n await this.updateMessage(channel, ts, text);\n },\n enqueueEvent: (event: BotEvent) => this.enqueueEvent(event),\n getPlatformInfo: () => this.getPlatformInfo(),\n };\n }\n\n private async routeSlashLoginCommand(payload: {\n command: string;\n text?: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const commandSuffix = payload.text?.trim();\n const commandText = commandSuffix ? `${payload.command} ${commandSuffix}` : payload.command;\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const sourceChannelId = payload.channel_id;\n const isDirectMessage = sourceChannelId.startsWith(\"D\");\n const targetChannelId = isDirectMessage\n ? sourceChannelId\n : await this.openDirectMessage(payload.user_id);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n\n this.logToFile(targetChannelId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n if (!isDirectMessage) {\n await this.postEphemeral(\n sourceChannelId,\n payload.user_id,\n `我已私訊你 ${PRODUCT_NAME} 的登入連結,請到私訊完成設定。`,\n );\n }\n\n const event: BotEvent = {\n type: \"dm\",\n conversationId: targetChannelId,\n ...(isDirectMessage ? {} : { vaultConversationId: sourceChannelId }),\n conversationKind: \"direct\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n sessionKey: targetChannelId,\n };\n\n const adapters = this.createCommandAdapters(\n targetChannelId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n );\n\n await this.handler.handleEvent(event, this, adapters, false);\n }\n\n private async routeSlashNewCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n if (!conversationId.startsWith(\"D\")) {\n await this.postEphemeral(\n conversationId,\n payload.user_id,\n `為了避免誤清除共享上下文,${payload.command} 目前只能在與 ${PRODUCT_NAME} 的私訊中使用。`,\n );\n return;\n }\n\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: payload.command,\n attachments: [],\n isBot: false,\n });\n\n const commandBot = this.createSlashCommandBot(conversationId);\n await this.handler.handleNew(conversationId, conversationId, commandBot);\n }\n\n private async routeSlashSessionCommand(payload: {\n command: string;\n channel_id: string;\n user_id: string;\n user_name?: string;\n }): Promise<void> {\n const conversationId = payload.channel_id;\n const isDirectMessage = conversationId.startsWith(\"D\");\n const createdAt = new Date();\n const eventTs = (createdAt.getTime() / 1000).toFixed(6);\n const userName = payload.user_name ?? this.getUser(payload.user_id)?.userName;\n const commandText = payload.command;\n\n this.logToFile(conversationId, {\n date: createdAt.toISOString(),\n ts: eventTs,\n user: payload.user_id,\n userName,\n text: commandText,\n attachments: [],\n isBot: false,\n });\n\n const sessionKey = conversationId;\n const event: BotEvent = {\n type: \"dm\",\n conversationId,\n conversationKind: isDirectMessage ? \"direct\" : \"shared\",\n ts: eventTs,\n user: payload.user_id,\n text: commandText,\n attachments: [],\n sessionKey,\n };\n\n const adapters = this.createCommandAdapters(\n conversationId,\n payload.user_id,\n userName,\n commandText,\n eventTs,\n isDirectMessage ? {} : { ephemeralChannelId: conversationId },\n );\n\n await this.handler.handleEvent(event, this, adapters, false);\n }\n\n private setupEventHandlers(): void {\n // Channel @mentions\n this.socketClient.on(\"app_mention\", ({ event, ack }) => {\n const e = event as {\n text: string;\n channel: string;\n user: string;\n ts: string;\n thread_ts?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip DMs (handled by message event)\n if (e.channel.startsWith(\"D\")) {\n ack();\n return;\n }\n\n // Top-level mentions use a persistent channel session.\n // Thread replies get their own isolated session (channelId:thread_ts).\n const sessionKey = resolveSlackSessionKey(e.channel, e.thread_ts);\n\n const slackEvent: SlackEvent = {\n type: \"mention\",\n conversationId: e.channel,\n conversationKind: \"shared\",\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: e.text.replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n files: e.files,\n sessionKey,\n };\n\n const attachmentsPromise = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,\n );\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Check for stop command - execute immediately, don't queue!\n if (slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n this.getQueue(this.resolveQueueKey(e.channel, sessionKey)).enqueue(async () => {\n slackEvent.attachments = await attachmentsPromise;\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n false,\n );\n });\n\n ack();\n });\n\n // All messages (for logging) + DMs (for triggering)\n this.socketClient.on(\"message\", ({ event, ack }) => {\n const e = event as {\n text?: string;\n channel: string;\n user?: string;\n ts: string;\n thread_ts?: string;\n channel_type?: string;\n subtype?: string;\n bot_id?: string;\n files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n };\n\n // Skip bot messages, edits, etc.\n if (e.bot_id || !e.user || e.user === this.botUserId) {\n ack();\n return;\n }\n if (e.subtype !== undefined && e.subtype !== \"file_share\") {\n ack();\n return;\n }\n if (!e.text && (!e.files || e.files.length === 0)) {\n ack();\n return;\n }\n\n const isDM = e.channel_type === \"im\";\n const conversationKind: ConversationKind = isDM ? \"direct\" : \"shared\";\n const isBotMention = e.text?.includes(`<@${this.botUserId}>`);\n\n // Skip channel @mentions - already handled by app_mention event\n if (!isDM && isBotMention) {\n ack();\n return;\n }\n\n const isSharedThreadReply =\n !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);\n const sessionKey =\n isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;\n\n const slackEvent: SlackEvent = {\n type: isDM ? \"dm\" : \"mention\",\n conversationId: e.channel,\n conversationKind,\n channel: e.channel,\n ts: e.ts,\n thread_ts: e.thread_ts,\n user: e.user,\n text: (e.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n files: e.files,\n sessionKey,\n };\n\n const attachmentsPromise = this.logUserMessage(slackEvent);\n\n // Only trigger processing for messages AFTER startup (not replayed old messages)\n if (this.startupTs && e.ts < this.startupTs) {\n log.logInfo(\n `[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`,\n );\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Check for stop command in channel threads (without @mention)\n // app_mention handles \"@mama stop\", but bare \"stop\" in a thread comes here\n if (!isDM && e.thread_ts && slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this);\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n // Trigger handler for DMs and bare replies inside shared-channel threads.\n if (isDM || isSharedThreadReply) {\n const activeSessionKey =\n slackEvent.sessionKey ?? resolveSlackSessionKey(e.channel, e.thread_ts);\n // Check for stop command - execute immediately, don't queue!\n if (slackEvent.text.toLowerCase().trim() === \"stop\") {\n const stopTarget = this.resolveStopTarget(e.channel, e.thread_ts);\n if (stopTarget) {\n this.handler.handleStop(stopTarget, e.channel, this); // Don't await, don't queue\n } else {\n this.postMessage(e.channel, formatNothingRunning(\"slack\"));\n }\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n ack();\n return;\n }\n\n this.getQueue(this.resolveQueueKey(e.channel, activeSessionKey)).enqueue(async () => {\n slackEvent.attachments = await attachmentsPromise;\n const adapters = createSlackAdapters(slackEvent, this, false);\n return this.handler.handleEvent(\n slackEvent as unknown as import(\"../../adapter.js\").BotEvent,\n this,\n adapters,\n false,\n );\n });\n } else {\n void attachmentsPromise.catch((err) => {\n log.logWarning(\"Failed to log Slack message\", String(err));\n });\n }\n\n ack();\n });\n\n this.socketClient.on(\"slash_commands\", async ({ body, ack }) => {\n const payload = body as {\n command?: string;\n text?: string;\n channel_id?: string;\n user_id?: string;\n user_name?: string;\n };\n\n await ack();\n\n if (!payload.command || !payload.channel_id || !payload.user_id) {\n return;\n }\n\n const handlerPromise =\n payload.command === \"/pi-login\"\n ? this.routeSlashLoginCommand({\n command: payload.command,\n text: payload.text,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-new\"\n ? this.routeSlashNewCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : payload.command === \"/pi-session\"\n ? this.routeSlashSessionCommand({\n command: payload.command,\n channel_id: payload.channel_id,\n user_id: payload.user_id,\n user_name: payload.user_name,\n })\n : null;\n\n if (!handlerPromise) {\n return;\n }\n\n handlerPromise.catch((err) => {\n log.logWarning(\n \"Slack slash command error\",\n err instanceof Error ? err.message : String(err),\n );\n });\n });\n\n // App Home tab\n this.socketClient.on(\"app_home_opened\", ({ event, ack }) => {\n const e = event as { user: string; tab: string };\n ack();\n if (e.tab !== \"home\") return;\n\n this.webClient.views\n .publish({\n user_id: e.user,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to publish App Home view`, String(err));\n });\n });\n\n // Handle button clicks (Force Stop)\n this.socketClient.on(\"block_actions\", async ({ body, ack }) => {\n const action = body.actions?.[0];\n if (!action || !action.action_id?.startsWith(\"force_stop_\")) {\n ack();\n return;\n }\n\n ack();\n const sessionKey = action.action_id.replace(\"force_stop_\", \"\").replace(/_/g, \":\");\n const userId = body.user?.id;\n const channelId = body.container?.channel_id || sessionKey.split(\":\")[0];\n\n log.logInfo(`[Force Stop] User ${userId} requested force stop for ${sessionKey}`);\n\n // Use handler's forceStop method\n this.handler.forceStop(sessionKey);\n\n // Notify in channel\n await this.postMessage(channelId, formatForceStopped(\"slack\", userId ?? \"unknown\"));\n\n // Refresh home tab\n if (userId) {\n this.webClient.views\n .publish({\n user_id: userId,\n view: this.buildHomeView(),\n })\n .catch((err) => {\n log.logWarning(`Failed to refresh App Home view`, String(err));\n });\n }\n });\n }\n\n /**\n * Log a user message to log.jsonl after attachments are ready.\n */\n private async logUserMessage(event: SlackEvent): Promise<Attachment[]> {\n const user = this.users.get(event.user);\n const attachments = event.files\n ? await this.store.processAttachments(event.channel, event.files, event.ts)\n : [];\n this.logToFile(event.channel, {\n date: new Date(parseFloat(event.ts) * 1000).toISOString(),\n ts: event.ts,\n threadTs: event.thread_ts,\n user: event.user,\n userName: user?.userName,\n displayName: user?.displayName,\n text: event.text,\n attachments,\n isBot: false,\n });\n return attachments;\n }\n\n // ==========================================================================\n // Private - Backfill\n // ==========================================================================\n\n private async getExistingTimestamps(channelId: string): Promise<Set<string>> {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const timestamps = new Set<string>();\n if (!existsSync(logPath)) return timestamps;\n\n const content = await readFile(logPath, \"utf-8\");\n const lines = content.trim().split(\"\\n\").filter(Boolean);\n for (let i = 0; i < lines.length; i++) {\n try {\n const entry = JSON.parse(lines[i]);\n if (entry.ts) timestamps.add(entry.ts);\n } catch (err) {\n log.logWarning(\n `Skipping malformed log entry at ${logPath}:${i + 1}`,\n err instanceof Error ? err.message : String(err),\n );\n }\n }\n return timestamps;\n }\n\n private async backfillChannel(channelId: string): Promise<number> {\n const existingTs = await this.getExistingTimestamps(channelId);\n\n // Find the biggest ts in log.jsonl\n let latestTs: string | undefined;\n for (const ts of existingTs) {\n if (!latestTs || parseFloat(ts) > parseFloat(latestTs)) latestTs = ts;\n }\n\n type Message = {\n user?: string;\n bot_id?: string;\n text?: string;\n ts?: string;\n thread_ts?: string;\n subtype?: string;\n files?: Array<{ name: string }>;\n };\n const allMessages: Message[] = [];\n\n let cursor: string | undefined;\n let pageCount = 0;\n const maxPages = 3;\n\n do {\n const result = await this.webClient.conversations.history({\n channel: channelId,\n oldest: latestTs, // Only fetch messages newer than what we have\n inclusive: false,\n limit: 1000,\n cursor,\n });\n if (result.messages) {\n allMessages.push(...(result.messages as Message[]));\n }\n cursor = result.response_metadata?.next_cursor;\n pageCount++;\n } while (cursor && pageCount < maxPages);\n\n // Filter: include mama's messages, exclude other bots, skip already logged\n const relevantMessages = allMessages.filter((msg) => {\n if (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates\n if (msg.user === this.botUserId) return true;\n if (msg.bot_id) return false;\n if (msg.subtype !== undefined && msg.subtype !== \"file_share\") return false;\n if (!msg.user) return false;\n if (!msg.text && (!msg.files || msg.files.length === 0)) return false;\n return true;\n });\n\n // Reverse to chronological order\n relevantMessages.reverse();\n\n // Log each message to log.jsonl\n for (const msg of relevantMessages) {\n const isMamaMessage = msg.user === this.botUserId;\n const user = this.users.get(msg.user!);\n // Strip @mentions from text (same as live messages)\n const text = (msg.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim();\n const attachments = msg.files\n ? await this.store.processAttachments(channelId, msg.files, msg.ts!)\n : [];\n\n this.logToFile(channelId, {\n date: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n ts: msg.ts!,\n threadTs: msg.thread_ts,\n user: isMamaMessage ? \"bot\" : msg.user!,\n userName: isMamaMessage ? undefined : user?.userName,\n displayName: isMamaMessage ? undefined : user?.displayName,\n text,\n attachments,\n isBot: isMamaMessage,\n });\n }\n\n return relevantMessages.length;\n }\n\n private async backfillAllChannels(): Promise<void> {\n const startTime = Date.now();\n\n // Only backfill channels that already have a log.jsonl (mama has interacted with them before)\n const channelsToBackfill: Array<[string, SlackChannel]> = [];\n for (const [channelId, channel] of this.channels) {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n if (existsSync(logPath)) {\n channelsToBackfill.push([channelId, channel]);\n }\n }\n\n log.logBackfillStart(channelsToBackfill.length);\n\n let totalMessages = 0;\n for (const [channelId, channel] of channelsToBackfill) {\n try {\n const count = await this.backfillChannel(channelId);\n if (count > 0) log.logBackfillChannel(channel.name, count);\n totalMessages += count;\n } catch (error) {\n log.logWarning(`Failed to backfill #${channel.name}`, String(error));\n }\n\n // Add delay between channels to avoid hitting Slack rate limits\n if (channelId !== channelsToBackfill[channelsToBackfill.length - 1][0]) {\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n }\n\n const durationMs = Date.now() - startTime;\n log.logBackfillComplete(totalMessages, durationMs);\n }\n\n // ==========================================================================\n // Private - Fetch Users/Channels\n // ==========================================================================\n\n private async fetchUsers(): Promise<void> {\n let cursor: string | undefined;\n do {\n const result = await this.webClient.users.list({ limit: 200, cursor });\n const members = result.members as\n | Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n | undefined;\n if (members) {\n for (const u of members) {\n if (u.id && u.name && !u.deleted) {\n this.users.set(u.id, {\n id: u.id,\n userName: u.name,\n displayName: u.real_name || u.name,\n });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n\n private async fetchChannels(): Promise<void> {\n // Fetch public/private channels\n let cursor: string | undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"public_channel,private_channel\",\n exclude_archived: true,\n limit: 200,\n cursor,\n });\n const channels = result.channels as\n | Array<{ id?: string; name?: string; is_member?: boolean }>\n | undefined;\n if (channels) {\n for (const c of channels) {\n if (c.id && c.name && c.is_member) {\n this.channels.set(c.id, { id: c.id, name: c.name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n\n // Also fetch DM channels (IMs)\n cursor = undefined;\n do {\n const result = await this.webClient.conversations.list({\n types: \"im\",\n limit: 200,\n cursor,\n });\n const ims = result.channels as Array<{ id?: string; user?: string }> | undefined;\n if (ims) {\n for (const im of ims) {\n if (im.id) {\n // Use user's name as channel name for DMs\n const user = im.user ? this.users.get(im.user) : undefined;\n const name = user ? `DM:${user.userName}` : `DM:${im.id}`;\n this.channels.set(im.id, { id: im.id, name });\n }\n }\n }\n cursor = result.response_metadata?.next_cursor;\n } while (cursor);\n }\n}\n"]}
|
|
@@ -1,81 +1,24 @@
|
|
|
1
1
|
import { SocketModeClient } from "@slack/socket-mode";
|
|
2
2
|
import { WebClient } from "@slack/web-api";
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
4
|
import { readFile } from "fs/promises";
|
|
5
5
|
import { basename, join } from "path";
|
|
6
6
|
import * as log from "../../log.js";
|
|
7
7
|
import { PRODUCT_NAME, formatForceStopped, formatNothingRunning } from "../../ui-copy.js";
|
|
8
|
+
import { appendBotResponseLog, appendChannelLog, ChannelQueue, resolveOnlyScopedStopTarget, resolveStopTarget, withRetry, } from "../shared.js";
|
|
9
|
+
// Slack WebClient errors carry either `code: "rate_limited"` (retry-after) or
|
|
10
|
+
// the legacy `data.error === "rate_limited"` / 429 status shape.
|
|
11
|
+
function slackIsRateLimited(err) {
|
|
12
|
+
if (err.code === "rate_limited")
|
|
13
|
+
return true;
|
|
14
|
+
const data = err.data;
|
|
15
|
+
return data?.error === "rate_limited" || data?.response?.status === 429;
|
|
16
|
+
}
|
|
17
|
+
const slackRetry = (fn) => withRetry(fn, { isRateLimited: slackIsRateLimited });
|
|
8
18
|
import { createSlackAdapters } from "./context.js";
|
|
9
19
|
import { hasMaterializedSlackBranchSession } from "./branch-manager.js";
|
|
10
20
|
import { resolveSlackSessionKey } from "./session.js";
|
|
11
21
|
// ============================================================================
|
|
12
|
-
// Exponential backoff utility for Slack API calls
|
|
13
|
-
// ============================================================================
|
|
14
|
-
/**
|
|
15
|
-
* Retry a function with exponential backoff on rate limit errors.
|
|
16
|
-
*/
|
|
17
|
-
async function withRetry(fn, maxRetries = 3, baseDelayMs = 1000) {
|
|
18
|
-
let lastError;
|
|
19
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
20
|
-
try {
|
|
21
|
-
return await fn();
|
|
22
|
-
}
|
|
23
|
-
catch (err) {
|
|
24
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
25
|
-
// Check for rate limit errors
|
|
26
|
-
let isRateLimited = false;
|
|
27
|
-
// Check for rate_limited error code (Slack SDK)
|
|
28
|
-
if ("code" in lastError && lastError.code === "rate_limited") {
|
|
29
|
-
isRateLimited = true;
|
|
30
|
-
}
|
|
31
|
-
// Check for rate_limited in error response
|
|
32
|
-
if ("data" in lastError) {
|
|
33
|
-
const data = lastError
|
|
34
|
-
.data;
|
|
35
|
-
if (data?.error === "rate_limited" || data?.response?.status === 429) {
|
|
36
|
-
isRateLimited = true;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
if (isRateLimited) {
|
|
40
|
-
const delay = baseDelayMs * Math.pow(2, attempt);
|
|
41
|
-
log.logWarning(`Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
|
|
42
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
// Non-retryable error
|
|
46
|
-
throw lastError;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
throw lastError;
|
|
50
|
-
}
|
|
51
|
-
class ChannelQueue {
|
|
52
|
-
constructor() {
|
|
53
|
-
this.queue = [];
|
|
54
|
-
this.processing = false;
|
|
55
|
-
}
|
|
56
|
-
enqueue(work) {
|
|
57
|
-
this.queue.push(work);
|
|
58
|
-
this.processNext();
|
|
59
|
-
}
|
|
60
|
-
size() {
|
|
61
|
-
return this.queue.length;
|
|
62
|
-
}
|
|
63
|
-
async processNext() {
|
|
64
|
-
if (this.processing || this.queue.length === 0)
|
|
65
|
-
return;
|
|
66
|
-
this.processing = true;
|
|
67
|
-
const work = this.queue.shift();
|
|
68
|
-
try {
|
|
69
|
-
await work();
|
|
70
|
-
}
|
|
71
|
-
catch (err) {
|
|
72
|
-
log.logWarning("Queue error", err instanceof Error ? err.message : String(err));
|
|
73
|
-
}
|
|
74
|
-
this.processing = false;
|
|
75
|
-
this.processNext();
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
// ============================================================================
|
|
79
22
|
// SlackBot
|
|
80
23
|
// ============================================================================
|
|
81
24
|
export class SlackBot {
|
|
@@ -123,18 +66,21 @@ export class SlackBot {
|
|
|
123
66
|
return Array.from(this.channels.values());
|
|
124
67
|
}
|
|
125
68
|
async postMessage(channel, text) {
|
|
126
|
-
return
|
|
69
|
+
return slackRetry(async () => {
|
|
127
70
|
const result = await this.webClient.chat.postMessage({ channel, text });
|
|
128
71
|
return result.ts;
|
|
129
72
|
});
|
|
130
73
|
}
|
|
131
74
|
async postEphemeral(channel, user, text) {
|
|
132
|
-
return
|
|
75
|
+
return slackRetry(async () => {
|
|
133
76
|
await this.webClient.chat.postEphemeral({ channel, user, text });
|
|
134
77
|
});
|
|
135
78
|
}
|
|
79
|
+
async postPrivate(conversationId, userId, text) {
|
|
80
|
+
await this.postEphemeral(conversationId, userId, text);
|
|
81
|
+
}
|
|
136
82
|
async openDirectMessage(userId) {
|
|
137
|
-
return
|
|
83
|
+
return slackRetry(async () => {
|
|
138
84
|
const result = await this.webClient.conversations.open({ users: userId });
|
|
139
85
|
const channelId = result.channel?.id;
|
|
140
86
|
if (!channelId) {
|
|
@@ -144,12 +90,12 @@ export class SlackBot {
|
|
|
144
90
|
});
|
|
145
91
|
}
|
|
146
92
|
async updateMessage(channel, ts, text) {
|
|
147
|
-
return
|
|
93
|
+
return slackRetry(async () => {
|
|
148
94
|
await this.webClient.chat.update({ channel, ts, text });
|
|
149
95
|
});
|
|
150
96
|
}
|
|
151
97
|
async deleteMessage(channel, ts) {
|
|
152
|
-
return
|
|
98
|
+
return slackRetry(async () => {
|
|
153
99
|
await this.webClient.chat.delete({ channel, ts });
|
|
154
100
|
});
|
|
155
101
|
}
|
|
@@ -158,7 +104,7 @@ export class SlackBot {
|
|
|
158
104
|
// ==========================================================================
|
|
159
105
|
/** Set the status for an assistant thread (shows "thinking" state) */
|
|
160
106
|
async setAssistantStatus(channel, threadTs, status) {
|
|
161
|
-
return
|
|
107
|
+
return slackRetry(async () => {
|
|
162
108
|
await this.webClient.assistant.threads.setStatus({
|
|
163
109
|
channel_id: channel,
|
|
164
110
|
thread_ts: threadTs,
|
|
@@ -167,7 +113,7 @@ export class SlackBot {
|
|
|
167
113
|
});
|
|
168
114
|
}
|
|
169
115
|
async postInThread(channel, threadTs, text) {
|
|
170
|
-
return
|
|
116
|
+
return slackRetry(async () => {
|
|
171
117
|
// Use Block Kit section for long messages to trigger Slack's "Show more" collapsing (~700 chars)
|
|
172
118
|
const SECTION_TEXT_LIMIT = 3000;
|
|
173
119
|
if (text.length > 500) {
|
|
@@ -187,7 +133,7 @@ export class SlackBot {
|
|
|
187
133
|
});
|
|
188
134
|
}
|
|
189
135
|
async postInThreadBlocks(channel, threadTs, text, blocks) {
|
|
190
|
-
return
|
|
136
|
+
return slackRetry(async () => {
|
|
191
137
|
const result = await this.webClient.chat.postMessage({
|
|
192
138
|
channel,
|
|
193
139
|
thread_ts: threadTs,
|
|
@@ -198,7 +144,7 @@ export class SlackBot {
|
|
|
198
144
|
});
|
|
199
145
|
}
|
|
200
146
|
async uploadFile(channel, filePath, title, threadTs) {
|
|
201
|
-
return
|
|
147
|
+
return slackRetry(async () => {
|
|
202
148
|
const fileName = title || basename(filePath);
|
|
203
149
|
const fileContent = readFileSync(filePath);
|
|
204
150
|
await this.webClient.files.uploadV2({
|
|
@@ -210,29 +156,11 @@ export class SlackBot {
|
|
|
210
156
|
});
|
|
211
157
|
});
|
|
212
158
|
}
|
|
213
|
-
/**
|
|
214
|
-
* Log a message to log.jsonl (SYNC)
|
|
215
|
-
* This is the ONLY place messages are written to log.jsonl
|
|
216
|
-
*/
|
|
217
159
|
logToFile(channel, entry) {
|
|
218
|
-
|
|
219
|
-
if (!existsSync(dir))
|
|
220
|
-
mkdirSync(dir, { recursive: true });
|
|
221
|
-
appendFileSync(join(dir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
|
|
160
|
+
appendChannelLog(this.workingDir, channel, entry);
|
|
222
161
|
}
|
|
223
|
-
/**
|
|
224
|
-
* Log a bot response to log.jsonl
|
|
225
|
-
*/
|
|
226
162
|
logBotResponse(channel, text, ts, threadTs) {
|
|
227
|
-
this.
|
|
228
|
-
date: new Date().toISOString(),
|
|
229
|
-
ts,
|
|
230
|
-
threadTs,
|
|
231
|
-
user: "bot",
|
|
232
|
-
text,
|
|
233
|
-
attachments: [],
|
|
234
|
-
isBot: true,
|
|
235
|
-
});
|
|
163
|
+
appendBotResponseLog(this.workingDir, channel, text, ts, threadTs);
|
|
236
164
|
}
|
|
237
165
|
getPlatformInfo() {
|
|
238
166
|
return {
|
|
@@ -291,7 +219,7 @@ export class SlackBot {
|
|
|
291
219
|
getQueue(channelId) {
|
|
292
220
|
let queue = this.queues.get(channelId);
|
|
293
221
|
if (!queue) {
|
|
294
|
-
queue = new ChannelQueue();
|
|
222
|
+
queue = new ChannelQueue("Slack");
|
|
295
223
|
this.queues.set(channelId, queue);
|
|
296
224
|
}
|
|
297
225
|
return queue;
|
|
@@ -303,6 +231,14 @@ export class SlackBot {
|
|
|
303
231
|
? sessionKey
|
|
304
232
|
: conversationId;
|
|
305
233
|
}
|
|
234
|
+
shouldTriggerSharedThreadReply(channelId, threadTs) {
|
|
235
|
+
if (!threadTs)
|
|
236
|
+
return false;
|
|
237
|
+
const sessionKey = resolveSlackSessionKey(channelId, threadTs);
|
|
238
|
+
if (this.handler.isRunning(sessionKey))
|
|
239
|
+
return true;
|
|
240
|
+
return hasMaterializedSlackBranchSession(join(this.workingDir, channelId), sessionKey);
|
|
241
|
+
}
|
|
306
242
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
307
243
|
buildHomeView() {
|
|
308
244
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -439,76 +375,19 @@ export class SlackBot {
|
|
|
439
375
|
});
|
|
440
376
|
return { type: "home", blocks };
|
|
441
377
|
}
|
|
442
|
-
/**
|
|
443
|
-
* Resolve which session key to stop.
|
|
444
|
-
* When stop is called from a thread, the thread session (channelId:thread_ts) might
|
|
445
|
-
* not be running — but the channel session (channelId) might be, because the bot's
|
|
446
|
-
* reply to a top-level mention creates a thread. Check both, prefer thread first.
|
|
447
|
-
*
|
|
448
|
-
* For top-level stop requests, fall back to the only running scoped thread session in
|
|
449
|
-
* this conversation when there is exactly one candidate. This keeps DM/channel stop
|
|
450
|
-
* useful after thread sessions were introduced without guessing between multiple threads.
|
|
451
|
-
*/
|
|
452
378
|
resolveStopTarget(channelId, threadTs) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
379
|
+
const directTarget = resolveStopTarget({
|
|
380
|
+
handler: this.handler,
|
|
381
|
+
conversationId: channelId,
|
|
382
|
+
sessionKey: threadTs ? resolveSlackSessionKey(channelId, threadTs) : undefined,
|
|
383
|
+
});
|
|
384
|
+
if (directTarget)
|
|
385
|
+
return directTarget;
|
|
386
|
+
if (threadTs)
|
|
459
387
|
return null;
|
|
460
|
-
|
|
461
|
-
if (this.handler.isRunning(channelId))
|
|
462
|
-
return channelId;
|
|
463
|
-
const runningInConversation = this.handler
|
|
464
|
-
.getRunningSessions()
|
|
465
|
-
.map((session) => session.sessionKey)
|
|
466
|
-
.filter((sessionKey) => sessionKey.startsWith(`${channelId}:`));
|
|
467
|
-
return runningInConversation.length === 1 ? runningInConversation[0] : null;
|
|
388
|
+
return resolveOnlyScopedStopTarget(this.handler, channelId);
|
|
468
389
|
}
|
|
469
|
-
|
|
470
|
-
const message = {
|
|
471
|
-
id: ts,
|
|
472
|
-
sessionKey: conversationId,
|
|
473
|
-
conversationKind: "direct",
|
|
474
|
-
userId,
|
|
475
|
-
userName,
|
|
476
|
-
text,
|
|
477
|
-
attachments: [],
|
|
478
|
-
};
|
|
479
|
-
const responseCtx = {
|
|
480
|
-
respond: async (responseText) => {
|
|
481
|
-
const messageTs = await this.postMessage(conversationId, responseText);
|
|
482
|
-
this.logBotResponse(conversationId, responseText, messageTs);
|
|
483
|
-
},
|
|
484
|
-
replaceResponse: async (responseText) => {
|
|
485
|
-
const messageTs = await this.postMessage(conversationId, responseText);
|
|
486
|
-
this.logBotResponse(conversationId, responseText, messageTs);
|
|
487
|
-
},
|
|
488
|
-
respondDiagnostic: async (responseText) => {
|
|
489
|
-
const messageTs = await this.postMessage(conversationId, responseText);
|
|
490
|
-
this.logBotResponse(conversationId, responseText, messageTs);
|
|
491
|
-
},
|
|
492
|
-
respondToolResult: async (result) => {
|
|
493
|
-
const duration = (result.durationMs / 1000).toFixed(1);
|
|
494
|
-
const text = `${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`;
|
|
495
|
-
const messageTs = await this.postMessage(conversationId, text);
|
|
496
|
-
this.logBotResponse(conversationId, text, messageTs);
|
|
497
|
-
},
|
|
498
|
-
setTyping: async () => { },
|
|
499
|
-
setWorking: async () => { },
|
|
500
|
-
uploadFile: async (filePath, title) => {
|
|
501
|
-
await this.uploadFile(conversationId, filePath, title);
|
|
502
|
-
},
|
|
503
|
-
deleteResponse: async () => { },
|
|
504
|
-
};
|
|
505
|
-
return {
|
|
506
|
-
message,
|
|
507
|
-
responseCtx,
|
|
508
|
-
platform: this.getPlatformInfo(),
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
createPrivateSessionCommandAdapters(conversationId, userId, userName, text, ts, options) {
|
|
390
|
+
createCommandAdapters(conversationId, userId, userName, text, ts, options = {}) {
|
|
512
391
|
const message = {
|
|
513
392
|
id: ts,
|
|
514
393
|
sessionKey: conversationId,
|
|
@@ -518,8 +397,7 @@ export class SlackBot {
|
|
|
518
397
|
text,
|
|
519
398
|
attachments: [],
|
|
520
399
|
};
|
|
521
|
-
|
|
522
|
-
const respondPrivately = async (responseText) => {
|
|
400
|
+
const respond = async (responseText) => {
|
|
523
401
|
if (options.ephemeralChannelId) {
|
|
524
402
|
await this.postEphemeral(options.ephemeralChannelId, userId, responseText);
|
|
525
403
|
return;
|
|
@@ -528,23 +406,12 @@ export class SlackBot {
|
|
|
528
406
|
this.logBotResponse(conversationId, responseText, messageTs);
|
|
529
407
|
};
|
|
530
408
|
const responseCtx = {
|
|
531
|
-
respond
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
},
|
|
535
|
-
replaceResponse: async (responseText) => {
|
|
536
|
-
if (!hasResponded) {
|
|
537
|
-
hasResponded = true;
|
|
538
|
-
}
|
|
539
|
-
await respondPrivately(responseText);
|
|
540
|
-
},
|
|
541
|
-
respondDiagnostic: async (responseText) => {
|
|
542
|
-
await respondPrivately(responseText);
|
|
543
|
-
},
|
|
409
|
+
respond,
|
|
410
|
+
replaceResponse: respond,
|
|
411
|
+
respondDiagnostic: respond,
|
|
544
412
|
respondToolResult: async (result) => {
|
|
545
413
|
const duration = (result.durationMs / 1000).toFixed(1);
|
|
546
|
-
|
|
547
|
-
await respondPrivately(formatted);
|
|
414
|
+
await respond(`${result.isError ? "Error" : "Done"} ${result.toolName} (${duration}s)\n${result.result}`);
|
|
548
415
|
},
|
|
549
416
|
setTyping: async () => { },
|
|
550
417
|
setWorking: async () => { },
|
|
@@ -601,6 +468,7 @@ export class SlackBot {
|
|
|
601
468
|
const event = {
|
|
602
469
|
type: "dm",
|
|
603
470
|
conversationId: targetChannelId,
|
|
471
|
+
...(isDirectMessage ? {} : { vaultConversationId: sourceChannelId }),
|
|
604
472
|
conversationKind: "direct",
|
|
605
473
|
ts: eventTs,
|
|
606
474
|
user: payload.user_id,
|
|
@@ -608,7 +476,7 @@ export class SlackBot {
|
|
|
608
476
|
attachments: [],
|
|
609
477
|
sessionKey: targetChannelId,
|
|
610
478
|
};
|
|
611
|
-
const adapters = this.
|
|
479
|
+
const adapters = this.createCommandAdapters(targetChannelId, payload.user_id, userName, commandText, eventTs);
|
|
612
480
|
await this.handler.handleEvent(event, this, adapters, false);
|
|
613
481
|
}
|
|
614
482
|
async routeSlashNewCommand(payload) {
|
|
@@ -659,7 +527,7 @@ export class SlackBot {
|
|
|
659
527
|
attachments: [],
|
|
660
528
|
sessionKey,
|
|
661
529
|
};
|
|
662
|
-
const adapters = this.
|
|
530
|
+
const adapters = this.createCommandAdapters(conversationId, payload.user_id, userName, commandText, eventTs, isDirectMessage ? {} : { ephemeralChannelId: conversationId });
|
|
663
531
|
await this.handler.handleEvent(event, this, adapters, false);
|
|
664
532
|
}
|
|
665
533
|
setupEventHandlers() {
|
|
@@ -742,7 +610,7 @@ export class SlackBot {
|
|
|
742
610
|
ack();
|
|
743
611
|
return;
|
|
744
612
|
}
|
|
745
|
-
const isSharedThreadReply = !isDM &&
|
|
613
|
+
const isSharedThreadReply = !isDM && this.shouldTriggerSharedThreadReply(e.channel, e.thread_ts);
|
|
746
614
|
const sessionKey = isDM || isSharedThreadReply ? resolveSlackSessionKey(e.channel, e.thread_ts) : undefined;
|
|
747
615
|
const slackEvent = {
|
|
748
616
|
type: isDM ? "dm" : "mention",
|
|
@@ -924,13 +792,15 @@ export class SlackBot {
|
|
|
924
792
|
return timestamps;
|
|
925
793
|
const content = await readFile(logPath, "utf-8");
|
|
926
794
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
927
|
-
for (
|
|
795
|
+
for (let i = 0; i < lines.length; i++) {
|
|
928
796
|
try {
|
|
929
|
-
const entry = JSON.parse(
|
|
797
|
+
const entry = JSON.parse(lines[i]);
|
|
930
798
|
if (entry.ts)
|
|
931
799
|
timestamps.add(entry.ts);
|
|
932
800
|
}
|
|
933
|
-
catch {
|
|
801
|
+
catch (err) {
|
|
802
|
+
log.logWarning(`Skipping malformed log entry at ${logPath}:${i + 1}`, err instanceof Error ? err.message : String(err));
|
|
803
|
+
}
|
|
934
804
|
}
|
|
935
805
|
return timestamps;
|
|
936
806
|
}
|