{"version":3,"file":"slack.d.ts","sourceRoot":"","sources":["../src/slack.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 { basename, join } from \"path\";\nimport * as log from \"./log.js\";\nimport type { Attachment, ChannelStore } from \"./store.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SlackEvent {\n\ttype: \"mention\" | \"dm\";\n\tchannel: string;\n\tts: string;\n\tuser: string;\n\ttext: string;\n\tfiles?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;\n\t/** Processed attachments with local paths (populated after logUserMessage) */\n\tattachments?: Attachment[];\n}\n\nexport interface SlackUser {\n\tid: string;\n\tuserName: string;\n\tdisplayName: string;\n}\n\nexport interface SlackChannel {\n\tid: string;\n\tname: string;\n}\n\n// Types used by agent.ts\nexport interface ChannelInfo {\n\tid: string;\n\tname: string;\n}\n\nexport interface UserInfo {\n\tid: string;\n\tuserName: string;\n\tdisplayName: string;\n}\n\nexport interface SlackContext {\n\tmessage: {\n\t\ttext: string;\n\t\trawText: string;\n\t\tuser: string;\n\t\tuserName?: string;\n\t\tchannel: string;\n\t\tts: string;\n\t\tattachments: Array<{ local: string }>;\n\t};\n\tchannelName?: string;\n\tchannels: ChannelInfo[];\n\tusers: UserInfo[];\n\trespond: (text: string, shouldLog?: boolean) => Promise<void>;\n\treplaceMessage: (text: string) => Promise<void>;\n\trespondInThread: (text: string) => Promise<void>;\n\tsetTyping: (isTyping: boolean) => Promise<void>;\n\tuploadFile: (filePath: string, title?: string) => Promise<void>;\n\tsetWorking: (working: boolean) => Promise<void>;\n\tdeleteMessage: () => Promise<void>;\n}\n\nexport interface MomHandler {\n\t/**\n\t * Check if channel is currently running (SYNC)\n\t */\n\tisRunning(channelId: string): boolean;\n\n\t/**\n\t * Handle an event that triggers mom (ASYNC)\n\t * Called only when isRunning() returned false for user messages.\n\t * Events always queue and pass isEvent=true.\n\t */\n\thandleEvent(event: SlackEvent, slack: SlackBot, isEvent?: boolean): Promise<void>;\n\n\t/**\n\t * Handle stop command (ASYNC)\n\t * Called when user says \"stop\" while mom is running\n\t */\n\thandleStop(channelId: string, slack: SlackBot): Promise<void>;\n}\n\n// ============================================================================\n// Per-channel queue for sequential processing\n// ============================================================================\n\ntype QueuedWork = () => Promise<void>;\n\nclass ChannelQueue {\n\tprivate queue: QueuedWork[] = [];\n\tprivate processing = false;\n\n\tenqueue(work: QueuedWork): void {\n\t\tthis.queue.push(work);\n\t\tthis.processNext();\n\t}\n\n\tsize(): number {\n\t\treturn this.queue.length;\n\t}\n\n\tprivate async processNext(): Promise<void> {\n\t\tif (this.processing || this.queue.length === 0) return;\n\t\tthis.processing = true;\n\t\tconst work = this.queue.shift()!;\n\t\ttry {\n\t\t\tawait work();\n\t\t} catch (err) {\n\t\t\tlog.logWarning(\"Queue error\", err instanceof Error ? err.message : String(err));\n\t\t}\n\t\tthis.processing = false;\n\t\tthis.processNext();\n\t}\n}\n\n// ============================================================================\n// SlackBot\n// ============================================================================\n\nexport class SlackBot {\n\tprivate socketClient: SocketModeClient;\n\tprivate webClient: WebClient;\n\tprivate handler: MomHandler;\n\tprivate workingDir: string;\n\tprivate store: ChannelStore;\n\tprivate botUserId: string | null = null;\n\tprivate startupTs: string | null = null; // Messages older than this are just logged, not processed\n\n\tprivate users = new Map<string, SlackUser>();\n\tprivate channels = new Map<string, SlackChannel>();\n\tprivate queues = new Map<string, ChannelQueue>();\n\n\tconstructor(\n\t\thandler: MomHandler,\n\t\tconfig: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },\n\t) {\n\t\tthis.handler = handler;\n\t\tthis.workingDir = config.workingDir;\n\t\tthis.store = config.store;\n\t\tthis.socketClient = new SocketModeClient({ appToken: config.appToken });\n\t\tthis.webClient = new WebClient(config.botToken);\n\t}\n\n\t// ==========================================================================\n\t// Public API\n\t// ==========================================================================\n\n\tasync start(): Promise<void> {\n\t\tconst auth = await this.webClient.auth.test();\n\t\tthis.botUserId = auth.user_id as string;\n\n\t\tawait Promise.all([this.fetchUsers(), this.fetchChannels()]);\n\t\tlog.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);\n\n\t\tawait this.backfillAllChannels();\n\n\t\tthis.setupEventHandlers();\n\t\tawait this.socketClient.start();\n\n\t\t// Record startup time - messages older than this are just logged, not processed\n\t\tthis.startupTs = (Date.now() / 1000).toFixed(6);\n\n\t\tlog.logConnected();\n\t}\n\n\tgetUser(userId: string): SlackUser | undefined {\n\t\treturn this.users.get(userId);\n\t}\n\n\tgetChannel(channelId: string): SlackChannel | undefined {\n\t\treturn this.channels.get(channelId);\n\t}\n\n\tgetAllUsers(): SlackUser[] {\n\t\treturn Array.from(this.users.values());\n\t}\n\n\tgetAllChannels(): SlackChannel[] {\n\t\treturn Array.from(this.channels.values());\n\t}\n\n\tasync postMessage(channel: string, text: string): Promise<string> {\n\t\tconst result = await this.webClient.chat.postMessage({ channel, text });\n\t\treturn result.ts as string;\n\t}\n\n\tasync updateMessage(channel: string, ts: string, text: string): Promise<void> {\n\t\tawait this.webClient.chat.update({ channel, ts, text });\n\t}\n\n\tasync deleteMessage(channel: string, ts: string): Promise<void> {\n\t\tawait this.webClient.chat.delete({ channel, ts });\n\t}\n\n\tasync postInThread(channel: string, threadTs: string, text: string): Promise<string> {\n\t\tconst result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });\n\t\treturn result.ts as string;\n\t}\n\n\tasync uploadFile(channel: string, filePath: string, title?: string): Promise<void> {\n\t\tconst fileName = title || basename(filePath);\n\t\tconst fileContent = readFileSync(filePath);\n\t\tawait this.webClient.files.uploadV2({\n\t\t\tchannel_id: channel,\n\t\t\tfile: fileContent,\n\t\t\tfilename: fileName,\n\t\t\ttitle: fileName,\n\t\t});\n\t}\n\n\t/**\n\t * Log a message to log.jsonl (SYNC)\n\t * This is the ONLY place messages are written to log.jsonl\n\t */\n\tlogToFile(channel: string, entry: object): void {\n\t\tconst dir = join(this.workingDir, channel);\n\t\tif (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n\t\tappendFileSync(join(dir, \"log.jsonl\"), `${JSON.stringify(entry)}\\n`);\n\t}\n\n\t/**\n\t * Log a bot response to log.jsonl\n\t */\n\tlogBotResponse(channel: string, text: string, ts: string): void {\n\t\tthis.logToFile(channel, {\n\t\t\tdate: new Date().toISOString(),\n\t\t\tts,\n\t\t\tuser: \"bot\",\n\t\t\ttext,\n\t\t\tattachments: [],\n\t\t\tisBot: true,\n\t\t});\n\t}\n\n\t// ==========================================================================\n\t// Events Integration\n\t// ==========================================================================\n\n\t/**\n\t * Enqueue an event for processing. Always queues (no \"already working\" rejection).\n\t * Returns true if enqueued, false if queue is full (max 5).\n\t */\n\tenqueueEvent(event: SlackEvent): boolean {\n\t\tconst queue = this.getQueue(event.channel);\n\t\tif (queue.size() >= 5) {\n\t\t\tlog.logWarning(`Event queue full for ${event.channel}, discarding: ${event.text.substring(0, 50)}`);\n\t\t\treturn false;\n\t\t}\n\t\tlog.logInfo(`Enqueueing event for ${event.channel}: ${event.text.substring(0, 50)}`);\n\t\tqueue.enqueue(() => this.handler.handleEvent(event, this, true));\n\t\treturn true;\n\t}\n\n\t// ==========================================================================\n\t// Private - Event Handlers\n\t// ==========================================================================\n\n\tprivate getQueue(channelId: string): ChannelQueue {\n\t\tlet queue = this.queues.get(channelId);\n\t\tif (!queue) {\n\t\t\tqueue = new ChannelQueue();\n\t\t\tthis.queues.set(channelId, queue);\n\t\t}\n\t\treturn queue;\n\t}\n\n\tprivate setupEventHandlers(): void {\n\t\t// Channel @mentions\n\t\tthis.socketClient.on(\"app_mention\", ({ event, ack }) => {\n\t\t\tconst e = event as {\n\t\t\t\ttext: string;\n\t\t\t\tchannel: string;\n\t\t\t\tuser: string;\n\t\t\t\tts: string;\n\t\t\t\tfiles?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n\t\t\t};\n\n\t\t\t// Skip DMs (handled by message event)\n\t\t\tif (e.channel.startsWith(\"D\")) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst slackEvent: SlackEvent = {\n\t\t\t\ttype: \"mention\",\n\t\t\t\tchannel: e.channel,\n\t\t\t\tts: e.ts,\n\t\t\t\tuser: e.user,\n\t\t\t\ttext: e.text.replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n\t\t\t\tfiles: e.files,\n\t\t\t};\n\n\t\t\t// SYNC: Log to log.jsonl (ALWAYS, even for old messages)\n\t\t\t// Also downloads attachments in background and stores local paths\n\t\t\tslackEvent.attachments = this.logUserMessage(slackEvent);\n\n\t\t\t// Only trigger processing for messages AFTER startup (not replayed old messages)\n\t\t\tif (this.startupTs && e.ts < this.startupTs) {\n\t\t\t\tlog.logInfo(\n\t\t\t\t\t`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,\n\t\t\t\t);\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for stop command - execute immediately, don't queue!\n\t\t\tif (slackEvent.text.toLowerCase().trim() === \"stop\") {\n\t\t\t\tif (this.handler.isRunning(e.channel)) {\n\t\t\t\t\tthis.handler.handleStop(e.channel, this); // Don't await, don't queue\n\t\t\t\t} else {\n\t\t\t\t\tthis.postMessage(e.channel, \"_Nothing running_\");\n\t\t\t\t}\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// SYNC: Check if busy\n\t\t\tif (this.handler.isRunning(e.channel)) {\n\t\t\t\tthis.postMessage(e.channel, \"_Already working. Say `@mom stop` to cancel._\");\n\t\t\t} else {\n\t\t\t\tthis.getQueue(e.channel).enqueue(() => this.handler.handleEvent(slackEvent, this));\n\t\t\t}\n\n\t\t\tack();\n\t\t});\n\n\t\t// All messages (for logging) + DMs (for triggering)\n\t\tthis.socketClient.on(\"message\", ({ event, ack }) => {\n\t\t\tconst e = event as {\n\t\t\t\ttext?: string;\n\t\t\t\tchannel: string;\n\t\t\t\tuser?: string;\n\t\t\t\tts: string;\n\t\t\t\tchannel_type?: string;\n\t\t\t\tsubtype?: string;\n\t\t\t\tbot_id?: string;\n\t\t\t\tfiles?: Array<{ name: string; url_private_download?: string; url_private?: string }>;\n\t\t\t};\n\n\t\t\t// Skip bot messages, edits, etc.\n\t\t\tif (e.bot_id || !e.user || e.user === this.botUserId) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (e.subtype !== undefined && e.subtype !== \"file_share\") {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!e.text && (!e.files || e.files.length === 0)) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst isDM = e.channel_type === \"im\";\n\t\t\tconst isBotMention = e.text?.includes(`<@${this.botUserId}>`);\n\n\t\t\t// Skip channel @mentions - already handled by app_mention event\n\t\t\tif (!isDM && isBotMention) {\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst slackEvent: SlackEvent = {\n\t\t\t\ttype: isDM ? \"dm\" : \"mention\",\n\t\t\t\tchannel: e.channel,\n\t\t\t\tts: e.ts,\n\t\t\t\tuser: e.user,\n\t\t\t\ttext: (e.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim(),\n\t\t\t\tfiles: e.files,\n\t\t\t};\n\n\t\t\t// SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)\n\t\t\t// Also downloads attachments in background and stores local paths\n\t\t\tslackEvent.attachments = this.logUserMessage(slackEvent);\n\n\t\t\t// Only trigger processing for messages AFTER startup (not replayed old messages)\n\t\t\tif (this.startupTs && e.ts < this.startupTs) {\n\t\t\t\tlog.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);\n\t\t\t\tack();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Only trigger handler for DMs\n\t\t\tif (isDM) {\n\t\t\t\t// Check for stop command - execute immediately, don't queue!\n\t\t\t\tif (slackEvent.text.toLowerCase().trim() === \"stop\") {\n\t\t\t\t\tif (this.handler.isRunning(e.channel)) {\n\t\t\t\t\t\tthis.handler.handleStop(e.channel, this); // Don't await, don't queue\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.postMessage(e.channel, \"_Nothing running_\");\n\t\t\t\t\t}\n\t\t\t\t\tack();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (this.handler.isRunning(e.channel)) {\n\t\t\t\t\tthis.postMessage(e.channel, \"_Already working. Say `stop` to cancel._\");\n\t\t\t\t} else {\n\t\t\t\t\tthis.getQueue(e.channel).enqueue(() => this.handler.handleEvent(slackEvent, this));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tack();\n\t\t});\n\t}\n\n\t/**\n\t * Log a user message to log.jsonl (SYNC)\n\t * Downloads attachments in background via store\n\t */\n\tprivate logUserMessage(event: SlackEvent): Attachment[] {\n\t\tconst user = this.users.get(event.user);\n\t\t// Process attachments - queues downloads in background\n\t\tconst attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];\n\t\tthis.logToFile(event.channel, {\n\t\t\tdate: new Date(parseFloat(event.ts) * 1000).toISOString(),\n\t\t\tts: event.ts,\n\t\t\tuser: event.user,\n\t\t\tuserName: user?.userName,\n\t\t\tdisplayName: user?.displayName,\n\t\t\ttext: event.text,\n\t\t\tattachments,\n\t\t\tisBot: false,\n\t\t});\n\t\treturn attachments;\n\t}\n\n\t// ==========================================================================\n\t// Private - Backfill\n\t// ==========================================================================\n\n\tprivate getExistingTimestamps(channelId: string): Set<string> {\n\t\tconst logPath = join(this.workingDir, channelId, \"log.jsonl\");\n\t\tconst timestamps = new Set<string>();\n\t\tif (!existsSync(logPath)) return timestamps;\n\n\t\tconst content = readFileSync(logPath, \"utf-8\");\n\t\tconst lines = content.trim().split(\"\\n\").filter(Boolean);\n\t\tfor (const line of lines) {\n\t\t\ttry {\n\t\t\t\tconst entry = JSON.parse(line);\n\t\t\t\tif (entry.ts) timestamps.add(entry.ts);\n\t\t\t} catch {}\n\t\t}\n\t\treturn timestamps;\n\t}\n\n\tprivate async backfillChannel(channelId: string): Promise<number> {\n\t\tconst existingTs = this.getExistingTimestamps(channelId);\n\n\t\t// Find the biggest ts in log.jsonl\n\t\tlet latestTs: string | undefined;\n\t\tfor (const ts of existingTs) {\n\t\t\tif (!latestTs || parseFloat(ts) > parseFloat(latestTs)) latestTs = ts;\n\t\t}\n\n\t\ttype Message = {\n\t\t\tuser?: string;\n\t\t\tbot_id?: string;\n\t\t\ttext?: string;\n\t\t\tts?: string;\n\t\t\tsubtype?: string;\n\t\t\tfiles?: Array<{ name: string }>;\n\t\t};\n\t\tconst allMessages: Message[] = [];\n\n\t\tlet cursor: string | undefined;\n\t\tlet pageCount = 0;\n\t\tconst maxPages = 3;\n\n\t\tdo {\n\t\t\tconst result = await this.webClient.conversations.history({\n\t\t\t\tchannel: channelId,\n\t\t\t\toldest: latestTs, // Only fetch messages newer than what we have\n\t\t\t\tinclusive: false,\n\t\t\t\tlimit: 1000,\n\t\t\t\tcursor,\n\t\t\t});\n\t\t\tif (result.messages) {\n\t\t\t\tallMessages.push(...(result.messages as Message[]));\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t\tpageCount++;\n\t\t} while (cursor && pageCount < maxPages);\n\n\t\t// Filter: include mom's messages, exclude other bots, skip already logged\n\t\tconst relevantMessages = allMessages.filter((msg) => {\n\t\t\tif (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates\n\t\t\tif (msg.user === this.botUserId) return true;\n\t\t\tif (msg.bot_id) return false;\n\t\t\tif (msg.subtype !== undefined && msg.subtype !== \"file_share\") return false;\n\t\t\tif (!msg.user) return false;\n\t\t\tif (!msg.text && (!msg.files || msg.files.length === 0)) return false;\n\t\t\treturn true;\n\t\t});\n\n\t\t// Reverse to chronological order\n\t\trelevantMessages.reverse();\n\n\t\t// Log each message to log.jsonl\n\t\tfor (const msg of relevantMessages) {\n\t\t\tconst isMomMessage = msg.user === this.botUserId;\n\t\t\tconst user = this.users.get(msg.user!);\n\t\t\t// Strip @mentions from text (same as live messages)\n\t\t\tconst text = (msg.text || \"\").replace(/<@[A-Z0-9]+>/gi, \"\").trim();\n\t\t\t// Process attachments - queues downloads in background\n\t\t\tconst attachments = msg.files ? this.store.processAttachments(channelId, msg.files, msg.ts!) : [];\n\n\t\t\tthis.logToFile(channelId, {\n\t\t\t\tdate: new Date(parseFloat(msg.ts!) * 1000).toISOString(),\n\t\t\t\tts: msg.ts!,\n\t\t\t\tuser: isMomMessage ? \"bot\" : msg.user!,\n\t\t\t\tuserName: isMomMessage ? undefined : user?.userName,\n\t\t\t\tdisplayName: isMomMessage ? undefined : user?.displayName,\n\t\t\t\ttext,\n\t\t\t\tattachments,\n\t\t\t\tisBot: isMomMessage,\n\t\t\t});\n\t\t}\n\n\t\treturn relevantMessages.length;\n\t}\n\n\tprivate async backfillAllChannels(): Promise<void> {\n\t\tconst startTime = Date.now();\n\n\t\t// Only backfill channels that already have a log.jsonl (mom has interacted with them before)\n\t\tconst channelsToBackfill: Array<[string, SlackChannel]> = [];\n\t\tfor (const [channelId, channel] of this.channels) {\n\t\t\tconst logPath = join(this.workingDir, channelId, \"log.jsonl\");\n\t\t\tif (existsSync(logPath)) {\n\t\t\t\tchannelsToBackfill.push([channelId, channel]);\n\t\t\t}\n\t\t}\n\n\t\tlog.logBackfillStart(channelsToBackfill.length);\n\n\t\tlet totalMessages = 0;\n\t\tfor (const [channelId, channel] of channelsToBackfill) {\n\t\t\ttry {\n\t\t\t\tconst count = await this.backfillChannel(channelId);\n\t\t\t\tif (count > 0) log.logBackfillChannel(channel.name, count);\n\t\t\t\ttotalMessages += count;\n\t\t\t} catch (error) {\n\t\t\t\tlog.logWarning(`Failed to backfill #${channel.name}`, String(error));\n\t\t\t}\n\t\t}\n\n\t\tconst durationMs = Date.now() - startTime;\n\t\tlog.logBackfillComplete(totalMessages, durationMs);\n\t}\n\n\t// ==========================================================================\n\t// Private - Fetch Users/Channels\n\t// ==========================================================================\n\n\tprivate async fetchUsers(): Promise<void> {\n\t\tlet cursor: string | undefined;\n\t\tdo {\n\t\t\tconst result = await this.webClient.users.list({ limit: 200, cursor });\n\t\t\tconst members = result.members as\n\t\t\t\t| Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>\n\t\t\t\t| undefined;\n\t\t\tif (members) {\n\t\t\t\tfor (const u of members) {\n\t\t\t\t\tif (u.id && u.name && !u.deleted) {\n\t\t\t\t\t\tthis.users.set(u.id, { id: u.id, userName: u.name, displayName: u.real_name || u.name });\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t} while (cursor);\n\t}\n\n\tprivate async fetchChannels(): Promise<void> {\n\t\t// Fetch public/private channels\n\t\tlet cursor: string | undefined;\n\t\tdo {\n\t\t\tconst result = await this.webClient.conversations.list({\n\t\t\t\ttypes: \"public_channel,private_channel\",\n\t\t\t\texclude_archived: true,\n\t\t\t\tlimit: 200,\n\t\t\t\tcursor,\n\t\t\t});\n\t\t\tconst channels = result.channels as Array<{ id?: string; name?: string; is_member?: boolean }> | undefined;\n\t\t\tif (channels) {\n\t\t\t\tfor (const c of channels) {\n\t\t\t\t\tif (c.id && c.name && c.is_member) {\n\t\t\t\t\t\tthis.channels.set(c.id, { id: c.id, name: c.name });\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t} while (cursor);\n\n\t\t// Also fetch DM channels (IMs)\n\t\tcursor = undefined;\n\t\tdo {\n\t\t\tconst result = await this.webClient.conversations.list({\n\t\t\t\ttypes: \"im\",\n\t\t\t\tlimit: 200,\n\t\t\t\tcursor,\n\t\t\t});\n\t\t\tconst ims = result.channels as Array<{ id?: string; user?: string }> | undefined;\n\t\t\tif (ims) {\n\t\t\t\tfor (const im of ims) {\n\t\t\t\t\tif (im.id) {\n\t\t\t\t\t\t// Use user's name as channel name for DMs\n\t\t\t\t\t\tconst user = im.user ? this.users.get(im.user) : undefined;\n\t\t\t\t\t\tconst name = user ? `DM:${user.userName}` : `DM:${im.id}`;\n\t\t\t\t\t\tthis.channels.set(im.id, { id: im.id, name });\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcursor = result.response_metadata?.next_cursor;\n\t\t} while (cursor);\n\t}\n}\n"]}