diff --git a/CHANGELOG.md b/CHANGELOG.md index 870b919331f..858d235c93e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -237,6 +237,7 @@ Docs: https://docs.openclaw.ai ### Fixes - 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. +- 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. - 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. - 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) - 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. diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index b987585a254..a7a852ea14b 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -101,7 +101,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const { statusThreadTs, isThreadReply } = resolveSlackThreadTargets({ message, - replyToMode: ctx.replyToMode, + replyToMode: prepared.replyToMode, }); const messageTs = message.ts ?? message.event_ts; @@ -112,7 +112,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag // mark this to ensure only the first reply is threaded. const hasRepliedRef = { value: false }; const replyPlan = createSlackReplyDeliveryPlan({ - replyToMode: ctx.replyToMode, + replyToMode: prepared.replyToMode, incomingThreadTs, messageTs, hasRepliedRef, @@ -178,7 +178,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag nativeStreaming: slackStreaming.nativeStreaming, }); const streamThreadHint = resolveSlackStreamingThreadHint({ - replyToMode: ctx.replyToMode, + replyToMode: prepared.replyToMode, incomingThreadTs, messageTs, isThreadReply, @@ -200,7 +200,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag runtime, textLimit: ctx.textLimit, replyThreadTs, - replyToMode: ctx.replyToMode, + replyToMode: prepared.replyToMode, ...(slackIdentity ? { identity: slackIdentity } : {}), }); replyPlan.markSent(); diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index 548e2b0b471..1be530ee039 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -120,6 +120,9 @@ describe("slack prepareSlackMessage inbound contract", () => { botTokenSource: "config", appTokenSource: "config", config, + replyToMode: config.replyToMode, + replyToModeByChatType: config.replyToModeByChatType, + dm: config.dm, }; } @@ -166,6 +169,7 @@ describe("slack prepareSlackMessage inbound contract", () => { replyToMode: "all", thread: { initialHistoryLimit: 20 }, }, + replyToMode: "all", }; } @@ -473,6 +477,71 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000"); }); + it("respects replyToModeByChatType.direct override for DMs", async () => { + const slackCtx = createInboundSlackCtx({ + cfg: { + channels: { slack: { enabled: true, replyToMode: "all" } }, + } as OpenClawConfig, + replyToMode: "all", + }); + // oxlint-disable-next-line typescript/no-explicit-any + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + + const prepared = await prepareMessageWith( + slackCtx, + createSlackAccount({ replyToMode: "all", replyToModeByChatType: { direct: "off" } }), + createSlackMessage({}), // DM (channel_type: "im") + ); + + expect(prepared).toBeTruthy(); + expect(prepared!.replyToMode).toBe("off"); + expect(prepared!.ctxPayload.MessageThreadId).toBeUndefined(); + }); + + it("still threads channel messages when replyToModeByChatType.direct is off", async () => { + const slackCtx = createInboundSlackCtx({ + cfg: { + channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } }, + } as OpenClawConfig, + replyToMode: "all", + defaultRequireMention: false, + }); + // oxlint-disable-next-line typescript/no-explicit-any + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" }); + + const prepared = await prepareMessageWith( + slackCtx, + createSlackAccount({ replyToMode: "all", replyToModeByChatType: { direct: "off" } }), + createSlackMessage({ channel: "C123", channel_type: "channel" }), + ); + + expect(prepared).toBeTruthy(); + expect(prepared!.replyToMode).toBe("all"); + expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000"); + }); + + it("respects dm.replyToMode legacy override for DMs", async () => { + const slackCtx = createInboundSlackCtx({ + cfg: { + channels: { slack: { enabled: true, replyToMode: "all" } }, + } as OpenClawConfig, + replyToMode: "all", + }); + // oxlint-disable-next-line typescript/no-explicit-any + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + + const prepared = await prepareMessageWith( + slackCtx, + createSlackAccount({ replyToMode: "all", dm: { replyToMode: "off" } }), + createSlackMessage({}), // DM + ); + + expect(prepared).toBeTruthy(); + expect(prepared!.replyToMode).toBe("off"); + expect(prepared!.ctxPayload.MessageThreadId).toBeUndefined(); + }); + it("marks first thread turn and injects thread history for a new thread session", async () => { const { storePath } = makeTmpStorePath(); const replies = vi @@ -671,7 +740,7 @@ describe("prepareSlackMessage sender prefix", () => { async function prepareSenderPrefixMessage(ctx: SlackMonitorContext, text: string, ts: string) { return prepareSlackMessage({ ctx, - account: { accountId: "default", config: {} } as never, + account: { accountId: "default", config: {}, replyToMode: "off" } as never, message: { type: "message", channel: "C1", diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 02ee265f7ca..9462ac7676e 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -29,7 +29,7 @@ import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; -import type { ResolvedSlackAccount } from "../../accounts.js"; +import { resolveSlackReplyToMode, type ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; import { sendMessageSlack } from "../../send.js"; import { resolveSlackThreadContext } from "../../threading.js"; @@ -175,7 +175,9 @@ export async function prepareSlackMessage(params: { }); const baseSessionKey = route.sessionKey; - const threadContext = resolveSlackThreadContext({ message, replyToMode: ctx.replyToMode }); + const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel"; + const replyToMode = resolveSlackReplyToMode(account, chatType); + const threadContext = resolveSlackThreadContext({ message, replyToMode }); const threadTs = threadContext.incomingThreadTs; const isThreadReply = threadContext.isThreadReply; const threadKeys = resolveThreadSessionKeys({ @@ -666,6 +668,7 @@ export async function prepareSlackMessage(params: { channelConfig, replyTarget, ctxPayload, + replyToMode, isDirectMessage, isRoomish, historyKey, diff --git a/src/slack/monitor/message-handler/types.ts b/src/slack/monitor/message-handler/types.ts index 8fbf4a939dd..c99380d8b20 100644 --- a/src/slack/monitor/message-handler/types.ts +++ b/src/slack/monitor/message-handler/types.ts @@ -13,6 +13,7 @@ export type PreparedSlackMessage = { channelConfig: SlackChannelConfigResolved | null; replyTarget: string; ctxPayload: FinalizedMsgContext; + replyToMode: "off" | "first" | "all"; isDirectMessage: boolean; isRoomish: boolean; historyKey: string;