mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -41,6 +41,7 @@ function createTestContext() {
|
||||
sessionPrefix: "slack:slash",
|
||||
},
|
||||
textLimit: 4000,
|
||||
typingReaction: "",
|
||||
ackReactionScope: "group-mentions",
|
||||
mediaMaxBytes: 20 * 1024 * 1024,
|
||||
removeAckAfterReply: false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createInboundSlackTestContext(params: {
|
||||
},
|
||||
textLimit: 4000,
|
||||
ackReactionScope: "group-mentions",
|
||||
typingReaction: "",
|
||||
mediaMaxBytes: 1024,
|
||||
removeAckAfterReply: false,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -115,6 +115,7 @@ const baseParams = () => ({
|
||||
},
|
||||
textLimit: 4000,
|
||||
ackReactionScope: "group-mentions",
|
||||
typingReaction: "",
|
||||
mediaMaxBytes: 1,
|
||||
threadHistoryScope: "thread" as const,
|
||||
threadInheritParent: false,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user