refactor(plugin-sdk): publish route helpers

This commit is contained in:
Peter Steinberger
2026-04-28 01:09:57 +01:00
parent f368d3b49f
commit e27c32b9b0
45 changed files with 1016 additions and 347 deletions

View File

@@ -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

View File

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

View File

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

View File

@@ -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,
});

View File

@@ -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({

View File

@@ -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"

View File

@@ -185,6 +185,7 @@
"channel-pairing-paths",
"channel-policy",
"channel-send-result",
"channel-route",
"channel-targets",
"context-visibility-runtime",
"feishu",

View File

@@ -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

View File

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

View File

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

View File

@@ -468,7 +468,7 @@ describe("subagent announce seam flow", () => {
expect.objectContaining({
deliver: true,
channel: "telegram",
accountId: "bot:123",
accountId: "bot-123",
to: "-1001234567890",
}),
);

View File

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

View File

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

View File

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

View File

@@ -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 };
}

View File

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

View File

@@ -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,
});
});

View File

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

View File

@@ -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" }),

View File

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

View File

@@ -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);
}

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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);
});
});

View File

@@ -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}`;

View File

@@ -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 } : {}),
},
];
}

View File

@@ -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 };
}

View File

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

View File

@@ -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;
}

View File

@@ -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,
});

View File

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

View File

@@ -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,
};
}

View File

@@ -1,4 +1,4 @@
import { stringifyRouteThreadId } from "../../channels/route/ref.js";
import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,

View File

@@ -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,
}));

View File

@@ -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 {

View File

@@ -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<NativeApprovalTarget>({
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<NativeApprovalTarget>({
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<NativeApprovalTarget>({
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<ProviderTarget>({
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<NativeApprovalTarget>({
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<ProviderTarget>({
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", () => {

View File

@@ -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<TTarget> = (
target: TTarget,
request: ApprovalRequest,
) => TTarget | null | undefined;
export function createChannelNativeOriginTargetResolver<TTarget>(params: {
type NativeOriginResolverParams<TTarget extends NativeApprovalTarget> = {
channel: string;
shouldHandleRequest?: (params: ApprovalResolverParams) => boolean;
resolveTurnSourceTarget: (request: ApprovalRequest) => TTarget | null;
@@ -27,27 +28,130 @@ export function createChannelNativeOriginTargetResolver<TTarget>(params: {
sessionTarget: ExecApprovalSessionTarget,
request: ApprovalRequest,
) => TTarget | null;
normalizeTarget?: NativeApprovalTargetNormalizer<TTarget>;
normalizeTargetForMatch?: NativeApprovalTargetNormalizer<TTarget>;
targetsMatch?: (a: TTarget, b: TTarget) => boolean;
resolveFallbackTarget?: (request: ApprovalRequest) => TTarget | null;
};
type CustomOriginResolverParams<TTarget> = {
channel: string;
shouldHandleRequest?: (params: ApprovalResolverParams) => boolean;
resolveTurnSourceTarget: (request: ApprovalRequest) => TTarget | null;
resolveSessionTarget: (
sessionTarget: ExecApprovalSessionTarget,
request: ApprovalRequest,
) => TTarget | null;
normalizeTarget?: NativeApprovalTargetNormalizer<TTarget>;
normalizeTargetForMatch?: NativeApprovalTargetNormalizer<TTarget>;
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<TTarget>(
params: CustomOriginResolverParams<TTarget>,
): (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<TTarget>(
params: NativeOriginResolverParams<NativeApprovalTarget> | CustomOriginResolverParams<TTarget>,
): params is CustomOriginResolverParams<TTarget> {
return typeof params.targetsMatch === "function";
}
export function createChannelNativeOriginTargetResolver<TTarget extends NativeApprovalTarget>(
params: NativeOriginResolverParams<TTarget>,
): (input: ApprovalResolverParams) => TTarget | null;
export function createChannelNativeOriginTargetResolver<TTarget>(
params: CustomOriginResolverParams<TTarget>,
): (input: ApprovalResolverParams) => TTarget | null;
export function createChannelNativeOriginTargetResolver<TTarget>(
params: NativeOriginResolverParams<NativeApprovalTarget> | CustomOriginResolverParams<TTarget>,
): (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,

View File

@@ -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,
});
});
});

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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")],

View File

@@ -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<TRuntime> = 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<TRuntime>(
options?: TypedRuntimeEnvOptions<TRuntime>,
export function createTypedRuntimeEnv<TRuntime extends RuntimeEnv = OutputRuntimeEnv>(
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<TRuntime>(
export function createNonExitingTypedRuntimeEnv<TRuntime extends RuntimeEnv = OutputRuntimeEnv>(
runtimeShape?: (runtime: TRuntime) => void,
): TRuntime {
return createTypedRuntimeEnv<TRuntime>({ throwOnExit: false, __runtimeShape: runtimeShape });
return createTypedRuntimeEnv<TRuntime>({ throwOnExit: false }, runtimeShape);
}

View File

@@ -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));
}

View File

@@ -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;