fix(slack): normalize route binding targets

This commit is contained in:
Peter Steinberger
2026-05-02 05:08:34 +01:00
parent ee94d21f1f
commit 3e2a2c7b74
4 changed files with 139 additions and 1 deletions

View File

@@ -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=<user id>)` 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 `<!subteam^...>` 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.

View File

@@ -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:<agentId>:slack:channel:<channelId>`.
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.

View File

@@ -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<OpenClawConfig["bindings"]>[number];
type SlackRouteBindingPeer = NonNullable<SlackRouteBinding["match"]["peer"]>;
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,

View File

@@ -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(),