refactor: use channel target resolution APIs (#85814)

* refactor: use channel target resolution apis

* refactor: satisfy delivery lint

* refactor: remove unused target parsing shim

* fix: preserve routed cron topic targets
This commit is contained in:
Peter Steinberger
2026-05-23 21:26:55 +01:00
committed by GitHub
parent fd2a9adbe6
commit c4f0da00a9
52 changed files with 2145 additions and 438 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
- Maintainer skills: exclude plugin SDK/API boundary work from `openclaw-landable-bug-sweep` so bugbash sweeps stay focused on small paper-cut fixes.
- QA-Lab/diagnostics: extend the OpenTelemetry smoke harness to prove trace, metric, and log export, and add first-class Prometheus and observability smoke aliases.
- Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.
- Plugin SDK/cron delivery: route cron delivery through the modern target resolver and outbound session-route APIs, deprecate parser-backed target helpers and `plugin-sdk/messaging-targets`, and move bundled callers to `plugin-sdk/channel-targets`.
- Crabbox: keep the local wrapper's provider validation synced with the installed Crabbox binary while preserving supported aliases such as `docker` and `blacksmith`. (#85302) Thanks @hxy91819.
- Maintainer skills: add `openclaw-landable-bug-sweep` for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.
- Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight.

View File

@@ -119,9 +119,9 @@ route-like fields, compare a child thread with its parent route, or build a
stable dedupe key from `{ channel, to, accountId, threadId }`. The helper
normalizes numeric thread ids the same way core does, so plugins should prefer
it over ad hoc `String(threadId)` comparisons.
Plugins with provider-specific target grammar can inject their parser into
`resolveChannelRouteTargetWithParser(...)` and still get the same route target
shape and thread fallback semantics core uses.
Plugins with provider-specific target grammar should expose
`messaging.resolveOutboundSessionRoute(...)` so core gets provider-native
session and thread identity without using parser shims.
Bundled plugins that need the same parsing before the channel registry boots
can also expose a top-level `session-key-api.ts` file with a matching
@@ -253,7 +253,7 @@ surfaces:
- `openclaw/plugin-sdk/inbound-envelope` and
`openclaw/plugin-sdk/inbound-reply-dispatch` for inbound route/envelope and
record-and-dispatch wiring
- `openclaw/plugin-sdk/messaging-targets` for target parsing/matching
- `openclaw/plugin-sdk/channel-targets` for target parsing helpers
- `openclaw/plugin-sdk/outbound-media` and
`openclaw/plugin-sdk/outbound-runtime` for media loading plus outbound
identity/send delegates and payload planning

View File

@@ -467,16 +467,23 @@ releases.
| `channelRouteIdentityKey(...)` | `channelRouteDedupeKey(...)` |
| `channelRouteKey(...)` | `channelRouteCompactKey(...)` |
| `ComparableChannelTarget` | `ChannelRouteParsedTarget` |
| `resolveComparableTargetForChannel(...)` | `resolveRouteTargetForChannel(...)` |
| `resolveComparableTargetForLoadedChannel(...)` | `resolveRouteTargetForLoadedChannel(...)` |
| `comparableChannelTargetsMatch(...)` | `channelRouteTargetsMatchExact(...)` |
| `comparableChannelTargetsShareRoute(...)` | `channelRouteTargetsShareConversation(...)` |
The modern route helpers normalize `{ channel, to, accountId, threadId }`
consistently across native approvals, reply suppression, inbound dedupe,
cron delivery, and session routing. If your plugin owns custom target
grammar, use `resolveChannelRouteTargetWithParser(...)` to adapt that
parser into the same route target contract.
cron delivery, and session routing.
Do not add new uses of `ChannelMessagingAdapter.parseExplicitTarget` or
the parser-backed loaded-route helpers (`parseExplicitTargetForLoadedChannel`
or `resolveRouteTargetForLoadedChannel`) or
`resolveChannelRouteTargetWithParser(...)` from `plugin-sdk/channel-route`.
Those hooks are deprecated and remain only for older plugins during the
migration window. New channel plugins should use
`messaging.targetResolver.resolveTarget(...)` for target id normalization
and directory-miss fallback, `messaging.inferTargetChatType(...)` when core
needs an early peer kind, and `messaging.resolveOutboundSessionRoute(...)`
for provider-native session and thread identity.
</Step>
@@ -518,7 +525,7 @@ releases.
| `plugin-sdk/channel-lifecycle` | Account status and draft stream lifecycle helpers | `createAccountStatusSink`, draft preview finalization helpers |
| `plugin-sdk/inbound-envelope` | Inbound envelope helpers | Shared route + envelope builder helpers |
| `plugin-sdk/inbound-reply-dispatch` | Inbound reply helpers | Shared record-and-dispatch helpers |
| `plugin-sdk/messaging-targets` | Messaging target parsing | Target parsing/matching helpers |
| `plugin-sdk/messaging-targets` | Deprecated target parsing import path | Use `plugin-sdk/channel-targets` for generic target parsing helpers, `plugin-sdk/channel-route` for route comparison, and plugin-owned `messaging.targetResolver` / `messaging.resolveOutboundSessionRoute` for provider-specific target resolution |
| `plugin-sdk/outbound-media` | Outbound media helpers | Shared outbound media loading |
| `plugin-sdk/outbound-send-deps` | Outbound send dependency helpers | Lightweight `resolveOutboundSendDep` lookup without importing the full outbound runtime |
| `plugin-sdk/outbound-runtime` | Outbound runtime helpers | Outbound delivery, identity/send delegate, session, formatting, and payload planning helpers |

View File

@@ -117,7 +117,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
| `plugin-sdk/channel-message-runtime` | Runtime delivery helpers that may load outbound delivery, including `deliverInboundReplyWithMessageSendContext`, `sendDurableMessageBatch`, and `withDurableMessageSendContext`. Deprecated reply-dispatch bridges remain importable for compatibility dispatchers only. Use from monitor/send runtime modules, not hot plugin bootstrap files. |
| `plugin-sdk/inbound-envelope` | Shared inbound route + envelope builder helpers |
| `plugin-sdk/inbound-reply-dispatch` | Legacy shared inbound record-and-dispatch helpers, visible/final dispatch predicates, and deprecated `deliverDurableInboundReplyPayload` compatibility for prepared channel dispatchers. New channel receive/dispatch code should import runtime lifecycle helpers from `plugin-sdk/channel-message-runtime`. |
| `plugin-sdk/messaging-targets` | Target parsing/matching helpers |
| `plugin-sdk/messaging-targets` | Deprecated target parsing alias; use `plugin-sdk/channel-targets` |
| `plugin-sdk/outbound-media` | Shared outbound media loading helpers |
| `plugin-sdk/outbound-send-deps` | Lightweight outbound send dependency lookup for channel adapters |
| `plugin-sdk/outbound-runtime` | Outbound identity, send delegate, session, formatting, and payload planning helpers. Direct delivery helpers such as `deliverOutboundPayloads` are deprecated compatibility substrate; use `plugin-sdk/channel-message-runtime` for new send paths. |

View File

@@ -93,14 +93,6 @@ export const clickClackPlugin: ChannelPlugin<ResolvedClickClackAccount> = create
messaging: {
targetPrefixes: ["clickclack", "cc"],
normalizeTarget: normalizeClickClackTarget,
parseExplicitTarget: ({ raw }) => {
const parsed = parseClickClackTarget(raw);
return {
to: buildClickClackTarget(parsed),
threadId: parsed.kind === "thread" ? parsed.id : undefined,
chatType: parsed.chatType,
};
},
inferTargetChatType: ({ to }) => parseClickClackTarget(to).chatType,
targetResolver: {
looksLikeId: looksLikeClickClackTarget,

View File

@@ -142,18 +142,3 @@ export function resolveDiscordInboundConversation(params: {
});
return conversationId ? { conversationId } : null;
}
export function parseDiscordExplicitTarget(raw: string) {
try {
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
if (!target) {
return null;
}
return {
to: target.normalized,
chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const),
};
} catch {
return null;
}
}

View File

@@ -200,28 +200,20 @@ describe("discordPlugin outbound", () => {
);
});
it("preserves normalized explicit Discord targets for delivery routing", () => {
const parseExplicitTarget = discordPlugin.messaging?.parseExplicitTarget;
if (!parseExplicitTarget) {
throw new Error("Expected discordPlugin.messaging.parseExplicitTarget to be defined");
it("preserves normalized Discord targets for delivery routing", () => {
const messaging = discordPlugin.messaging;
if (!messaging?.normalizeTarget || !messaging.inferTargetChatType) {
throw new Error("Expected discordPlugin.messaging target helpers to be defined");
}
expect(parseExplicitTarget({ raw: "user:123" })).toEqual({
to: "user:123",
chatType: "direct",
});
expect(parseExplicitTarget({ raw: "<@!456>" })).toEqual({
to: "user:456",
chatType: "direct",
});
expect(parseExplicitTarget({ raw: "channel:789" })).toEqual({
to: "channel:789",
chatType: "channel",
});
expect(parseExplicitTarget({ raw: "1470130713209602050" })).toEqual({
to: "channel:1470130713209602050",
chatType: "channel",
});
expect(messaging.normalizeTarget("user:123")).toBe("user:123");
expect(messaging.inferTargetChatType({ to: "user:123" })).toBe("direct");
expect(messaging.normalizeTarget("<@!456>")).toBe("user:456");
expect(messaging.inferTargetChatType({ to: "<@!456>" })).toBe("direct");
expect(messaging.normalizeTarget("channel:789")).toBe("channel:789");
expect(messaging.inferTargetChatType({ to: "channel:789" })).toBe("channel");
expect(messaging.normalizeTarget("1470130713209602050")).toBe("channel:1470130713209602050");
expect(messaging.inferTargetChatType({ to: "1470130713209602050" })).toBe("channel");
});
it("resolves Discord usernames through the messaging target resolver", async () => {

View File

@@ -44,7 +44,6 @@ import {
buildDiscordCrossContextPresentation,
matchDiscordAcpConversation,
normalizeDiscordAcpConversationId,
parseDiscordExplicitTarget,
resolveDiscordAttachedOutboundTarget,
resolveDiscordCommandConversation,
resolveDiscordInboundConversation,
@@ -320,8 +319,17 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
normalizeExplicitSessionKey: ({ sessionKey, ctx }) =>
normalizeExplicitDiscordSessionKey(sessionKey, ctx),
resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`),
parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType,
inferTargetChatType: ({ to }) => {
try {
const parsed = parseDiscordTarget(to, { defaultKind: "channel" });
if (!parsed) {
return undefined;
}
return parsed?.kind === "user" ? "direct" : "channel";
} catch {
return undefined;
}
},
buildCrossContextPresentation: buildDiscordCrossContextPresentation,
resolveOutboundSessionRoute: (params) => resolveDiscordOutboundSessionRoute(params),
targetResolver: {

View File

@@ -5,7 +5,7 @@ import {
type MessagingTarget,
type MessagingTargetKind,
type MessagingTargetParseOptions,
} from "openclaw/plugin-sdk/messaging-targets";
} from "openclaw/plugin-sdk/channel-targets";
export type DiscordTargetKind = MessagingTargetKind;

View File

@@ -1,5 +1,5 @@
import { buildMessagingTarget, type MessagingTarget } from "openclaw/plugin-sdk/channel-targets";
import type { DirectoryConfigParams } from "openclaw/plugin-sdk/directory-runtime";
import { buildMessagingTarget, type MessagingTarget } from "openclaw/plugin-sdk/messaging-targets";
import { resolveDiscordAccount, resolveDiscordAccountAllowFrom } from "./accounts.js";
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
import { listDiscordDirectoryPeersLive } from "./directory-live.js";

View File

@@ -92,14 +92,6 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
},
messaging: {
normalizeTarget: normalizeQaTarget,
parseExplicitTarget: ({ raw }) => {
const parsed = parseQaTarget(raw);
return {
to: buildQaTarget(parsed),
threadId: parsed.threadId,
chatType: parsed.chatType,
};
},
inferTargetChatType: ({ to }) => parseQaTarget(to).chatType,
targetResolver: {
looksLikeId: (raw) =>

View File

@@ -150,17 +150,6 @@ function inferSignalTargetChatType(rawTo: string) {
return "direct" as const;
}
function parseSignalExplicitTarget(raw: string) {
const normalized = normalizeSignalMessagingTarget(raw);
if (!normalized) {
return null;
}
return {
to: normalized,
chatType: inferSignalTargetChatType(normalized),
};
}
function buildSignalBaseSessionKey(params: {
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
agentId: string;
@@ -308,7 +297,6 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
messaging: {
targetPrefixes: ["signal"],
normalizeTarget: normalizeSignalMessagingTarget,
parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw),
inferTargetChatType: ({ to }) => inferSignalTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveSignalOutboundSessionRoute(params),
targetResolver: {

View File

@@ -232,7 +232,7 @@ async function resolveSlackSendContext(params: {
return { send, threadTsValue, tokenOverride };
}
function parseSlackExplicitTarget(raw: string) {
function resolveSlackRouteTarget(raw: string) {
const target = parseSlackTarget(raw, { defaultKind: "channel" });
if (!target) {
return null;
@@ -584,8 +584,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
: { to: normalizeSlackMessagingTarget(`channel:${child}`) };
},
resolveSessionTarget: ({ id }) => normalizeSlackMessagingTarget(`channel:${id}`),
parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType,
inferTargetChatType: ({ to }) => resolveSlackRouteTarget(to)?.chatType,
resolveOutboundSessionRoute: async (params) => await resolveSlackOutboundSessionRoute(params),
transformReplyPayload: ({ payload, cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId })
@@ -604,7 +603,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
looksLikeId: looksLikeSlackTargetId,
hint: "<channelId|user:ID|channel:ID>",
resolveTarget: async ({ input }) => {
const parsed = parseSlackExplicitTarget(input);
const parsed = resolveSlackRouteTarget(input);
if (!parsed) {
return null;
}

View File

@@ -6,7 +6,7 @@ import {
type MessagingTarget,
type MessagingTargetKind,
type MessagingTargetParseOptions,
} from "openclaw/plugin-sdk/messaging-targets";
} from "openclaw/plugin-sdk/channel-targets";
export type SlackTargetKind = MessagingTargetKind;

View File

@@ -464,7 +464,7 @@ function resolveTelegramDeliveryTarget(params: {
};
}
function parseTelegramExplicitTarget(raw: string) {
function resolveTelegramRouteTarget(raw: string) {
const target = parseTelegramTarget(raw);
return {
to: target.chatId,
@@ -496,7 +496,7 @@ function shouldStripTelegramThreadFromAnnounceOrigin(params: {
if (!requesterChannel && !requesterTo.startsWith("telegram:")) {
return true;
}
const requesterTarget = parseTelegramExplicitTarget(requesterTo);
const requesterTarget = resolveTelegramRouteTarget(requesterTo);
if (requesterTarget.chatType !== "group") {
return true;
}
@@ -504,7 +504,7 @@ function shouldStripTelegramThreadFromAnnounceOrigin(params: {
if (!entryTo) {
return false;
}
const entryTarget = parseTelegramExplicitTarget(entryTo);
const entryTarget = resolveTelegramRouteTarget(entryTo);
return entryTarget.to !== requesterTarget.to;
}
@@ -753,8 +753,7 @@ export const telegramPlugin = createChatChannelPlugin({
resolveSessionConversation: ({ kind, rawId }) =>
resolveTelegramSessionConversation({ kind, rawId }),
resolveSessionTarget: ({ kind, id }) => resolveTelegramSessionTarget({ kind, id }),
parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType,
inferTargetChatType: ({ to }) => resolveTelegramRouteTarget(to).chatType,
preserveHeartbeatThreadIdForGroupRoute: true,
formatTargetDisplay: ({ target, display, kind }) => {
const formatted = display?.trim();

View File

@@ -51,7 +51,7 @@ const loadWhatsAppChannelReactAction = createLazyRuntimeModule(
() => import("./channel-react-action.js"),
);
function parseWhatsAppExplicitTarget(raw: string) {
function resolveWhatsAppTargetInfo(raw: string) {
const normalized = normalizeWhatsAppTarget(raw);
if (!normalized) {
return null;
@@ -120,8 +120,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
targetPrefixes: ["whatsapp"],
normalizeTarget: normalizeWhatsAppMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveWhatsAppOutboundSessionRoute(params),
parseExplicitTarget: ({ raw }) => parseWhatsAppExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseWhatsAppExplicitTarget(to)?.chatType,
inferTargetChatType: ({ to }) => resolveWhatsAppTargetInfo(to)?.chatType,
targetResolver: {
looksLikeId: looksLikeWhatsAppTargetId,
hint: "<E.164|group JID|newsletter JID>",

View File

@@ -19,6 +19,7 @@
"matrix",
"mattermost",
"media-generation-runtime-shared",
"messaging-targets",
"memory-core",
"memory-core-engine-runtime",
"memory-core-host-events",

View File

@@ -1,4 +1,7 @@
import { resolveAgentWorkspaceDir } from "../../agents/agent-scope-config.js";
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope-config.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js";
@@ -11,7 +14,7 @@ import type { SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import {
resolveAgentDeliveryPlan,
resolveAgentDeliveryPlanWithSessionRoute,
resolveAgentOutboundTarget,
} from "../../infra/outbound/agent-delivery.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
@@ -416,6 +419,13 @@ export async function deliverAgentCommandResult(
): Promise<AgentCommandDeliveryResult> {
const { cfg, deps, runtime, opts, outboundSession, sessionEntry, payloads, result } = params;
const effectiveSessionKey = outboundSession?.key ?? opts.sessionKey;
const deliveryAgentId =
outboundSession?.agentId ??
resolveSessionAgentId({
sessionKey: effectiveSessionKey,
config: cfg,
}) ??
resolveDefaultAgentId(cfg);
const deliver = opts.deliver === true;
const bestEffortDeliver = opts.bestEffortDeliver === true;
const turnSourceChannel = opts.runContext?.messageChannel ?? opts.messageChannel;
@@ -424,7 +434,10 @@ export async function deliverAgentCommandResult(
const turnSourceThreadId = opts.runContext?.currentThreadTs ?? opts.threadId;
const explicitChannelHint = (opts.replyChannel ?? opts.channel)?.trim();
const resolveDeliveryRouting = async (candidateSessionEntry: SessionEntry | undefined) => {
const deliveryPlan = resolveAgentDeliveryPlan({
const deliveryPlan = await resolveAgentDeliveryPlanWithSessionRoute({
cfg,
agentId: deliveryAgentId,
currentSessionKey: effectiveSessionKey,
sessionEntry: candidateSessionEntry,
requestedChannel: opts.replyChannel ?? opts.channel,
explicitTo: opts.replyTo ?? opts.to,

View File

@@ -1,4 +1,10 @@
import { resolveRouteTargetForLoadedChannel } from "../channels/plugins/target-parsing-loaded.js";
import { getLoadedChannelPluginForRead } from "../channels/plugins/registry-loaded-read.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
import {
stripTargetKindPrefix,
stripTargetProviderPrefix,
stripTargetTopicSuffix,
} from "../infra/outbound/channel-target-prefix.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
deliveryContextFromSession,
@@ -12,31 +18,20 @@ import type {
import { isInternalMessageChannel } from "../utils/message-channel.js";
export type { DeliveryContext } from "../utils/delivery-context.types.js";
function stripThreadRouteSuffix(target: string): string {
return /^(.*):topic:[^:]+$/u.exec(target)?.[1] ?? target;
}
function normalizeAnnounceRouteTarget(context?: DeliveryContext): string | undefined {
const rawTo = normalizeOptionalString(context?.to);
if (!rawTo) {
return undefined;
}
const channel = normalizeOptionalString(context?.channel);
const parsed = channel
? resolveRouteTargetForLoadedChannel({
channel,
rawTarget: rawTo,
fallbackThreadId: context?.threadId,
})
: null;
let route = stripThreadRouteSuffix(parsed?.to ?? rawTo);
if (channel && route.toLowerCase().startsWith(`${channel}:`)) {
route = route.slice(channel.length + 1);
}
if (route.startsWith("group:") || route.startsWith("channel:")) {
route = route.slice(route.indexOf(":") + 1);
}
return route || undefined;
const messaging = channel
? getLoadedChannelPluginForRead(channel as ChannelId)?.messaging
: undefined;
const route = stripTargetTopicSuffix(
stripTargetKindPrefix(stripTargetProviderPrefix(rawTo, channel ?? ""), ["group", "channel"]),
);
const normalized = messaging?.normalizeTarget?.(route) ?? route;
return normalized || undefined;
}
function shouldStripThreadFromAnnounceEntry(

View File

@@ -1,5 +1,15 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { extractSimpleExplicitGroupId } from "./group-id-simple.js";
import { extractExplicitGroupId } from "./group-id.js";
afterEach(() => {
setActivePluginRegistry(createTestRegistry());
});
describe("extractSimpleExplicitGroupId", () => {
it("returns undefined for empty/null input", () => {
@@ -42,3 +52,53 @@ describe("extractSimpleExplicitGroupId", () => {
expect(extractSimpleExplicitGroupId("just-a-string")).toBeUndefined();
});
});
describe("extractExplicitGroupId", () => {
it("strips Telegram numeric topic shorthand after target normalization", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: {
...createChannelTestPluginBase({
id: "telegram",
capabilities: { chatTypes: ["group"] },
}),
messaging: {
normalizeTarget: () => "telegram:-100200300:77",
inferTargetChatType: () => "group",
},
},
},
]),
);
expect(extractExplicitGroupId("telegram:-100200300:77")).toBe("-100200300");
});
it("keeps legacy parser-only group target extraction quarantined", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "legacygroup",
source: "test",
plugin: {
...createChannelTestPluginBase({
id: "legacygroup",
capabilities: { chatTypes: ["group"] },
}),
messaging: {
parseExplicitTarget: ({ raw }: { raw: string }) =>
raw.startsWith("legacygroup:")
? { to: "group:room-a:topic:77", chatType: "group" as const }
: null,
},
},
},
]),
);
expect(extractExplicitGroupId("legacygroup:room-a:topic:77")).toBe("room-a");
});
});

View File

@@ -1,12 +1,71 @@
import { getLoadedChannelPluginById } from "../../channels/plugins/registry-loaded.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-loaded-read.js";
import type { ChannelMessagingAdapter } from "../../channels/plugins/types.public.js";
import { normalizeAnyChannelId } from "../../channels/registry.js";
import {
stripTargetKindPrefix,
stripTargetProviderPrefix,
stripTargetTopicSuffix,
} from "../../infra/outbound/channel-target-prefix.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import { extractSimpleExplicitGroupId } from "./group-id-simple.js";
function extractInferredGroupTargetId(params: {
raw: string;
channelId: string;
messaging?: ChannelMessagingAdapter;
}): string | undefined {
const normalized = params.messaging?.normalizeTarget?.(params.raw);
const candidates = [normalized, params.raw].filter(
(candidate, index, values): candidate is string =>
Boolean(candidate) && values.indexOf(candidate) === index,
);
for (const candidate of candidates) {
const chatType = params.messaging?.inferTargetChatType?.({ to: candidate });
if (chatType === "direct" || chatType == null) {
continue;
}
const target = stripTargetTopicSuffix(
stripTargetKindPrefix(stripTargetProviderPrefix(candidate, params.channelId), [
"group",
"channel",
"conversation",
"room",
"thread",
]),
{ allowNumericShorthand: params.channelId === "telegram" },
);
if (target) {
return target;
}
}
return undefined;
}
function extractLegacyParsedGroupTargetId(params: {
raw: string;
channelId: string;
messaging?: ChannelMessagingAdapter;
}): string | undefined {
const parsed = params.messaging?.parseExplicitTarget?.({ raw: params.raw });
if (parsed?.chatType === "direct" || parsed?.chatType == null) {
return undefined;
}
const target = stripTargetTopicSuffix(
stripTargetKindPrefix(stripTargetProviderPrefix(parsed.to, params.channelId), [
"group",
"channel",
"conversation",
"room",
"thread",
]),
{ allowNumericShorthand: params.channelId === "telegram" },
);
return target || undefined;
}
export function extractExplicitGroupId(raw: string | undefined | null): string | undefined {
const trimmed = normalizeOptionalString(raw) ?? "";
if (!trimmed) {
@@ -19,12 +78,20 @@ export function extractExplicitGroupId(raw: string | undefined | null): string |
const firstPart = trimmed.split(":").find(Boolean);
const channelId =
normalizeAnyChannelId(firstPart ?? "") ?? normalizeOptionalLowercaseString(firstPart);
const messaging = channelId
? (getLoadedChannelPluginById(channelId) as ChannelPlugin | undefined)?.messaging
: undefined;
const parsed = messaging?.parseExplicitTarget?.({ raw: trimmed }) ?? null;
if (parsed && parsed.chatType && parsed.chatType !== "direct") {
return parsed.to.replace(/:topic:.*$/, "") || undefined;
const messaging = channelId ? getLoadedChannelPluginForRead(channelId)?.messaging : undefined;
if (!channelId) {
return undefined;
}
return undefined;
return (
extractInferredGroupTargetId({
raw: trimmed,
channelId,
messaging,
}) ??
extractLegacyParsedGroupTargetId({
raw: trimmed,
channelId,
messaging,
})
);
}

View File

@@ -162,6 +162,120 @@ describe("conversation resolution", () => {
});
});
it("strips provider prefixes from normalized fallback conversation targets", () => {
registerChannelPlugin({
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
messaging: {
normalizeTarget: () => "telegram:-1001234567890:topic:77",
},
});
expect(
resolveCommandConversationResolution({
cfg: testConfig,
channel: "telegram",
accountId: "default",
originatingTo: "-1001234567890:topic:77",
})?.canonical,
).toEqual({
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890",
});
});
it("strips kind-prefixed normalized topic routes before fallback resolution", () => {
registerChannelPlugin({
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
messaging: {
normalizeTarget: () => "telegram:group:-1001234567890:topic:77",
},
});
expect(
resolveCommandConversationResolution({
cfg: testConfig,
channel: "telegram",
accountId: "default",
originatingTo: "group:-1001234567890:topic:77",
})?.canonical,
).toEqual({
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890",
});
});
it("normalizes alias-prefixed topic routes before fallback resolution", () => {
registerChannelPlugin({
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
messaging: {
targetPrefixes: ["tg"],
normalizeTarget: () => "telegram:group:-1001234567890:topic:77",
},
});
expect(
resolveCommandConversationResolution({
cfg: testConfig,
channel: "telegram",
accountId: "default",
originatingTo: "tg:group:-1001234567890:topic:77",
})?.canonical,
).toEqual({
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890",
});
});
it("strips Telegram numeric topic shorthand in fallback resolution", () => {
registerChannelPlugin({
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
messaging: {
normalizeTarget: () => "telegram:-1001234567890:77",
},
});
expect(
resolveCommandConversationResolution({
cfg: testConfig,
channel: "telegram",
accountId: "default",
originatingTo: "-1001234567890:77",
})?.canonical,
).toEqual({
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890",
});
});
it("keeps parser-only fallback conversation targets during the migration window", () => {
registerChannelPlugin({
...createChannelTestPluginBase({ id: "legacychat", label: "Legacy chat" }),
messaging: {
parseExplicitTarget: ({ raw }) =>
raw === "room-a:topic:77"
? { to: "room-a", threadId: 77, chatType: "group" as const }
: null,
},
});
expect(
resolveCommandConversationResolution({
cfg: testConfig,
channel: "legacychat",
accountId: "default",
originatingTo: "room-a:topic:77",
})?.canonical,
).toEqual({
channel: "legacychat",
accountId: "default",
conversationId: "room-a",
});
});
it("normalizes numeric command thread ids through the shared route contract", () => {
registerChannelPlugin({
...createChannelTestPluginBase({ id: "test-chat", label: "Test chat" }),

View File

@@ -1,4 +1,10 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolveTargetPrefixedChannel,
stripTargetKindPrefix,
stripTargetProviderPrefix,
stripTargetTopicSuffix,
} from "../infra/outbound/channel-target-prefix.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";
@@ -9,7 +15,7 @@ import {
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { getLoadedChannelPlugin, normalizeChannelId } from "./plugins/index.js";
import { parseExplicitTargetForChannel } from "./plugins/target-parsing.js";
import { resolveExplicitDeliveryTargetCompat } from "./plugins/target-parsing-loaded.js";
import {
resolveBundledChannelThreadBindingDefaultPlacement,
resolveBundledChannelThreadBindingInboundConversation,
@@ -165,6 +171,30 @@ function resolveBindingAccountId(params: {
);
}
function resolveFallbackConversationTargetId(params: {
rawTarget: string;
allowNumericTopicShorthand?: boolean;
}): string | undefined {
const { allowNumericTopicShorthand = false } = params;
const target = normalizeOptionalString(params.rawTarget);
if (!target) {
return undefined;
}
const withoutKind = stripTargetKindPrefix(target);
const withoutTopic = stripTargetTopicSuffix(withoutKind, {
allowNumericShorthand: allowNumericTopicShorthand,
});
return (
resolveConversationIdFromTargets({
targets: [withoutTopic],
}) ??
(withoutTopic !== target ? withoutTopic : undefined) ??
resolveConversationIdFromTargets({
targets: [target],
})
);
}
function resolveChannelTargetId(params: {
channel: string;
target?: string | null;
@@ -186,20 +216,38 @@ function resolveChannelTargetId(params: {
return target;
}
const explicitConversationId = resolveConversationIdFromTargets({
targets: [target],
});
if (explicitConversationId) {
return explicitConversationId;
const prefixedChannel = resolveTargetPrefixedChannel(target);
if (!prefixedChannel || prefixedChannel !== params.channel) {
const explicitConversationId = resolveFallbackConversationTargetId({
rawTarget: target,
allowNumericTopicShorthand: params.channel === "telegram",
});
if (explicitConversationId) {
return explicitConversationId;
}
}
const parsed = parseExplicitTargetForChannel(params.channel, target);
const parsedTarget = normalizeOptionalString(parsed?.to);
if (parsedTarget) {
const normalizedTarget = normalizeOptionalString(
resolveRuntimeChannelPlugin(params.channel)?.messaging?.normalizeTarget?.(target),
);
if (normalizedTarget) {
const withoutProvider = stripTargetProviderPrefix(normalizedTarget, params.channel);
const conversationId = resolveFallbackConversationTargetId({
rawTarget: withoutProvider,
allowNumericTopicShorthand: params.channel === "telegram",
});
return conversationId || withoutProvider || normalizedTarget;
}
const parsedTarget = resolveExplicitDeliveryTargetCompat({
channel: params.channel,
rawTarget: target,
});
if (parsedTarget?.to) {
return (
resolveConversationIdFromTargets({
targets: [parsedTarget],
}) ?? parsedTarget
targets: [parsedTarget.to],
}) ?? parsedTarget.to
);
}

View File

@@ -1,20 +1,51 @@
import {
channelRouteTargetsMatchExact,
channelRouteTargetsShareConversation,
resolveChannelRouteTargetWithParser,
type ChannelRouteExplicitTarget,
type ChannelRouteParsedTarget,
} from "../../plugin-sdk/channel-route.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeOptionalThreadValue,
} from "../../shared/string-coerce.js";
import { getChannelPlugin, normalizeChannelId } from "./index.js";
import { getLoadedChannelPluginForRead } from "./registry-loaded-read.js";
export type { ChannelRouteParsedTarget } from "../../plugin-sdk/channel-route.js";
export type ParsedChannelExplicitTarget = ChannelRouteExplicitTarget;
/** @deprecated Use `ChannelRouteParsedTarget`; provider-specific target grammar should live in `messaging.resolveOutboundSessionRoute`. */
export type ParsedChannelExplicitTarget = {
to: string;
threadId?: string | number;
chatType?: "direct" | "group" | "channel";
};
export function resolveCompatParsedRouteTarget(params: {
channel: string;
rawTarget?: string | null;
fallbackThreadId?: string | number | null;
parseTarget: (channel: string, rawTarget: string) => ParsedChannelExplicitTarget | null;
}): ChannelRouteParsedTarget | null {
const channel = normalizeLowercaseStringOrEmpty(params.channel);
const rawTo = normalizeOptionalString(params.rawTarget);
if (!channel || !rawTo) {
return null;
}
const parsed = params.parseTarget(channel, rawTo);
const fallbackThreadId = normalizeOptionalThreadValue(params.fallbackThreadId);
return {
channel,
rawTo,
to: parsed?.to ?? rawTo,
threadId: normalizeOptionalThreadValue(parsed?.threadId ?? fallbackThreadId),
chatType: parsed?.chatType,
};
}
/** @deprecated Use `ChannelRouteParsedTarget`. */
export type ComparableChannelTarget = ChannelRouteParsedTarget;
/** @deprecated Use `messaging.targetResolver` and `messaging.resolveOutboundSessionRoute`. */
export function parseExplicitTargetForLoadedChannel(
channel: string,
rawTarget: string,
@@ -23,25 +54,39 @@ export function parseExplicitTargetForLoadedChannel(
if (!resolvedChannel) {
return null;
}
const normalizedChannel = normalizeChannelId(resolvedChannel) ?? resolvedChannel;
return (
getLoadedChannelPluginForRead(resolvedChannel)?.messaging?.parseExplicitTarget?.({
getLoadedChannelPluginForRead(normalizedChannel)?.messaging?.parseExplicitTarget?.({
raw: rawTarget,
}) ?? null
}) ??
getChannelPlugin(normalizedChannel)?.messaging?.parseExplicitTarget?.({
raw: rawTarget,
}) ??
null
);
}
/** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */
export function resolveRouteTargetForLoadedChannel(params: {
channel: string;
rawTarget?: string | null;
fallbackThreadId?: string | number | null;
}): ChannelRouteParsedTarget | null {
return resolveChannelRouteTargetWithParser({
return resolveCompatParsedRouteTarget({
...params,
parseExplicitTarget: parseExplicitTargetForLoadedChannel,
parseTarget: parseExplicitTargetForLoadedChannel,
});
}
/** @deprecated Use `resolveRouteTargetForLoadedChannel`. */
export function resolveExplicitDeliveryTargetCompat(params: {
channel: string;
rawTarget?: string | null;
fallbackThreadId?: string | number | null;
}): ChannelRouteParsedTarget | null {
return resolveRouteTargetForLoadedChannel(params);
}
/** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */
export function resolveComparableTargetForLoadedChannel(params: {
channel: string;
rawTarget?: string | null;

View File

@@ -7,12 +7,10 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
comparableChannelTargetsMatch,
parseExplicitTargetForChannel,
parseExplicitTargetForLoadedChannel,
resolveComparableTargetForChannel,
resolveRouteTargetForChannel,
resolveComparableTargetForLoadedChannel,
resolveRouteTargetForLoadedChannel,
} from "./target-parsing.js";
} from "./target-parsing-loaded.js";
function parseThreadedTargetForTest(raw: string): {
to: string;
@@ -99,27 +97,27 @@ function setMinimalTargetParsingRegistry(): void {
);
}
describe("parseExplicitTargetForChannel", () => {
describe("parseExplicitTargetForLoadedChannel", () => {
beforeEach(() => {
setMinimalTargetParsingRegistry();
});
it("parses threaded targets via the registered channel plugin contract", () => {
expect(
parseExplicitTargetForChannel("mock-threaded", "threaded:group:room-a:topic:77"),
parseExplicitTargetForLoadedChannel("mock-threaded", "threaded:group:room-a:topic:77"),
).toEqual({
to: "room-a",
threadId: 77,
chatType: "group",
});
expect(parseExplicitTargetForChannel("mock-threaded", "room-a")).toEqual({
expect(parseExplicitTargetForLoadedChannel("mock-threaded", "room-a")).toEqual({
to: "room-a",
chatType: undefined,
});
});
it("parses registered non-bundled channel targets via the active plugin contract", () => {
expect(parseExplicitTargetForChannel("demo-target", "team-room")).toEqual({
expect(parseExplicitTargetForLoadedChannel("demo-target", "team-room")).toEqual({
to: "TEAM-ROOM",
chatType: "direct",
});
@@ -131,7 +129,7 @@ describe("parseExplicitTargetForChannel", () => {
it("builds route targets from plugin-owned grammar", () => {
expect(
resolveRouteTargetForChannel({
resolveRouteTargetForLoadedChannel({
channel: "mock-threaded",
rawTarget: "threaded:group:room-a:topic:77",
}),
@@ -157,11 +155,11 @@ describe("parseExplicitTargetForChannel", () => {
});
it("matches route targets when only the plugin grammar differs", () => {
const topicTarget = resolveRouteTargetForChannel({
const topicTarget = resolveRouteTargetForLoadedChannel({
channel: "mock-threaded",
rawTarget: "threaded:room-a:topic:77",
});
const bareTarget = resolveRouteTargetForChannel({
const bareTarget = resolveRouteTargetForLoadedChannel({
channel: "mock-threaded",
rawTarget: "room-a",
});
@@ -181,11 +179,11 @@ describe("parseExplicitTargetForChannel", () => {
});
it("compares numeric and string thread ids through the shared route contract", () => {
const numericThread = resolveRouteTargetForChannel({
const numericThread = resolveRouteTargetForLoadedChannel({
channel: "mock-threaded",
rawTarget: "threaded:room-a:topic:77",
});
const stringThread = resolveRouteTargetForChannel({
const stringThread = resolveRouteTargetForLoadedChannel({
channel: "mock-threaded",
rawTarget: "room-a",
fallbackThreadId: "77",
@@ -200,11 +198,11 @@ describe("parseExplicitTargetForChannel", () => {
});
it("keeps deprecated comparable target helpers as route wrappers", () => {
const numericThread = resolveComparableTargetForChannel({
const numericThread = resolveComparableTargetForLoadedChannel({
channel: "mock-threaded",
rawTarget: "threaded:room-a:topic:77",
});
const stringThread = resolveRouteTargetForChannel({
const stringThread = resolveRouteTargetForLoadedChannel({
channel: "mock-threaded",
rawTarget: "room-a",
fallbackThreadId: "77",

View File

@@ -1,58 +0,0 @@
import { resolveChannelRouteTargetWithParser } from "../../plugin-sdk/channel-route.js";
import { normalizeChatChannelId } from "../registry.js";
import { getChannelPlugin, normalizeChannelId } from "./index.js";
import type {
ChannelRouteParsedTarget,
ParsedChannelExplicitTarget,
} from "./target-parsing-loaded.js";
export {
comparableChannelTargetsMatch,
comparableChannelTargetsShareRoute,
parseExplicitTargetForLoadedChannel,
resolveComparableTargetForLoadedChannel,
resolveRouteTargetForLoadedChannel,
} from "./target-parsing-loaded.js";
export type {
ComparableChannelTarget,
ChannelRouteParsedTarget,
ParsedChannelExplicitTarget,
} from "./target-parsing-loaded.js";
function parseWithPlugin(
getPlugin: (channel: string) => ReturnType<typeof getChannelPlugin>,
rawChannel: string,
rawTarget: string,
): ParsedChannelExplicitTarget | null {
const channel = normalizeChatChannelId(rawChannel) ?? normalizeChannelId(rawChannel);
if (!channel) {
return null;
}
return getPlugin(channel)?.messaging?.parseExplicitTarget?.({ raw: rawTarget }) ?? null;
}
export function parseExplicitTargetForChannel(
channel: string,
rawTarget: string,
): ParsedChannelExplicitTarget | null {
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;
}): ChannelRouteParsedTarget | null {
return resolveRouteTargetForChannel(params);
}

View File

@@ -561,6 +561,11 @@ export type ChannelMessagingAdapter = {
id: string;
threadId?: string | null;
}) => string | undefined;
/**
* @deprecated Use `targetResolver` for target id normalization and
* `resolveOutboundSessionRoute` for session/thread identity. This remains for
* compatibility with older route parsing helpers.
*/
parseExplicitTarget?: (params: { raw: string }) => {
to: string;
threadId?: string | number;

View File

@@ -8,6 +8,7 @@ import type {
} from "../channels/plugins/types.adapters.js";
import { callGateway } from "../gateway/call.js";
import { resolveOutboundSendDep } from "../infra/outbound/send-deps.js";
import { buildChannelOutboundSessionRoute } from "../plugin-sdk/core.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
@@ -164,13 +165,47 @@ export function setupIsolatedAgentTurnMocks(params?: { fast?: boolean }): void {
id: "telegram",
outbound: telegramOutboundForTest,
messaging: {
parseExplicitTarget: ({ raw }) => {
const target = parseTelegramTargetForTest(raw);
return {
to: target.chatId,
threadId: target.messageThreadId,
chatType: target.chatType === "unknown" ? undefined : target.chatType,
};
inferTargetChatType: ({ to }) => {
const target = parseTelegramTargetForTest(to);
return target.chatType === "unknown" ? undefined : target.chatType;
},
targetResolver: {
resolveTarget: async ({ input }) => {
const parsed = parseTelegramTargetForTest(input);
if (!parsed.chatId) {
return null;
}
return {
to:
parsed.messageThreadId == null
? parsed.chatId
: `${parsed.chatId}:topic:${parsed.messageThreadId}`,
kind: parsed.chatType === "direct" ? "user" : "group",
source: "normalized",
};
},
},
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target, threadId }) => {
const parsed = parseTelegramTargetForTest(target);
const resolvedThreadId = parsed.messageThreadId ?? threadId ?? undefined;
const chatType = parsed.chatType === "direct" ? "direct" : "group";
return buildChannelOutboundSessionRoute({
cfg,
agentId,
channel: "telegram",
accountId,
peer: {
kind: chatType,
id:
chatType === "group" && resolvedThreadId !== undefined
? `${parsed.chatId}:topic:${resolvedThreadId}`
: parsed.chatId,
},
chatType,
from: `telegram:${parsed.chatId}`,
to: parsed.chatId,
...(resolvedThreadId !== undefined ? { threadId: resolvedThreadId } : {}),
});
},
},
}),

View File

@@ -1,20 +1,72 @@
import type { ParsedChannelExplicitTarget } from "../../channels/plugins/target-parsing-loaded.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveOutboundChannelPlugin } from "../../infra/outbound/channel-resolution.js";
import {
resolveOutboundSessionRoute,
type OutboundSessionRoute,
} from "../../infra/outbound/outbound-session.js";
import {
resolveChannelTarget,
type ResolvedMessagingTarget,
} from "../../infra/outbound/target-resolver.js";
export { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-loaded-read.js";
export { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js";
export { resolveFirstBoundAccountId } from "../../routing/bound-account-read.js";
export function parseExplicitTargetForDelivery(params: {
export async function resolveChannelTargetForDelivery(params: {
cfg: OpenClawConfig;
channel: string;
rawTarget: string;
}): ParsedChannelExplicitTarget | null {
return (
channel: ChannelId;
input: string;
accountId?: string | null;
}): Promise<{ ok: true; target: ResolvedMessagingTarget } | { ok: false; error: Error }> {
resolveOutboundChannelPlugin({
channel: params.channel,
cfg: params.cfg,
allowBootstrap: true,
});
try {
return await resolveChannelTarget({
cfg: params.cfg,
channel: params.channel,
input: params.input,
accountId: params.accountId,
unknownTargetMode: "normalized",
});
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err : new Error(String(err)),
};
}
}
export async function resolveOutboundSessionRouteForDelivery(params: {
cfg: OpenClawConfig;
channel: ChannelId;
agentId: string;
accountId?: string | null;
target: string;
resolvedTarget?: ResolvedMessagingTarget;
threadId?: string | number | null;
currentSessionKey?: string;
}): Promise<OutboundSessionRoute | null> {
resolveOutboundChannelPlugin({
channel: params.channel,
cfg: params.cfg,
allowBootstrap: true,
});
return await resolveOutboundSessionRoute(params);
}
export function channelCanResolveOutboundSessionRoute(params: {
cfg: OpenClawConfig;
channel: ChannelId;
}): boolean {
return Boolean(
resolveOutboundChannelPlugin({
channel: params.channel,
cfg: params.cfg,
allowBootstrap: true,
})?.messaging?.parseExplicitTarget?.({ raw: params.rawTarget }) ?? null
})?.messaging?.resolveOutboundSessionRoute,
);
}

View File

@@ -3,8 +3,10 @@ import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
forumMessagingForTest,
parseTelegramTargetForTest,
telegramMessagingForTest,
} from "../../infra/outbound/targets.test-helpers.js";
import { buildChannelOutboundSessionRoute } from "../../plugin-sdk/core.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
@@ -101,14 +103,14 @@ function createAllowlistAwareStubOutbound(label: string): ChannelOutboundAdapter
}
const normalizeTelegramTargetForDeliveryTest = vi.fn((raw: string): string | undefined => {
const target = telegramMessagingForTest.parseExplicitTarget?.({ raw });
if (!target?.to) {
const target = parseTelegramTargetForTest(raw);
if (!target.chatId) {
return undefined;
}
const normalizedTo = target.to.toLowerCase();
return target.threadId == null
const normalizedTo = target.chatId.toLowerCase();
return target.messageThreadId == null
? `telegram:${normalizedTo}`
: `telegram:${normalizedTo}:topic:${target.threadId}`;
: `telegram:${normalizedTo}:topic:${target.messageThreadId}`;
});
beforeEach(() => {
@@ -144,6 +146,40 @@ beforeEach(() => {
}),
source: "test",
},
{
pluginId: "signal",
plugin: createOutboundTestPlugin({
id: "signal",
outbound: createStubOutbound("Signal"),
messaging: {
targetPrefixes: ["signal"],
inferTargetChatType: ({ to }) =>
to
.replace(/^signal:/i, "")
.trim()
.toLowerCase()
.startsWith("group:")
? "group"
: "direct",
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => {
const stripped = target.replace(/^signal:/i, "").trim();
const isGroup = stripped.toLowerCase().startsWith("group:");
const peerId = isGroup ? stripped.slice("group:".length).trim() : stripped;
return buildChannelOutboundSessionRoute({
cfg,
agentId,
channel: "signal",
accountId,
peer: { kind: isGroup ? "group" : "direct", id: peerId },
chatType: isGroup ? "group" : "direct",
from: isGroup ? `group:${peerId}` : `signal:${peerId}`,
to: isGroup ? `group:${peerId}` : `signal:${peerId}`,
});
},
},
}),
source: "test",
},
{
pluginId: "alpha",
plugin: {
@@ -430,7 +466,313 @@ describe("resolveDeliveryTarget", () => {
});
});
it("skips id-like target normalization for dry-run delivery previews", async () => {
it("fails ambiguous directory targets instead of picking a best match", async () => {
setMainSessionEntry(undefined);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "alpha",
source: "test",
plugin: {
...createOutboundTestPlugin({
id: "alpha",
outbound: createStubOutbound("Alpha"),
messaging: { targetPrefixes: ["alpha"] },
}),
directory: {
listGroups: async () => [
{ id: "channel:ops-a", name: "ops", rank: 1 },
{ id: "channel:ops-b", name: "ops", rank: 2 },
],
},
},
},
]),
);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "alpha",
to: "ops",
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected ambiguous target error");
}
expect(result.error.message).toContain("Ambiguous");
});
it("surfaces target resolver exceptions instead of treating raw names as resolved", async () => {
setMainSessionEntry(undefined);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "alpha",
source: "test",
plugin: {
...createOutboundTestPlugin({
id: "alpha",
outbound: createStubOutbound("Alpha"),
messaging: { targetPrefixes: ["alpha"] },
}),
directory: {
listGroups: async () => {
throw new Error("directory auth failed");
},
},
},
},
]),
);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "alpha",
to: "ops",
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected target resolver error");
}
expect(result.error.message).toContain("directory auth failed");
});
it("keeps parser-derived explicit thread ids for parser-only cron targets", async () => {
setMainSessionEntry(undefined);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "alpha",
source: "test",
plugin: createOutboundTestPlugin({
id: "alpha",
outbound: createStubOutbound("Alpha"),
messaging: {
targetPrefixes: ["alpha"],
parseExplicitTarget: ({ raw }) =>
raw === "alpha:room-a:topic:77"
? { to: "room-a", threadId: 77, chatType: "group" as const }
: null,
},
}),
},
]),
);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "alpha",
to: "alpha:room-a:topic:77",
});
expect(result.ok).toBe(true);
expect(result.to).toBe("room-a");
expect(result.threadId).toBe(77);
});
it("does not treat parser-only target normalization as a parser thread id", async () => {
setLastSessionEntry({
sessionId: "sess-parser-stale-thread",
lastChannel: "alpha",
lastTo: "room-a",
lastThreadId: "stale-thread",
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "alpha",
source: "test",
plugin: createOutboundTestPlugin({
id: "alpha",
outbound: createStubOutbound("Alpha"),
messaging: {
targetPrefixes: ["alpha"],
parseExplicitTarget: ({ raw }) =>
raw === "alpha:room-b" ? { to: "room-b", chatType: "group" as const } : null,
},
}),
},
]),
);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "alpha",
to: "alpha:room-b",
});
expect(result.ok).toBe(true);
expect(result.to).toBe("room-b");
expect(result.threadId).toBeUndefined();
});
it("uses canonical route targets even when the route has no thread", async () => {
setMainSessionEntry(undefined);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "alpha",
source: "test",
plugin: createOutboundTestPlugin({
id: "alpha",
outbound: createStubOutbound("Alpha"),
messaging: {
targetPrefixes: ["alpha"],
inferTargetChatType: ({ to }) => (to.startsWith("group:") ? "group" : "direct"),
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => {
const stripped = target.replace(/^alpha:/i, "");
return buildChannelOutboundSessionRoute({
cfg,
agentId,
channel: "alpha",
accountId,
peer: { kind: "group", id: stripped.replace(/^group:/i, "") },
chatType: "group",
from: `alpha:${stripped}`,
to: stripped.replace(/^group:/i, ""),
});
},
},
}),
},
]),
);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "alpha",
to: "alpha:group:room-a",
});
expect(result.ok).toBe(true);
expect(result.to).toBe("room-a");
expect(result.threadId).toBeUndefined();
});
it("keeps provider-qualified normalized targets for provider route parsing", async () => {
setMainSessionEntry(undefined);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: createOutboundTestPlugin({
id: "telegram",
outbound: createStubOutbound("Telegram"),
messaging: {
targetPrefixes: ["telegram"],
normalizeTarget: () => "telegram:group:-100200300:topic:77",
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => {
const match = /^telegram:group:(-?\d+):topic:(\d+)$/i.exec(target);
const chatId = match?.[1] ?? target;
const threadId = match?.[2] ? Number.parseInt(match[2], 10) : undefined;
return buildChannelOutboundSessionRoute({
cfg,
agentId,
channel: "telegram",
accountId,
peer: { kind: "group", id: chatId },
chatType: "group",
from: `telegram:group:${chatId}`,
to: chatId,
...(threadId != null ? { threadId } : {}),
});
},
},
}),
},
]),
);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "telegram",
to: "telegram:group:-100200300:topic:77",
});
expect(result.ok).toBe(true);
expect(result.to).toBe("-100200300");
expect(result.threadId).toBe(77);
});
it("ignores stale previous-route parse failures for explicit cron targets", async () => {
setLastSessionEntry({
sessionId: "sess-stale-route",
lastChannel: "alpha",
lastTo: "bad:stored:target",
lastThreadId: "old-thread",
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "alpha",
source: "test",
plugin: createOutboundTestPlugin({
id: "alpha",
outbound: createStubOutbound("Alpha"),
messaging: {
targetPrefixes: ["alpha"],
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => {
if (target === "bad:stored:target") {
throw new Error("stale route parse failed");
}
const stripped = target.replace(/^alpha:/i, "");
return buildChannelOutboundSessionRoute({
cfg,
agentId,
channel: "alpha",
accountId,
peer: { kind: "group", id: stripped },
chatType: "group",
from: `alpha:group:${stripped}`,
to: stripped,
});
},
},
}),
},
]),
);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "alpha",
to: "alpha:room-a",
});
expect(result.ok).toBe(true);
expect(result.to).toBe("room-a");
expect(result.threadId).toBeUndefined();
});
it("keeps cron route canonicalization best-effort when explicit route resolution fails", async () => {
setMainSessionEntry(undefined);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "alpha",
source: "test",
plugin: createOutboundTestPlugin({
id: "alpha",
outbound: createStubOutbound("Alpha"),
messaging: {
targetPrefixes: ["alpha"],
inferTargetChatType: () => "group",
resolveOutboundSessionRoute: () => {
throw new Error("route lookup failed");
},
},
}),
},
]),
);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "alpha",
to: "room-a",
});
expect(result.ok).toBe(true);
expect(result.to).toBe("room-a");
expect(result.threadId).toBeUndefined();
});
it("uses target resolution for dry-run delivery previews", async () => {
setMainSessionEntry(undefined);
vi.mocked(maybeResolveIdLikeTarget).mockClear();
@@ -446,7 +788,12 @@ describe("resolveDeliveryTarget", () => {
expect(result.ok).toBe(true);
expect(result.to).toBe("123456789");
expect(maybeResolveIdLikeTarget).not.toHaveBeenCalled();
expect(maybeResolveIdLikeTarget).toHaveBeenCalledWith({
cfg: makeCfg({ bindings: [] }),
channel: "forum",
input: "123456789",
accountId: undefined,
});
});
it("falls back to the runtime target resolver when the channel plugin is not already loaded", async () => {
@@ -625,7 +972,7 @@ describe("resolveDeliveryTarget", () => {
expect(result.threadId).toBe("thread-2");
});
it("keeps a session Telegram topic threadId when a bare explicit target matches the topic route", async () => {
it("does not carry a Telegram topic threadId to a bare explicit group target", async () => {
setLastSessionEntry({
sessionId: "sess-telegram-topic",
lastChannel: "telegram",
@@ -641,12 +988,11 @@ describe("resolveDeliveryTarget", () => {
expect(result.ok).toBe(true);
expect(result.to).toBe("-100200300");
expect(result.threadId).toBe(77);
expect(result.threadId).toBeUndefined();
expect(normalizeTelegramTargetForDeliveryTest).toHaveBeenCalledWith("-100200300");
expect(normalizeTelegramTargetForDeliveryTest).toHaveBeenCalledWith("-100200300:topic:77");
});
it("drops carried threadId instead of throwing when target normalization fails", async () => {
it("surfaces target normalization failures instead of using a raw fallback", async () => {
setLastSessionEntry({
sessionId: "sess-telegram-topic-invalid",
lastChannel: "telegram",
@@ -662,9 +1008,11 @@ describe("resolveDeliveryTarget", () => {
to: "-100200300",
});
expect(result.ok).toBe(true);
expect(result.to).toBe("-100200300");
expect(result.threadId).toBeUndefined();
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected target normalization error");
}
expect(result.error.message).toContain("target normalizer exploded");
});
it("drops a session Telegram topic threadId when a bare explicit target names a different chat", async () => {
@@ -711,6 +1059,22 @@ describe("resolveDeliveryTarget", () => {
expect(result.to).toBe("1234567890");
});
it("rejects provider-prefixed explicit targets without a recipient", async () => {
setMainSessionEntry(undefined);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "last",
to: "telegram:",
});
expect(result.ok).toBe(false);
expect(result.channel).toBe("telegram");
expect(result.to).toBeUndefined();
if (result.ok) {
throw new Error("expected missing target error");
}
expect(result.error.message).toContain("Target is required");
});
it("returns an error when channel selection is ambiguous", async () => {
setMainSessionEntry(undefined);
vi.mocked(resolveMessageChannelSelection).mockRejectedValueOnce(
@@ -872,6 +1236,20 @@ describe("resolveDeliveryTarget", () => {
expect(result.threadId).toBe(1008013);
});
it("keeps semantic group prefixes for provider route resolution", async () => {
setMainSessionEntry(undefined);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "signal",
to: "signal:group:ops",
});
expect(result.ok).toBe(true);
expect(result.channel).toBe("signal");
expect(result.to).toBe("group:ops");
expect(result.threadId).toBeUndefined();
});
it("keeps explicit delivery threadId on first run without session history", async () => {
setMainSessionEntry(undefined);
@@ -930,6 +1308,42 @@ describe("resolveDeliveryTarget", () => {
expect(result.threadId).toBe(77);
});
it("resolves plugin default targets through the modern target route", async () => {
setMainSessionEntry(undefined);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: {
...createOutboundTestPlugin({
id: "telegram",
outbound: createStubOutbound("Telegram"),
messaging: {
...telegramMessagingForTest,
normalizeTarget: normalizeTelegramTargetForDeliveryTest,
},
}),
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
resolveDefaultTo: () => "-100200300:77",
},
},
source: "test",
},
]),
);
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "telegram",
to: undefined,
});
expect(result.ok).toBe(true);
expect(result.to).toBe("-100200300");
expect(result.threadId).toBe(77);
});
it("prefers explicit telegram :topic: targets over session-derived threadId", async () => {
setLastSessionEntry({
sessionId: "sess-telegram-topic",

View File

@@ -1,3 +1,4 @@
import { resolveExplicitDeliveryTargetCompat } from "../../channels/plugins/target-parsing-loaded.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
import { resolveStorePath } from "../../config/sessions/paths.js";
@@ -5,16 +6,15 @@ import { readSessionEntry } from "../../config/sessions/store-load.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-id-resolution.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { stripTargetProviderPrefix } from "../../infra/outbound/channel-target-prefix.js";
import type { OutboundSessionRoute } from "../../infra/outbound/outbound-session.js";
import type { ResolvedMessagingTarget } from "../../infra/outbound/target-resolver.js";
import { tryResolveLoadedOutboundTarget } from "../../infra/outbound/targets-loaded.js";
import {
resolveSessionDeliveryTarget,
type ExplicitTargetParser,
} from "../../infra/outbound/targets-session.js";
import { resolveSessionDeliveryTarget } from "../../infra/outbound/targets-session.js";
import type { OutboundChannel } from "../../infra/outbound/targets.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { normalizeOptionalThreadValue } from "../../shared/string-coerce.js";
import { resolveCronStoredDeliveryContext } from "../delivery-context.js";
import { resolveCronAgentSessionKey } from "./session-key.js";
@@ -63,53 +63,6 @@ async function resolveOutboundTargetWithRuntime(
}
}
function normalizeTargetForThreadCarry(
channel: Exclude<OutboundChannel, "none"> | undefined,
to: string | undefined,
parseExplicitTarget: ExplicitTargetParser,
): string | undefined {
if (!channel || !to) {
return undefined;
}
try {
const normalized = normalizeTargetForProvider(channel, to);
const comparable = normalized ?? to.trim();
if (!comparable) {
return undefined;
}
const parsed = parseExplicitTarget(channel, comparable);
const base = parsed?.to ?? comparable;
return normalizeTargetForProvider(channel, base) ?? base;
} catch {
return undefined;
}
}
function deliveryTargetsShareThreadRoute(params: {
channel: Exclude<OutboundChannel, "none"> | undefined;
to: string | undefined;
lastTo: string | undefined;
parseExplicitTarget: ExplicitTargetParser;
}): boolean {
if (!params.to || !params.lastTo) {
return false;
}
if (params.to === params.lastTo) {
return true;
}
const normalizedTo = normalizeTargetForThreadCarry(
params.channel,
params.to,
params.parseExplicitTarget,
);
const normalizedLastTo = normalizeTargetForThreadCarry(
params.channel,
params.lastTo,
params.parseExplicitTarget,
);
return Boolean(normalizedTo && normalizedLastTo && normalizedTo === normalizedLastTo);
}
const channelSelectionRuntimeLoader = createLazyImportLoader(
() => import("../../infra/outbound/channel-selection.runtime.js"),
);
@@ -124,6 +77,50 @@ async function loadChannelSelectionRuntime() {
async function loadDeliveryTargetRuntime() {
return await deliveryTargetRuntimeLoader.load();
}
function isNonEmptyThreadId(value: string | number | undefined | null): value is string | number {
return value != null && value !== "";
}
function routesSharePeer(left?: OutboundSessionRoute | null, right?: OutboundSessionRoute | null) {
return Boolean(
left &&
right &&
left.baseSessionKey === right.baseSessionKey &&
left.peer.kind === right.peer.kind &&
left.peer.id === right.peer.id,
);
}
function shouldCarrySessionThread(params: {
resolved: ReturnType<typeof resolveSessionDeliveryTarget>;
explicitTo?: string;
route?: OutboundSessionRoute | null;
lastRoute?: OutboundSessionRoute | null;
}) {
if (!isNonEmptyThreadId(params.resolved.threadId)) {
return false;
}
if (!params.explicitTo) {
return (
params.resolved.channel === params.resolved.lastChannel &&
params.resolved.to === params.resolved.lastTo
);
}
return routesSharePeer(params.route, params.lastRoute);
}
function stripSelectedProviderPrefix(params: {
channel: Exclude<OutboundChannel, "none">;
to?: string;
}): string | undefined {
const trimmed = params.to?.trim();
if (!trimmed) {
return undefined;
}
const stripped = stripTargetProviderPrefix(trimmed, params.channel).trim();
return stripped || undefined;
}
export async function resolveDeliveryTarget(
cfg: OpenClawConfig,
agentId: string,
@@ -141,12 +138,6 @@ export async function resolveDeliveryTarget(
const explicitTo = typeof jobPayload.to === "string" ? jobPayload.to : undefined;
const allowMismatchedLastTo = requestedChannel === "last";
const deliveryTargetRuntime = await loadDeliveryTargetRuntime();
const parseExplicitTarget: ExplicitTargetParser = (channel, rawTarget) =>
deliveryTargetRuntime.parseExplicitTargetForDelivery({
cfg,
channel,
rawTarget,
});
const sessionCfg = cfg.session;
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
@@ -186,7 +177,6 @@ export async function resolveDeliveryTarget(
explicitTo,
explicitThreadId: jobPayload.threadId,
allowMismatchedLastTo,
parseExplicitTarget,
});
let fallbackChannel: Exclude<OutboundChannel, "none"> | undefined;
@@ -217,7 +207,6 @@ export async function resolveDeliveryTarget(
fallbackChannel,
allowMismatchedLastTo,
mode: preliminary.mode,
parseExplicitTarget,
})
: preliminary;
@@ -246,29 +235,13 @@ export async function resolveDeliveryTarget(
accountId = jobPayload.accountId;
}
// Carry threadId when it was explicitly set (from :topic: parsing or config)
// or when delivering to the same recipient as the session's last conversation.
// Session-derived threadIds are dropped when the target differs to prevent
// stale thread IDs from leaking to a different chat.
let threadId =
resolved.threadId &&
(resolved.threadIdExplicit ||
deliveryTargetsShareThreadRoute({
channel,
to: resolved.to,
lastTo: resolved.lastTo,
parseExplicitTarget,
}))
? resolved.threadId
: undefined;
if (!channel) {
return {
ok: false,
channel: undefined,
to: undefined,
accountId,
threadId,
threadId: undefined,
mode,
error:
channelResolutionError ??
@@ -276,6 +249,10 @@ export async function resolveDeliveryTarget(
};
}
const explicitThreadId = isNonEmptyThreadId(jobPayload.threadId)
? jobPayload.threadId
: undefined;
let effectiveAllowFrom: string[] | undefined;
if (mode === "implicit") {
const { getLoadedChannelPluginForRead, mapAllowFromEntries } = deliveryTargetRuntime;
@@ -306,6 +283,7 @@ export async function resolveDeliveryTarget(
}
}
const preResolvedRouteTargetCandidate = toCandidate;
const docked = await resolveOutboundTargetWithRuntime({
channel,
to: toCandidate,
@@ -320,31 +298,148 @@ export async function resolveDeliveryTarget(
channel,
to: undefined,
accountId,
threadId,
threadId: explicitThreadId,
mode,
error: docked.error,
};
}
toCandidate = docked.to;
let resolvedTarget: ResolvedMessagingTarget | undefined;
const targetResolution = await deliveryTargetRuntime.resolveChannelTargetForDelivery({
cfg,
channel,
input: toCandidate,
accountId,
});
if (!targetResolution.ok) {
return {
ok: false,
channel,
to: undefined,
accountId,
threadId: explicitThreadId,
mode,
error: targetResolution.error,
};
}
resolvedTarget = targetResolution.target;
const routeTargetCandidate =
resolvedTarget.source === "directory"
? resolvedTarget.to
: (preResolvedRouteTargetCandidate ?? toCandidate);
const selectedTarget = stripSelectedProviderPrefix({
channel,
to: resolvedTarget.to,
});
if (!selectedTarget) {
return {
ok: false,
channel,
to: undefined,
accountId,
threadId: explicitThreadId,
mode,
error: new Error("Target is required"),
};
}
toCandidate = selectedTarget;
const route = await (async () => {
try {
return await deliveryTargetRuntime.resolveOutboundSessionRouteForDelivery({
cfg,
channel,
agentId,
accountId,
target: routeTargetCandidate,
resolvedTarget,
threadId: explicitThreadId,
currentSessionKey: threadSessionKey ?? mainSessionKey,
});
} catch {
return null;
}
})();
const routeCanCanonicalizeTarget = deliveryTargetRuntime.channelCanResolveOutboundSessionRoute({
cfg,
channel,
});
const routeShouldCanonicalizeTarget =
route && (route.threadId !== undefined || route.to !== routeTargetCandidate);
if (route && routeCanCanonicalizeTarget && routeShouldCanonicalizeTarget) {
const routeTo = stripSelectedProviderPrefix({
channel,
to: route.to,
});
if (!routeTo) {
return {
ok: false,
channel,
to: undefined,
accountId,
threadId: explicitThreadId,
mode,
error: new Error("Target is required"),
};
}
toCandidate = routeTo;
}
const lastTo = resolved.lastTo;
const lastRoute =
lastTo && resolved.lastChannel === channel
? await (async () => {
try {
return await deliveryTargetRuntime.resolveOutboundSessionRouteForDelivery({
cfg,
channel,
agentId,
accountId: resolved.lastAccountId ?? accountId,
target: lastTo,
threadId: resolved.lastThreadId,
currentSessionKey: threadSessionKey ?? mainSessionKey,
});
} catch {
return null;
}
})()
: null;
const parserExplicitThreadId =
explicitThreadId == null && explicitTo
? normalizeOptionalThreadValue(
resolveExplicitDeliveryTargetCompat({
channel,
rawTarget: explicitTo,
})?.threadId,
)
: undefined;
const threadId =
explicitThreadId ??
route?.threadId ??
parserExplicitThreadId ??
(shouldCarrySessionThread({
resolved,
explicitTo,
route,
lastRoute,
})
? resolved.threadId
: undefined);
if (options?.dryRun) {
return {
ok: true,
channel,
to: docked.to,
to: toCandidate,
accountId,
threadId,
mode,
};
}
const idLikeTarget = await maybeResolveIdLikeTarget({
cfg,
channel,
input: docked.to,
accountId,
});
return {
ok: true,
channel,
to: idLikeTarget?.to ?? docked.to,
to: toCandidate,
accountId,
threadId,
mode,

View File

@@ -56,7 +56,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { formatUncaughtError } from "../../infra/errors.js";
import {
resolveAgentDeliveryPlan,
resolveAgentDeliveryPlanWithSessionRoute,
resolveAgentOutboundTarget,
} from "../../infra/outbound/agent-delivery.js";
import { shouldDowngradeDeliveryToSessionOnly } from "../../infra/outbound/best-effort-delivery.js";
@@ -1550,7 +1550,12 @@ export const agentHandlers: GatewayRequestHandlers = {
const turnSourceChannel = normalizeOptionalString(request.channel);
const turnSourceTo = normalizeOptionalString(request.to);
const turnSourceAccountId = normalizeOptionalString(request.accountId);
const deliveryPlan = resolveAgentDeliveryPlan({
const deliveryPlan = await resolveAgentDeliveryPlanWithSessionRoute({
cfg: cfgForAgent ?? cfg,
agentId: resolvedSessionKey
? resolveAgentIdFromSessionKey(resolvedSessionKey)
: (agentId ?? resolveDefaultAgentId(cfgForAgent ?? cfg)),
currentSessionKey: resolvedSessionKey,
sessionEntry,
requestedChannel: request.replyChannel ?? request.channel,
explicitTo,

View File

@@ -24,6 +24,7 @@ import {
} from "./heartbeat-runner.js";
import {
resolveHeartbeatDeliveryTarget,
resolveHeartbeatDeliveryTargetWithSessionRoute,
resolveHeartbeatSenderContext,
} from "./outbound/targets.js";
import { telegramMessagingForTest } from "./outbound/targets.test-helpers.js";
@@ -581,8 +582,8 @@ describe("resolveHeartbeatDeliveryTarget", () => {
{ name: "topic suffix", to: "-100111:topic:42", expectedTo: "-100111", expectedThreadId: 42 },
{ name: "plain chat id", to: "-100111", expectedTo: "-100111", expectedThreadId: undefined },
])(
"parses optional telegram :topic: threadId suffix: $name",
({ to, expectedTo, expectedThreadId }) => {
"parses optional telegram :topic: threadId suffix through session route: $name",
async ({ to, expectedTo, expectedThreadId }) => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
@@ -590,7 +591,11 @@ describe("resolveHeartbeatDeliveryTarget", () => {
},
},
};
const result = resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry });
const result = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg,
agentId: "heartbeat-agent",
entry: baseEntry,
});
expect(result.channel).toBe("telegram");
expect(result.to).toBe(expectedTo);
expect(result.threadId).toBe(expectedThreadId);

View File

@@ -131,7 +131,7 @@ import {
import type { OutboundSendDeps } from "./outbound/deliver.js";
import { buildOutboundSessionContext } from "./outbound/session-context.js";
import {
resolveHeartbeatDeliveryTarget,
resolveHeartbeatDeliveryTargetWithSessionRoute,
resolveHeartbeatSenderContext,
} from "./outbound/targets.js";
import {
@@ -1438,10 +1438,12 @@ export async function runHeartbeatOnce(opts: {
const heartbeatForDelivery = commitmentDeliveryContext
? { ...heartbeat, target: "last", to: undefined, accountId: undefined }
: heartbeat;
const delivery = resolveHeartbeatDeliveryTarget({
const delivery = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg,
agentId,
entry,
heartbeat: heartbeatForDelivery,
currentSessionKey: sessionKey,
// Isolated heartbeat runs drain system events from their dedicated
// `:heartbeat` session, not from the base session we peek during preflight.
// Reusing base-session turnSource routing here can pin later isolated runs

View File

@@ -1,7 +1,11 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
resolveOutboundTarget: vi.fn(() => ({ ok: true as const, to: "+1999" })),
resolveOutboundChannelPlugin: vi.fn<() => unknown>(() => null),
resolveOutboundTarget: vi.fn<() => { ok: true; to: string } | { ok: false; error: Error }>(
() => ({ ok: true, to: "+1999" }),
),
resolveOutboundSessionRoute: vi.fn<() => Promise<unknown>>(async () => null),
resolveSessionDeliveryTarget: vi.fn(
(params: {
entry?: {
@@ -53,7 +57,6 @@ const mocks = vi.hoisted(() => ({
threadId:
params.explicitThreadId ??
(channel && channel === lastChannel ? lastThreadId : undefined),
threadIdExplicit: params.explicitThreadId != null,
mode,
lastChannel,
lastTo,
@@ -69,6 +72,14 @@ vi.mock("./targets.js", () => ({
resolveSessionDeliveryTarget: mocks.resolveSessionDeliveryTarget,
}));
vi.mock("./channel-resolution.js", () => ({
resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin,
}));
vi.mock("./outbound-session.js", () => ({
resolveOutboundSessionRoute: mocks.resolveOutboundSessionRoute,
}));
vi.mock("../../utils/message-channel.js", () => ({
INTERNAL_MESSAGE_CHANNEL: "webchat",
isDeliverableMessageChannel: (channel: string) => ["directchat", "workspace"].includes(channel),
@@ -79,14 +90,23 @@ vi.mock("../../utils/message-channel.js", () => ({
import type { OpenClawConfig } from "../../config/config.js";
let resolveAgentDeliveryPlan: typeof import("./agent-delivery.js").resolveAgentDeliveryPlan;
let resolveAgentDeliveryPlanWithSessionRoute: typeof import("./agent-delivery.js").resolveAgentDeliveryPlanWithSessionRoute;
let resolveAgentOutboundTarget: typeof import("./agent-delivery.js").resolveAgentOutboundTarget;
beforeAll(async () => {
({ resolveAgentDeliveryPlan, resolveAgentOutboundTarget } = await import("./agent-delivery.js"));
({
resolveAgentDeliveryPlan,
resolveAgentDeliveryPlanWithSessionRoute,
resolveAgentOutboundTarget,
} = await import("./agent-delivery.js"));
});
beforeEach(() => {
mocks.resolveOutboundChannelPlugin.mockReset();
mocks.resolveOutboundChannelPlugin.mockReturnValue(null);
mocks.resolveOutboundTarget.mockClear();
mocks.resolveOutboundSessionRoute.mockReset();
mocks.resolveOutboundSessionRoute.mockResolvedValue(null);
mocks.resolveSessionDeliveryTarget.mockClear();
});
@@ -220,4 +240,162 @@ describe("agent delivery helpers", () => {
expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled();
expect(resolved.resolvedTo).toBe("+1555");
});
it("resolves explicit delivery targets through plugin session routing", async () => {
const pluginRouteResolver = vi.fn();
mocks.resolveOutboundChannelPlugin.mockReturnValue({
messaging: { resolveOutboundSessionRoute: pluginRouteResolver },
});
mocks.resolveOutboundTarget.mockReturnValueOnce({
ok: true,
to: "channel:C123",
});
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
sessionKey: "agent:workspace:channel:C123",
baseSessionKey: "agent:workspace:channel:C123",
peer: { kind: "channel", id: "C123" },
chatType: "channel",
from: "workspace:channel:C123",
to: "channel:C123",
threadId: "1700000000.000100",
});
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
cfg: {} as OpenClawConfig,
agentId: "agent",
currentSessionKey: "agent:main",
sessionEntry: {
sessionId: "s4",
updatedAt: 4,
deliveryContext: { channel: "workspace", to: "channel:C999" },
},
requestedChannel: "workspace",
explicitTo: "workspace:channel:C123:thread:1700000000.000100",
accountId: "work",
wantsDelivery: true,
});
expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith({
cfg: {},
channel: "workspace",
agentId: "agent",
accountId: "work",
target: "channel:C123",
currentSessionKey: "agent:main",
threadId: undefined,
});
expect(plan.resolvedTo).toBe("channel:C123");
expect(plan.resolvedThreadId).toBe("1700000000.000100");
});
it("does not session-route explicit targets before outbound normalization succeeds", async () => {
mocks.resolveOutboundChannelPlugin.mockReturnValue({
messaging: { resolveOutboundSessionRoute: vi.fn() },
});
mocks.resolveOutboundTarget.mockReturnValueOnce({
ok: false,
error: new Error("ambiguous target"),
});
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
cfg: {} as OpenClawConfig,
agentId: "agent",
sessionEntry: undefined,
requestedChannel: "workspace",
explicitTo: "1470130713209602050",
accountId: undefined,
wantsDelivery: true,
});
expect(mocks.resolveOutboundSessionRoute).not.toHaveBeenCalled();
expect(plan.resolvedTo).toBe("1470130713209602050");
});
it("falls back to the original plan when session-route canonicalization fails", async () => {
mocks.resolveOutboundChannelPlugin.mockReturnValue({
messaging: { resolveOutboundSessionRoute: vi.fn() },
});
mocks.resolveOutboundTarget.mockReturnValueOnce({
ok: true,
to: "channel:C123",
});
mocks.resolveOutboundSessionRoute.mockRejectedValueOnce(new Error("route lookup failed"));
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
cfg: {} as OpenClawConfig,
agentId: "agent",
sessionEntry: undefined,
requestedChannel: "workspace",
explicitTo: "channel:C123",
accountId: undefined,
wantsDelivery: true,
});
expect(plan.resolvedTo).toBe("channel:C123");
expect(plan.resolvedThreadId).toBeUndefined();
});
it("does not session-route targets when delivery is disabled", async () => {
mocks.resolveOutboundChannelPlugin.mockReturnValue({
messaging: { resolveOutboundSessionRoute: vi.fn() },
});
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
cfg: {} as OpenClawConfig,
agentId: "agent",
sessionEntry: undefined,
requestedChannel: "workspace",
explicitTo: "channel:C123",
accountId: undefined,
wantsDelivery: false,
});
expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled();
expect(mocks.resolveOutboundSessionRoute).not.toHaveBeenCalled();
expect(plan.resolvedTo).toBe("channel:C123");
});
it("does not pass inherited session threads into explicit retarget routing", async () => {
mocks.resolveOutboundChannelPlugin.mockReturnValue({
messaging: { resolveOutboundSessionRoute: vi.fn() },
});
mocks.resolveOutboundTarget.mockReturnValueOnce({
ok: true,
to: "channel:C123",
});
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
sessionKey: "agent:workspace:channel:C123",
baseSessionKey: "agent:workspace:channel:C123",
peer: { kind: "channel", id: "C123" },
chatType: "channel",
from: "workspace:channel:C123",
to: "channel:C123",
});
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
cfg: {} as OpenClawConfig,
agentId: "agent",
sessionEntry: {
sessionId: "s-thread",
updatedAt: 5,
deliveryContext: {
channel: "workspace",
to: "channel:C999",
threadId: "old-thread",
},
},
requestedChannel: "workspace",
explicitTo: "channel:C123",
accountId: undefined,
wantsDelivery: true,
});
expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith(
expect.objectContaining({
target: "channel:C123",
threadId: undefined,
}),
);
expect(plan.resolvedThreadId).toBeUndefined();
});
});

View File

@@ -1,4 +1,5 @@
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.public.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
@@ -10,6 +11,8 @@ import {
normalizeMessageChannel,
type GatewayMessageChannel,
} from "../../utils/message-channel.js";
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
import { resolveOutboundSessionRoute } from "./outbound-session.js";
import type { OutboundTargetResolution } from "./targets.js";
import {
resolveOutboundTarget,
@@ -130,6 +133,67 @@ export function resolveAgentDeliveryPlan(params: {
};
}
export async function resolveAgentDeliveryPlanWithSessionRoute(
params: Parameters<typeof resolveAgentDeliveryPlan>[0] & {
cfg: OpenClawConfig;
agentId: string;
currentSessionKey?: string;
},
): Promise<AgentDeliveryPlan> {
const plan = resolveAgentDeliveryPlan(params);
if (
!params.wantsDelivery ||
!plan.resolvedTo ||
!isDeliverableMessageChannel(plan.resolvedChannel) ||
!resolveOutboundChannelPlugin({
channel: plan.resolvedChannel,
cfg: params.cfg,
allowBootstrap: true,
})?.messaging?.resolveOutboundSessionRoute
) {
return plan;
}
const normalizedTarget = resolveOutboundTarget({
channel: plan.resolvedChannel,
to: plan.resolvedTo,
cfg: params.cfg,
accountId: plan.resolvedAccountId,
mode: plan.deliveryTargetMode ?? "explicit",
});
if (!normalizedTarget.ok) {
return plan;
}
const explicitThreadId =
params.explicitThreadId != null && params.explicitThreadId !== ""
? params.explicitThreadId
: undefined;
const route = await (async () => {
try {
return await resolveOutboundSessionRoute({
cfg: params.cfg,
channel: plan.resolvedChannel as ChannelId,
agentId: params.agentId,
accountId: plan.resolvedAccountId,
target: normalizedTarget.to,
currentSessionKey: params.currentSessionKey,
threadId: plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId,
});
} catch {
return null;
}
})();
if (!route) {
return plan;
}
return {
...plan,
resolvedTo: route.to,
resolvedThreadId:
route.threadId ??
(plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId),
};
}
export function resolveAgentOutboundTarget(params: {
cfg: OpenClawConfig;
plan: AgentDeliveryPlan;

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { stripTargetTopicSuffix } from "./channel-target-prefix.js";
describe("stripTargetTopicSuffix", () => {
it("strips explicit topic suffixes", () => {
expect(stripTargetTopicSuffix("room-a:topic:77")).toBe("room-a");
});
it("strips Telegram numeric topic shorthand only when requested", () => {
expect(stripTargetTopicSuffix("-100200300:77", { allowNumericShorthand: true })).toBe(
"-100200300",
);
});
it("keeps generic colon targets intact", () => {
expect(stripTargetTopicSuffix("room:123")).toBe("room:123");
expect(stripTargetTopicSuffix("room-a:child")).toBe("room-a:child");
});
});

View File

@@ -12,6 +12,41 @@ const TARGET_KIND_PREFIXES = new Set([
"user",
]);
export function stripTargetProviderPrefix(raw: string, ...providers: string[]): string {
const trimmed = raw.trim();
const lower = normalizeOptionalLowercaseString(trimmed) ?? "";
for (const provider of providers) {
const normalizedProvider = normalizeOptionalLowercaseString(provider);
if (normalizedProvider && lower.startsWith(`${normalizedProvider}:`)) {
return trimmed.slice(normalizedProvider.length + 1).trim();
}
}
return trimmed;
}
export function stripTargetKindPrefix(
raw: string,
kinds: readonly string[] = ["channel", "conversation", "dm", "group", "room", "thread", "user"],
): string {
const kindPattern = kinds
.map((kind) => normalizeOptionalLowercaseString(kind))
.filter((kind): kind is string => Boolean(kind))
.join("|");
return kindPattern ? raw.replace(new RegExp(`^(${kindPattern}):`, "i"), "").trim() : raw.trim();
}
export function stripTargetTopicSuffix(
raw: string,
options: { allowNumericShorthand?: boolean } = {},
): string {
const trimmed = raw.trim();
const numericTopicMatch = options.allowNumericShorthand ? /^(-?\d+):(\d+)$/.exec(trimmed) : null;
if (numericTopicMatch?.[1]) {
return numericTopicMatch[1];
}
return trimmed.replace(/:topic:.*$/i, "").trim();
}
export type ChannelTargetProviderPrefix = {
prefix: string;
channel: string;

View File

@@ -585,11 +585,21 @@ export function setMinimalOutboundSessionPluginRegistryForTests(): void {
capabilities: { chatTypes: ["direct", "group", "channel"] },
}),
messaging: {
parseExplicitTarget: ({ raw }) =>
raw.startsWith("spaces/") ? { to: raw, chatType: "group" } : null,
inferTargetChatType: ({ to }) => (to.startsWith("spaces/") ? "group" : undefined),
targetPrefixes: ["fallbackchat"],
},
},
{
...createChannelTestPluginBase({
id: "legacyparser",
label: "Legacy Parser",
capabilities: { chatTypes: ["direct", "group", "channel"] },
}),
messaging: {
parseExplicitTarget: ({ raw }) =>
raw === "team-ops" ? { to: raw, chatType: "group" } : null,
},
},
];
setActivePluginRegistry(
createTestRegistry(

View File

@@ -398,6 +398,18 @@ describe("resolveOutboundSessionRoute", () => {
chatType: "channel",
},
},
{
name: "Legacy parser-only plugin chat type fallback",
cfg: baseConfig,
channel: "legacyparser",
target: "team-ops",
expected: {
sessionKey: "agent:main:legacyparser:group:team-ops",
from: "legacyparser:group:team-ops",
to: "channel:team-ops",
chatType: "group",
},
},
] satisfies NamedRouteCase[])("$name", async ({ name: _name, ...params }) => {
await expectResolvedRoute(params);
});

View File

@@ -69,8 +69,7 @@ function inferPeerKindFromPlugin(params: {
}): ChatType | undefined {
for (const target of params.targets) {
const inferred = normalizeInferredPeerKind(
params.plugin?.messaging?.parseExplicitTarget?.({ raw: target })?.chatType ??
params.plugin?.messaging?.inferTargetChatType?.({ to: target }),
params.plugin?.messaging?.inferTargetChatType?.({ to: target }),
);
if (inferred) {
return inferred;
@@ -79,6 +78,20 @@ function inferPeerKindFromPlugin(params: {
return undefined;
}
function inferPeerKindFromLegacyParser(params: {
plugin: ReturnType<typeof resolveOutboundChannelPlugin>;
targets: readonly string[];
}): ChatType | undefined {
for (const target of params.targets) {
const parsed = params.plugin?.messaging?.parseExplicitTarget?.({ raw: target });
const inferred = normalizeInferredPeerKind(parsed?.chatType);
if (inferred) {
return inferred;
}
}
return undefined;
}
function inferPeerKindFromFallbackPrefixes(targets: readonly string[]): ChatType | undefined {
for (const target of targets) {
for (const fallback of FALLBACK_TARGET_KIND_PREFIXES) {
@@ -120,6 +133,7 @@ function inferPeerKind(params: {
);
return (
inferPeerKindFromPlugin({ plugin, targets }) ??
inferPeerKindFromLegacyParser({ plugin, targets }) ??
inferPeerKindFromFallbackPrefixes(targets) ??
"direct"
);

View File

@@ -49,6 +49,8 @@ export async function resolveChannelTarget(params: {
accountId?: string | null;
preferredKind?: TargetResolveKind;
runtime?: RuntimeEnv;
resolveAmbiguous?: ResolveAmbiguousMode;
unknownTargetMode?: "error" | "normalized";
}): Promise<ResolveMessagingTargetResult> {
return resolveMessagingTarget(params);
}
@@ -343,6 +345,7 @@ export async function resolveMessagingTarget(params: {
preferredKind?: TargetResolveKind;
runtime?: RuntimeEnv;
resolveAmbiguous?: ResolveAmbiguousMode;
unknownTargetMode?: "error" | "normalized";
}): Promise<ResolveMessagingTargetResult> {
const raw = normalizeChannelTargetInput(params.input);
if (!raw) {
@@ -441,6 +444,13 @@ export async function resolveMessagingTarget(params: {
};
}
if (params.unknownTargetMode === "normalized") {
return buildNormalizedResolveResult({
normalized,
kind,
});
}
return {
ok: false,
error: unknownTargetError(providerLabel, raw, hint),

View File

@@ -1,11 +1,12 @@
import { parseExplicitTargetForLoadedChannel } from "../../channels/plugins/target-parsing-loaded.js";
import { resolveExplicitDeliveryTargetCompat } 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 {
type ChannelRouteExplicitTargetParser,
channelRouteTargetsShareConversation,
resolveChannelRouteTargetWithParser,
} from "../../plugin-sdk/channel-route.js";
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeOptionalThreadValue,
} from "../../shared/string-coerce.js";
import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js";
import {
isDeliverableMessageChannel,
@@ -22,8 +23,6 @@ export type SessionDeliveryTarget = {
to?: string;
accountId?: string;
threadId?: string | number;
/** Whether threadId came from an explicit source (config/param/:topic: parsing) vs session history. */
threadIdExplicit?: boolean;
mode: ChannelOutboundTargetMode;
lastChannel?: DeliverableMessageChannel;
lastTo?: string;
@@ -31,35 +30,29 @@ export type SessionDeliveryTarget = {
lastThreadId?: string | number;
};
export type ExplicitTargetParser = ChannelRouteExplicitTargetParser;
function resolveParsedRouteTarget(params: {
channel: string;
rawTarget?: string | null;
fallbackThreadId?: string | number | null;
parseExplicitTarget?: ExplicitTargetParser;
}) {
return resolveChannelRouteTargetWithParser({
...params,
parseExplicitTarget: params.parseExplicitTarget ?? parseExplicitTargetForLoadedChannel,
const channel = normalizeLowercaseStringOrEmpty(params.channel);
const rawTo = normalizeOptionalString(params.rawTarget);
if (!channel || !rawTo) {
return null;
}
const parsed = resolveExplicitDeliveryTargetCompat({
channel,
rawTarget: rawTo,
fallbackThreadId: params.fallbackThreadId,
});
}
function parseExplicitDeliveryTarget(params: {
channel?: DeliverableMessageChannel;
fallbackChannel?: DeliverableMessageChannel;
raw?: string;
parseExplicitTarget?: ExplicitTargetParser;
}) {
const raw = params.raw?.trim();
if (!raw) {
return null;
}
const provider = params.channel ?? params.fallbackChannel;
if (!provider) {
return null;
}
return (params.parseExplicitTarget ?? parseExplicitTargetForLoadedChannel)(provider, raw);
const threadId = normalizeOptionalThreadValue(parsed?.threadId ?? params.fallbackThreadId);
return {
channel,
rawTo,
to: parsed?.to ?? rawTo,
...(threadId != null ? { threadId } : {}),
chatType: parsed?.chatType,
};
}
export function resolveSessionDeliveryTarget(params: {
@@ -80,7 +73,6 @@ export function resolveSessionDeliveryTarget(params: {
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
parseExplicitTarget?: ExplicitTargetParser;
}): SessionDeliveryTarget {
const context = deliveryContextFromSession(params.entry);
const sessionLastChannel =
@@ -90,7 +82,6 @@ export function resolveSessionDeliveryTarget(params: {
channel: sessionLastChannel,
rawTarget: context?.to,
fallbackThreadId: context?.threadId,
parseExplicitTarget: params.parseExplicitTarget,
})
: null;
@@ -101,12 +92,13 @@ export function resolveSessionDeliveryTarget(params: {
channel: params.turnSourceChannel,
rawTarget: params.turnSourceTo,
fallbackThreadId: params.turnSourceThreadId,
parseExplicitTarget: params.parseExplicitTarget,
})
: null;
const hasTurnSourceThreadId = parsedTurnSourceTarget?.threadId != null;
const lastChannel = hasTurnSourceChannel ? params.turnSourceChannel : sessionLastChannel;
const lastTo = hasTurnSourceChannel ? params.turnSourceTo : context?.to;
const lastTo = hasTurnSourceChannel
? (parsedTurnSourceTarget?.to ?? params.turnSourceTo)
: (parsedSessionTarget?.to ?? context?.to);
const lastAccountId = hasTurnSourceChannel ? params.turnSourceAccountId : context?.accountId;
const turnToMatchesSession =
!params.turnSourceTo ||
@@ -149,20 +141,18 @@ export function resolveSessionDeliveryTarget(params: {
channel = params.fallbackChannel;
}
let explicitTo = rawExplicitTo;
const parsedExplicitTarget = parseExplicitDeliveryTarget({
channel,
fallbackChannel: !channel ? lastChannel : undefined,
raw: rawExplicitTo,
parseExplicitTarget: params.parseExplicitTarget,
});
if (parsedExplicitTarget?.to) {
explicitTo = parsedExplicitTarget.to;
}
const explicitThreadId =
params.explicitThreadId != null && params.explicitThreadId !== ""
? params.explicitThreadId
: parsedExplicitTarget?.threadId;
const parsedExplicitTarget =
channel && rawExplicitTo
? resolveExplicitDeliveryTargetCompat({
channel,
rawTarget: rawExplicitTo,
fallbackThreadId: params.explicitThreadId,
})
: null;
const explicitTo = parsedExplicitTarget?.to ?? rawExplicitTo;
const explicitThreadId = normalizeOptionalThreadValue(
parsedExplicitTarget?.threadId ?? params.explicitThreadId,
);
let to = explicitTo;
if (!to && lastTo) {
@@ -190,7 +180,6 @@ export function resolveSessionDeliveryTarget(params: {
to,
accountId,
threadId: resolvedThreadId,
threadIdExplicit: resolvedThreadId != null && explicitThreadId != null,
mode,
lastChannel,
lastTo,

View File

@@ -4,6 +4,7 @@ import type {
ChannelPlugin,
} from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { buildChannelOutboundSessionRoute } from "../../plugin-sdk/core.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
function readTestDefaultTo(cfg: OpenClawConfig, channelId: string): string | undefined {
@@ -16,7 +17,7 @@ function stripTestPrefix(raw: string, channelId: string): string {
return raw.replace(new RegExp(`^${channelId}:`, "i"), "").trim();
}
function parseForumTargetForTest(raw: string): {
export function parseForumTargetForTest(raw: string): {
roomId: string;
threadId?: number;
chatType: "direct" | "group" | "unknown";
@@ -54,7 +55,7 @@ function createGenericResolveTarget(
};
}
function parseTelegramTargetForTest(raw: string): {
export function parseTelegramTargetForTest(raw: string): {
chatId: string;
messageThreadId?: number;
chatType: "direct" | "group" | "unknown";
@@ -81,30 +82,39 @@ function parseTelegramTargetForTest(raw: string): {
export const telegramMessagingForTest: ChannelMessagingAdapter = {
targetPrefixes: ["telegram", "tg"],
parseExplicitTarget: ({ raw }) => {
const target = parseTelegramTargetForTest(raw);
return {
to: target.chatId,
threadId: target.messageThreadId,
chatType: target.chatType === "unknown" ? undefined : target.chatType,
};
},
inferTargetChatType: ({ to }) => {
const target = parseTelegramTargetForTest(to);
return target.chatType === "unknown" ? undefined : target.chatType;
},
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target, resolvedTarget, threadId }) => {
const parsed = parseTelegramTargetForTest(target);
const resolvedThreadId = parsed.messageThreadId ?? threadId ?? undefined;
const isGroup =
parsed.chatType === "group" ||
(parsed.chatType === "unknown" &&
resolvedTarget?.kind !== undefined &&
resolvedTarget.kind !== "user");
const peerId =
isGroup && resolvedThreadId ? `${parsed.chatId}:topic:${resolvedThreadId}` : parsed.chatId;
return buildChannelOutboundSessionRoute({
cfg,
agentId,
channel: "telegram",
accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: peerId,
},
chatType: isGroup ? "group" : "direct",
from: isGroup ? `telegram:group:${peerId}` : `telegram:${parsed.chatId}`,
to: parsed.chatId,
...(resolvedThreadId !== undefined ? { threadId: resolvedThreadId } : {}),
});
},
};
export const forumMessagingForTest: ChannelMessagingAdapter = {
targetPrefixes: ["forum"],
parseExplicitTarget: ({ raw }) => {
const target = parseForumTargetForTest(raw);
return {
to: target.roomId,
threadId: target.threadId,
chatType: target.chatType === "unknown" ? undefined : target.chatType,
};
},
inferTargetChatType: ({ to }) => {
const target = parseForumTargetForTest(to);
return target.chatType === "unknown" ? undefined : target.chatType;
@@ -112,6 +122,25 @@ export const forumMessagingForTest: ChannelMessagingAdapter = {
targetResolver: {
hint: "<room|dm target>",
},
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target, threadId }) => {
const parsed = parseForumTargetForTest(target);
const resolvedThreadId = parsed.threadId ?? threadId ?? undefined;
const chatType = parsed.chatType === "direct" ? "direct" : "group";
return buildChannelOutboundSessionRoute({
cfg,
agentId,
channel: "forum",
accountId,
peer: {
kind: chatType,
id: parsed.roomId,
},
chatType,
from: chatType === "direct" ? `forum:${parsed.roomId}` : `forum:group:${parsed.roomId}`,
to: parsed.roomId,
...(resolvedThreadId !== undefined ? { threadId: resolvedThreadId } : {}),
});
},
preserveHeartbeatThreadIdForGroupRoute: true,
};

View File

@@ -4,6 +4,7 @@ import type { SessionEntry } from "../../config/sessions/types.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/runtime.js";
import {
resolveHeartbeatDeliveryTarget,
resolveHeartbeatDeliveryTargetWithSessionRoute,
resolveOutboundTarget,
resolveSessionDeliveryTarget,
} from "./targets.js";
@@ -15,6 +16,7 @@ import {
import {
createForumTargetTestPlugin,
createGenericTargetTestPlugin,
createTestChannelPlugin,
createTargetsTestRegistry,
} from "./targets.test-helpers.js";
@@ -34,7 +36,9 @@ beforeEach(() => {
mocks.normalizeDeliverableOutboundChannel.mockReset();
mocks.normalizeDeliverableOutboundChannel.mockImplementation((value?: string | null) => {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : undefined;
return ["alpha", "beta", "forum"].includes(String(normalized)) ? normalized : undefined;
return ["alpha", "beta", "forum", "telegram"].includes(String(normalized))
? normalized
: undefined;
});
mocks.resolveOutboundChannelPlugin.mockReset();
mocks.resolveOutboundChannelPlugin.mockImplementation(
@@ -160,7 +164,6 @@ describe("resolveSessionDeliveryTarget", () => {
to: params.to,
accountId: undefined,
threadId: undefined,
threadIdExplicit: false,
mode: "implicit",
lastChannel: params.lastChannel,
lastTo: params.lastTo,
@@ -169,7 +172,7 @@ describe("resolveSessionDeliveryTarget", () => {
});
};
const expectTopicParsedFromExplicitTo = (
const expectTopicTargetKeptRaw = (
entry: Parameters<typeof resolveSessionDeliveryTarget>[0]["entry"],
) => {
const resolved = resolveSessionDeliveryTarget({
@@ -177,8 +180,8 @@ describe("resolveSessionDeliveryTarget", () => {
requestedChannel: "last",
explicitTo: "room:ops:topic:1008013",
});
expect(resolved.to).toBe("room:ops");
expect(resolved.threadId).toBe(1008013);
expect(resolved.to).toBe("room:ops:topic:1008013");
expect(resolved.threadId).toBeUndefined();
};
it("derives implicit delivery from the last route", () => {
@@ -198,7 +201,6 @@ describe("resolveSessionDeliveryTarget", () => {
to: "Room One",
accountId: "acct-1",
threadId: undefined,
threadIdExplicit: false,
mode: "implicit",
lastChannel: "alpha",
lastTo: "Room One",
@@ -226,6 +228,63 @@ describe("resolveSessionDeliveryTarget", () => {
});
});
it("keeps parser-only explicit target compatibility during the migration window", () => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
setActivePluginRegistry(
createTargetsTestRegistry([
{
...alpha,
messaging: {
targetPrefixes: ["alpha"],
parseExplicitTarget: ({ raw }) =>
raw === "alpha:room-a:topic:77"
? { to: "room-a", threadId: 77, chatType: "group" as const }
: null,
},
},
]),
);
const resolved = resolveSessionDeliveryTarget({
requestedChannel: "alpha",
explicitTo: "alpha:room-a:topic:77",
});
expect(resolved.to).toBe("room-a");
expect(resolved.threadId).toBe(77);
});
it("keeps parser-only session target thread compatibility during the migration window", () => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
setActivePluginRegistry(
createTargetsTestRegistry([
{
...alpha,
messaging: {
targetPrefixes: ["alpha"],
parseExplicitTarget: ({ raw }) =>
raw === "alpha:room-a:topic:77"
? { to: "room-a", threadId: 77, chatType: "group" as const }
: null,
},
},
]),
);
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-parser",
updatedAt: 1,
lastChannel: "alpha",
lastTo: "alpha:room-a:topic:77",
},
requestedChannel: "last",
});
expect(resolved.to).toBe("room-a");
expect(resolved.threadId).toBe(77);
});
it("uses an explicit provider-prefixed target before last-session channel fallback", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
@@ -348,8 +407,8 @@ describe("resolveSessionDeliveryTarget", () => {
});
});
it("parses plugin-owned explicit targets into threadId", () => {
expectTopicParsedFromExplicitTo({
it("keeps plugin-owned explicit targets raw for route resolution", () => {
expectTopicTargetKeptRaw({
sessionId: "sess-topic",
updatedAt: 1,
lastChannel: "forum",
@@ -357,8 +416,8 @@ describe("resolveSessionDeliveryTarget", () => {
});
});
it("parses plugin-owned explicit targets even when lastTo is absent", () => {
expectTopicParsedFromExplicitTo({
it("keeps plugin-owned explicit targets raw when lastTo is absent", () => {
expectTopicTargetKeptRaw({
sessionId: "sess-no-last",
updatedAt: 1,
lastChannel: "forum",
@@ -429,7 +488,7 @@ describe("resolveSessionDeliveryTarget", () => {
});
expect(resolved.threadId).toBe(42);
expect(resolved.to).toBe("room:ops");
expect(resolved.to).toBe("room:ops:topic:1008013");
});
const resolveHeartbeatTarget = (entry: SessionEntry, directPolicy?: "allow" | "block") =>
@@ -639,10 +698,9 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.channel).toBe("forum");
expect(resolved.to).toBe("room:ops");
expect(resolved.threadId).toBe(42);
expect(resolved.threadIdExplicit).toBe(true);
});
it("parses explicit heartbeat plugin targets into threadId", () => {
it("keeps explicit heartbeat plugin targets raw for modern route resolution", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
@@ -652,11 +710,250 @@ describe("resolveSessionDeliveryTarget", () => {
},
});
expect(resolved.channel).toBe("forum");
expect(resolved.to).toBe("room:ops:topic:1008013");
expect(resolved.threadId).toBeUndefined();
});
it("resolves explicit heartbeat plugin targets through the outbound session route", async () => {
const cfg: OpenClawConfig = {};
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg,
agentId: "main",
heartbeat: {
target: "forum",
to: "room:ops:topic:1008013",
},
});
expect(resolved.channel).toBe("forum");
expect(resolved.to).toBe("room:ops");
expect(resolved.threadId).toBe(1008013);
});
it("blocks heartbeat targets that route to direct chats after canonicalization", async () => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
setActivePluginRegistry(
createTargetsTestRegistry([
{
...alpha,
messaging: {
...alpha.messaging,
resolveOutboundSessionRoute: () => ({
sessionKey: "main:alpha:user:u123",
baseSessionKey: "main:alpha:user:u123",
peer: { kind: "direct", id: "u123" },
chatType: "direct",
from: "alpha:u123",
to: "user:u123",
}),
},
},
]),
);
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: {},
agentId: "main",
entry: {
sessionId: "sess-heartbeat-routed-direct",
updatedAt: 1,
lastChannel: "alpha",
lastTo: "channel:D123",
},
heartbeat: {
target: "last",
directPolicy: "block",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
});
it("uses resolved target kind before applying heartbeat directPolicy to routed handles", async () => {
setActivePluginRegistry(
createTargetsTestRegistry([
createTestChannelPlugin({
id: "telegram",
label: "Telegram",
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to }) =>
to
? { ok: true as const, to: to.trim() }
: { ok: false as const, error: new Error("target required") },
},
messaging: {
targetPrefixes: ["telegram"],
inferTargetChatType: () => "group",
targetResolver: {
resolveTarget: async ({ normalized }) => ({
to: normalized,
kind: "group",
source: "directory",
}),
},
resolveOutboundSessionRoute: ({ target, resolvedTarget }) => {
const isGroup = resolvedTarget?.kind === "group";
return {
sessionKey: `main:telegram:${isGroup ? "group" : "user"}:${target}`,
baseSessionKey: `main:telegram:${isGroup ? "group" : "user"}:${target}`,
peer: { kind: isGroup ? "group" : "direct", id: target },
chatType: isGroup ? "group" : "direct",
from: isGroup ? `telegram:group:${target}` : `telegram:${target}`,
to: target,
};
},
},
}),
]),
);
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: {},
agentId: "main",
heartbeat: {
target: "telegram",
to: "@public_group",
directPolicy: "block",
},
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("@public_group");
expect(resolved.chatType).toBe("group");
});
it("keeps heartbeat route canonicalization best-effort when target resolution fails", async () => {
setActivePluginRegistry(
createTargetsTestRegistry([
createTestChannelPlugin({
id: "telegram",
label: "Telegram",
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to }) =>
to
? { ok: true as const, to: to.trim() }
: { ok: false as const, error: new Error("target required") },
},
messaging: {
targetPrefixes: ["telegram"],
inferTargetChatType: () => "group",
targetResolver: {
resolveTarget: async () => {
throw new Error("directory unavailable");
},
},
resolveOutboundSessionRoute: ({ target }) => ({
sessionKey: `main:telegram:group:${target}`,
baseSessionKey: `main:telegram:group:${target}`,
peer: { kind: "group", id: target },
chatType: "group",
from: `telegram:group:${target}`,
to: target,
}),
},
}),
]),
);
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: {},
agentId: "main",
heartbeat: {
target: "telegram",
to: "@public_group",
},
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("@public_group");
expect(resolved.chatType).toBe("group");
});
it("keeps heartbeat route canonicalization best-effort when route resolution fails", async () => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
setActivePluginRegistry(
createTargetsTestRegistry([
{
...alpha,
messaging: {
...alpha.messaging,
inferTargetChatType: () => "group",
resolveOutboundSessionRoute: () => {
throw new Error("route lookup failed");
},
},
},
]),
);
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: {},
agentId: "main",
entry: {
sessionId: "sess-heartbeat-route-failure",
updatedAt: 1,
lastChannel: "alpha",
lastTo: "group:ops",
},
heartbeat: {
target: "last",
},
});
expect(resolved.channel).toBe("alpha");
expect(resolved.to).toBe("group:ops");
expect(resolved.chatType).toBe("group");
});
it("applies default heartbeat directPolicy after route canonicalization", async () => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
setActivePluginRegistry(
createTargetsTestRegistry([
{
...alpha,
messaging: {
...alpha.messaging,
resolveOutboundSessionRoute: () => ({
sessionKey: "main:alpha:user:u123",
baseSessionKey: "main:alpha:user:u123",
peer: { kind: "direct", id: "u123" },
chatType: "direct",
from: "alpha:u123",
to: "user:u123",
}),
},
},
]),
);
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: {
agents: {
defaults: {
heartbeat: {
target: "last",
directPolicy: "block",
},
},
},
} as OpenClawConfig,
agentId: "main",
entry: {
sessionId: "sess-heartbeat-default-routed-direct",
updatedAt: 1,
lastChannel: "alpha",
lastTo: "channel:D123",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("dm-blocked");
});
it("preserves route threadId for heartbeat target=last on plugin-owned group sessions", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
@@ -910,7 +1207,7 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)",
expect(resolved.threadId).toBe(1122);
});
it("matches bare stored routes against topic-scoped turn routes via plugin grammar", () => {
it("does not use plugin grammar to match bare stored routes against topic-scoped turn routes", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-forum-topic-mixed-shape",
@@ -926,7 +1223,7 @@ describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)",
expect(resolved.channel).toBe("forum");
expect(resolved.to).toBe("forum:room:ops:topic:1122");
expect(resolved.threadId).toBe(1122);
expect(resolved.threadId).toBeUndefined();
});
it("does not fall back to session lastThreadId when turnSourceChannel differs from session channel", () => {

View File

@@ -1,6 +1,7 @@
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.core.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -22,6 +23,8 @@ import {
normalizeDeliverableOutboundChannel,
resolveOutboundChannelPlugin,
} from "./channel-resolution.js";
import { resolveOutboundSessionRoute } from "./outbound-session.js";
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
import {
resolveOutboundTargetWithPlugin,
type OutboundTargetResolution,
@@ -263,6 +266,79 @@ function buildNoHeartbeatDeliveryTarget(params: {
};
}
export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
cfg: OpenClawConfig;
agentId: string;
entry?: SessionEntry;
heartbeat?: AgentDefaultsConfig["heartbeat"];
turnSource?: DeliveryContext;
currentSessionKey?: string;
}): Promise<OutboundTarget> {
const delivery = resolveHeartbeatDeliveryTarget(params);
const heartbeat = params.heartbeat ?? params.cfg.agents?.defaults?.heartbeat;
if (delivery.channel === "none" || !delivery.to) {
return delivery;
}
const deliveryTo = delivery.to;
const plugin = resolveOutboundChannelPlugin({
channel: delivery.channel,
cfg: params.cfg,
});
if (!plugin?.messaging?.resolveOutboundSessionRoute) {
return delivery;
}
let routeResolvedTarget: ResolvedMessagingTarget | undefined;
const targetResolution = await (async () => {
try {
return await resolveChannelTarget({
cfg: params.cfg,
channel: delivery.channel as ChannelId,
input: deliveryTo,
accountId: delivery.accountId,
unknownTargetMode: "normalized",
});
} catch {
return null;
}
})();
if (targetResolution?.ok) {
routeResolvedTarget = targetResolution.target;
}
const route = await (async () => {
try {
return await resolveOutboundSessionRoute({
cfg: params.cfg,
channel: delivery.channel as ChannelId,
agentId: params.agentId,
accountId: delivery.accountId,
target: routeResolvedTarget?.to ?? deliveryTo,
resolvedTarget: routeResolvedTarget,
currentSessionKey: params.currentSessionKey,
threadId: delivery.threadId,
});
} catch {
return null;
}
})();
if (!route) {
return delivery;
}
if (route.chatType === "direct" && heartbeat?.directPolicy === "block") {
return buildNoHeartbeatDeliveryTarget({
reason: "dm-blocked",
accountId: delivery.accountId,
lastChannel: delivery.lastChannel,
lastAccountId: delivery.lastAccountId,
});
}
return {
...delivery,
to: route.to,
chatType: route.chatType,
threadId: route.threadId ?? delivery.threadId,
};
}
function inferChatTypeFromTarget(params: {
channel: DeliverableMessageChannel;
to: string;

View File

@@ -180,7 +180,7 @@ describe("plugin-sdk channel-route", () => {
).toBe(false);
});
it("resolves parsed route targets through an injected channel grammar", () => {
it("keeps deprecated parser wrapper wired for public SDK compatibility", () => {
expect(
resolveChannelRouteTargetWithParser({
channel: "Mock",

View File

@@ -44,12 +44,14 @@ export type ChannelRouteTargetInput = Pick<
export type ChannelRouteKeyInput = ChannelRouteRef | ChannelRouteTargetInput;
/** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */
export type ChannelRouteExplicitTarget = {
to: string;
threadId?: string | number;
chatType?: ChannelRouteChatType;
};
/** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */
export type ChannelRouteExplicitTargetParser = (
channel: string,
rawTarget: string,
@@ -125,6 +127,7 @@ export type ChannelRouteParsedTarget = ChannelRouteTargetInput & {
chatType?: ChannelRouteChatType;
};
/** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */
export function resolveChannelRouteTargetWithParser(params: {
channel: string;
rawTarget?: string | null;

View File

@@ -1,3 +1,4 @@
/** @deprecated Use `openclaw/plugin-sdk/channel-targets`. */
export {
buildMessagingTarget,
ensureTargetId,

View File

@@ -8,6 +8,23 @@ import {
} from "./registry.js";
const datePattern = /^\d{4}-\d{2}-\d{2}$/u;
const sourceRootsForDeprecatedCallGuard = [
"src",
"extensions",
"packages",
"test",
"scripts",
] as const;
const deprecatedTargetParserCallPattern =
/\.parseExplicitTarget\?\.\s*\(|parseExplicitTargetFor(?:Channel|LoadedChannel)\s*\(|resolveRouteTargetFor(?:Channel|LoadedChannel)\s*\(/u;
const deprecatedTargetParserCompatFiles = new Set([
"src/auto-reply/reply/group-id.ts",
"src/channels/plugins/target-parsing-loaded.ts",
"src/channels/plugins/target-parsing.test.ts",
"src/infra/outbound/outbound-session.ts",
"src/infra/outbound/outbound-session.test-helpers.ts",
"src/plugins/compat/registry.test.ts",
]);
const knownDeprecatedSurfaceMarkers = [
{
@@ -150,6 +167,36 @@ const knownDeprecatedSurfaceMarkers = [
file: "src/channels/plugins/target-parsing-loaded.ts",
marker: "ComparableChannelTarget",
},
{
code: "channel-explicit-target-parser",
file: "src/channels/plugins/types.core.ts",
marker: "parseExplicitTarget?:",
},
{
code: "channel-explicit-target-parser",
file: "src/plugin-sdk/channel-route.ts",
marker: "resolveChannelRouteTargetWithParser",
},
{
code: "channel-explicit-target-parser",
file: "src/channels/plugins/target-parsing-loaded.ts",
marker: "ParsedChannelExplicitTarget",
},
{
code: "channel-explicit-target-parser",
file: "src/channels/plugins/target-parsing-loaded.ts",
marker: "parseExplicitTargetForLoadedChannel",
},
{
code: "channel-explicit-target-parser",
file: "src/channels/plugins/target-parsing-loaded.ts",
marker: "resolveRouteTargetForLoadedChannel",
},
{
code: "channel-messaging-targets-subpath",
file: "src/plugin-sdk/messaging-targets.ts",
marker: "openclaw/plugin-sdk/channel-targets",
},
] as const;
function parseDate(date: string): Date {
@@ -169,6 +216,20 @@ function expectNonEmptyStringList(values: readonly string[], label: string) {
}
}
function listSourceFiles(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory()) {
if (entry.name === "dist" || entry.name === "node_modules") {
return [];
}
return listSourceFiles(path);
}
return /\.(?:ts|tsx|mts|cts)$/u.test(entry.name) ? [path] : [];
});
}
describe("plugin compatibility registry", () => {
it("keeps compatibility codes unique and lookup-safe", () => {
const records = listPluginCompatRecords();
@@ -215,4 +276,13 @@ describe("plugin compatibility registry", () => {
expect(fs.readFileSync(surface.file, "utf8"), surface.file).toContain(surface.marker);
}
});
it("keeps deprecated explicit target parser calls inside compatibility shims", () => {
const offenders = sourceRootsForDeprecatedCallGuard
.flatMap((root) => listSourceFiles(root))
.filter((file) => !deprecatedTargetParserCompatFiles.has(file))
.filter((file) => deprecatedTargetParserCallPattern.test(fs.readFileSync(file, "utf8")));
expect(offenders).toEqual([]);
});
});

View File

@@ -33,11 +33,11 @@ export const PLUGIN_COMPAT_RECORDS = [
removeAfter: "2026-08-16",
replacement: "`gateway_stop` hook",
docsPath: "/plugins/hooks#upcoming-deprecations",
surfaces: ["api.on(\"deactivate\", ...)", "plugin typed hook registration"],
surfaces: ['api.on("deactivate", ...)', "plugin typed hook registration"],
diagnostics: ["plugin runtime compatibility warning"],
tests: ["src/plugins/loader.test.ts"],
releaseNote:
"`api.on(\"deactivate\", ...)` remains wired as a deprecated compatibility alias while plugins migrate to `gateway_stop`.",
'`api.on("deactivate", ...)` remains wired as a deprecated compatibility alias while plugins migrate to `gateway_stop`.',
},
{
code: "hook-only-plugin-shape",
@@ -203,13 +203,13 @@ export const PLUGIN_COMPAT_RECORDS = [
warningStarts: "2026-04-28",
removeAfter: "2026-07-28",
replacement:
"`resolveRouteTargetForChannel`, `ChannelRouteParsedTarget`, `channelRouteTargetsMatchExact`, and `channelRouteTargetsShareConversation`",
"`ChannelRouteParsedTarget`, `channelRouteTargetsMatchExact`, `channelRouteTargetsShareConversation`, and `messaging.resolveOutboundSessionRoute` for provider-specific target grammar",
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",
"src/channels/plugins/target-parsing-loaded ComparableChannelTarget",
"src/channels/plugins/target-parsing-loaded resolveComparableTargetForLoadedChannel",
"src/channels/plugins/target-parsing-loaded comparableChannelTargetsMatch",
"src/channels/plugins/target-parsing-loaded comparableChannelTargetsShareRoute",
],
diagnostics: ["plugin SDK compatibility warning"],
tests: [
@@ -217,6 +217,49 @@ export const PLUGIN_COMPAT_RECORDS = [
"src/plugins/contracts/plugin-sdk-subpaths.test.ts",
],
},
{
code: "channel-explicit-target-parser",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-28",
deprecated: "2026-05-23",
warningStarts: "2026-05-23",
removeAfter: "2026-08-23",
replacement:
"`messaging.targetResolver` for target normalization and `messaging.resolveOutboundSessionRoute` for session/thread identity",
docsPath: "/plugins/sdk-migration",
surfaces: [
"ChannelMessagingAdapter.parseExplicitTarget",
"openclaw/plugin-sdk/channel-route ChannelRouteExplicitTarget",
"openclaw/plugin-sdk/channel-route ChannelRouteExplicitTargetParser",
"openclaw/plugin-sdk/channel-route resolveChannelRouteTargetWithParser",
"src/channels/plugins/target-parsing-loaded ParsedChannelExplicitTarget",
"src/channels/plugins/target-parsing-loaded parseExplicitTargetForLoadedChannel",
"src/channels/plugins/target-parsing-loaded resolveRouteTargetForLoadedChannel",
],
diagnostics: ["plugin SDK compatibility warning"],
tests: [
"src/channels/plugins/contracts/test-helpers/surface-contract-suite.ts",
"src/plugins/compat/registry.test.ts",
],
},
{
code: "channel-messaging-targets-subpath",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-28",
deprecated: "2026-05-23",
warningStarts: "2026-05-23",
removeAfter: "2026-08-23",
replacement: "`openclaw/plugin-sdk/channel-targets`",
docsPath: "/plugins/sdk-migration",
surfaces: ["openclaw/plugin-sdk/messaging-targets"],
diagnostics: ["plugin SDK compatibility warning"],
tests: [
"src/plugins/compat/registry.test.ts",
"src/plugins/contracts/plugin-sdk-subpaths.test.ts",
],
},
{
code: "bundled-plugin-allowlist",
status: "active",

View File

@@ -860,7 +860,6 @@ describe("plugin-sdk subpath exports", () => {
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",