diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac3703aa..168d8ea85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,7 +99,7 @@ Docs: https://docs.openclaw.ai - Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic. - Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia. - Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13. -- Slack/Queue routing: preserve string `thread_ts` values through collect-mode queue drain and DM `deliveryContext` updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2. +- Slack/Queue routing: preserve string `thread_ts` values through collect-mode queue drain and DM `deliveryContext` updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2 and @vincentkoc. - Telegram/Native commands: set `ctx.Provider="telegram"` for native slash-command context so elevated gate checks resolve provider correctly (fixes `provider (ctx.Provider)` failures in `/elevated` flows). (#23748) Thanks @serhii12. - Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester. - Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 4a1bda699..35ade5d1a 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -241,7 +241,7 @@ Manual reply tags are supported: - `[[reply_to_current]]` - `[[reply_to:]]` -Note: `replyToMode="off"` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored. +Note: `replyToMode="off"` disables **all** reply threading in Slack, including explicit `[[reply_to_*]]` tags. This differs from Telegram, where explicit tags are still honored in `"off"` mode. The difference reflects the platform threading models: Slack threads hide messages from the channel, while Telegram replies remain visible in the main chat flow. ## Media, chunking, and delivery diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index f431f71b3..88bb40ca4 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -183,7 +183,7 @@ export const slackPlugin: ChannelPlugin = { threading: { resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), - allowExplicitReplyTagsWhenOff: true, + allowExplicitReplyTagsWhenOff: false, buildToolContext: (params) => buildSlackThreadingToolContext(params), }, messaging: { diff --git a/src/auto-reply/reply/reply-plumbing.test.ts b/src/auto-reply/reply/reply-plumbing.test.ts index 881147f16..6d8a3d532 100644 --- a/src/auto-reply/reply/reply-plumbing.test.ts +++ b/src/auto-reply/reply/reply-plumbing.test.ts @@ -206,7 +206,7 @@ describe("applyReplyThreading auto-threading", () => { expect(result[0].replyToId).toBeUndefined(); }); - it("keeps explicit tags for Slack when off mode allows tags", () => { + it("strips explicit tags for Slack when off mode disallows tags", () => { const result = applyReplyThreading({ payloads: [{ text: "[[reply_to_current]]A" }], replyToMode: "off", @@ -215,8 +215,7 @@ describe("applyReplyThreading auto-threading", () => { }); expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); - expect(result[0].replyToTag).toBe(true); + expect(result[0].replyToId).toBeUndefined(); }); it("keeps explicit tags for Telegram when off mode is enabled", () => { diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 2e287aa79..c773aa43c 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -492,7 +492,7 @@ const DOCKS: Record = { threading: { resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), - allowExplicitReplyTagsWhenOff: true, + allowExplicitReplyTagsWhenOff: false, buildToolContext: (params) => buildSlackThreadingToolContext(params), }, }, diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index 5f2bfcbfa..3f42ff8a3 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -300,6 +300,7 @@ describe("monitorSlackProvider tool results", () => { return { text: "final reply" }; }); + setDirectMessageReplyMode("all"); await runSlackMessageOnce(monitorSlackProvider, { event: makeSlackMessageEvent(), }); diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index afcfdd626..d84037e08 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -86,7 +86,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }); } - const { statusThreadTs } = resolveSlackThreadTargets({ + const { statusThreadTs, isThreadReply } = resolveSlackThreadTargets({ message, replyToMode: ctx.replyToMode, }); @@ -103,6 +103,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag incomingThreadTs, messageTs, hasRepliedRef, + chatType: prepared.isDirectMessage ? "direct" : "channel", + isThreadReply, }); const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel; diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 35003bedf..19eab0d18 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -121,7 +121,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const useAccessGroups = cfg.commands?.useAccessGroups !== false; const reactionMode = slackCfg.reactionNotifications ?? "own"; const reactionAllowlist = slackCfg.reactionAllowlist ?? []; - const replyToMode = slackCfg.replyToMode ?? "all"; + const replyToMode = slackCfg.replyToMode ?? "off"; const threadHistoryScope = slackCfg.thread?.historyScope ?? "thread"; const threadInheritParent = slackCfg.thread?.inheritParent ?? false; const slashCommand = resolveSlackSlashCommandConfig(opts.slashCommand ?? slackCfg.slashCommand); diff --git a/src/slack/monitor/replies.ts b/src/slack/monitor/replies.ts index 083c59b3f..3f92ee332 100644 --- a/src/slack/monitor/replies.ts +++ b/src/slack/monitor/replies.ts @@ -88,10 +88,19 @@ function createSlackReplyReferencePlanner(params: { incomingThreadTs: string | undefined; messageTs: string | undefined; hasReplied?: boolean; + chatType?: "direct" | "channel" | "group"; + isThreadReply?: boolean; }) { - // When already inside a Slack thread, always stay in it regardless of - // replyToMode — thread_ts is required to keep messages in the thread. - const effectiveMode = params.incomingThreadTs ? "all" : params.replyToMode; + // When already inside a Slack thread, stay in it — but for DMs where the + // "thread" was created by the typing indicator (not a real thread reply), + // respect the user's replyToMode setting. + // See: https://github.com/openclaw/openclaw/issues/16868 + const effectiveMode = + params.chatType === "direct" && !params.isThreadReply + ? params.replyToMode + : params.incomingThreadTs + ? "all" + : params.replyToMode; return createReplyReferencePlanner({ replyToMode: effectiveMode, existingId: params.incomingThreadTs, @@ -105,12 +114,16 @@ export function createSlackReplyDeliveryPlan(params: { incomingThreadTs: string | undefined; messageTs: string | undefined; hasRepliedRef: { value: boolean }; + chatType?: "direct" | "channel" | "group"; + isThreadReply?: boolean; }): SlackReplyDeliveryPlan { const replyReference = createSlackReplyReferencePlanner({ replyToMode: params.replyToMode, incomingThreadTs: params.incomingThreadTs, messageTs: params.messageTs, hasReplied: params.hasRepliedRef.value, + chatType: params.chatType, + isThreadReply: params.isThreadReply, }); return { nextThreadTs: () => replyReference.use(), diff --git a/src/slack/threading.test.ts b/src/slack/threading.test.ts index a9f107254..24e64c122 100644 --- a/src/slack/threading.test.ts +++ b/src/slack/threading.test.ts @@ -31,7 +31,7 @@ describe("resolveSlackThreadTargets", () => { expect(statusThreadTs).toBe("123"); }); - it("keeps status threading even when reply threading is off", () => { + it("does not thread status indicator when reply threading is off", () => { const { replyThreadTs, statusThreadTs } = resolveSlackThreadTargets({ replyToMode: "off", message: { @@ -42,7 +42,7 @@ describe("resolveSlackThreadTargets", () => { }); expect(replyThreadTs).toBeUndefined(); - expect(statusThreadTs).toBe("123"); + expect(statusThreadTs).toBeUndefined(); }); it("sets messageThreadId for top-level messages when replyToMode is all", () => { diff --git a/src/slack/threading.ts b/src/slack/threading.ts index 3a95beb2e..870999f40 100644 --- a/src/slack/threading.ts +++ b/src/slack/threading.ts @@ -34,12 +34,21 @@ export function resolveSlackThreadContext(params: { }; } +/** + * Resolves Slack thread targeting for replies and status indicators. + * + * @returns replyThreadTs - Thread timestamp for reply messages + * @returns statusThreadTs - Thread timestamp for status indicators (typing, etc.) + * @returns isThreadReply - true if this is a genuine user reply in a thread, + * false if thread_ts comes from a bot status message (e.g. typing indicator) + */ export function resolveSlackThreadTargets(params: { message: SlackMessageEvent | SlackAppMentionEvent; replyToMode: ReplyToMode; }) { - const { incomingThreadTs, messageTs } = resolveSlackThreadContext(params); + const ctx = resolveSlackThreadContext(params); + const { incomingThreadTs, messageTs, isThreadReply } = ctx; const replyThreadTs = incomingThreadTs ?? (params.replyToMode === "all" ? messageTs : undefined); - const statusThreadTs = replyThreadTs ?? messageTs; - return { replyThreadTs, statusThreadTs }; + const statusThreadTs = replyThreadTs; + return { replyThreadTs, statusThreadTs, isThreadReply }; }