mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(slack): normalize route binding targets
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user