diff --git a/lib/admins/slack/__tests__/fetchBotMentions.test.ts b/lib/admins/slack/__tests__/fetchBotMentions.test.ts index 23c05169..713cda9b 100644 --- a/lib/admins/slack/__tests__/fetchBotMentions.test.ts +++ b/lib/admins/slack/__tests__/fetchBotMentions.test.ts @@ -29,7 +29,7 @@ vi.mock("@/lib/admins/slack/getCutoffTs", () => ({ describe("fetchBotMentions", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); }); it("throws when token is not set", async () => { @@ -108,6 +108,80 @@ describe("fetchBotMentions", () => { vi.unstubAllEnvs(); }); + it("finds bot mentions inside thread replies", async () => { + vi.stubEnv("TEST_BOT_TOKEN", "xoxb-test"); + + vi.mocked(getBotUserId).mockResolvedValue("U_BOT"); + vi.mocked(getBotChannels).mockResolvedValue([{ id: "C1", name: "general" }]); + vi.mocked(getCutoffTs).mockReturnValue(null); + + // conversations.history returns a threaded parent (reply_count > 0) but no top-level mention + vi.mocked(slackGet) + .mockResolvedValueOnce({ + ok: true, + messages: [ + { + type: "message", + user: "U1", + text: "check this out", + ts: "1705312200.000000", + reply_count: 2, + thread_ts: "1705312200.000000", + }, + ], + }) + // conversations.replies for the thread + .mockResolvedValueOnce({ + ok: true, + messages: [ + { + type: "message", + user: "U1", + text: "check this out", + ts: "1705312200.000000", + }, + { + type: "message", + user: "U2", + text: "<@U_BOT> make a video for this", + ts: "1705312400.000000", + }, + { + type: "message", + bot_id: "B1", + text: "here is your video https://example.com/video", + ts: "1705312500.000000", + }, + ], + }); + + vi.mocked(getSlackUserInfo).mockResolvedValue({ name: "Bob", avatar: null }); + + const mockFetchThreadResponses = vi.fn().mockResolvedValue([["https://example.com/video"]]); + + const result = await fetchBotMentions({ + tokenEnvVar: "TEST_BOT_TOKEN", + period: "all", + fetchThreadResponses: mockFetchThreadResponses, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + user_id: "U2", + user_name: "Bob", + prompt: "make a video for this", + channel_id: "C1", + channel_name: "general", + }); + + // Should use the thread_ts for fetching responses, not the reply ts + expect(mockFetchThreadResponses).toHaveBeenCalledWith("xoxb-test", [ + { channelId: "C1", ts: "1705312200.000000" }, + ]); + + vi.unstubAllEnvs(); + }); + it("sorts tags newest first", async () => { vi.stubEnv("TEST_BOT_TOKEN", "xoxb-test"); diff --git a/lib/admins/slack/fetchBotMentions.ts b/lib/admins/slack/fetchBotMentions.ts index dad389b5..b1ff67e2 100644 --- a/lib/admins/slack/fetchBotMentions.ts +++ b/lib/admins/slack/fetchBotMentions.ts @@ -25,18 +25,39 @@ interface ConversationsHistoryResponse { text?: string; ts?: string; bot_id?: string; + reply_count?: number; + thread_ts?: string; }>; response_metadata?: { next_cursor?: string }; } +interface ConversationsRepliesResponse { + ok: boolean; + error?: string; + messages?: Array<{ + type: string; + user?: string; + text?: string; + ts?: string; + bot_id?: string; + }>; +} + interface RawMention { userId: string; prompt: string; ts: string; + threadTs: string; channelId: string; channelName: string; } +interface ThreadParent { + channelId: string; + channelName: string; + ts: string; +} + interface FetchBotMentionsOptions { tokenEnvVar: string; period: AdminPeriod; @@ -47,8 +68,8 @@ interface FetchBotMentionsOptions { } /** - * Fetches all Slack messages where a bot was mentioned. - * Generic over the bot token and thread response extractor. + * Fetches all Slack messages where a bot was mentioned, including mentions + * within thread replies. Generic over the bot token and thread response extractor. * * @param options - Configuration for which bot and how to extract thread responses * @returns Array of BotTag objects representing each mention event, sorted newest first @@ -66,6 +87,8 @@ export async function fetchBotMentions(options: FetchBotMentionsOptions): Promis const cutoffTs = getCutoffTs(period); const mentions: RawMention[] = []; const userCache: Record = {}; + const threadParents: ThreadParent[] = []; + const topLevelMentionThreads = new Set(); for (const channel of channels) { let cursor: string | undefined; @@ -84,9 +107,19 @@ export async function fetchBotMentions(options: FetchBotMentionsOptions): Promis for (const msg of history.messages ?? []) { if (msg.bot_id) continue; + if (!msg.ts) continue; + + // Track threaded messages for scanning thread replies + if (msg.reply_count && msg.reply_count > 0) { + threadParents.push({ + channelId: channel.id, + channelName: channel.name, + ts: msg.ts, + }); + } + if (!msg.user) continue; if (!msg.text?.includes(mentionPattern)) continue; - if (!msg.ts) continue; if (!userCache[msg.user]) { userCache[msg.user] = await getSlackUserInfo(token, msg.user); @@ -96,18 +129,72 @@ export async function fetchBotMentions(options: FetchBotMentionsOptions): Promis userId: msg.user, prompt: (msg.text ?? "").replace(new RegExp(`<@${botUserId}>\\s*`, "g"), "").trim(), ts: msg.ts, + threadTs: msg.ts, channelId: channel.id, channelName: channel.name, }); + + topLevelMentionThreads.add(`${channel.id}:${msg.ts}`); } cursor = history.response_metadata?.next_cursor || undefined; } while (cursor); } + // Scan thread replies for additional bot mentions + const THREAD_BATCH_SIZE = 5; + const THREAD_BATCH_DELAY_MS = 1100; + + for (let i = 0; i < threadParents.length; i += THREAD_BATCH_SIZE) { + if (i > 0) { + await new Promise(resolve => setTimeout(resolve, THREAD_BATCH_DELAY_MS)); + } + const batch = threadParents.slice(i, i + THREAD_BATCH_SIZE); + const batchResults = await Promise.allSettled( + batch.map(async tp => { + const replies = await slackGet( + "conversations.replies", + token, + { channel: tp.channelId, ts: tp.ts }, + ); + if (!replies.ok) return []; + + const threadMentions: RawMention[] = []; + for (const msg of replies.messages ?? []) { + if (msg.bot_id) continue; + if (!msg.user) continue; + if (!msg.text?.includes(mentionPattern)) continue; + if (!msg.ts) continue; + // Skip the parent message (already captured as top-level) + if (msg.ts === tp.ts) continue; + + if (!userCache[msg.user]) { + userCache[msg.user] = await getSlackUserInfo(token, msg.user); + } + + threadMentions.push({ + userId: msg.user, + prompt: (msg.text ?? "").replace(new RegExp(`<@${botUserId}>\\s*`, "g"), "").trim(), + ts: msg.ts, + threadTs: tp.ts, + channelId: tp.channelId, + channelName: tp.channelName, + }); + } + return threadMentions; + }), + ); + + for (const result of batchResults) { + if (result.status === "fulfilled") { + mentions.push(...result.value); + } + } + } + const responses = await fetchThreadResponses( token, - mentions.map(m => ({ channelId: m.channelId, ts: m.ts })), + mentions.map(m => ({ channelId: m.channelId, ts: m.threadTs })), ); const tags: BotTag[] = mentions.map((m, i) => {