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 <vincentkoc@ieee.org>
This commit is contained in:
Dale Yarborough
2026-03-03 23:07:17 -06:00
committed by GitHub
parent 230fea1ca6
commit a95a0be133
10 changed files with 115 additions and 32 deletions

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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) => {

View File

@@ -41,6 +41,7 @@ function createTestContext() {
sessionPrefix: "slack:slash",
},
textLimit: 4000,
typingReaction: "",
ackReactionScope: "group-mentions",
mediaMaxBytes: 20 * 1024 * 1024,
removeAckAfterReply: false,

View File

@@ -52,6 +52,7 @@ export type SlackMonitorContext = {
slashCommand: Required<import("../../config/config.js").SlackSlashCommandConfig>;
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,

View File

@@ -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({

View File

@@ -46,6 +46,7 @@ export function createInboundSlackTestContext(params: {
},
textLimit: 4000,
ackReactionScope: "group-mentions",
typingReaction: "",
mediaMaxBytes: 1024,
removeAckAfterReply: false,
});

View File

@@ -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<string, { systemPrompt: string }>;
}) {
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>): 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>): 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),

View File

@@ -115,6 +115,7 @@ const baseParams = () => ({
},
textLimit: 4000,
ackReactionScope: "group-mentions",
typingReaction: "",
mediaMaxBytes: 1,
threadHistoryScope: "thread" as const,
threadInheritParent: false,

View File

@@ -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,
});