diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 36290b9fd7b..e7a9b9ddfd8 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -bd1db6be3ae54ce8ba12599d5a0e57117670ecbb9ba2697d0c2fc79bfa83e3d1 plugin-sdk-api-baseline.json -41a47969072f07398326c26fca7d419e67a2a97fb495eae15769ded574c843c8 plugin-sdk-api-baseline.jsonl +92af5bb106da8278417701c301bc0dcc346cb21886956ab44b2b857e37b581be plugin-sdk-api-baseline.json +9139536904eea7239a0d0060562270b06eb43ab755c9e012a5c6687447bbcb48 plugin-sdk-api-baseline.jsonl diff --git a/extensions/discord/src/approval-native.ts b/extensions/discord/src/approval-native.ts index 1119188d552..75a22b75d90 100644 --- a/extensions/discord/src/approval-native.ts +++ b/extensions/discord/src/approval-native.ts @@ -132,7 +132,6 @@ function createDiscordOriginTargetResolver(configOverride?: DiscordExecApprovalC } : null; }, - targetsMatch: (a, b) => a.to === b.to && a.threadId === b.threadId, resolveFallbackTarget: (request) => { const sessionConversation = resolveApprovalRequestSessionConversation({ request, diff --git a/extensions/matrix/src/approval-native.ts b/extensions/matrix/src/approval-native.ts index 326559075cf..8d7b7e15887 100644 --- a/extensions/matrix/src/approval-native.ts +++ b/extensions/matrix/src/approval-native.ts @@ -85,11 +85,11 @@ function resolveSessionMatrixOriginTarget(sessionTarget: { }; } -function matrixTargetsMatch(a: MatrixOriginTarget, b: MatrixOriginTarget): boolean { - return ( - normalizeComparableTarget(a.to) === normalizeComparableTarget(b.to) && - (a.threadId ?? "") === (b.threadId ?? "") - ); +function normalizeMatrixOriginTarget(target: MatrixOriginTarget): MatrixOriginTarget { + return { + ...target, + to: normalizeComparableTarget(target.to), + }; } function hasMatrixPluginApprovers(params: { cfg: CoreConfig; accountId?: string | null }): boolean { @@ -159,7 +159,7 @@ const resolveMatrixOriginTarget = createChannelNativeOriginTargetResolver({ }), resolveTurnSourceTarget: resolveTurnSourceMatrixOriginTarget, resolveSessionTarget: resolveSessionMatrixOriginTarget, - targetsMatch: matrixTargetsMatch, + normalizeTargetForMatch: normalizeMatrixOriginTarget, resolveFallbackTarget: (request) => { const sessionConversation = resolveApprovalRequestSessionConversation({ request, diff --git a/extensions/slack/src/approval-native.ts b/extensions/slack/src/approval-native.ts index de22b19cb06..556f0b51084 100644 --- a/extensions/slack/src/approval-native.ts +++ b/extensions/slack/src/approval-native.ts @@ -14,6 +14,10 @@ import type { PluginApprovalRequest, } from "openclaw/plugin-sdk/approval-runtime"; import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract"; +import { + channelRouteTargetsMatchExact, + stringifyRouteThreadId, +} from "openclaw/plugin-sdk/channel-route"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -69,12 +73,7 @@ function resolveTurnSourceSlackOriginTarget(request: ApprovalRequest): SlackOrig if (!parsed) { return null; } - const threadId = - typeof request.request.turnSourceThreadId === "string" - ? normalizeOptionalString(request.request.turnSourceThreadId) - : typeof request.request.turnSourceThreadId === "number" - ? String(request.request.turnSourceThreadId) - : undefined; + const threadId = stringifyRouteThreadId(request.request.turnSourceThreadId); return { to: `${parsed.kind}:${parsed.id}`, threadId, @@ -87,12 +86,7 @@ function resolveSessionSlackOriginTarget(sessionTarget: { }): SlackOriginTarget { return { to: sessionTarget.to, - threadId: - typeof sessionTarget.threadId === "string" - ? normalizeOptionalString(sessionTarget.threadId) - : typeof sessionTarget.threadId === "number" - ? String(sessionTarget.threadId) - : undefined, + threadId: stringifyRouteThreadId(sessionTarget.threadId), }; } @@ -117,10 +111,25 @@ function resolveSlackFallbackOriginTarget(request: ApprovalRequest): SlackOrigin }; } +function normalizeSlackOriginTarget(target: SlackOriginTarget): SlackOriginTarget { + return { + ...target, + to: normalizeComparableTarget(target.to), + }; +} + function slackTargetsMatch(a: SlackOriginTarget, b: SlackOriginTarget): boolean { return ( - normalizeComparableTarget(a.to) === normalizeComparableTarget(b.to) && - normalizeSlackThreadMatchKey(a.threadId) === normalizeSlackThreadMatchKey(b.threadId) + channelRouteTargetsMatchExact({ + left: { + channel: "slack", + to: a.to, + }, + right: { + channel: "slack", + to: b.to, + }, + }) && normalizeSlackThreadMatchKey(a.threadId) === normalizeSlackThreadMatchKey(b.threadId) ); } @@ -134,6 +143,7 @@ const resolveSlackOriginTarget = createChannelNativeOriginTargetResolver({ }), resolveTurnSourceTarget: resolveTurnSourceSlackOriginTarget, resolveSessionTarget: resolveSessionSlackOriginTarget, + normalizeTargetForMatch: normalizeSlackOriginTarget, targetsMatch: slackTargetsMatch, resolveFallbackTarget: resolveSlackFallbackOriginTarget, }); diff --git a/extensions/telegram/src/approval-native.ts b/extensions/telegram/src/approval-native.ts index 56a0a82a5e4..b423599b442 100644 --- a/extensions/telegram/src/approval-native.ts +++ b/extensions/telegram/src/approval-native.ts @@ -61,12 +61,6 @@ function resolveSessionTelegramOriginTarget(sessionTarget: { }; } -function telegramTargetsMatch(a: TelegramOriginTarget, b: TelegramOriginTarget): boolean { - const normalizedA = normalizeTelegramChatId(a.to) ?? a.to; - const normalizedB = normalizeTelegramChatId(b.to) ?? b.to; - return normalizedA === normalizedB && a.threadId === b.threadId; -} - const resolveTelegramOriginTarget = createChannelNativeOriginTargetResolver({ channel: "telegram", shouldHandleRequest: ({ cfg, accountId, request }) => @@ -77,7 +71,6 @@ const resolveTelegramOriginTarget = createChannelNativeOriginTargetResolver({ }), resolveTurnSourceTarget: resolveTurnSourceTelegramOriginTarget, resolveSessionTarget: resolveSessionTelegramOriginTarget, - targetsMatch: telegramTargetsMatch, }); const resolveTelegramApproverDmTargets = createChannelApproverDmTargetResolver({ diff --git a/package.json b/package.json index 59af27d7ab6..b6817924035 100644 --- a/package.json +++ b/package.json @@ -806,6 +806,10 @@ "types": "./dist/plugin-sdk/channel-send-result.d.ts", "default": "./dist/plugin-sdk/channel-send-result.js" }, + "./plugin-sdk/channel-route": { + "types": "./dist/plugin-sdk/channel-route.d.ts", + "default": "./dist/plugin-sdk/channel-route.js" + }, "./plugin-sdk/channel-targets": { "types": "./dist/plugin-sdk/channel-targets.d.ts", "default": "./dist/plugin-sdk/channel-targets.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index c04c6066f42..8871b45d3d8 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -185,6 +185,7 @@ "channel-pairing-paths", "channel-policy", "channel-send-result", + "channel-route", "channel-targets", "context-visibility-runtime", "feishu", diff --git a/src/agents/command/run-context.ts b/src/agents/command/run-context.ts index b6c121a6c0a..4a8f18b8d6c 100644 --- a/src/agents/command/run-context.ts +++ b/src/agents/command/run-context.ts @@ -1,3 +1,4 @@ +import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { normalizeAccountId } from "../../utils/account-id.js"; import { resolveMessageChannel } from "../../utils/message-channel.js"; import type { AgentCommandOpts, AgentRunContext } from "./types.js"; @@ -39,7 +40,10 @@ export function resolveAgentRunContext(opts: AgentCommandOpts): AgentRunContext opts.threadId !== "" && opts.threadId !== null ) { - merged.currentThreadTs = String(opts.threadId); + const threadId = stringifyRouteThreadId(opts.threadId); + if (threadId) { + merged.currentThreadTs = threadId; + } } // Populate currentChannelId from the outbound target so channel threading diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 4ff309f0b2a..b272bb44143 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ConversationRef } from "../infra/outbound/session-binding-service.js"; +import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js"; import { normalizeAccountId } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { isCronSessionKey } from "../sessions/session-key-utils.js"; @@ -115,7 +116,7 @@ function resolveBoundConversationOrigin(params: { ? conversationId : undefined) ?? (params.requesterOrigin?.threadId != null && params.requesterOrigin.threadId !== "" - ? String(params.requesterOrigin.threadId) + ? stringifyRouteThreadId(params.requesterOrigin.threadId) : undefined); if ( requesterTo && @@ -295,7 +296,7 @@ export async function resolveSubagentCompletionOrigin(params: { const accountId = normalizeAccountId(requesterOrigin?.accountId); const threadId = requesterOrigin?.threadId != null && requesterOrigin.threadId !== "" - ? String(requesterOrigin.threadId).trim() + ? stringifyRouteThreadId(requesterOrigin.threadId) : undefined; const conversationId = threadId || @@ -380,7 +381,9 @@ async function sendAnnounce(item: AnnounceQueueItem) { const requesterIsSubagent = isInternalAnnounceRequesterSession(item.sessionKey); const origin = item.origin; const threadId = - origin?.threadId != null && origin.threadId !== "" ? String(origin.threadId) : undefined; + origin?.threadId != null && origin.threadId !== "" + ? stringifyRouteThreadId(origin.threadId) + : undefined; const deliveryTarget = !requesterIsSubagent ? resolveExternalBestEffortDeliveryTarget({ channel: origin?.channel, diff --git a/src/agents/subagent-announce-origin.ts b/src/agents/subagent-announce-origin.ts index ecb8bfd0e73..f46e187a320 100644 --- a/src/agents/subagent-announce-origin.ts +++ b/src/agents/subagent-announce-origin.ts @@ -1,4 +1,4 @@ -import { resolveComparableTargetForLoadedChannel } from "../channels/plugins/target-parsing-loaded.js"; +import { resolveRouteTargetForLoadedChannel } from "../channels/plugins/target-parsing-loaded.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { deliveryContextFromSession, @@ -23,7 +23,7 @@ function normalizeAnnounceRouteTarget(context?: DeliveryContext): string | undef } const channel = normalizeOptionalString(context?.channel); const parsed = channel - ? resolveComparableTargetForLoadedChannel({ + ? resolveRouteTargetForLoadedChannel({ channel, rawTarget: rawTo, fallbackThreadId: context?.threadId, diff --git a/src/agents/subagent-announce.test.ts b/src/agents/subagent-announce.test.ts index f4413756482..2d4d08c3e26 100644 --- a/src/agents/subagent-announce.test.ts +++ b/src/agents/subagent-announce.test.ts @@ -468,7 +468,7 @@ describe("subagent announce seam flow", () => { expect.objectContaining({ deliver: true, channel: "telegram", - accountId: "bot:123", + accountId: "bot-123", to: "-1001234567890", }), ); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 644631b0d48..217bf94a266 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -5,6 +5,7 @@ import { isAcpRuntimeSpawnAvailable } from "../acp/runtime/availability.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { SubagentSpawnPreparation } from "../context-engine/types.js"; +import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js"; import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-registry-state.js"; import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js"; import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; @@ -1059,7 +1060,9 @@ export async function spawnSubagentDirect( to: childSessionOrigin?.to ?? undefined, accountId: childSessionOrigin?.accountId ?? undefined, threadId: - childSessionOrigin?.threadId != null ? String(childSessionOrigin.threadId) : undefined, + childSessionOrigin?.threadId != null + ? stringifyRouteThreadId(childSessionOrigin.threadId) + : undefined, idempotencyKey: childIdem, deliver: deliverInitialChildRunDirectly, lane: AGENT_LANE_SUBAGENT, diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index c83cc4bfb89..19b4b3ec9cc 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { GroupKeyResolution } from "../config/sessions.js"; +import { channelRouteDedupeKey } from "../plugin-sdk/channel-route.js"; import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; import { createInboundDebouncer } from "./inbound-debounce.js"; import { installGroupRequireMentionTestPlugins } from "./inbound.group-require-mention-test-plugins.js"; @@ -209,7 +210,16 @@ describe("inbound dedupe", () => { OriginatingTo: "telegram:123", MessageSid: "42", }; - expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42"); + expect(buildInboundDedupeKey(ctx)).toBe( + JSON.stringify([ + "", + channelRouteDedupeKey({ + channel: "telegram", + to: "telegram:123", + }), + "42", + ]), + ); }); it("skips duplicates with the same key", () => { diff --git a/src/auto-reply/reply/inbound-dedupe.ts b/src/auto-reply/reply/inbound-dedupe.ts index 4036c15106f..c78a92402b6 100644 --- a/src/auto-reply/reply/inbound-dedupe.ts +++ b/src/auto-reply/reply/inbound-dedupe.ts @@ -1,6 +1,6 @@ -import { channelRouteIdentityKey } from "../../channels/route/ref.js"; import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { resolveGlobalDedupeCache, type DedupeCache } from "../../infra/dedupe.js"; +import { channelRouteDedupeKey } from "../../plugin-sdk/channel-route.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; import { @@ -69,7 +69,7 @@ export function buildInboundDedupeKey(ctx: MsgContext): string | null { } const sessionScope = resolveInboundDedupeSessionScope(ctx); const accountId = normalizeOptionalString(ctx.AccountId) ?? ""; - const routeKey = channelRouteIdentityKey({ + const routeKey = channelRouteDedupeKey({ channel: provider, to: peerId, accountId, diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index 31cb3f13acc..23528c5a4e2 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -1,4 +1,4 @@ -import { channelRouteKey, normalizeChannelRouteRef } from "../../../channels/route/ref.js"; +import { channelRouteCompactKey } from "../../../plugin-sdk/channel-route.js"; import { defaultRuntime } from "../../../runtime.js"; import { resolveGlobalMap } from "../../../shared/global-singleton.js"; import { @@ -135,7 +135,7 @@ function resolveCrossChannelKey(item: FollowupRun): { cross?: true; key?: string if (!isRoutableChannel(channel) || !to) { return { cross: true }; } - const key = channelRouteKey(normalizeChannelRouteRef({ channel, to, accountId, threadId })); + const key = channelRouteCompactKey({ channel, to, accountId, threadId }); return key ? { key } : { cross: true }; } diff --git a/src/auto-reply/reply/queue/enqueue.ts b/src/auto-reply/reply/queue/enqueue.ts index 3974913f2ba..39cf17e4ec1 100644 --- a/src/auto-reply/reply/queue/enqueue.ts +++ b/src/auto-reply/reply/queue/enqueue.ts @@ -1,5 +1,5 @@ -import { channelRouteIdentityKey } from "../../../channels/route/ref.js"; import { resolveGlobalDedupeCache } from "../../../infra/dedupe.js"; +import { channelRouteDedupeKey } from "../../../plugin-sdk/channel-route.js"; import { normalizeOptionalString } from "../../../shared/string-coerce.js"; import { applyQueueDropPolicy, shouldSkipQueueItem } from "../../../utils/queue-helpers.js"; import { kickFollowupDrainIfIdle, rememberFollowupDrainCallback } from "./drain.js"; @@ -18,7 +18,7 @@ const RECENT_QUEUE_MESSAGE_IDS = resolveGlobalDedupeCache(RECENT_QUEUE_MESSAGE_I }); function followupRouteIdentityKey(run: FollowupRun): string { - return channelRouteIdentityKey({ + return channelRouteDedupeKey({ channel: run.originatingChannel, to: run.originatingTo, accountId: run.originatingAccountId, diff --git a/src/auto-reply/reply/reply-payloads-dedupe.ts b/src/auto-reply/reply/reply-payloads-dedupe.ts index df148aba9de..7077dbc4760 100644 --- a/src/auto-reply/reply/reply-payloads-dedupe.ts +++ b/src/auto-reply/reply/reply-payloads-dedupe.ts @@ -2,8 +2,12 @@ import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js"; import type { MessagingToolSend } from "../../agents/pi-embedded-messaging.types.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import { normalizeAnyChannelId } from "../../channels/registry.js"; -import { stringifyRouteThreadId } from "../../channels/route/ref.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; +import { + channelRouteTargetsMatchExact, + stringifyRouteThreadId, + type ChannelRouteTargetInput, +} from "../../plugin-sdk/channel-route.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import { normalizeLowercaseStringOrEmpty, @@ -102,6 +106,29 @@ function resolveTargetProviderForComparison(params: { return targetProvider; } +type SuppressionRouteTarget = ChannelRouteTargetInput & { + channel: string; + to: string; +}; + +function normalizeRouteTargetForSuppression(params: { + provider: string; + rawTarget?: string; + accountId?: string; + threadId?: string; +}): SuppressionRouteTarget | null { + const to = normalizeTargetForProvider(params.provider, params.rawTarget); + if (!to) { + return null; + } + return { + channel: params.provider, + to, + ...(params.accountId ? { accountId: params.accountId } : {}), + ...(params.threadId != null ? { threadId: params.threadId } : {}), + }; +} + function targetsMatchForSuppression(params: { provider: string; originTarget: string; @@ -148,21 +175,31 @@ export function shouldSuppressMessagingToolReplies(params: { return false; } const targetRaw = normalizeOptionalString(target.to); - if (originRawTarget && targetRaw === originRawTarget && !target.threadId) { + const routeAccount = originAccount ?? targetAccount; + const originRoute = normalizeRouteTargetForSuppression({ + provider, + rawTarget: originRawTarget, + accountId: routeAccount, + }); + if (!originRoute) { + return false; + } + const targetRoute = normalizeRouteTargetForSuppression({ + provider: targetProvider, + rawTarget: targetRaw, + accountId: routeAccount, + threadId: target.threadId, + }); + if (!targetRoute) { + return false; + } + if (channelRouteTargetsMatchExact({ left: originRoute, right: targetRoute })) { return true; } - const originTarget = normalizeTargetForProvider(provider, originRawTarget); - if (!originTarget) { - return false; - } - const targetKey = normalizeTargetForProvider(targetProvider, targetRaw); - if (!targetKey) { - return false; - } return targetsMatchForSuppression({ provider, - originTarget, - targetKey, + originTarget: originRoute.to, + targetKey: targetRoute.to, targetThreadId: target.threadId, }); }); diff --git a/src/auto-reply/reply/reply-payloads.test.ts b/src/auto-reply/reply/reply-payloads.test.ts index be377fdef16..ab2c5f0d0f2 100644 --- a/src/auto-reply/reply/reply-payloads.test.ts +++ b/src/auto-reply/reply/reply-payloads.test.ts @@ -159,6 +159,30 @@ describe("shouldSuppressMessagingToolReplies", () => { ).toBe(false); }); + it("suppresses when only one side carries the account id", () => { + expect( + shouldSuppressMessagingToolReplies({ + messageProvider: "telegram", + originatingTo: "123", + accountId: "work", + messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }], + }), + ).toBe(true); + }); + + it("does not suppress when route accounts differ", () => { + expect( + shouldSuppressMessagingToolReplies({ + messageProvider: "telegram", + originatingTo: "123", + accountId: "work", + messagingToolSentTargets: [ + { tool: "message", provider: "telegram", to: "123", accountId: "personal" }, + ], + }), + ).toBe(false); + }); + it("suppresses telegram topic-origin replies when explicit threadId matches", () => { installTelegramSuppressionRegistry(); expect( diff --git a/src/channels/conversation-resolution.test.ts b/src/channels/conversation-resolution.test.ts index 5016f4c51e2..45d1bd28fbc 100644 --- a/src/channels/conversation-resolution.test.ts +++ b/src/channels/conversation-resolution.test.ts @@ -162,6 +162,31 @@ describe("conversation resolution", () => { }); }); + it("normalizes numeric command thread ids through the shared route contract", () => { + registerChannelPlugin({ + ...createChannelTestPluginBase({ id: "test-chat", label: "Test chat" }), + }); + + expect( + resolveCommandConversationResolution({ + cfg: testConfig, + channel: "test-chat", + accountId: "default", + originatingTo: "test-chat:channel:parent-room", + threadId: 42.9, + }), + ).toEqual({ + canonical: { + channel: "test-chat", + accountId: "default", + conversationId: "42", + parentConversationId: "parent-room", + }, + threadId: "42", + source: "command-fallback", + }); + }); + it("uses the runtime inbound resolver and preserves provider canonical ids", () => { registerChannelPlugin({ ...createChannelTestPluginBase({ id: "discord", label: "Discord" }), @@ -270,6 +295,31 @@ describe("conversation resolution", () => { }); }); + it("normalizes numeric inbound thread ids through the shared route contract", () => { + registerChannelPlugin({ + ...createChannelTestPluginBase({ id: "test-chat", label: "Test chat" }), + }); + + expect( + resolveInboundConversationResolution({ + cfg: testConfig, + channel: "test-chat", + accountId: "default", + to: "test-chat:channel:parent-room", + threadId: 42.9, + }), + ).toEqual({ + canonical: { + channel: "test-chat", + accountId: "default", + conversationId: "42", + parentConversationId: "parent-room", + }, + threadId: "42", + source: "inbound-fallback", + }); + }); + it("resolves placement from runtime plugin metadata", () => { registerChannelPlugin({ ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), diff --git a/src/channels/conversation-resolution.ts b/src/channels/conversation-resolution.ts index edf6d4c0db5..340abc4dedb 100644 --- a/src/channels/conversation-resolution.ts +++ b/src/channels/conversation-resolution.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; import { normalizeConversationTargetRef } from "../infra/outbound/session-binding-normalization.js"; +import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js"; import { getActivePluginChannelRegistry } from "../plugins/runtime.js"; import { normalizeLowercaseStringOrEmpty, @@ -249,9 +250,7 @@ export function resolveCommandConversationResolution( plugin, cfg: params.cfg, }); - const threadId = normalizeOptionalString( - params.threadId != null ? String(params.threadId) : undefined, - ); + const threadId = stringifyRouteThreadId(params.threadId); const commandParams: ChannelCommandConversationContext = { accountId, @@ -358,9 +357,7 @@ export function resolveInboundConversationResolution( plugin, cfg: params.cfg, }); - const threadId = normalizeOptionalString( - params.threadId != null ? String(params.threadId) : undefined, - ); + const threadId = stringifyRouteThreadId(params.threadId); const resolverParams = { from: normalizeOptionalString(params.from), to: normalizeOptionalString(params.to), diff --git a/src/channels/plugins/target-parsing-loaded.ts b/src/channels/plugins/target-parsing-loaded.ts index c91fbe849bb..f59e4f78e75 100644 --- a/src/channels/plugins/target-parsing-loaded.ts +++ b/src/channels/plugins/target-parsing-loaded.ts @@ -1,27 +1,19 @@ import { - normalizeOptionalString, - normalizeOptionalThreadValue, -} from "../../shared/string-coerce.js"; -import type { ChatType } from "../chat-type.js"; -import { - channelRoutesMatchExact, - channelRoutesShareConversation, - normalizeChannelRouteRef, -} from "../route/ref.js"; + channelRouteTargetsMatchExact, + channelRouteTargetsShareConversation, + resolveChannelRouteTargetWithParser, + type ChannelRouteExplicitTarget, + type ChannelRouteParsedTarget, +} from "../../plugin-sdk/channel-route.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { getLoadedChannelPluginForRead } from "./registry-loaded-read.js"; -export type ParsedChannelExplicitTarget = { - to: string; - threadId?: string | number; - chatType?: ChatType; -}; +export type { ChannelRouteParsedTarget } from "../../plugin-sdk/channel-route.js"; -export type ComparableChannelTarget = { - rawTo: string; - to: string; - threadId?: string | number; - chatType?: ChatType; -}; +export type ParsedChannelExplicitTarget = ChannelRouteExplicitTarget; + +/** @deprecated Use `ChannelRouteParsedTarget`. */ +export type ComparableChannelTarget = ChannelRouteParsedTarget; export function parseExplicitTargetForLoadedChannel( channel: string, @@ -38,52 +30,38 @@ export function parseExplicitTargetForLoadedChannel( ); } +export function resolveRouteTargetForLoadedChannel(params: { + channel: string; + rawTarget?: string | null; + fallbackThreadId?: string | number | null; +}): ChannelRouteParsedTarget | null { + return resolveChannelRouteTargetWithParser({ + ...params, + parseExplicitTarget: parseExplicitTargetForLoadedChannel, + }); +} + +/** @deprecated Use `resolveRouteTargetForLoadedChannel`. */ export function resolveComparableTargetForLoadedChannel(params: { channel: string; rawTarget?: string | null; fallbackThreadId?: string | number | null; -}): ComparableChannelTarget | null { - const rawTo = normalizeOptionalString(params.rawTarget); - if (!rawTo) { - return null; - } - const parsed = parseExplicitTargetForLoadedChannel(params.channel, rawTo); - const fallbackThreadId = normalizeOptionalThreadValue(params.fallbackThreadId); - return { - rawTo, - to: parsed?.to ?? rawTo, - threadId: normalizeOptionalThreadValue(parsed?.threadId ?? fallbackThreadId), - chatType: parsed?.chatType, - }; +}): ChannelRouteParsedTarget | null { + return resolveRouteTargetForLoadedChannel(params); } +/** @deprecated Use `channelRouteTargetsMatchExact` from `openclaw/plugin-sdk/channel-route`. */ export function comparableChannelTargetsMatch(params: { - left?: ComparableChannelTarget | null; - right?: ComparableChannelTarget | null; + left?: ChannelRouteParsedTarget | null; + right?: ChannelRouteParsedTarget | null; }): boolean { - return channelRoutesMatchExact({ - left: targetToRoute(params.left), - right: targetToRoute(params.right), - }); + return channelRouteTargetsMatchExact(params); } +/** @deprecated Use `channelRouteTargetsShareConversation` from `openclaw/plugin-sdk/channel-route`. */ export function comparableChannelTargetsShareRoute(params: { - left?: ComparableChannelTarget | null; - right?: ComparableChannelTarget | null; + left?: ChannelRouteParsedTarget | null; + right?: ChannelRouteParsedTarget | null; }): boolean { - return channelRoutesShareConversation({ - left: targetToRoute(params.left), - right: targetToRoute(params.right), - }); -} - -function targetToRoute(target?: ComparableChannelTarget | null) { - return target - ? normalizeChannelRouteRef({ - to: target.to, - rawTo: target.rawTo, - threadId: target.threadId, - chatType: target.chatType, - }) - : undefined; + return channelRouteTargetsShareConversation(params); } diff --git a/src/channels/plugins/target-parsing.test.ts b/src/channels/plugins/target-parsing.test.ts index 269a772dd72..9c98b7c5c91 100644 --- a/src/channels/plugins/target-parsing.test.ts +++ b/src/channels/plugins/target-parsing.test.ts @@ -1,13 +1,17 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { + channelRouteTargetsMatchExact, + channelRouteTargetsShareConversation, +} from "../../plugin-sdk/channel-route.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { comparableChannelTargetsMatch, - comparableChannelTargetsShareRoute, parseExplicitTargetForChannel, parseExplicitTargetForLoadedChannel, resolveComparableTargetForChannel, - resolveComparableTargetForLoadedChannel, + resolveRouteTargetForChannel, + resolveRouteTargetForLoadedChannel, } from "./target-parsing.js"; function parseThreadedTargetForTest(raw: string): { @@ -125,24 +129,26 @@ describe("parseExplicitTargetForChannel", () => { }); }); - it("builds comparable targets from plugin-owned grammar", () => { + it("builds route targets from plugin-owned grammar", () => { expect( - resolveComparableTargetForChannel({ + resolveRouteTargetForChannel({ channel: "mock-threaded", rawTarget: "threaded:group:room-a:topic:77", }), ).toEqual({ + channel: "mock-threaded", rawTo: "threaded:group:room-a:topic:77", to: "room-a", threadId: 77, chatType: "group", }); expect( - resolveComparableTargetForLoadedChannel({ + resolveRouteTargetForLoadedChannel({ channel: "mock-threaded", rawTarget: "threaded:group:room-a:topic:77", }), ).toEqual({ + channel: "mock-threaded", rawTo: "threaded:group:room-a:topic:77", to: "room-a", threadId: 77, @@ -150,24 +156,24 @@ describe("parseExplicitTargetForChannel", () => { }); }); - it("matches comparable targets when only the plugin grammar differs", () => { - const topicTarget = resolveComparableTargetForChannel({ + it("matches route targets when only the plugin grammar differs", () => { + const topicTarget = resolveRouteTargetForChannel({ channel: "mock-threaded", rawTarget: "threaded:room-a:topic:77", }); - const bareTarget = resolveComparableTargetForChannel({ + const bareTarget = resolveRouteTargetForChannel({ channel: "mock-threaded", rawTarget: "room-a", }); expect( - comparableChannelTargetsMatch({ + channelRouteTargetsMatchExact({ left: topicTarget, right: bareTarget, }), ).toBe(false); expect( - comparableChannelTargetsShareRoute({ + channelRouteTargetsShareConversation({ left: topicTarget, right: bareTarget, }), @@ -175,11 +181,30 @@ describe("parseExplicitTargetForChannel", () => { }); it("compares numeric and string thread ids through the shared route contract", () => { + const numericThread = resolveRouteTargetForChannel({ + channel: "mock-threaded", + rawTarget: "threaded:room-a:topic:77", + }); + const stringThread = resolveRouteTargetForChannel({ + channel: "mock-threaded", + rawTarget: "room-a", + fallbackThreadId: "77", + }); + + expect( + channelRouteTargetsMatchExact({ + left: numericThread, + right: stringThread, + }), + ).toBe(true); + }); + + it("keeps deprecated comparable target helpers as route wrappers", () => { const numericThread = resolveComparableTargetForChannel({ channel: "mock-threaded", rawTarget: "threaded:room-a:topic:77", }); - const stringThread = resolveComparableTargetForChannel({ + const stringThread = resolveRouteTargetForChannel({ channel: "mock-threaded", rawTarget: "room-a", fallbackThreadId: "77", diff --git a/src/channels/plugins/target-parsing.ts b/src/channels/plugins/target-parsing.ts index 9d16fbc230d..f6cd2947f7a 100644 --- a/src/channels/plugins/target-parsing.ts +++ b/src/channels/plugins/target-parsing.ts @@ -1,11 +1,8 @@ -import { - normalizeOptionalString, - normalizeOptionalThreadValue, -} from "../../shared/string-coerce.js"; +import { resolveChannelRouteTargetWithParser } from "../../plugin-sdk/channel-route.js"; import { normalizeChatChannelId } from "../registry.js"; import { getChannelPlugin, normalizeChannelId } from "./index.js"; import type { - ComparableChannelTarget, + ChannelRouteParsedTarget, ParsedChannelExplicitTarget, } from "./target-parsing-loaded.js"; export { @@ -13,9 +10,11 @@ export { comparableChannelTargetsShareRoute, parseExplicitTargetForLoadedChannel, resolveComparableTargetForLoadedChannel, + resolveRouteTargetForLoadedChannel, } from "./target-parsing-loaded.js"; export type { ComparableChannelTarget, + ChannelRouteParsedTarget, ParsedChannelExplicitTarget, } from "./target-parsing-loaded.js"; @@ -38,21 +37,22 @@ export function parseExplicitTargetForChannel( return parseWithPlugin(getChannelPlugin, channel, rawTarget); } +export function resolveRouteTargetForChannel(params: { + channel: string; + rawTarget?: string | null; + fallbackThreadId?: string | number | null; +}): ChannelRouteParsedTarget | null { + return resolveChannelRouteTargetWithParser({ + ...params, + parseExplicitTarget: parseExplicitTargetForChannel, + }); +} + +/** @deprecated Use `resolveRouteTargetForChannel`. */ export function resolveComparableTargetForChannel(params: { channel: string; rawTarget?: string | null; fallbackThreadId?: string | number | null; -}): ComparableChannelTarget | null { - const rawTo = normalizeOptionalString(params.rawTarget); - if (!rawTo) { - return null; - } - const parsed = parseExplicitTargetForChannel(params.channel, rawTo); - const fallbackThreadId = normalizeOptionalThreadValue(params.fallbackThreadId); - return { - rawTo, - to: parsed?.to ?? rawTo, - threadId: normalizeOptionalThreadValue(parsed?.threadId ?? fallbackThreadId), - chatType: parsed?.chatType, - }; +}): ChannelRouteParsedTarget | null { + return resolveRouteTargetForChannel(params); } diff --git a/src/channels/route/ref.test.ts b/src/channels/route/ref.test.ts deleted file mode 100644 index 78b368e9339..00000000000 --- a/src/channels/route/ref.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - channelRouteIdentityKey, - channelRouteKey, - channelRoutesMatchExact, - channelRoutesShareConversation, - normalizeChannelRouteRef, - stringifyRouteThreadId, -} from "./ref.js"; - -describe("channel route refs", () => { - it("normalizes target, account, and thread fields", () => { - expect( - normalizeChannelRouteRef({ - channel: " Slack ", - accountId: " Work ", - rawTo: " channel:C1 ", - to: " C1 ", - threadId: " 171234.567 ", - }), - ).toEqual({ - channel: "slack", - accountId: "work", - target: { - rawTo: "channel:C1", - to: "C1", - }, - thread: { - id: "171234.567", - }, - }); - }); - - it("normalizes numeric thread ids for route keys", () => { - const route = normalizeChannelRouteRef({ - channel: "telegram", - to: "-100123", - threadId: 42.9, - }); - - expect(stringifyRouteThreadId(route?.thread?.id)).toBe("42"); - expect(channelRouteKey(route)).toBe("telegram|-100123||42"); - }); - - it("builds a stable identity key from route-like input", () => { - expect( - channelRouteIdentityKey({ - channel: " Telegram ", - to: " -100123 ", - accountId: " Work ", - threadId: 42.9, - }), - ).toBe( - channelRouteIdentityKey({ - channel: "telegram", - to: "-100123", - accountId: "work", - threadId: "42", - }), - ); - }); - - it("matches exact routes when numeric and string thread ids are equivalent", () => { - expect( - channelRoutesMatchExact({ - left: normalizeChannelRouteRef({ - channel: "telegram", - to: "-100123", - threadId: 42, - }), - right: normalizeChannelRouteRef({ - channel: "telegram", - to: "-100123", - threadId: "42", - }), - }), - ).toBe(true); - }); - - it("shares conversation when one side is the parent route", () => { - expect( - channelRoutesShareConversation({ - left: normalizeChannelRouteRef({ - channel: "slack", - to: "channel:C1", - threadId: "171234.567", - }), - right: normalizeChannelRouteRef({ - channel: "slack", - to: "channel:C1", - }), - }), - ).toBe(true); - }); - - it("does not share different child threads", () => { - expect( - channelRoutesShareConversation({ - left: normalizeChannelRouteRef({ - channel: "matrix", - to: "room:!abc:example.org", - threadId: "$root-1", - }), - right: normalizeChannelRouteRef({ - channel: "matrix", - to: "room:!abc:example.org", - threadId: "$root-2", - }), - }), - ).toBe(false); - }); -}); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 25afa7825fd..2ec7fa7e642 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -18,6 +18,7 @@ import { formatErrorMessage } from "../../infra/errors.js"; import type { OutboundDeliveryResult } from "../../infra/outbound/deliver.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import { hasReplyPayloadContent } from "../../interactive/payload.js"; +import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -311,7 +312,7 @@ function buildDirectCronDeliveryIdempotencyKey(params: { const threadId = params.delivery.threadId == null || params.delivery.threadId === "" ? "" - : String(params.delivery.threadId); + : (stringifyRouteThreadId(params.delivery.threadId) ?? ""); const accountId = params.delivery.accountId?.trim() ?? ""; const normalizedTo = normalizeDeliveryTarget(params.delivery.channel, params.delivery.to); return `cron-direct-delivery:v1:${executionId}:${params.delivery.channel}:${accountId}:${normalizedTo}:${threadId}`; diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index ee1f39aea26..2a7e5e857d0 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -6,6 +6,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { CliDeps } from "../../cli/outbound-send-deps.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { resolveCronDeliveryPlan, type CronDeliveryPlan } from "../delivery-plan.js"; import type { @@ -278,6 +279,7 @@ function resolveMessagingToolSentTargets(params: { if (!params.resolvedDelivery.ok) { return []; } + const threadId = stringifyRouteThreadId(params.resolvedDelivery.threadId); return [ { tool: "message", @@ -286,9 +288,7 @@ function resolveMessagingToolSentTargets(params: { ? { accountId: params.resolvedDelivery.accountId } : {}), ...(params.resolvedDelivery.to ? { to: params.resolvedDelivery.to } : {}), - ...(params.resolvedDelivery.threadId - ? { threadId: String(params.resolvedDelivery.threadId) } - : {}), + ...(threadId ? { threadId } : {}), }, ]; } diff --git a/src/gateway/server-methods/restart-request.ts b/src/gateway/server-methods/restart-request.ts index 8dd47633166..cba29546289 100644 --- a/src/gateway/server-methods/restart-request.ts +++ b/src/gateway/server-methods/restart-request.ts @@ -1,3 +1,4 @@ +import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; type RestartDeliveryContext = { @@ -29,10 +30,7 @@ function parseRestartDeliveryContext(params: unknown): { deliveryContext.channel || deliveryContext.to || deliveryContext.accountId ? deliveryContext : undefined; - const threadId = - typeof context.threadId === "number" && Number.isFinite(context.threadId) - ? String(Math.trunc(context.threadId)) - : normalizeOptionalString(context.threadId); + const threadId = stringifyRouteThreadId(context.threadId); return { deliveryContext: normalizedContext, threadId }; } diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts index 9fc50dd33c9..d8b0db8ad42 100644 --- a/src/gateway/server-methods/tools-effective.ts +++ b/src/gateway/server-methods/tools-effective.ts @@ -1,6 +1,7 @@ import type { EffectiveToolInventoryResult } from "../../agents/tools-effective-inventory.types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logDebug, logWarn } from "../../logger.js"; +import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { ADMIN_SCOPE } from "../method-scopes.js"; import { @@ -248,11 +249,11 @@ function resolveTrustedToolsEffectiveContext(params: { currentChannelId: delivery?.to, currentThreadTs: delivery?.threadId != null - ? String(delivery.threadId) + ? stringifyRouteThreadId(delivery.threadId) : loaded.entry.lastThreadId != null - ? String(loaded.entry.lastThreadId) + ? stringifyRouteThreadId(loaded.entry.lastThreadId) : loaded.entry.origin?.threadId != null - ? String(loaded.entry.origin.threadId) + ? stringifyRouteThreadId(loaded.entry.origin.threadId) : undefined, groupId: loaded.entry.groupId, groupChannel: loaded.entry.groupChannel, diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index 2fd1f432519..68555be2686 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -34,6 +34,7 @@ import { } from "../infra/session-delivery-queue.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js"; import { recordInboundSessionAndDispatchReply } from "../plugin-sdk/inbound-reply-dispatch.js"; import type { OutboundReplyPayload } from "../plugin-sdk/reply-payload.js"; import { @@ -462,7 +463,7 @@ async function loadRestartSentinelStartupTask(params: { const threadId = payload.threadId ?? sessionThreadId ?? - (origin?.threadId != null ? String(origin.threadId) : undefined); + (origin?.threadId != null ? stringifyRouteThreadId(origin.threadId) : undefined); let resolvedTo: string | undefined; let replyToId: string | undefined; let resolvedThreadId = threadId; @@ -488,7 +489,7 @@ async function loadRestartSentinelStartupTask(params: { resolvedThreadId = replyTransport && Object.hasOwn(replyTransport, "threadId") ? replyTransport.threadId != null - ? String(replyTransport.threadId) + ? stringifyRouteThreadId(replyTransport.threadId) : undefined : threadId; } diff --git a/src/infra/approval-native-target-key.ts b/src/infra/approval-native-target-key.ts index f2068b2f82e..c8d0c5980b5 100644 --- a/src/infra/approval-native-target-key.ts +++ b/src/infra/approval-native-target-key.ts @@ -1,8 +1,8 @@ import type { ChannelApprovalNativeTarget } from "../channels/plugins/approval-native.types.js"; -import { channelRouteIdentityKey } from "../channels/route/ref.js"; +import { channelRouteDedupeKey } from "../plugin-sdk/channel-route.js"; export function buildChannelApprovalNativeTargetKey(target: ChannelApprovalNativeTarget): string { - return channelRouteIdentityKey({ + return channelRouteDedupeKey({ to: target.to, threadId: target.threadId, }); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index cc4ea05ec60..f3b45d36741 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -3,7 +3,6 @@ import { getLoadedChannelPlugin, resolveChannelApprovalAdapter, } from "../channels/plugins/index.js"; -import { channelRouteIdentityKey } from "../channels/route/ref.js"; import { getRuntimeConfig } from "../config/config.js"; import type { ExecApprovalForwardingConfig, @@ -17,6 +16,7 @@ import { buildPluginApprovalPendingReplyPayload, buildPluginApprovalResolvedReplyPayload, } from "../plugin-sdk/approval-renderers.js"; +import { channelRouteDedupeKey } from "../plugin-sdk/channel-route.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { isDeliverableMessageChannel, @@ -170,7 +170,7 @@ function shouldForwardRoute(params: { function buildTargetKey(target: ExecApprovalForwardTarget): string { const channel = normalizeMessageChannel(target.channel) ?? target.channel; - return channelRouteIdentityKey({ + return channelRouteDedupeKey({ channel, to: target.to, accountId: target.accountId, diff --git a/src/infra/outbound/best-effort-delivery.ts b/src/infra/outbound/best-effort-delivery.ts index 7967ca781f7..c53b494f15a 100644 --- a/src/infra/outbound/best-effort-delivery.ts +++ b/src/infra/outbound/best-effort-delivery.ts @@ -1,3 +1,4 @@ +import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { INTERNAL_MESSAGE_CHANNEL, @@ -33,7 +34,7 @@ export function resolveExternalBestEffortDeliveryTarget(params: { accountId: deliver ? normalizeOptionalString(params.accountId) : undefined, threadId: deliver && params.threadId != null && params.threadId !== "" - ? String(params.threadId) + ? stringifyRouteThreadId(params.threadId) : undefined, }; } diff --git a/src/infra/outbound/conversation-id.ts b/src/infra/outbound/conversation-id.ts index b9c9068f8c8..f0e1d315a28 100644 --- a/src/infra/outbound/conversation-id.ts +++ b/src/infra/outbound/conversation-id.ts @@ -1,4 +1,4 @@ -import { stringifyRouteThreadId } from "../../channels/route/ref.js"; +import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, diff --git a/src/infra/outbound/targets-session.ts b/src/infra/outbound/targets-session.ts index a33265a5367..7b8272058e2 100644 --- a/src/infra/outbound/targets-session.ts +++ b/src/infra/outbound/targets-session.ts @@ -1,10 +1,10 @@ import { - comparableChannelTargetsShareRoute, parseExplicitTargetForLoadedChannel, - resolveComparableTargetForLoadedChannel, + resolveRouteTargetForLoadedChannel, } from "../../channels/plugins/target-parsing-loaded.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.public.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { channelRouteTargetsShareConversation } from "../../plugin-sdk/channel-route.js"; import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; import { isDeliverableMessageChannel, @@ -68,7 +68,7 @@ export function resolveSessionDeliveryTarget(params: { const sessionLastChannel = context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined; const parsedSessionTarget = sessionLastChannel - ? resolveComparableTargetForLoadedChannel({ + ? resolveRouteTargetForLoadedChannel({ channel: sessionLastChannel, rawTarget: context?.to, fallbackThreadId: context?.threadId, @@ -78,7 +78,7 @@ export function resolveSessionDeliveryTarget(params: { const hasTurnSourceChannel = params.turnSourceChannel != null; const parsedTurnSourceTarget = hasTurnSourceChannel && params.turnSourceChannel - ? resolveComparableTargetForLoadedChannel({ + ? resolveRouteTargetForLoadedChannel({ channel: params.turnSourceChannel, rawTarget: params.turnSourceTo, fallbackThreadId: params.turnSourceThreadId, @@ -92,7 +92,7 @@ export function resolveSessionDeliveryTarget(params: { !params.turnSourceTo || !context?.to || (params.turnSourceChannel === sessionLastChannel && - comparableChannelTargetsShareRoute({ + channelRouteTargetsShareConversation({ left: parsedTurnSourceTarget, right: parsedSessionTarget, })); diff --git a/src/infra/system-events.ts b/src/infra/system-events.ts index 01d1c2d75c3..e5022b3e5ce 100644 --- a/src/infra/system-events.ts +++ b/src/infra/system-events.ts @@ -2,7 +2,7 @@ // prefixed to the next prompt. We intentionally avoid persistence to keep // events ephemeral. Events are session-scoped and require an explicit key. -import { channelRouteIdentityKey } from "../channels/route/ref.js"; +import { channelRouteDedupeKey } from "../plugin-sdk/channel-route.js"; import { resolveGlobalMap } from "../shared/global-singleton.js"; import { normalizeOptionalLowercaseString, @@ -136,7 +136,7 @@ function areDeliveryContextsEqual(left?: DeliveryContext, right?: DeliveryContex if (!left || !right) { return false; } - return channelRouteIdentityKey(left) === channelRouteIdentityKey(right); + return channelRouteDedupeKey(left) === channelRouteDedupeKey(right); } function areSystemEventsEqual(left: SystemEvent, right: SystemEvent): boolean { diff --git a/src/plugin-sdk/approval-native-helpers.test.ts b/src/plugin-sdk/approval-native-helpers.test.ts index 234e70a8001..5cc1513b485 100644 --- a/src/plugin-sdk/approval-native-helpers.test.ts +++ b/src/plugin-sdk/approval-native-helpers.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest"; import { createChannelApproverDmTargetResolver, createChannelNativeOriginTargetResolver, + type NativeApprovalTarget, + nativeApprovalTargetsMatch, } from "./approval-native-helpers.js"; import type { OpenClawConfig } from "./config-runtime.js"; describe("createChannelNativeOriginTargetResolver", () => { it("reuses shared turn-source routing and respects shouldHandle gating", () => { - const resolveOriginTarget = createChannelNativeOriginTargetResolver({ + const resolveOriginTarget = createChannelNativeOriginTargetResolver({ channel: "matrix", shouldHandleRequest: ({ accountId }) => accountId === "ops", resolveTurnSourceTarget: (request) => ({ @@ -18,7 +20,6 @@ describe("createChannelNativeOriginTargetResolver", () => { to: sessionTarget.to, threadId: sessionTarget.threadId, }), - targetsMatch: (a, b) => a.to === b.to && a.threadId === b.threadId, }); expect( @@ -64,6 +65,162 @@ describe("createChannelNativeOriginTargetResolver", () => { }), ).toBeNull(); }); + + it("uses shared route semantics for the default target matcher", () => { + expect( + nativeApprovalTargetsMatch({ + channel: "telegram", + left: { to: "-100123", threadId: 42.9 }, + right: { to: "-100123", threadId: "42" }, + }), + ).toBe(true); + expect( + nativeApprovalTargetsMatch({ + channel: "telegram", + left: { to: "-100123", accountId: "work" }, + right: { to: "-100123" }, + }), + ).toBe(false); + + const resolveOriginTarget = createChannelNativeOriginTargetResolver({ + channel: "telegram", + resolveTurnSourceTarget: () => ({ to: "-100123", threadId: 42.9 }), + resolveSessionTarget: () => ({ to: "-100123", threadId: "42" }), + }); + + expect( + resolveOriginTarget({ + cfg: {} as OpenClawConfig, + request: { + id: "req-1", + request: { + command: "echo hi", + turnSourceChannel: "telegram", + turnSourceTo: "-100123", + turnSourceThreadId: 42.9, + turnSourceAccountId: "default", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toEqual({ to: "-100123", threadId: 42.9 }); + }); + + it("normalizes resolved targets before matching origin candidates", () => { + const resolveOriginTarget = createChannelNativeOriginTargetResolver({ + channel: "slack", + resolveTurnSourceTarget: () => ({ to: "CHANNEL:C1", threadId: "171234.567890" }), + resolveSessionTarget: () => ({ to: "channel:c1", threadId: "171234.567890" }), + normalizeTarget: (target) => ({ + ...target, + to: target.to.toLowerCase(), + }), + }); + + expect( + resolveOriginTarget({ + cfg: {} as OpenClawConfig, + request: { + id: "req-1", + request: { + command: "echo hi", + turnSourceChannel: "slack", + turnSourceTo: "CHANNEL:C1", + turnSourceThreadId: "171234.567890", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toEqual({ to: "channel:c1", threadId: "171234.567890" }); + }); + + it("normalizes custom target shapes before invoking a custom matcher", () => { + type ProviderTarget = { id: string; shard?: string }; + + const resolveOriginTarget = createChannelNativeOriginTargetResolver({ + channel: "custom", + resolveTurnSourceTarget: () => ({ id: "ROOM-1", shard: "a" }), + resolveSessionTarget: () => ({ id: "room-1", shard: "b" }), + normalizeTarget: (target) => ({ ...target, id: target.id.toLowerCase() }), + targetsMatch: (left, right) => left.id === right.id, + }); + + expect( + resolveOriginTarget({ + cfg: {} as OpenClawConfig, + request: { + id: "req-1", + request: { + command: "echo hi", + sessionKey: "agent:main:custom:room-1", + turnSourceChannel: "custom", + turnSourceTo: "ROOM-1", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toEqual({ id: "room-1", shard: "a" }); + }); + + it("normalizes only match inputs when delivery targets must stay provider-native", () => { + const resolveOriginTarget = createChannelNativeOriginTargetResolver({ + channel: "slack", + resolveTurnSourceTarget: () => ({ to: "channel:C1", threadId: "171234.567890" }), + resolveSessionTarget: () => ({ to: "channel:c1", threadId: "171234.567890" }), + normalizeTargetForMatch: (target) => ({ + ...target, + to: target.to.toLowerCase(), + }), + }); + + expect( + resolveOriginTarget({ + cfg: {} as OpenClawConfig, + request: { + id: "req-1", + request: { + command: "echo hi", + turnSourceChannel: "slack", + turnSourceTo: "channel:C1", + turnSourceThreadId: "171234.567890", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toEqual({ to: "channel:C1", threadId: "171234.567890" }); + }); + + it("keeps custom target matchers generic", () => { + type ProviderTarget = { id: string; shard?: string }; + + const resolveOriginTarget = createChannelNativeOriginTargetResolver({ + channel: "custom", + resolveTurnSourceTarget: () => ({ id: "room-1", shard: "a" }), + resolveSessionTarget: () => ({ id: "room-1", shard: "b" }), + targetsMatch: (left, right) => left.id === right.id, + }); + + expect( + resolveOriginTarget({ + cfg: {} as OpenClawConfig, + request: { + id: "req-1", + request: { + command: "echo hi", + sessionKey: "agent:main:custom:room-1", + turnSourceChannel: "custom", + turnSourceTo: "room-1", + }, + createdAtMs: 0, + expiresAtMs: 1000, + }, + }), + ).toEqual({ id: "room-1", shard: "a" }); + }); }); describe("createChannelApproverDmTargetResolver", () => { diff --git a/src/plugin-sdk/approval-native-helpers.ts b/src/plugin-sdk/approval-native-helpers.ts index 1dc11b6e8df..23f0cd94681 100644 --- a/src/plugin-sdk/approval-native-helpers.ts +++ b/src/plugin-sdk/approval-native-helpers.ts @@ -2,6 +2,7 @@ import type { ExecApprovalSessionTarget } from "../infra/exec-approval-session-t import { resolveApprovalRequestOriginTarget } from "../infra/exec-approval-session-target.js"; import type { ExecApprovalRequest } from "../infra/exec-approvals.js"; import type { PluginApprovalRequest } from "../infra/plugin-approvals.js"; +import { channelRouteTargetsMatchExact } from "./channel-route.js"; import type { OpenClawConfig } from "./config-runtime.js"; type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; @@ -14,12 +15,12 @@ type ApprovalResolverParams = { request: ApprovalRequest; }; -type NativeApprovalTarget = { - to: string; - threadId?: string | number | null; -}; +type NativeApprovalTargetNormalizer = ( + target: TTarget, + request: ApprovalRequest, +) => TTarget | null | undefined; -export function createChannelNativeOriginTargetResolver(params: { +type NativeOriginResolverParams = { channel: string; shouldHandleRequest?: (params: ApprovalResolverParams) => boolean; resolveTurnSourceTarget: (request: ApprovalRequest) => TTarget | null; @@ -27,27 +28,130 @@ export function createChannelNativeOriginTargetResolver(params: { sessionTarget: ExecApprovalSessionTarget, request: ApprovalRequest, ) => TTarget | null; + normalizeTarget?: NativeApprovalTargetNormalizer; + normalizeTargetForMatch?: NativeApprovalTargetNormalizer; + targetsMatch?: (a: TTarget, b: TTarget) => boolean; + resolveFallbackTarget?: (request: ApprovalRequest) => TTarget | null; +}; + +type CustomOriginResolverParams = { + channel: string; + shouldHandleRequest?: (params: ApprovalResolverParams) => boolean; + resolveTurnSourceTarget: (request: ApprovalRequest) => TTarget | null; + resolveSessionTarget: ( + sessionTarget: ExecApprovalSessionTarget, + request: ApprovalRequest, + ) => TTarget | null; + normalizeTarget?: NativeApprovalTargetNormalizer; + normalizeTargetForMatch?: NativeApprovalTargetNormalizer; targetsMatch: (a: TTarget, b: TTarget) => boolean; resolveFallbackTarget?: (request: ApprovalRequest) => TTarget | null; -}) { +}; + +export type NativeApprovalTarget = { + to: string; + accountId?: string | null; + threadId?: string | number | null; +}; + +export function nativeApprovalTargetsMatch(params: { + channel?: string | null; + left: NativeApprovalTarget; + right: NativeApprovalTarget; +}): boolean { + return channelRouteTargetsMatchExact({ + left: { + channel: params.channel, + to: params.left.to, + accountId: params.left.accountId, + threadId: params.left.threadId, + }, + right: { + channel: params.channel, + to: params.right.to, + accountId: params.right.accountId, + threadId: params.right.threadId, + }, + }); +} + +function isNativeApprovalTarget(value: unknown): value is NativeApprovalTarget { + return Boolean( + value && typeof value === "object" && typeof (value as { to?: unknown }).to === "string", + ); +} + +function nativeApprovalTargetMatcher(channel: string): (left: unknown, right: unknown) => boolean { + return (left, right) => + isNativeApprovalTarget(left) && + isNativeApprovalTarget(right) && + nativeApprovalTargetsMatch({ channel, left, right }); +} + +function createOriginTargetResolver( + params: CustomOriginResolverParams, +): (input: ApprovalResolverParams) => TTarget | null { return (input: ApprovalResolverParams): TTarget | null => { if (params.shouldHandleRequest && !params.shouldHandleRequest(input)) { return null; } + const normalizeTarget = (target: TTarget | null): TTarget | null => { + if (!target) { + return null; + } + return params.normalizeTarget + ? (params.normalizeTarget(target, input.request) ?? null) + : target; + }; + const normalizeTargetForMatch = (target: TTarget): TTarget | null => + params.normalizeTargetForMatch?.(target, input.request) ?? target; return resolveApprovalRequestOriginTarget({ cfg: input.cfg, request: input.request, channel: params.channel, accountId: input.accountId, - resolveTurnSourceTarget: params.resolveTurnSourceTarget, + resolveTurnSourceTarget: (request) => + normalizeTarget(params.resolveTurnSourceTarget(request)), resolveSessionTarget: (sessionTarget) => - params.resolveSessionTarget(sessionTarget, input.request), - targetsMatch: params.targetsMatch, - resolveFallbackTarget: params.resolveFallbackTarget, + normalizeTarget(params.resolveSessionTarget(sessionTarget, input.request)), + targetsMatch: (left, right) => { + const normalizedLeft = normalizeTargetForMatch(left); + const normalizedRight = normalizeTargetForMatch(right); + return Boolean( + normalizedLeft && normalizedRight && params.targetsMatch(normalizedLeft, normalizedRight), + ); + }, + resolveFallbackTarget: params.resolveFallbackTarget + ? (request) => normalizeTarget(params.resolveFallbackTarget?.(request) ?? null) + : undefined, }); }; } +function hasCustomTargetsMatch( + params: NativeOriginResolverParams | CustomOriginResolverParams, +): params is CustomOriginResolverParams { + return typeof params.targetsMatch === "function"; +} + +export function createChannelNativeOriginTargetResolver( + params: NativeOriginResolverParams, +): (input: ApprovalResolverParams) => TTarget | null; +export function createChannelNativeOriginTargetResolver( + params: CustomOriginResolverParams, +): (input: ApprovalResolverParams) => TTarget | null; +export function createChannelNativeOriginTargetResolver( + params: NativeOriginResolverParams | CustomOriginResolverParams, +): (input: ApprovalResolverParams) => NativeApprovalTarget | TTarget | null { + if (hasCustomTargetsMatch(params)) { + return createOriginTargetResolver(params); + } + return createOriginTargetResolver({ + ...params, + targetsMatch: nativeApprovalTargetMatcher(params.channel), + }); +} + export function createChannelApproverDmTargetResolver< TApprover, TTarget extends NativeApprovalTarget = NativeApprovalTarget, diff --git a/src/plugin-sdk/channel-route.test.ts b/src/plugin-sdk/channel-route.test.ts new file mode 100644 index 00000000000..b630bd6d2fa --- /dev/null +++ b/src/plugin-sdk/channel-route.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { + channelRouteCompactKey, + channelRouteDedupeKey, + channelRouteIdentityKey, + channelRouteKey, + channelRouteTargetsMatchExact, + channelRouteTargetsShareConversation, + channelRoutesMatchExact, + channelRoutesShareConversation, + normalizeChannelRouteRef, + resolveChannelRouteTargetWithParser, + stringifyRouteThreadId, +} from "./channel-route.js"; + +describe("plugin-sdk channel-route", () => { + it("normalizes target, account, and thread fields", () => { + expect( + normalizeChannelRouteRef({ + channel: " Slack ", + accountId: " Work ", + rawTo: " channel:C1 ", + to: " C1 ", + threadId: " 171234.567 ", + }), + ).toEqual({ + channel: "slack", + accountId: "work", + target: { + rawTo: "channel:C1", + to: "C1", + }, + thread: { + id: "171234.567", + }, + }); + }); + + it("normalizes numeric thread ids for route keys", () => { + const route = normalizeChannelRouteRef({ + channel: "telegram", + to: "-100123", + threadId: 42.9, + }); + + expect(stringifyRouteThreadId(route?.thread?.id)).toBe("42"); + expect(channelRouteCompactKey(route)).toBe("telegram|-100123||42"); + expect(channelRouteKey(route)).toBe(channelRouteCompactKey(route)); + }); + + it("builds compact route keys from raw route-like input", () => { + expect( + channelRouteCompactKey({ + channel: " Slack ", + to: " C1 ", + accountId: " Work ", + threadId: " 171234.567 ", + }), + ).toBe("slack|C1|work|171234.567"); + }); + + it("builds a stable dedupe key from route-like input", () => { + expect( + channelRouteDedupeKey({ + channel: " Telegram ", + to: " -100123 ", + accountId: " Work ", + threadId: 42.9, + }), + ).toBe( + channelRouteDedupeKey({ + channel: "telegram", + to: "-100123", + accountId: "work", + threadId: "42", + }), + ); + }); + + it("keeps deprecated identity key alias wired to the dedupe key", () => { + const input = { + channel: "telegram", + to: "-100123", + accountId: "work", + threadId: "42", + }; + expect(channelRouteIdentityKey(input)).toBe(channelRouteDedupeKey(input)); + }); + + it("matches exact routes when numeric and string thread ids are equivalent", () => { + expect( + channelRoutesMatchExact({ + left: normalizeChannelRouteRef({ + channel: "telegram", + to: "-100123", + threadId: 42, + }), + right: normalizeChannelRouteRef({ + channel: "telegram", + to: "-100123", + threadId: "42", + }), + }), + ).toBe(true); + expect( + channelRouteTargetsMatchExact({ + left: { + channel: "telegram", + to: "-100123", + threadId: 42, + }, + right: { + channel: "telegram", + to: "-100123", + threadId: "42", + }, + }), + ).toBe(true); + }); + + it("requires account equality for exact route matches", () => { + expect( + channelRouteTargetsMatchExact({ + left: { + channel: "telegram", + to: "-100123", + accountId: "work", + }, + right: { + channel: "telegram", + to: "-100123", + }, + }), + ).toBe(false); + }); + + it("shares conversation when one side is the parent route", () => { + expect( + channelRoutesShareConversation({ + left: normalizeChannelRouteRef({ + channel: "slack", + to: "channel:C1", + threadId: "171234.567", + }), + right: normalizeChannelRouteRef({ + channel: "slack", + to: "channel:C1", + }), + }), + ).toBe(true); + expect( + channelRouteTargetsShareConversation({ + left: { + channel: "slack", + to: "channel:C1", + threadId: "171234.567", + }, + right: { + channel: "slack", + to: "channel:C1", + }, + }), + ).toBe(true); + }); + + it("does not share different child threads", () => { + expect( + channelRoutesShareConversation({ + left: normalizeChannelRouteRef({ + channel: "matrix", + to: "room:!abc:example.org", + threadId: "$root-1", + }), + right: normalizeChannelRouteRef({ + channel: "matrix", + to: "room:!abc:example.org", + threadId: "$root-2", + }), + }), + ).toBe(false); + }); + + it("resolves parsed route targets through an injected channel grammar", () => { + expect( + resolveChannelRouteTargetWithParser({ + channel: "Mock", + rawTarget: " room-a:topic:77 ", + fallbackThreadId: 11, + parseExplicitTarget: (_channel, rawTarget) => { + const match = /^(.*):topic:(\d+)$/u.exec(rawTarget); + return match + ? { to: match[1] ?? rawTarget, threadId: Number.parseInt(match[2] ?? "", 10) } + : null; + }, + }), + ).toEqual({ + channel: "mock", + rawTo: "room-a:topic:77", + to: "room-a", + threadId: 77, + chatType: undefined, + }); + }); +}); diff --git a/src/channels/route/ref.ts b/src/plugin-sdk/channel-route.ts similarity index 54% rename from src/channels/route/ref.ts rename to src/plugin-sdk/channel-route.ts index e0b7b9b67f5..81283858b16 100644 --- a/src/channels/route/ref.ts +++ b/src/plugin-sdk/channel-route.ts @@ -1,10 +1,11 @@ -import { normalizeOptionalAccountId } from "../../routing/account-id.js"; +import { normalizeOptionalAccountId } from "../routing/account-id.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, normalizeOptionalThreadValue, -} from "../../shared/string-coerce.js"; -import type { ChatType } from "../chat-type.js"; +} from "../shared/string-coerce.js"; + +export type ChannelRouteChatType = "direct" | "group" | "channel"; export type ChannelRouteThreadKind = "topic" | "thread" | "reply"; @@ -16,7 +17,7 @@ export type ChannelRouteRef = { target?: { to: string; rawTo?: string; - chatType?: ChatType; + chatType?: ChannelRouteChatType; }; thread?: { id: string | number; @@ -30,7 +31,7 @@ export type ChannelRouteRefInput = { accountId?: unknown; to?: unknown; rawTo?: unknown; - chatType?: ChatType; + chatType?: ChannelRouteChatType; threadId?: unknown; threadKind?: ChannelRouteThreadKind; threadSource?: ChannelRouteThreadSource; @@ -41,6 +42,19 @@ export type ChannelRouteTargetInput = Pick< "channel" | "accountId" | "to" | "rawTo" | "chatType" | "threadId" >; +export type ChannelRouteKeyInput = ChannelRouteRef | ChannelRouteTargetInput; + +export type ChannelRouteExplicitTarget = { + to: string; + threadId?: string | number; + chatType?: ChannelRouteChatType; +}; + +export type ChannelRouteExplicitTargetParser = ( + channel: string, + rawTarget: string, +) => ChannelRouteExplicitTarget | null; + export function normalizeRouteThreadId(value: unknown): string | number | undefined { return normalizeOptionalThreadValue(value); } @@ -103,7 +117,37 @@ export function normalizeChannelRouteTarget( return input ? normalizeChannelRouteRef(input) : undefined; } -export function channelRouteIdentityKey(input?: ChannelRouteTargetInput | null): string { +export type ChannelRouteParsedTarget = ChannelRouteTargetInput & { + channel: string; + rawTo: string; + to: string; + threadId?: string | number; + chatType?: ChannelRouteChatType; +}; + +export function resolveChannelRouteTargetWithParser(params: { + channel: string; + rawTarget?: string | null; + fallbackThreadId?: string | number | null; + parseExplicitTarget: ChannelRouteExplicitTargetParser; +}): ChannelRouteParsedTarget | null { + const channel = normalizeLowercaseStringOrEmpty(params.channel); + const rawTo = normalizeOptionalString(params.rawTarget); + if (!channel || !rawTo) { + return null; + } + const parsed = params.parseExplicitTarget(channel, rawTo); + const fallbackThreadId = normalizeOptionalThreadValue(params.fallbackThreadId); + return { + channel, + rawTo, + to: parsed?.to ?? rawTo, + threadId: normalizeOptionalThreadValue(parsed?.threadId ?? fallbackThreadId), + chatType: parsed?.chatType, + }; +} + +export function channelRouteDedupeKey(input?: ChannelRouteTargetInput | null): string { const route = normalizeChannelRouteTarget(input); return JSON.stringify([ route?.channel ?? "", @@ -113,6 +157,11 @@ export function channelRouteIdentityKey(input?: ChannelRouteTargetInput | null): ]); } +/** @deprecated Use `channelRouteDedupeKey`. */ +export function channelRouteIdentityKey(input?: ChannelRouteTargetInput | null): string { + return channelRouteDedupeKey(input); +} + function threadIdsEqual(left?: string | number, right?: string | number): boolean { const normalizedLeft = stringifyRouteThreadId(left); const normalizedRight = stringifyRouteThreadId(right); @@ -123,6 +172,10 @@ function accountsCompatible(left?: string, right?: string): boolean { return !left || !right || left === right; } +function accountsEqual(left?: string, right?: string): boolean { + return (left ?? "") === (right ?? ""); +} + export function channelRoutesMatchExact(params: { left?: ChannelRouteRef | null; right?: ChannelRouteRef | null; @@ -133,9 +186,9 @@ export function channelRoutesMatchExact(params: { } return ( left.channel === right.channel && - left.accountId === right.accountId && - channelRouteTarget(left) === channelRouteTarget(right) && - threadIdsEqual(channelRouteThreadId(left), channelRouteThreadId(right)) + left.target?.to === right.target?.to && + accountsEqual(left.accountId, right.accountId) && + threadIdsEqual(left.thread?.id, right.thread?.id) ); } @@ -147,30 +200,61 @@ export function channelRoutesShareConversation(params: { if (!left || !right) { return false; } - if (left.channel && right.channel && left.channel !== right.channel) { + if ( + left.channel !== right.channel || + left.target?.to !== right.target?.to || + !accountsCompatible(left.accountId, right.accountId) + ) { return false; } - if (!accountsCompatible(left.accountId, right.accountId)) { - return false; - } - if (channelRouteTarget(left) !== channelRouteTarget(right)) { - return false; - } - const leftThreadId = channelRouteThreadId(left); - const rightThreadId = channelRouteThreadId(right); - if (leftThreadId == null || rightThreadId == null) { + if (left.thread?.id == null || right.thread?.id == null) { return true; } - return threadIdsEqual(leftThreadId, rightThreadId); + return threadIdsEqual(left.thread.id, right.thread.id); } -export function channelRouteKey(route?: ChannelRouteRef): string | undefined { - const normalized = normalizeChannelRouteRef({ - channel: route?.channel, - accountId: route?.accountId, - to: route?.target?.to, - threadId: route?.thread?.id, +export function channelRouteTargetsMatchExact(params: { + left?: ChannelRouteTargetInput | null; + right?: ChannelRouteTargetInput | null; +}): boolean { + return channelRoutesMatchExact({ + left: normalizeChannelRouteTarget(params.left), + right: normalizeChannelRouteTarget(params.right), }); +} + +export function channelRouteTargetsShareConversation(params: { + left?: ChannelRouteTargetInput | null; + right?: ChannelRouteTargetInput | null; +}): boolean { + return channelRoutesShareConversation({ + left: normalizeChannelRouteTarget(params.left), + right: normalizeChannelRouteTarget(params.right), + }); +} + +function isChannelRouteRef(route: ChannelRouteKeyInput): route is ChannelRouteRef { + return "target" in route || "thread" in route; +} + +function normalizeChannelRouteKeyInput( + route?: ChannelRouteKeyInput | null, +): ChannelRouteRef | undefined { + if (!route) { + return undefined; + } + return isChannelRouteRef(route) + ? normalizeChannelRouteRef({ + channel: route.channel, + to: route.target?.to, + accountId: route.accountId, + threadId: route.thread?.id, + }) + : normalizeChannelRouteTarget(route); +} + +export function channelRouteCompactKey(route?: ChannelRouteKeyInput | null): string | undefined { + const normalized = normalizeChannelRouteKeyInput(route); if (!normalized?.channel || !normalized.target?.to) { return undefined; } @@ -181,3 +265,8 @@ export function channelRouteKey(route?: ChannelRouteRef): string | undefined { stringifyRouteThreadId(normalized.thread?.id) ?? "", ].join("|"); } + +/** @deprecated Use `channelRouteCompactKey`. */ +export function channelRouteKey(route?: ChannelRouteRef): string | undefined { + return channelRouteCompactKey(route); +} diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index 9988b3d6bb5..51f9ce466be 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -120,6 +120,16 @@ const knownDeprecatedSurfaceMarkers = [ file: "src/commands/doctor/shared/legacy-x-search-migrate.ts", marker: "tools.web.x_search", }, + { + code: "channel-route-key-aliases", + file: "src/plugin-sdk/channel-route.ts", + marker: "channelRouteIdentityKey", + }, + { + code: "channel-target-comparable-aliases", + file: "src/channels/plugins/target-parsing-loaded.ts", + marker: "ComparableChannelTarget", + }, ] as const; function parseDate(date: string): Date { diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index b31ddd31400..eb53b370a60 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -51,6 +51,49 @@ export const PLUGIN_COMPAT_RECORDS = [ "src/plugins/contracts/plugin-sdk-subpaths.test.ts", ], }, + { + code: "channel-route-key-aliases", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-28", + deprecated: "2026-04-28", + warningStarts: "2026-04-28", + removeAfter: "2026-07-28", + replacement: "`channelRouteDedupeKey` and `channelRouteCompactKey`", + docsPath: "/plugins/sdk-migration", + surfaces: [ + "openclaw/plugin-sdk/channel-route channelRouteIdentityKey", + "openclaw/plugin-sdk/channel-route channelRouteKey", + ], + diagnostics: ["plugin SDK compatibility warning"], + tests: [ + "src/plugin-sdk/channel-route.test.ts", + "src/plugins/contracts/plugin-sdk-subpaths.test.ts", + ], + }, + { + code: "channel-target-comparable-aliases", + status: "deprecated", + owner: "sdk", + introduced: "2026-04-28", + deprecated: "2026-04-28", + warningStarts: "2026-04-28", + removeAfter: "2026-07-28", + replacement: + "`resolveRouteTargetForChannel`, `ChannelRouteParsedTarget`, `channelRouteTargetsMatchExact`, and `channelRouteTargetsShareConversation`", + docsPath: "/plugins/sdk-migration", + surfaces: [ + "src/channels/plugins/target-parsing ComparableChannelTarget", + "src/channels/plugins/target-parsing resolveComparableTargetForChannel", + "src/channels/plugins/target-parsing comparableChannelTargetsMatch", + "src/channels/plugins/target-parsing comparableChannelTargetsShareRoute", + ], + diagnostics: ["plugin SDK compatibility warning"], + tests: [ + "src/channels/plugins/target-parsing.test.ts", + "src/plugins/contracts/plugin-sdk-subpaths.test.ts", + ], + }, { code: "bundled-plugin-allowlist", status: "active", diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index d504c424897..455076dcfc2 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -668,6 +668,46 @@ describe("plugin-sdk subpath exports", () => { expect(matches).toEqual([]); }); + it("keeps deprecated comparable channel target helpers behind compatibility shims", () => { + const matches = findRepoFilesContaining({ + roots: [ + resolve(REPO_ROOT, "src"), + resolve(REPO_ROOT, "extensions"), + resolve(REPO_ROOT, "test"), + ], + pattern: + /\b(?:ComparableChannelTarget|resolveComparableTargetFor(?:Channel|LoadedChannel)|comparableChannelTargets(?:Match|ShareRoute))\b/u, + exclude: [ + "src/channels/plugins/target-parsing.ts", + "src/channels/plugins/target-parsing-loaded.ts", + "src/channels/plugins/target-parsing.test.ts", + "src/plugins/compat/registry.ts", + "src/plugins/compat/registry.test.ts", + "src/plugins/contracts/plugin-sdk-subpaths.test.ts", + ], + }); + expect(matches).toEqual([]); + }); + + it("keeps deprecated channel route key aliases behind compatibility shims", () => { + const matches = findRepoFilesContaining({ + roots: [ + resolve(REPO_ROOT, "src"), + resolve(REPO_ROOT, "extensions"), + resolve(REPO_ROOT, "test"), + ], + pattern: /\b(?:channelRouteIdentityKey|channelRouteKey)\b/u, + exclude: [ + "src/plugin-sdk/channel-route.ts", + "src/plugin-sdk/channel-route.test.ts", + "src/plugins/compat/registry.ts", + "src/plugins/compat/registry.test.ts", + "src/plugins/contracts/plugin-sdk-subpaths.test.ts", + ], + }); + expect(matches).toEqual([]); + }); + it("keeps removed channel-named runtime boundaries out of core imports", () => { const matches = findRepoFilesContaining({ roots: [resolve(REPO_ROOT, "src")], diff --git a/src/test-utils/plugin-runtime-env.ts b/src/test-utils/plugin-runtime-env.ts index deb65dc696f..8968513cd1d 100644 --- a/src/test-utils/plugin-runtime-env.ts +++ b/src/test-utils/plugin-runtime-env.ts @@ -1,14 +1,10 @@ -import type { OutputRuntimeEnv } from "openclaw/plugin-sdk/runtime"; +import type { OutputRuntimeEnv, RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { vi } from "vitest"; type RuntimeEnvOptions = { throwOnExit?: boolean; }; -type TypedRuntimeEnvOptions = RuntimeEnvOptions & { - readonly __runtimeShape?: (runtime: TRuntime) => void; -}; - export function createRuntimeEnv(options?: RuntimeEnvOptions): OutputRuntimeEnv { const throwOnExit = options?.throwOnExit ?? true; return { @@ -24,8 +20,9 @@ export function createRuntimeEnv(options?: RuntimeEnvOptions): OutputRuntimeEnv }; } -export function createTypedRuntimeEnv( - options?: TypedRuntimeEnvOptions, +export function createTypedRuntimeEnv( + options?: RuntimeEnvOptions, + _runtimeShape?: (runtime: TRuntime) => void, ): TRuntime { return createRuntimeEnv(options) as unknown as TRuntime; } @@ -34,8 +31,8 @@ export function createNonExitingRuntimeEnv(): OutputRuntimeEnv { return createRuntimeEnv({ throwOnExit: false }); } -export function createNonExitingTypedRuntimeEnv( +export function createNonExitingTypedRuntimeEnv( runtimeShape?: (runtime: TRuntime) => void, ): TRuntime { - return createTypedRuntimeEnv({ throwOnExit: false, __runtimeShape: runtimeShape }); + return createTypedRuntimeEnv({ throwOnExit: false }, runtimeShape); } diff --git a/src/utils/delivery-context.shared.ts b/src/utils/delivery-context.shared.ts index a22a15977f5..4cc01332f0f 100644 --- a/src/utils/delivery-context.shared.ts +++ b/src/utils/delivery-context.shared.ts @@ -1,9 +1,9 @@ import { - channelRouteKey, + channelRouteCompactKey, channelRouteThreadId, channelRouteTarget, - normalizeChannelRouteRef, -} from "../channels/route/ref.js"; + normalizeChannelRouteTarget, +} from "../plugin-sdk/channel-route.js"; import { normalizeAccountId } from "./account-id.js"; import type { DeliveryContext, DeliveryContextSessionSource } from "./delivery-context.types.js"; import { normalizeMessageChannel } from "./message-channel-core.js"; @@ -13,7 +13,7 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon if (!context) { return undefined; } - const route = normalizeChannelRouteRef({ + const route = normalizeChannelRouteTarget({ channel: typeof context.channel === "string" ? (normalizeMessageChannel(context.channel) ?? context.channel.trim()) @@ -131,13 +131,5 @@ export function mergeDeliveryContext( } export function deliveryContextKey(context?: DeliveryContext): string | undefined { - const normalized = normalizeDeliveryContext(context); - return channelRouteKey( - normalizeChannelRouteRef({ - channel: normalized?.channel, - to: normalized?.to, - accountId: normalized?.accountId, - threadId: normalized?.threadId, - }), - ); + return channelRouteCompactKey(normalizeDeliveryContext(context)); } diff --git a/src/utils/delivery-context.types.ts b/src/utils/delivery-context.types.ts index d1b609dc736..6e0bfb0d76f 100644 --- a/src/utils/delivery-context.types.ts +++ b/src/utils/delivery-context.types.ts @@ -1,4 +1,9 @@ -export type DeliveryContext = { +import type { ChannelRouteTargetInput } from "../plugin-sdk/channel-route.js"; + +export type DeliveryContext = Pick< + ChannelRouteTargetInput, + "accountId" | "channel" | "threadId" | "to" +> & { channel?: string; to?: string; accountId?: string;