diff --git a/CHANGELOG.md b/CHANGELOG.md index 183f356cab4..e56a3d1262e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 6ba7f40e459..acba96b4838 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -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 diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 9d1232dbff4..5f825dbee82 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -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. @@ -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 | diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 30017e55f60..5bdc1b8982a 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -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. | diff --git a/extensions/clickclack/src/channel.ts b/extensions/clickclack/src/channel.ts index 0ce0f86f34e..11e25e70059 100644 --- a/extensions/clickclack/src/channel.ts +++ b/extensions/clickclack/src/channel.ts @@ -93,14 +93,6 @@ export const clickClackPlugin: ChannelPlugin = 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, diff --git a/extensions/discord/src/channel.conversation.ts b/extensions/discord/src/channel.conversation.ts index 54d6f881242..d0501baa755 100644 --- a/extensions/discord/src/channel.conversation.ts +++ b/extensions/discord/src/channel.conversation.ts @@ -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; - } -} diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 1150a181925..65c09dee5da 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -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 () => { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 94a1f7f09e9..d364d9504ed 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -44,7 +44,6 @@ import { buildDiscordCrossContextPresentation, matchDiscordAcpConversation, normalizeDiscordAcpConversationId, - parseDiscordExplicitTarget, resolveDiscordAttachedOutboundTarget, resolveDiscordCommandConversation, resolveDiscordInboundConversation, @@ -320,8 +319,17 @@ export const discordPlugin: ChannelPlugin 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: { diff --git a/extensions/discord/src/target-parsing.ts b/extensions/discord/src/target-parsing.ts index 2f73b6d9b74..e54b6d5fd4f 100644 --- a/extensions/discord/src/target-parsing.ts +++ b/extensions/discord/src/target-parsing.ts @@ -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; diff --git a/extensions/discord/src/target-resolver.ts b/extensions/discord/src/target-resolver.ts index 668298627c2..e490e72084a 100644 --- a/extensions/discord/src/target-resolver.ts +++ b/extensions/discord/src/target-resolver.ts @@ -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"; diff --git a/extensions/qa-channel/src/channel.ts b/extensions/qa-channel/src/channel.ts index e41571ed361..f230a3672fb 100644 --- a/extensions/qa-channel/src/channel.ts +++ b/extensions/qa-channel/src/channel.ts @@ -92,14 +92,6 @@ export const qaChannelPlugin: ChannelPlugin = 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) => diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index b238dd7955f..f45481f520e 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -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[0]["cfg"]; agentId: string; @@ -308,7 +297,6 @@ export const signalPlugin: ChannelPlugin = messaging: { targetPrefixes: ["signal"], normalizeTarget: normalizeSignalMessagingTarget, - parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw), inferTargetChatType: ({ to }) => inferSignalTargetChatType(to), resolveOutboundSessionRoute: (params) => resolveSignalOutboundSessionRoute(params), targetResolver: { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index fa09cc2344c..576df77f57a 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -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 = 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 = crea looksLikeId: looksLikeSlackTargetId, hint: "", resolveTarget: async ({ input }) => { - const parsed = parseSlackExplicitTarget(input); + const parsed = resolveSlackRouteTarget(input); if (!parsed) { return null; } diff --git a/extensions/slack/src/target-parsing.ts b/extensions/slack/src/target-parsing.ts index 7f7720fa336..10b934c2874 100644 --- a/extensions/slack/src/target-parsing.ts +++ b/extensions/slack/src/target-parsing.ts @@ -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; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 402c4600739..7197f25ae9a 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -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(); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 93fd42d3335..9f07b32ba34 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -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 = 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: "", diff --git a/scripts/lib/plugin-sdk-deprecated-public-subpaths.json b/scripts/lib/plugin-sdk-deprecated-public-subpaths.json index b53b2956f91..34e1fea2fed 100644 --- a/scripts/lib/plugin-sdk-deprecated-public-subpaths.json +++ b/scripts/lib/plugin-sdk-deprecated-public-subpaths.json @@ -19,6 +19,7 @@ "matrix", "mattermost", "media-generation-runtime-shared", + "messaging-targets", "memory-core", "memory-core-engine-runtime", "memory-core-host-events", diff --git a/src/agents/command/delivery.ts b/src/agents/command/delivery.ts index 8c3cfe9d7f3..ba8a67aecc8 100644 --- a/src/agents/command/delivery.ts +++ b/src/agents/command/delivery.ts @@ -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 { 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, diff --git a/src/agents/subagent-announce-origin.ts b/src/agents/subagent-announce-origin.ts index f46e187a320..a44e1b9f29a 100644 --- a/src/agents/subagent-announce-origin.ts +++ b/src/agents/subagent-announce-origin.ts @@ -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( diff --git a/src/auto-reply/reply/group-id.test.ts b/src/auto-reply/reply/group-id.test.ts index 7ebd8548011..d58656f79f9 100644 --- a/src/auto-reply/reply/group-id.test.ts +++ b/src/auto-reply/reply/group-id.test.ts @@ -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"); + }); +}); diff --git a/src/auto-reply/reply/group-id.ts b/src/auto-reply/reply/group-id.ts index 25ed7841ce0..f8f9a1e92b6 100644 --- a/src/auto-reply/reply/group-id.ts +++ b/src/auto-reply/reply/group-id.ts @@ -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, + }) + ); } diff --git a/src/channels/conversation-resolution.test.ts b/src/channels/conversation-resolution.test.ts index 45d1bd28fbc..7009844e174 100644 --- a/src/channels/conversation-resolution.test.ts +++ b/src/channels/conversation-resolution.test.ts @@ -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" }), diff --git a/src/channels/conversation-resolution.ts b/src/channels/conversation-resolution.ts index 34cd125598a..8989292fa45 100644 --- a/src/channels/conversation-resolution.ts +++ b/src/channels/conversation-resolution.ts @@ -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 ); } diff --git a/src/channels/plugins/target-parsing-loaded.ts b/src/channels/plugins/target-parsing-loaded.ts index f59e4f78e75..2d7b5ef6fb1 100644 --- a/src/channels/plugins/target-parsing-loaded.ts +++ b/src/channels/plugins/target-parsing-loaded.ts @@ -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; diff --git a/src/channels/plugins/target-parsing.test.ts b/src/channels/plugins/target-parsing.test.ts index 9c98b7c5c91..52ec5763378 100644 --- a/src/channels/plugins/target-parsing.test.ts +++ b/src/channels/plugins/target-parsing.test.ts @@ -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", diff --git a/src/channels/plugins/target-parsing.ts b/src/channels/plugins/target-parsing.ts deleted file mode 100644 index f6cd2947f7a..00000000000 --- a/src/channels/plugins/target-parsing.ts +++ /dev/null @@ -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, - 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); -} diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index f91057e99bd..3e2214171fc 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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; diff --git a/src/cron/isolated-agent.test-setup.ts b/src/cron/isolated-agent.test-setup.ts index 7d9447bca34..963fd5b1adc 100644 --- a/src/cron/isolated-agent.test-setup.ts +++ b/src/cron/isolated-agent.test-setup.ts @@ -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 } : {}), + }); }, }, }), diff --git a/src/cron/isolated-agent/delivery-target.runtime.ts b/src/cron/isolated-agent/delivery-target.runtime.ts index 729fbacf233..473616bd8ec 100644 --- a/src/cron/isolated-agent/delivery-target.runtime.ts +++ b/src/cron/isolated-agent/delivery-target.runtime.ts @@ -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 { + 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, ); } diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index a8f4f57fbc6..a93b1aaa774 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -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", diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index aa9e93d2fa3..be4fb27b79f 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -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 | 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 | 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; + 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; + 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 | 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, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index e6117ed30b6..2fd1dafd57e 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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, diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 88f16edf7d8..aaa47451888 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index fd7a235494e..73600924776 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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 diff --git a/src/infra/outbound/agent-delivery.test.ts b/src/infra/outbound/agent-delivery.test.ts index dd8d6b63762..63fdb832b73 100644 --- a/src/infra/outbound/agent-delivery.test.ts +++ b/src/infra/outbound/agent-delivery.test.ts @@ -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>(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(); + }); }); diff --git a/src/infra/outbound/agent-delivery.ts b/src/infra/outbound/agent-delivery.ts index 4428cc4f44b..d8bac147763 100644 --- a/src/infra/outbound/agent-delivery.ts +++ b/src/infra/outbound/agent-delivery.ts @@ -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[0] & { + cfg: OpenClawConfig; + agentId: string; + currentSessionKey?: string; + }, +): Promise { + 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; diff --git a/src/infra/outbound/channel-target-prefix.test.ts b/src/infra/outbound/channel-target-prefix.test.ts new file mode 100644 index 00000000000..effc1742fe7 --- /dev/null +++ b/src/infra/outbound/channel-target-prefix.test.ts @@ -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"); + }); +}); diff --git a/src/infra/outbound/channel-target-prefix.ts b/src/infra/outbound/channel-target-prefix.ts index 2e5c3fefa7e..bfd172f5acc 100644 --- a/src/infra/outbound/channel-target-prefix.ts +++ b/src/infra/outbound/channel-target-prefix.ts @@ -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; diff --git a/src/infra/outbound/outbound-session.test-helpers.ts b/src/infra/outbound/outbound-session.test-helpers.ts index 460414cd377..4608a019d40 100644 --- a/src/infra/outbound/outbound-session.test-helpers.ts +++ b/src/infra/outbound/outbound-session.test-helpers.ts @@ -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( diff --git a/src/infra/outbound/outbound-session.test.ts b/src/infra/outbound/outbound-session.test.ts index 88fdf0aa745..e69b13fc3f5 100644 --- a/src/infra/outbound/outbound-session.test.ts +++ b/src/infra/outbound/outbound-session.test.ts @@ -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); }); diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index 32017c737a8..c38a1defa5a 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -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; + 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" ); diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 2c64521e178..1df1aef02dd 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -49,6 +49,8 @@ export async function resolveChannelTarget(params: { accountId?: string | null; preferredKind?: TargetResolveKind; runtime?: RuntimeEnv; + resolveAmbiguous?: ResolveAmbiguousMode; + unknownTargetMode?: "error" | "normalized"; }): Promise { return resolveMessagingTarget(params); } @@ -343,6 +345,7 @@ export async function resolveMessagingTarget(params: { preferredKind?: TargetResolveKind; runtime?: RuntimeEnv; resolveAmbiguous?: ResolveAmbiguousMode; + unknownTargetMode?: "error" | "normalized"; }): Promise { 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), diff --git a/src/infra/outbound/targets-session.ts b/src/infra/outbound/targets-session.ts index 49e36ff44b8..d32b98a4772 100644 --- a/src/infra/outbound/targets-session.ts +++ b/src/infra/outbound/targets-session.ts @@ -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, diff --git a/src/infra/outbound/targets.test-helpers.ts b/src/infra/outbound/targets.test-helpers.ts index feae595befa..0163dc880d4 100644 --- a/src/infra/outbound/targets.test-helpers.ts +++ b/src/infra/outbound/targets.test-helpers.ts @@ -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: "", }, + 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, }; diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 898f2d932b0..31d0fcf7de7 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -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[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", () => { diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 87384e3ed2c..a7ffb24dc55 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -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 { + 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; diff --git a/src/plugin-sdk/channel-route.test.ts b/src/plugin-sdk/channel-route.test.ts index b630bd6d2fa..7afb21d8d4e 100644 --- a/src/plugin-sdk/channel-route.test.ts +++ b/src/plugin-sdk/channel-route.test.ts @@ -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", diff --git a/src/plugin-sdk/channel-route.ts b/src/plugin-sdk/channel-route.ts index 81283858b16..50e5d11d441 100644 --- a/src/plugin-sdk/channel-route.ts +++ b/src/plugin-sdk/channel-route.ts @@ -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; diff --git a/src/plugin-sdk/messaging-targets.ts b/src/plugin-sdk/messaging-targets.ts index c373f42d6c7..699a1a21d21 100644 --- a/src/plugin-sdk/messaging-targets.ts +++ b/src/plugin-sdk/messaging-targets.ts @@ -1,3 +1,4 @@ +/** @deprecated Use `openclaw/plugin-sdk/channel-targets`. */ export { buildMessagingTarget, ensureTargetId, diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index d963edc8b19..8819a8dc2ef 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -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([]); + }); }); diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index 48cee0aa639..bb233ca4356 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -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", diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 8e22a17f3d4..ec8f44c0424 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -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",