diff --git a/CHANGELOG.md b/CHANGELOG.md index f3426a34c..1b431b625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ - Skills: emit MEDIA token after Nano Banana Pro image generation. Thanks @Iamadig for PR #271. - WhatsApp: set sender E.164 for direct chats so owner commands work in DMs. - Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251. +- Slack: send typing status updates via assistant threads. Thanks @thewilloftheshadow for PR #320. - Slack: fix Slack provider startup under Bun by using a named import for Bolt `App`. Thanks @snopoke for PR #299. - Discord: surface missing-permission hints (muted/role overrides) when replies fail. - Discord: use channel IDs for DMs instead of user IDs. Thanks @VACInc for PR #261. diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index cca971567..9f83bea1b 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -63,6 +63,11 @@ vi.mock("@slack/bolt", () => { user: { profile: { display_name: "Ada" } }, }), }, + assistant: { + threads: { + setStatus: vi.fn().mockResolvedValue({ ok: true }), + }, + }, reactions: { add: (...args: unknown[]) => reactMock(...args), }, @@ -149,6 +154,49 @@ describe("monitorSlackProvider tool results", () => { expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + it("updates assistant thread status when replies start", async () => { + replyMock.mockImplementation(async (_ctx, opts) => { + await opts?.onReplyStart?.(); + return { text: "final reply" }; + }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "hello", + ts: "123", + channel: "C1", + channel_type: "im", + }, + }); + + await flush(); + controller.abort(); + await run; + + const client = getSlackClient() as { + assistant?: { threads?: { setStatus?: ReturnType } }; + }; + expect(client.assistant?.threads?.setStatus).toHaveBeenCalledWith({ + token: "bot-token", + channel_id: "C1", + thread_ts: "123", + status: "is typing...", + }); + }); + it("accepts channel messages when mentionPatterns match", async () => { config = { messages: { responsePrefix: "PFX" }, diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index b4fca93b2..122404b2e 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -20,6 +20,7 @@ import { matchesMentionPatterns, } from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; +import type { TypingController } from "../auto-reply/reply/typing.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; @@ -499,6 +500,41 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } }; + const setSlackThreadStatus = async (params: { + channelId: string; + threadTs?: string; + status: string; + }) => { + if (!params.threadTs) return; + const payload = { + token: botToken, + channel_id: params.channelId, + thread_ts: params.threadTs, + status: params.status, + }; + const client = app.client as unknown as { + assistant?: { + threads?: { + setStatus?: (args: typeof payload) => Promise; + }; + }; + apiCall?: (method: string, args: typeof payload) => Promise; + }; + try { + if (client.assistant?.threads?.setStatus) { + await client.assistant.threads.setStatus(payload); + return; + } + if (typeof client.apiCall === "function") { + await client.apiCall("assistant.threads.setStatus", payload); + } + } catch (err) { + logVerbose( + `slack status update failed for channel ${params.channelId}: ${String(err)}`, + ); + } + }; + const isChannelAllowed = (params: { channelId?: string; channelName?: string; @@ -823,6 +859,15 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { // Only thread replies if the incoming message was in a thread. const incomingThreadTs = message.thread_ts; + const statusThreadTs = message.thread_ts ?? message.ts; + const onReplyStart = async () => { + await setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "is typing...", + }); + }; + let typingController: TypingController | undefined; const dispatcher = createReplyDispatcher({ responsePrefix: cfg.messages?.responsePrefix, deliver: async (payload) => { @@ -835,10 +880,18 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { threadTs: incomingThreadTs, }); }, + onIdle: () => { + typingController?.markDispatchIdle(); + }, onError: (err, info) => { runtime.error?.( danger(`slack ${info.kind} reply failed: ${String(err)}`), ); + void setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "", + }); }, }); @@ -846,8 +899,22 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ctx: ctxPayload, cfg, dispatcher, + replyOptions: { + onReplyStart, + onTypingController: (typing) => { + typingController = typing; + }, + }, }); - if (!queuedFinal) return; + typingController?.markDispatchIdle(); + if (!queuedFinal) { + await setSlackThreadStatus({ + channelId: message.channel, + threadTs: statusThreadTs, + status: "", + }); + return; + } if (shouldLogVerbose()) { const finalCount = counts.final; logVerbose(