| 34 | * Slack lookup that already produces the answer. |
| 35 | */ |
| 36 | export class SlackConversationStore { |
| 37 | private readonly client: WebClient; |
| 38 | private readonly botUserId: string; |
| 39 | /** Bot token used to download uploaded files from their `url_private`. */ |
| 40 | private readonly botToken: string; |
| 41 | private readonly filesConfig: FileDeliveryConfig; |
| 42 | /** Stable threadIds → conversation keys ("channelId::scope"). */ |
| 43 | private readonly participated = new Set<string>(); |
| 44 | |
| 45 | constructor(args: { |
| 46 | client: WebClient; |
| 47 | botUserId: string; |
| 48 | botToken: string; |
| 49 | files?: FileDeliveryConfig; |
| 50 | }) { |
| 51 | this.client = args.client; |
| 52 | this.botUserId = args.botUserId; |
| 53 | this.botToken = args.botToken; |
| 54 | this.filesConfig = args.files ?? {}; |
| 55 | } |
| 56 | |
| 57 | private keyOf(k: ConversationKey): string { |
| 58 | return `${k.channelId}::${k.scope}`; |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * A *fresh* AG-UI threadId per turn. |
| 63 | * |
| 64 | * We deliberately do NOT reuse a stable per-conversation threadId. |
| 65 | * Slack is our durable history (every turn is rebuilt from it via |
| 66 | * {@link fetchHistory}), so the LangGraph thread only needs to live for |
| 67 | * the duration of one turn. Reusing a stable threadId across turns lets |
| 68 | * the server-side thread accumulate the agent's *internal* messages |
| 69 | * (tool calls/results that never round-trip through Slack); on the next |
| 70 | * turn `@ag-ui/langgraph` regenerates state and the now-larger existing |
| 71 | * thread no longer matches the incoming history, surfacing as a |
| 72 | * "Message not found" failure. A unique thread per turn sidesteps that |
| 73 | * entirely. Restart-recovery for interrupts still works because the |
| 74 | * picker carries its turn's threadId in Slack message metadata (see |
| 75 | * `recoverFromStaleClick`). |
| 76 | */ |
| 77 | private newThreadId(k: ConversationKey): string { |
| 78 | return `slack-${k.channelId}-${k.scope}-${randomUUID()}`; |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Does the bot own this thread? "Ownership" means the bot has at least |
| 83 | * one prior reply in it (which is the natural definition since the bot |
| 84 | * only ever replies when @-mentioned or in a thread it already owns). |
| 85 | * |
| 86 | * Cached in-process; the first call after a restart is one Slack API |
| 87 | * round-trip, subsequent calls are O(1). |
| 88 | */ |
| 89 | async has(key: ConversationKey): Promise<boolean> { |
| 90 | if (this.participated.has(this.keyOf(key))) return true; |
| 91 | // DM "scope" is a sentinel — no Slack thread to query. We treat the |
| 92 | // listener-level DM gate as authoritative; DMs always go through. |
| 93 | if (key.scope === DM_SCOPE) return false; |
nothing calls this directly
no outgoing calls
no test coverage detected
searching dependent graphs…