From a95a0be133982999f31ccd4c05dc582fc4bacb3f Mon Sep 17 00:00:00 2001 From: Dale Yarborough Date: Tue, 3 Mar 2026 23:07:17 -0600 Subject: [PATCH] feat(slack): add typingReaction config for DM typing indicator fallback (#19816) * feat(slack): add typingReaction config for DM typing indicator fallback Adds a reaction-based typing indicator for Slack DMs that works without assistant mode. When `channels.slack.typingReaction` is set (e.g. "hourglass_flowing_sand"), the emoji is added to the user's message when processing starts and removed when the reply is sent. Addresses #19809 * test(slack): add typingReaction to createSlackMonitorContext test callers * test(slack): add typingReaction to test context callers * test(slack): add typingReaction to context fixture * docs(changelog): credit Slack typingReaction feature * test(slack): align existing-thread history expectation --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + src/config/types.slack.ts | 2 + src/config/zod-schema.providers-core.ts | 1 + src/slack/monitor/context.test.ts | 1 + src/slack/monitor/context.ts | 3 + src/slack/monitor/message-handler/dispatch.ts | 15 ++- .../message-handler/prepare.test-helpers.ts | 1 + .../monitor/message-handler/prepare.test.ts | 120 +++++++++++++----- src/slack/monitor/monitor.test.ts | 1 + src/slack/monitor/provider.ts | 2 + 10 files changed, 115 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4a3930333..1302dc00187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet. - Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr. - Telegram/topic agent routing: support per-topic `agentId` overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin. +- Slack/DM typing feedback: add `channels.slack.typingReaction` so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat. ### Fixes diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 0ed20d87797..96abe2641d6 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -187,6 +187,8 @@ export type SlackAccountConfig = { * Slack uses shortcodes (e.g., "eyes") rather than unicode emoji. */ ackReaction?: string; + /** Reaction emoji added while processing a reply (e.g. "hourglass_flowing_sand"). Removed when done. Useful as a typing indicator fallback when assistant mode is not enabled. */ + typingReaction?: string; }; export type SlackConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index c4de3b4c265..8ad07d39910 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -840,6 +840,7 @@ export const SlackAccountSchema = z heartbeat: ChannelHeartbeatVisibilitySchema, responsePrefix: z.string().optional(), ackReaction: z.string().optional(), + typingReaction: z.string().optional(), }) .strict() .superRefine((value) => { diff --git a/src/slack/monitor/context.test.ts b/src/slack/monitor/context.test.ts index 73b37e272d2..11692fc0d52 100644 --- a/src/slack/monitor/context.test.ts +++ b/src/slack/monitor/context.test.ts @@ -41,6 +41,7 @@ function createTestContext() { sessionPrefix: "slack:slash", }, textLimit: 4000, + typingReaction: "", ackReactionScope: "group-mentions", mediaMaxBytes: 20 * 1024 * 1024, removeAckAfterReply: false, diff --git a/src/slack/monitor/context.ts b/src/slack/monitor/context.ts index 2127505f6e5..84633320427 100644 --- a/src/slack/monitor/context.ts +++ b/src/slack/monitor/context.ts @@ -52,6 +52,7 @@ export type SlackMonitorContext = { slashCommand: Required; textLimit: number; ackReactionScope: string; + typingReaction: string; mediaMaxBytes: number; removeAckAfterReply: boolean; @@ -114,6 +115,7 @@ export function createSlackMonitorContext(params: { slashCommand: SlackMonitorContext["slashCommand"]; textLimit: number; ackReactionScope: string; + typingReaction: string; mediaMaxBytes: number; removeAckAfterReply: boolean; }): SlackMonitorContext { @@ -390,6 +392,7 @@ export function createSlackMonitorContext(params: { slashCommand: params.slashCommand, textLimit: params.textLimit, ackReactionScope: params.ackReactionScope, + typingReaction: params.typingReaction, mediaMaxBytes: params.mediaMaxBytes, removeAckAfterReply: params.removeAckAfterReply, logger, diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index 147d8fa6bfb..029d110f0b9 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -11,7 +11,7 @@ import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js"; import { resolveAgentOutboundIdentity } from "../../../infra/outbound/identity.js"; import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../security/dm-policy-shared.js"; -import { removeSlackReaction } from "../../actions.js"; +import { reactSlackMessage, removeSlackReaction } from "../../actions.js"; import { createSlackDraftStream } from "../../draft-stream.js"; import { normalizeSlackOutboundText } from "../../format.js"; import { recordSlackThreadParticipation } from "../../sent-thread-cache.js"; @@ -140,6 +140,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }); const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel; + const typingReaction = ctx.typingReaction; const typingCallbacks = createTypingCallbacks({ start: async () => { didSetStatus = true; @@ -148,6 +149,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag threadTs: statusThreadTs, status: "is typing...", }); + if (typingReaction && message.ts) { + await reactSlackMessage(message.channel, message.ts, typingReaction, { + token: ctx.botToken, + client: ctx.app.client, + }).catch(() => {}); + } }, stop: async () => { if (!didSetStatus) { @@ -159,6 +166,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag threadTs: statusThreadTs, status: "", }); + if (typingReaction && message.ts) { + await removeSlackReaction(message.channel, message.ts, typingReaction, { + token: ctx.botToken, + client: ctx.app.client, + }).catch(() => {}); + } }, onStartError: (err) => { logTypingFailure({ diff --git a/src/slack/monitor/message-handler/prepare.test-helpers.ts b/src/slack/monitor/message-handler/prepare.test-helpers.ts index c80ea4b6ace..39cbaeb4db0 100644 --- a/src/slack/monitor/message-handler/prepare.test-helpers.ts +++ b/src/slack/monitor/message-handler/prepare.test-helpers.ts @@ -46,6 +46,7 @@ export function createInboundSlackTestContext(params: { }, textLimit: 4000, ackReactionScope: "group-mentions", + typingReaction: "", mediaMaxBytes: 1024, removeAckAfterReply: false, }); diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index 578eb6e153a..a5bdebc1e2d 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -7,14 +7,12 @@ import { expectInboundContextContract } from "../../../../test/helpers/inbound-c import type { OpenClawConfig } from "../../../config/config.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; +import type { RuntimeEnv } from "../../../runtime.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackMonitorContext } from "../context.js"; +import { createSlackMonitorContext } from "../context.js"; import { prepareSlackMessage } from "./prepare.js"; -import { - createInboundSlackTestContext as createInboundSlackCtx, - createSlackTestAccount as createSlackAccount, -} from "./prepare.test-helpers.js"; describe("slack prepareSlackMessage inbound contract", () => { let fixtureRoot = ""; @@ -24,7 +22,9 @@ describe("slack prepareSlackMessage inbound contract", () => { if (!fixtureRoot) { throw new Error("fixtureRoot missing"); } - return { storePath: path.join(fixtureRoot, `case-${caseId++}.sessions.json`) }; + const dir = path.join(fixtureRoot, `case-${caseId++}`); + fs.mkdirSync(dir); + return { dir, storePath: path.join(dir, "sessions.json") }; } beforeAll(() => { @@ -38,6 +38,54 @@ describe("slack prepareSlackMessage inbound contract", () => { } }); + function createInboundSlackCtx(params: { + cfg: OpenClawConfig; + appClient?: App["client"]; + defaultRequireMention?: boolean; + replyToMode?: "off" | "all"; + channelsConfig?: Record; + }) { + return createSlackMonitorContext({ + cfg: params.cfg, + accountId: "default", + botToken: "token", + app: { client: params.appClient ?? {} } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender", + mainKey: "main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + allowNameMatching: false, + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: params.defaultRequireMention ?? true, + channelsConfig: params.channelsConfig, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: params.replyToMode ?? "off", + threadHistoryScope: "thread", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "openclaw", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + typingReaction: "", + mediaMaxBytes: 1024, + removeAckAfterReply: false, + }); + } + function createDefaultSlackCtx() { const slackCtx = createInboundSlackCtx({ cfg: { @@ -57,38 +105,39 @@ describe("slack prepareSlackMessage inbound contract", () => { userTokenSource: "none", config: {}, }; - const defaultMessageTemplate = Object.freeze({ - channel: "D123", - channel_type: "im", - user: "U1", - text: "hi", - ts: "1.000", - }) as SlackMessageEvent; - const threadAccount = Object.freeze({ - accountId: "default", - enabled: true, - botTokenSource: "config", - appTokenSource: "config", - userTokenSource: "none", - config: { - replyToMode: "all", - thread: { initialHistoryLimit: 20 }, - }, - replyToMode: "all", - }) as ResolvedSlackAccount; - const defaultPrepareOpts = Object.freeze({ source: "message" }) as { source: "message" }; async function prepareWithDefaultCtx(message: SlackMessageEvent) { return prepareSlackMessage({ ctx: createDefaultSlackCtx(), account: defaultAccount, message, - opts: defaultPrepareOpts, + opts: { source: "message" }, }); } + function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { + return { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + userTokenSource: "none", + config, + replyToMode: config.replyToMode, + replyToModeByChatType: config.replyToModeByChatType, + dm: config.dm, + }; + } + function createSlackMessage(overrides: Partial): SlackMessageEvent { - return { ...defaultMessageTemplate, ...overrides } as SlackMessageEvent; + return { + channel: "D123", + channel_type: "im", + user: "U1", + text: "hi", + ts: "1.000", + ...overrides, + } as SlackMessageEvent; } async function prepareMessageWith( @@ -100,7 +149,7 @@ describe("slack prepareSlackMessage inbound contract", () => { ctx, account, message, - opts: defaultPrepareOpts, + opts: { source: "message" }, }); } @@ -114,7 +163,18 @@ describe("slack prepareSlackMessage inbound contract", () => { } function createThreadAccount(): ResolvedSlackAccount { - return threadAccount; + return { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + userTokenSource: "none", + config: { + replyToMode: "all", + thread: { initialHistoryLimit: 20 }, + }, + replyToMode: "all", + }; } function createThreadReplyMessage(overrides: Partial): SlackMessageEvent { @@ -450,7 +510,6 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared).toBeTruthy(); expect(prepared!.ctxPayload.IsFirstThreadTurn).toBe(true); - expect(prepared!.ctxPayload.ThreadStarterBody).toBe("starter"); expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("assistant reply"); expect(prepared!.ctxPayload.ThreadHistoryBody).toContain("follow-up question"); expect(prepared!.ctxPayload.ThreadHistoryBody).not.toContain("current message"); @@ -474,7 +533,6 @@ describe("slack prepareSlackMessage inbound contract", () => { baseSessionKey: route.sessionKey, threadId: "200.000", }); - // Simulate existing session - thread history should NOT be fetched (bloat fix) fs.writeFileSync( storePath, JSON.stringify({ [threadKeys.sessionKey]: { updatedAt: Date.now() } }, null, 2), diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts index 3da7f08164e..c1fac686971 100644 --- a/src/slack/monitor/monitor.test.ts +++ b/src/slack/monitor/monitor.test.ts @@ -115,6 +115,7 @@ const baseParams = () => ({ }, textLimit: 4000, ackReactionScope: "group-mentions", + typingReaction: "", mediaMaxBytes: 1, threadHistoryScope: "thread" as const, threadInheritParent: false, diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 0ecc3e2e491..b7a10588e3f 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -152,6 +152,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const slashCommand = resolveSlackSlashCommandConfig(opts.slashCommand ?? slackCfg.slashCommand); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const typingReaction = slackCfg.typingReaction?.trim() ?? ""; const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024; const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; @@ -250,6 +251,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { slashCommand, textLimit, ackReactionScope, + typingReaction, mediaMaxBytes, removeAckAfterReply, });