Skip to content

Commit 9ae9439

Browse files
dbachelderclaudeTakhoffman
authored
fix(slack): resolve replyToMode per-message using chat type (#24717)
* fix(slack): resolve replyToMode per-message using chat type The Slack monitor resolved replyToMode once at startup from the top-level config, ignoring replyToModeByChatType overrides. This caused DM replies to be threaded even when replyToModeByChatType.direct was set to "off". Now the inbound message handler calls resolveSlackReplyToMode(account, chatType) per-message — the same function already used by the outbound dock and tool threading context — so per-chat-type overrides take effect on the inbound path. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Slack: add changelog for per-message replyToMode resolution --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 265b22c commit 9ae9439

File tree

5 files changed

+81
-7
lines changed

5 files changed

+81
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ Docs: https://docs.openclaw.ai
237237
### Fixes
238238

239239
- Slack/Identity: thread agent outbound identity (`chat:write.customize` overrides) through the channel reply delivery path so per-agent username, icon URL, and icon emoji are applied to all Slack replies including media messages. (#27134) Thanks @hou-rong.
240+
- Slack/Threading: resolve `replyToMode` per incoming message using chat-type-aware account config (`replyToModeByChatType` and legacy `dm.replyToMode`) so DM/channel reply threading honors overrides instead of always using monitor startup defaults. (#24717) Thanks @dbachelder.
240241
- Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.
241242
- Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156)
242243
- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl.

src/slack/monitor/message-handler/dispatch.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
101101

102102
const { statusThreadTs, isThreadReply } = resolveSlackThreadTargets({
103103
message,
104-
replyToMode: ctx.replyToMode,
104+
replyToMode: prepared.replyToMode,
105105
});
106106

107107
const messageTs = message.ts ?? message.event_ts;
@@ -112,7 +112,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
112112
// mark this to ensure only the first reply is threaded.
113113
const hasRepliedRef = { value: false };
114114
const replyPlan = createSlackReplyDeliveryPlan({
115-
replyToMode: ctx.replyToMode,
115+
replyToMode: prepared.replyToMode,
116116
incomingThreadTs,
117117
messageTs,
118118
hasRepliedRef,
@@ -178,7 +178,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
178178
nativeStreaming: slackStreaming.nativeStreaming,
179179
});
180180
const streamThreadHint = resolveSlackStreamingThreadHint({
181-
replyToMode: ctx.replyToMode,
181+
replyToMode: prepared.replyToMode,
182182
incomingThreadTs,
183183
messageTs,
184184
isThreadReply,
@@ -200,7 +200,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
200200
runtime,
201201
textLimit: ctx.textLimit,
202202
replyThreadTs,
203-
replyToMode: ctx.replyToMode,
203+
replyToMode: prepared.replyToMode,
204204
...(slackIdentity ? { identity: slackIdentity } : {}),
205205
});
206206
replyPlan.markSent();

src/slack/monitor/message-handler/prepare.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ describe("slack prepareSlackMessage inbound contract", () => {
120120
botTokenSource: "config",
121121
appTokenSource: "config",
122122
config,
123+
replyToMode: config.replyToMode,
124+
replyToModeByChatType: config.replyToModeByChatType,
125+
dm: config.dm,
123126
};
124127
}
125128

@@ -166,6 +169,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
166169
replyToMode: "all",
167170
thread: { initialHistoryLimit: 20 },
168171
},
172+
replyToMode: "all",
169173
};
170174
}
171175

@@ -473,6 +477,71 @@ describe("slack prepareSlackMessage inbound contract", () => {
473477
expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000");
474478
});
475479

