mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 23:13:43 +00:00
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 <noreply@anthropic.com> * Slack: add changelog for per-message replyToMode resolution --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -237,6 +237,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Fixes
|
### 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/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.
|
- 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)
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
|
|
||||||
const { statusThreadTs, isThreadReply } = resolveSlackThreadTargets({
|
const { statusThreadTs, isThreadReply } = resolveSlackThreadTargets({
|
||||||
message,
|
message,
|
||||||
replyToMode: ctx.replyToMode,
|
replyToMode: prepared.replyToMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageTs = message.ts ?? message.event_ts;
|
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.
|
// mark this to ensure only the first reply is threaded.
|
||||||
const hasRepliedRef = { value: false };
|
const hasRepliedRef = { value: false };
|
||||||
const replyPlan = createSlackReplyDeliveryPlan({
|
const replyPlan = createSlackReplyDeliveryPlan({
|
||||||
replyToMode: ctx.replyToMode,
|
replyToMode: prepared.replyToMode,
|
||||||
incomingThreadTs,
|
incomingThreadTs,
|
||||||
messageTs,
|
messageTs,
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
@@ -178,7 +178,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
nativeStreaming: slackStreaming.nativeStreaming,
|
nativeStreaming: slackStreaming.nativeStreaming,
|
||||||
});
|
});
|
||||||
const streamThreadHint = resolveSlackStreamingThreadHint({
|
const streamThreadHint = resolveSlackStreamingThreadHint({
|
||||||
replyToMode: ctx.replyToMode,
|
replyToMode: prepared.replyToMode,
|
||||||
incomingThreadTs,
|
incomingThreadTs,
|
||||||
messageTs,
|
messageTs,
|
||||||
isThreadReply,
|
isThreadReply,
|
||||||
@@ -200,7 +200,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
runtime,
|
runtime,
|
||||||
textLimit: ctx.textLimit,
|
textLimit: ctx.textLimit,
|
||||||
replyThreadTs,
|
replyThreadTs,
|
||||||
replyToMode: ctx.replyToMode,
|
replyToMode: prepared.replyToMode,
|
||||||
...(slackIdentity ? { identity: slackIdentity } : {}),
|
...(slackIdentity ? { identity: slackIdentity } : {}),
|
||||||
});
|
});
|
||||||
replyPlan.markSent();
|
replyPlan.markSent();
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
botTokenSource: "config",
|
botTokenSource: "config",
|
||||||
appTokenSource: "config",
|
appTokenSource: "config",
|
||||||
config,
|
config,
|
||||||
|
replyToMode: config.replyToMode,
|
||||||
|
replyToModeByChatType: config.replyToModeByChatType,
|
||||||
|
dm: config.dm,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +169,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
replyToMode: "all",
|
replyToMode: "all",
|
||||||
thread: { initialHistoryLimit: 20 },
|
thread: { initialHistoryLimit: 20 },
|
||||||
},
|
},
|
||||||
|
replyToMode: "all",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +477,71 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000");
|
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 () => {
|
it("marks first thread turn and injects thread history for a new thread session", async () => {
|
||||||
const { storePath } = makeTmpStorePath();
|
const { storePath } = makeTmpStorePath();
|
||||||
const replies = vi
|
const replies = vi
|
||||||
@@ -671,7 +740,7 @@ describe("prepareSlackMessage sender prefix", () => {
|
|||||||
async function prepareSenderPrefixMessage(ctx: SlackMonitorContext, text: string, ts: string) {
|
async function prepareSenderPrefixMessage(ctx: SlackMonitorContext, text: string, ts: string) {
|
||||||
return prepareSlackMessage({
|
return prepareSlackMessage({
|
||||||
ctx,
|
ctx,
|
||||||
account: { accountId: "default", config: {} } as never,
|
account: { accountId: "default", config: {}, replyToMode: "off" } as never,
|
||||||
message: {
|
message: {
|
||||||
type: "message",
|
type: "message",
|
||||||
channel: "C1",
|
channel: "C1",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { logVerbose, shouldLogVerbose } from "../../../globals.js";
|
|||||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||||
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../../../routing/session-key.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 { reactSlackMessage } from "../../actions.js";
|
||||||
import { sendMessageSlack } from "../../send.js";
|
import { sendMessageSlack } from "../../send.js";
|
||||||
import { resolveSlackThreadContext } from "../../threading.js";
|
import { resolveSlackThreadContext } from "../../threading.js";
|
||||||
@@ -175,7 +175,9 @@ export async function prepareSlackMessage(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const baseSessionKey = route.sessionKey;
|
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 threadTs = threadContext.incomingThreadTs;
|
||||||
const isThreadReply = threadContext.isThreadReply;
|
const isThreadReply = threadContext.isThreadReply;
|
||||||
const threadKeys = resolveThreadSessionKeys({
|
const threadKeys = resolveThreadSessionKeys({
|
||||||
@@ -666,6 +668,7 @@ export async function prepareSlackMessage(params: {
|
|||||||
channelConfig,
|
channelConfig,
|
||||||
replyTarget,
|
replyTarget,
|
||||||
ctxPayload,
|
ctxPayload,
|
||||||
|
replyToMode,
|
||||||
isDirectMessage,
|
isDirectMessage,
|
||||||
isRoomish,
|
isRoomish,
|
||||||
historyKey,
|
historyKey,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type PreparedSlackMessage = {
|
|||||||
channelConfig: SlackChannelConfigResolved | null;
|
channelConfig: SlackChannelConfigResolved | null;
|
||||||
replyTarget: string;
|
replyTarget: string;
|
||||||
ctxPayload: FinalizedMsgContext;
|
ctxPayload: FinalizedMsgContext;
|
||||||
|
replyToMode: "off" | "first" | "all";
|
||||||
isDirectMessage: boolean;
|
isDirectMessage: boolean;
|
||||||
isRoomish: boolean;
|
isRoomish: boolean;
|
||||||
historyKey: string;
|
historyKey: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user