From 3e2a2c7b7436d1fced63c4b49c0abfa531341db7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 05:08:34 +0100 Subject: [PATCH] fix(slack): normalize route binding targets --- CHANGELOG.md | 1 + docs/channels/slack.md | 1 + .../message-handler/prepare-routing.ts | 86 ++++++++++++++++++- .../monitor/message-handler/prepare.test.ts | 52 +++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e022aafff39..72f1033b6a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Slack/DMs: keep top-level direct messages on the stable DM session even when `replyToMode` targets Slack thread replies, preserving context across DM turns. Fixes #58832. Thanks @daye-jjeong. - Slack/delivery: preserve Slack Web API missing-scope details in outbound delivery errors, so queued retry state identifies the OAuth scope to add. Fixes #62391. Thanks @alexey-pelykh. - Slack/DMs: send text/block-only proactive DMs directly with `chat.postMessage(channel=)` while keeping conversation resolution for uploads and threaded sends. Fixes #62042. Thanks @MarkMolina. +- Slack/routing: match route bindings written with Slack target syntax such as `channel:C...`, `user:U...`, or `<@U...>`, so bound Slack peers route to the configured agent instead of `main`. Fixes #41608. Thanks @Winnsolutionsadmin. - Slack/mentions: resolve `` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack. - Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars. - Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 9ff573f4c64..06500026511 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -596,6 +596,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r ## Threading, sessions, and reply tags - DMs route as `direct`; channels as `channel`; MPIMs as `group`. +- Slack route bindings accept raw peer IDs plus Slack target forms such as `channel:C12345678`, `user:U12345678`, and `<@U12345678>`. - With default `session.dmScope=main`, Slack DMs collapse to agent main session. - Channel sessions: `agent::slack:channel:`. - Thread replies can create thread session suffixes (`:thread:`) when applicable. diff --git a/extensions/slack/src/monitor/message-handler/prepare-routing.ts b/extensions/slack/src/monitor/message-handler/prepare-routing.ts index 4eede47b79b..fd267d2bac0 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-routing.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-routing.ts @@ -7,6 +7,7 @@ import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { resolveSlackReplyToMode } from "../../account-reply-mode.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; +import { parseSlackTarget, type SlackTargetKind } from "../../targets.js"; import { resolveSlackThreadContext } from "../../threading.js"; import type { SlackMessageEvent } from "../../types.js"; @@ -31,6 +32,89 @@ type SlackRoutingContext = { historyKey: string; }; +type SlackRouteBinding = NonNullable[number]; +type SlackRouteBindingPeer = NonNullable; + +const slackRouteBindingConfigCache = new WeakMap< + OpenClawConfig, + { bindingsRef: OpenClawConfig["bindings"]; normalizedCfg: OpenClawConfig } +>(); + +function slackTargetDefaultKindForPeer(kind: SlackRouteBindingPeer["kind"]): SlackTargetKind { + return kind === "direct" ? "user" : "channel"; +} + +function slackTargetKindMatchesPeer( + peerKind: SlackRouteBindingPeer["kind"], + targetKind: SlackTargetKind, +): boolean { + if (targetKind === "user") { + return peerKind === "direct"; + } + return peerKind === "channel" || peerKind === "group"; +} + +function normalizeSlackRouteBindingPeer(peer: SlackRouteBindingPeer): SlackRouteBindingPeer { + const rawId = peer.id.trim(); + if (!rawId || rawId === "*") { + return peer; + } + + const target = (() => { + try { + return parseSlackTarget(rawId, { + defaultKind: slackTargetDefaultKindForPeer(peer.kind), + }); + } catch { + return undefined; + } + })(); + if (!target || !slackTargetKindMatchesPeer(peer.kind, target.kind) || target.id === peer.id) { + return peer; + } + return { ...peer, id: target.id }; +} + +function normalizeSlackRouteBindingConfig(cfg: OpenClawConfig): OpenClawConfig { + const bindings = cfg.bindings; + const cached = slackRouteBindingConfigCache.get(cfg); + if (cached && cached.bindingsRef === bindings) { + return cached.normalizedCfg; + } + if (!Array.isArray(bindings)) { + return cfg; + } + + let changed = false; + const normalizedBindings = bindings.map((binding) => { + if (binding.type === "acp" || binding.match.channel.trim().toLowerCase() !== "slack") { + return binding; + } + const peer = binding.match.peer; + if (!peer) { + return binding; + } + const normalizedPeer = normalizeSlackRouteBindingPeer(peer); + if (normalizedPeer === peer) { + return binding; + } + changed = true; + return { + ...binding, + match: { + ...binding.match, + peer: normalizedPeer, + }, + }; + }); + + const normalizedCfg = changed + ? ({ ...cfg, bindings: normalizedBindings } as OpenClawConfig) + : cfg; + slackRouteBindingConfigCache.set(cfg, { bindingsRef: bindings, normalizedCfg }); + return normalizedCfg; +} + function resolveSlackBaseConversationId(params: { message: SlackMessageEvent; isDirectMessage: boolean; @@ -48,7 +132,7 @@ function resolveSlackInitialAgentRoute(params: { isRoom: boolean; }) { return resolveAgentRoute({ - cfg: params.ctx.cfg, + cfg: normalizeSlackRouteBindingConfig(params.ctx.cfg), channel: "slack", accountId: params.account.accountId, teamId: params.ctx.teamId || undefined, diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index 52c1215458a..f11f24a7162 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -695,6 +695,58 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared!.ctxPayload.From).toBe("slack:group:G123"); }); + it("matches route bindings that use Slack target syntax for peers (#41608)", async () => { + const cases = [ + { + peer: { kind: "group", id: "channel:C0AJUGWG5L6" }, + message: createSlackMessage({ + channel: "C0AJUGWG5L6", + channel_type: "channel", + text: "strategy ping", + }), + expectedSessionKey: "agent:strategist:slack:channel:c0ajugwg5l6", + }, + { + peer: { kind: "direct", id: "user:U0ROUTE42" }, + message: createSlackMessage({ + channel: "D0ROUTE42", + channel_type: "im", + user: "U0ROUTE42", + text: "dm ping", + }), + expectedSessionKey: "agent:strategist:direct:u0route42", + }, + ] as const; + + for (const testCase of cases) { + const slackCtx = createInboundSlackCtx({ + cfg: { + session: { dmScope: "per-peer" }, + agents: { + list: [{ id: "main", default: true }, { id: "strategist" }], + }, + bindings: [ + { + agentId: "strategist", + match: { channel: "slack", peer: testCase.peer }, + }, + ], + channels: { slack: { enabled: true, groupPolicy: "open" } }, + } as OpenClawConfig, + defaultRequireMention: false, + }); + slackCtx.resolveChannelName = async () => ({ name: "strategy", type: "channel" }); + slackCtx.resolveUserName = async () => ({ name: "Alice" }); + + const prepared = await prepareMessageWith(slackCtx, createSlackAccount(), testCase.message); + + expect(prepared).toBeTruthy(); + expect(prepared!.route.agentId).toBe("strategist"); + expect(prepared!.route.matchedBy).toBe("binding.peer"); + expect(prepared!.ctxPayload.SessionKey).toBe(testCase.expectedSessionKey); + } + }); + it("respects replyToModeByChatType.direct override for DMs", async () => { const prepared = await prepareMessageWith( createReplyToAllSlackCtx(),