mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(slack): finalize replyToMode off threading behavior (#23799)
* fix: make replyToMode 'off' actually prevent threading in Slack Three independent bugs caused Slack replies to always create threads even when replyToMode was set to 'off': 1. Typing indicator created threads via statusThreadTs fallback (#16868) - resolveSlackThreadTargets fell back to messageTs for statusThreadTs - 'is typing...' was posted as thread reply, creating a thread - Fix: remove messageTs fallback, let statusThreadTs be undefined 2. [[reply_to_current]] tags bypassed replyToMode entirely (#16080) - Slack dock had allowExplicitReplyTagsWhenOff: true - Reply tags from system prompt always threaded regardless of config - Fix: set allowExplicitReplyTagsWhenOff to false for Slack 3. Contradictory replyToMode defaults in codebase (#20827) - monitor/provider.ts defaulted to 'all' - accounts.ts defaulted to 'off' (matching docs) - Fix: align provider.ts default to 'off' per documentation Fixes: openclaw/openclaw#16868, openclaw/openclaw#16080, openclaw/openclaw#20827 * fix(slack): respect replyToMode in DMs even with typing indicator thread When replyToMode is 'off' in DMs, replies should stay in the main conversation even when the typing indicator creates a thread context. Previously, when incomingThreadTs was set (from the typing indicator's thread), replyToMode was forced to 'all', causing all replies to go into the thread. Now, for direct messages, the user's configured replyToMode is always respected. For channels/groups, the existing behavior is preserved (stay in thread if already in one). This fix: - Keeps the typing indicator working (statusThreadTs fallback preserved) - Prevents DM replies from being forced into threads - Maintains channel thread continuity Fixes #16868 * refactor(slack): eliminate redundant resolveSlackThreadContext call - Add isThreadReply to resolveSlackThreadTargets return value - Remove duplicate call in dispatch.ts - Addresses greptile review feedback with cleaner DRY approach * docs(slack): add JSDoc to resolveSlackThreadTargets Document return values including isThreadReply distinction between genuine user thread replies vs bot status message thread context. * docs(changelog): record Slack replyToMode off threading fixes --------- Co-authored-by: James <jamesrp13@gmail.com> Co-authored-by: theoseo <suhong.seo@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -241,7 +241,7 @@ Manual reply tags are supported:
|
||||
- `[[reply_to_current]]`
|
||||
- `[[reply_to:<id>]]`
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||
allowExplicitReplyTagsWhenOff: true,
|
||||
allowExplicitReplyTagsWhenOff: false,
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
messaging: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -492,7 +492,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||
allowExplicitReplyTagsWhenOff: true,
|
||||
allowExplicitReplyTagsWhenOff: false,
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -300,6 +300,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
return { text: "final reply" };
|
||||
});
|
||||
|
||||
setDirectMessageReplyMode("all");
|
||||
await runSlackMessageOnce(monitorSlackProvider, {
|
||||
event: makeSlackMessageEvent(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user