480+
it("respects replyToModeByChatType.direct override for DMs", async () => {
481+
const slackCtx = createInboundSlackCtx({
482+
cfg: {
483+
channels: { slack: { enabled: true, replyToMode: "all" } },
484+
} as OpenClawConfig,
485+
replyToMode: "all",
486+
});
487+
// oxlint-disable-next-line typescript/no-explicit-any
488+
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
489+
490+
const prepared = await prepareMessageWith(
491+
slackCtx,
492+
createSlackAccount({ replyToMode: "all", replyToModeByChatType: { direct: "off" } }),
493+
createSlackMessage({}), // DM (channel_type: "im")
494+
);
495+
496+
expect(prepared).toBeTruthy();
497+
expect(prepared!.replyToMode).toBe("off");
498+
expect(prepared!.ctxPayload.MessageThreadId).toBeUndefined();
499+
});
500+
501+
it("still threads channel messages when replyToModeByChatType.direct is off", async () => {
502+
const slackCtx = createInboundSlackCtx({
503+
cfg: {
504+
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
505+
} as OpenClawConfig,
506+
replyToMode: "all",
507+
defaultRequireMention: false,
508+
});
509+
// oxlint-disable-next-line typescript/no-explicit-any
510+
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
511+
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
512+
513+
const prepared = await prepareMessageWith(
514+
slackCtx,
515+
createSlackAccount({ replyToMode: "all", replyToModeByChatType: { direct: "off" } }),
516+
createSlackMessage({ channel: "C123", channel_type: "channel" }),
517+
);
518+
519+
expect(prepared).toBeTruthy();
520+
expect(prepared!.replyToMode).toBe("all");
521+
expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000");
522+
});
523+
524+
it("respects dm.replyToMode legacy override for DMs", async () => {
525+
const slackCtx = createInboundSlackCtx({
526+
cfg: {
527+
channels: { slack: { enabled: true, replyToMode: "all" } },
528+
} as OpenClawConfig,
529+
replyToMode: "all",
530+
});
531+
// oxlint-disable-next-line typescript/no-explicit-any
532+
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
533+
534+
const prepared = await prepareMessageWith(
535+
slackCtx,
536+
createSlackAccount({ replyToMode: "all", dm: { replyToMode: "off" } }),
537+
createSlackMessage({}), // DM
538+
);
539+
540+
expect(prepared).toBeTruthy();
541+
expect(prepared!.replyToMode).toBe("off");
542+
expect(prepared!.ctxPayload.MessageThreadId).toBeUndefined();
543+
});
544+
476545
it("marks first thread turn and injects thread history for a new thread session", async () => {
477546
const { storePath } = makeTmpStorePath();
478547
const replies = vi
@@ -671,7 +740,7 @@ describe("prepareSlackMessage sender prefix", () => {
671740
async function prepareSenderPrefixMessage(ctx: SlackMonitorContext, text: string, ts: string) {
672741
return prepareSlackMessage({
673742
ctx,
674-
account: { accountId: "default", config: {} } as never,
743+
account: { accountId: "default", config: {}, replyToMode: "off" } as never,
675744
message: {
676745
type: "message",
677746
channel: "C1",

src/slack/monitor/message-handler/prepare.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { logVerbose, shouldLogVerbose } from "../../../globals.js";
2929
import { enqueueSystemEvent } from "../../../infra/system-events.js";
3030
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
3131
import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
32-
import type { ResolvedSlackAccount } from "../../accounts.js";
32+
import { resolveSlackReplyToMode, type ResolvedSlackAccount } from "../../accounts.js";
3333
import { reactSlackMessage } from "../../actions.js";
3434
import { sendMessageSlack } from "../../send.js";
3535
import { resolveSlackThreadContext } from "../../threading.js";
@@ -175,7 +175,9 @@ export async function prepareSlackMessage(params: {
175175
});
176176

177177
const baseSessionKey = route.sessionKey;
178-
const threadContext = resolveSlackThreadContext({ message, replyToMode: ctx.replyToMode });
178+
const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel";
179+
const replyToMode = resolveSlackReplyToMode(account, chatType);
180+
const threadContext = resolveSlackThreadContext({ message, replyToMode });
179181
const threadTs = threadContext.incomingThreadTs;
180182
const isThreadReply = threadContext.isThreadReply;
181183
const threadKeys = resolveThreadSessionKeys({
@@ -666,6 +668,7 @@ export async function prepareSlackMessage(params: {
666668
channelConfig,
667669
replyTarget,
668670
ctxPayload,
671+
replyToMode,
669672
isDirectMessage,
670673
isRoomish,
671674
historyKey,

src/slack/monitor/message-handler/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type PreparedSlackMessage = {
1313
channelConfig: SlackChannelConfigResolved | null;
1414
replyTarget: string;
1515
ctxPayload: FinalizedMsgContext;
16+
replyToMode: "off" | "first" | "all";
1617
isDirectMessage: boolean;
1718
isRoomish: boolean;
1819
historyKey: string;

0 commit comments

Comments
 (0)