diff --git a/src/agents/spawn-requester-origin.test.ts b/src/agents/spawn-requester-origin.test.ts index 2dceddc425d..06ded35d175 100644 --- a/src/agents/spawn-requester-origin.test.ts +++ b/src/agents/spawn-requester-origin.test.ts @@ -3,7 +3,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js"; describe("resolveRequesterOriginForChild", () => { - it("keeps canonical prefixed peer ids eligible for exact binding lookup", () => { + it.each([ + ["channel:conversation-a", "channel:conversation-a"], + ["thread:conversation-a/thread-a", "thread:conversation-a/thread-a"], + ])("keeps canonical prefixed peer id %s eligible for exact binding lookup", (to, peerId) => { const cfg = { bindings: [ { @@ -13,7 +16,7 @@ describe("resolveRequesterOriginForChild", () => { channel: "qa-channel", peer: { kind: "channel", - id: "channel:conversation-a", + id: peerId, }, accountId: "bot-alpha-qa", }, @@ -28,12 +31,12 @@ describe("resolveRequesterOriginForChild", () => { requesterAgentId: "main", requesterChannel: "qa-channel", requesterAccountId: "bot-beta", - requesterTo: "channel:conversation-a", + requesterTo: to, }), ).toMatchObject({ channel: "qa-channel", accountId: "bot-alpha-qa", - to: "channel:conversation-a", + to, }); }); }); diff --git a/src/agents/spawn-requester-origin.ts b/src/agents/spawn-requester-origin.ts index f20a9519c8d..6977aed89a7 100644 --- a/src/agents/spawn-requester-origin.ts +++ b/src/agents/spawn-requester-origin.ts @@ -4,10 +4,11 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveFirstBoundAccountId } from "../routing/bound-account-read.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; -// Delivery targets carry a channel-side prefix (e.g. Matrix uses -// `room:`; LINE uses `line:group:`), but route bindings store raw -// peer ids on `match.peer.id`. Peel namespace and kind prefixes so the raw peer -// id surfaces for binding lookup. +// Delivery targets often carry a transport wrapper (e.g. Matrix `room:` or +// LINE `line:group:`), while route bindings commonly store raw peer ids on +// `match.peer.id`. Peel wrappers for those lookups, and separately pass the +// original target as an exact-match alias for channels whose canonical peer ids +// intentionally include prefixes such as `channel:` or `thread:`. const KIND_PREFIX_TO_CHAT_TYPE: Readonly> = { "room:": "channel", "channel:": "channel", @@ -22,10 +23,7 @@ const KIND_PREFIX_TO_CHAT_TYPE: Readonly> = { "pm:": "direct", }; -// Matches any `:` prefix. Real-world peer ids (Matrix `!`/`@`, -// IRC `#`, Slack/Discord/LINE alphanumerics, numeric Telegram/WhatsApp, or -// email-style `user@server`) never start with a lowercase-alpha token followed -// by `:`, so this peels prefixes without risking the raw id itself. +// Matches one leading `:` wrapper at a time. const GENERIC_PREFIX_PATTERN = /^[a-z][a-z0-9_-]*:/i; export function extractRequesterPeer( @@ -92,7 +90,7 @@ export function resolveRequesterOriginForChild(params: { channelId: params.requesterChannel, agentId: params.targetAgentId, peerId: normalizedPeerId, - peerIdAliases: + exactPeerIdAliases: rawPeerIdAlias && rawPeerIdAlias !== normalizedPeerId ? [rawPeerIdAlias] : undefined, peerKind: inferredPeerKind, }) diff --git a/src/routing/bound-account-read.test.ts b/src/routing/bound-account-read.test.ts index f422f66842f..e931d4300a9 100644 --- a/src/routing/bound-account-read.test.ts +++ b/src/routing/bound-account-read.test.ts @@ -340,7 +340,7 @@ describe("resolveFirstBoundAccountId", () => { channelId: "qa-channel", agentId: "bot-alpha", peerId: "conversation-a", - peerIdAliases: ["channel:conversation-a"], + exactPeerIdAliases: ["channel:conversation-a"], peerKind: "channel", }), ).toBe("bot-alpha-conversation"); diff --git a/src/routing/bound-account-read.ts b/src/routing/bound-account-read.ts index 8ee6b68b612..c538595a49f 100644 --- a/src/routing/bound-account-read.ts +++ b/src/routing/bound-account-read.ts @@ -56,8 +56,25 @@ function peerKindMatches(a: ChatType, b: ChatType): boolean { if (a === b) { return true; } - const pair = new Set([a, b]); - return pair.has("group") && pair.has("channel"); + return (a === "group" && b === "channel") || (a === "channel" && b === "group"); +} + +function buildExactPeerIdSet(params: { + peerId?: string; + exactPeerIdAliases?: string[]; +}): Set { + const exactPeerIds = new Set(); + const peerId = params.peerId?.trim(); + if (peerId) { + exactPeerIds.add(peerId); + } + for (const alias of params.exactPeerIdAliases ?? []) { + const trimmed = alias.trim(); + if (trimmed) { + exactPeerIds.add(trimmed); + } + } + return exactPeerIds; } export function resolveFirstBoundAccountId(params: { @@ -65,7 +82,7 @@ export function resolveFirstBoundAccountId(params: { channelId: string; agentId: string; peerId?: string; - peerIdAliases?: string[]; + exactPeerIdAliases?: string[]; peerKind?: ChatType; }): string | undefined { const normalizedChannel = normalizeBindingChannelId(params.channelId); @@ -74,12 +91,10 @@ export function resolveFirstBoundAccountId(params: { } const normalizedAgentId = normalizeAgentId(params.agentId); const normalizedPeerId = params.peerId?.trim() || undefined; - const exactPeerIds = new Set( - [ - normalizedPeerId, - ...(params.peerIdAliases ?? []).map((value) => value.trim()).filter(Boolean), - ].filter((value): value is string => Boolean(value)), - ); + const exactPeerIds = buildExactPeerIdSet({ + peerId: normalizedPeerId, + exactPeerIdAliases: params.exactPeerIdAliases, + }); const normalizedPeerKind = normalizeChatType(params.peerKind) ?? undefined; let wildcardPeerMatch: string | undefined; let channelOnlyFallback: string | undefined;