diff --git a/CHANGELOG.md b/CHANGELOG.md index dc180089e23..efa0ff53db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Cron: make scheduler reload schedule comparison tolerate malformed persisted jobs, so one bad cron entry no longer aborts the whole tick. Fixes #75886. Thanks @samfox-ai. - Doctor/channels: warn after migrations when default Telegram or Discord accounts have no configured token and their env fallback (`TELEGRAM_BOT_TOKEN` or `DISCORD_BOT_TOKEN`) is unavailable, with secret-safe migration docs for checking state-dir `.env`. Fixes #74298. Thanks @lolaopenclaw. - Gateway/diagnostics: keep idle liveness samples in telemetry instead of visible warning logs unless diagnostic work is active, waiting, or queued. Thanks @vincentkoc. +- Channels/cron: reject provider-prefixed targets for the wrong channel and let prefixed announce targets such as `telegram:123` select their channel when delivery falls back to `last`, so Telegram IDs cannot be coerced into WhatsApp phone numbers. Fixes #56839. Thanks @bencoremans. - Control UI/chat: keep live replies visible when a raw session alias such as `main` sends the chat turn but Gateway emits events under the canonical session key for the same run. Fixes #73716. Thanks @teebes. - CLI/models: reject `--agent` on `openclaw models set` and `set-image` instead of silently writing agent-scoped requests to global model defaults. Fixes #68391. Thanks @derrickabellard. - CLI: stop treating the legacy singular `openclaw tool ...` token as a plugin id under restrictive `plugins.allow`, so it falls through as a normal unknown/reserved command instead of suggesting a stale allowlist entry. Fixes #64732. Thanks @efe-arv, @SweetSophia, and @hashtag1974. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index c4fc063ec91..bbdc6b3a702 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -0f9284c6349bf03d3d89c1d25031031840dae4ade032622ca212240ed19829f6 plugin-sdk-api-baseline.json -33706cf425386717973cc87357ae5e0df432dd5a519b4faea8b38e21d7daae78 plugin-sdk-api-baseline.jsonl +1fbd0ea7f65901d96653458ba414f9ac69dc0142ff3772e48d63de8b9fa5567f plugin-sdk-api-baseline.json +2d29f4e632b05bd365f414096c87a2a3d9718f13fdbf9538824cb32db2902436 plugin-sdk-api-baseline.jsonl diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index eb1b4650c6a..9956325a808 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -158,6 +158,8 @@ Before an isolated cron run enters the agent runner, OpenClaw checks reachable l Use `--announce --channel telegram --to "-1001234567890"` for channel delivery. For Telegram forum topics, use `-1001234567890:topic:123`; direct RPC/config callers may also pass `delivery.threadId` as a string or number. Slack/Discord/Mattermost targets should use explicit prefixes (`channel:`, `user:`). Matrix room IDs are case-sensitive; use the exact room ID or `room:!room:server` form from Matrix. +When announce delivery uses `channel: "last"` or omits `channel`, a provider-prefixed target such as `telegram:123` can select the channel before cron falls back to session history or a single configured channel. Only prefixes advertised by the loaded plugin are provider selectors. If `delivery.channel` is explicit, the target prefix must name the same provider; for example, `channel: "whatsapp"` with `to: "telegram:123"` is rejected instead of letting WhatsApp interpret the Telegram ID as a phone number. Target-kind and service prefixes such as `channel:`, `user:`, `imessage:`, and `sms:` remain channel-owned target syntax, not provider selectors. + For isolated jobs, chat delivery is shared. If a chat route is available, the agent can use the `message` tool even when the job uses `--no-deliver`. If the agent sends to the configured/current target, OpenClaw skips the fallback announce. Otherwise `announce`, `webhook`, and `none` only control what the runner does with the final reply after the agent turn. When an agent creates an isolated reminder from an active chat, OpenClaw stores the preserved live delivery target for the fallback announce route. Internal session keys may be lowercase; provider delivery targets are not reconstructed from those keys when current chat context is available. diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index c74944b462c..b7beff35c3e 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -21,6 +21,12 @@ host configuration. - **AgentId**: an isolated workspace + session store (“brain”). - **SessionKey**: the bucket key used to store context and control concurrency. +## Outbound target prefixes + +Explicit outbound targets may include a provider prefix, such as `telegram:123` or `tg:123`. Core treats that prefix as a channel-selection hint only when the selected channel is `last` or otherwise unresolved, and only when the loaded plugin advertises that prefix. If the caller already selected an explicit channel, the provider prefix must match that channel; cross-channel combinations such as WhatsApp delivery to `telegram:123` fail before plugin-specific target normalization. + +Target-kind and service prefixes such as `channel:`, `user:`, `room:`, `thread:`, `imessage:`, and `sms:` stay inside the selected channel's grammar. They do not select the provider by themselves. + ## Session key shapes (examples) Direct messages collapse to the agent’s **main** session by default: diff --git a/docs/channels/synology-chat.md b/docs/channels/synology-chat.md index 963fd8b90f2..9dabd066d58 100644 --- a/docs/channels/synology-chat.md +++ b/docs/channels/synology-chat.md @@ -110,6 +110,7 @@ Examples: ```bash openclaw message send --channel synology-chat --target 123456 --text "Hello from OpenClaw" openclaw message send --channel synology-chat --target synology-chat:123456 --text "Hello again" +openclaw message send --channel synology-chat --target synology:123456 --text "Short prefix" ``` Media sends are supported by URL-based file delivery. diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 05ff5d3ae81..754d61f151e 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -35,6 +35,8 @@ Run `openclaw cron --help` for the full command surface. See [Cron jobs](/automa `openclaw cron list` and `openclaw cron show ` preview the resolved delivery route. For `channel: "last"`, the preview shows whether the route resolved from the main or current session, or will fail closed. +Provider-prefixed targets can disambiguate unresolved announce channels. For example, `to: "telegram:123"` selects Telegram when `delivery.channel` is omitted or `last`. Only prefixes advertised by the loaded plugin are provider selectors. If `delivery.channel` is explicit, the prefix must match that channel; `channel: "whatsapp"` with `to: "telegram:123"` is rejected. Service prefixes such as `imessage:` and `sms:` remain channel-owned target syntax. + Isolated `cron add` jobs default to `--announce` delivery. Use `--no-deliver` to keep output internal. `--deliver` remains as a deprecated alias for `--announce`. diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 44cac6d1235..4d797877a01 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -129,6 +129,7 @@ export const bluebubblesPlugin: ChannelPlugin inferBlueBubblesTargetChatType(to), resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params), diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 700f4cf12bb..4a373828c55 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -218,6 +218,7 @@ export const discordPlugin: ChannelPlugin ], }, messaging: { + targetPrefixes: ["discord"], normalizeTarget: normalizeDiscordMessagingTarget, resolveInboundConversation: ({ from, to, conversationId, isGroup }) => resolveDiscordInboundConversation({ from, to, conversationId, isGroup }), diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 81bda8f4394..9f358cb33da 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1154,6 +1154,7 @@ export const feishuPlugin: ChannelPlugin normalizeFeishuTarget(raw) ?? undefined, resolveDeliveryTarget: ({ conversationId, parentConversationId }) => { const directId = parseFeishuDirectConversationId(conversationId); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index abce2cc05ea..1b1bfa0c951 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -144,6 +144,7 @@ export const googlechatPlugin = createChatChannelPlugin({ }, groups: googlechatGroupsAdapter, messaging: { + targetPrefixes: ["googlechat", "google-chat", "gchat"], normalizeTarget: normalizeGoogleChatTarget, targetResolver: { looksLikeId: (raw, normalized) => { diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index bcfa5b8bec6..f3489264d79 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -233,6 +233,7 @@ export const ircPlugin: ChannelPlugin = createChat }, }, messaging: { + targetPrefixes: ["irc"], normalizeTarget: normalizeIrcMessagingTarget, targetResolver: { looksLikeId: looksLikeIrcTargetId, diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 304dc8ba657..1c100f22e2b 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -42,6 +42,7 @@ export const linePlugin: ChannelPlugin = createChatChannelP resolveRequireMention: resolveLineGroupRequireMention, }, messaging: { + targetPrefixes: ["line"], normalizeTarget: (target) => { const trimmed = target.trim(); if (!trimmed) { diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 40971c22c70..9a0cf4fd5b7 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -376,6 +376,7 @@ export const matrixPlugin: ChannelPlugin = }).map(projectMatrixConversationBinding), }, messaging: { + targetPrefixes: ["matrix"], normalizeTarget: normalizeMatrixMessagingTarget, resolveInboundConversation: ({ to, conversationId, threadId }) => resolveMatrixInboundConversation({ to, conversationId, threadId }), diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 70dbc917d60..c5dfcab1e1f 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -306,6 +306,7 @@ export const mattermostPlugin: ChannelPlugin = create (await loadMattermostChannelRuntime()).listMattermostDirectoryPeers(params), }), messaging: { + targetPrefixes: ["mattermost"], defaultMarkdownTableMode: "off", normalizeTarget: normalizeMattermostMessagingTarget, resolveDeliveryTarget: ({ conversationId, parentConversationId }) => { diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index d3361504e33..eaaaa591a06 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -450,6 +450,7 @@ export const msteamsPlugin: ChannelPlugin resolveMSTeamsOutboundSessionRoute(params), targetResolver: { diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 600e0798621..240853fa380 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -119,6 +119,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy, }, messaging: { + targetPrefixes: ["nextcloud-talk", "nc-talk", "nc"], normalizeTarget: normalizeNextcloudTalkMessagingTarget, resolveOutboundSessionRoute: (params) => resolveNextcloudTalkOutboundSessionRoute(params), targetResolver: { diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index da3e56eda39..fcdcd93a094 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -118,6 +118,7 @@ export const nostrPlugin: ChannelPlugin = createChatChanne }), }, messaging: { + targetPrefixes: ["nostr"], normalizeTarget: (target) => { // Strip nostr: prefix if present const cleaned = target.trim().replace(/^nostr:/i, ""); diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts index 866b2ab83f0..e032ad6c127 100644 --- a/extensions/qqbot/src/channel.ts +++ b/extensions/qqbot/src/channel.ts @@ -99,6 +99,7 @@ export const qqbotPlugin: ChannelPlugin = { }, approvalCapability: getQQBotApprovalCapability(), messaging: { + targetPrefixes: ["qqbot"], /** Normalize common QQ Bot target formats into the canonical qqbot:... form. */ normalizeTarget: coreNormalizeTarget, targetResolver: { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index b30e48f5f62..8d37c813316 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -270,6 +270,7 @@ export const signalPlugin: ChannelPlugin = }, }, messaging: { + targetPrefixes: ["signal"], normalizeTarget: normalizeSignalMessagingTarget, parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw), inferTargetChatType: ({ to }) => inferSignalTargetChatType(to), diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 91ef8e64b53..46f6d944a42 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -385,6 +385,7 @@ export const slackPlugin: ChannelPlugin = crea resolveToolPolicy: resolveSlackGroupToolPolicy, }, messaging: { + targetPrefixes: ["slack"], normalizeTarget: normalizeSlackMessagingTarget, resolveDeliveryTarget: ({ conversationId, parentConversationId }) => { const parent = parentConversationId?.trim(); diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 12ec98cc802..f87e89a7966 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -345,6 +345,8 @@ describe("createSynologyChatPlugin", () => { it("normalizeTarget strips prefix and trims", () => { const plugin = createSynologyChatPlugin(); expect(plugin.messaging.normalizeTarget("synology-chat:123")).toBe("123"); + expect(plugin.messaging.normalizeTarget("synology_chat:123")).toBe("123"); + expect(plugin.messaging.normalizeTarget("synology:123")).toBe("123"); expect(plugin.messaging.normalizeTarget(" 456 ")).toBe("456"); expect(plugin.messaging.normalizeTarget("")).toBeUndefined(); }); @@ -353,6 +355,8 @@ describe("createSynologyChatPlugin", () => { const plugin = createSynologyChatPlugin(); expect(plugin.messaging.targetResolver.looksLikeId("12345")).toBe(true); expect(plugin.messaging.targetResolver.looksLikeId("synology-chat:99")).toBe(true); + expect(plugin.messaging.targetResolver.looksLikeId("synology_chat:99")).toBe(true); + expect(plugin.messaging.targetResolver.looksLikeId("synology:99")).toBe(true); expect(plugin.messaging.targetResolver.looksLikeId("notanumber")).toBe(false); expect(plugin.messaging.targetResolver.looksLikeId("")).toBe(false); }); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 02264bc8259..d979b5d21cc 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -155,6 +155,7 @@ type SynologyChatPlugin = Omit< }) => string[]; }; messaging: { + targetPrefixes?: readonly string[]; normalizeTarget: (target: string) => string | undefined; targetResolver: { looksLikeId: (id: string) => boolean; @@ -237,13 +238,14 @@ export function createSynologyChatPlugin(): SynologyChatPlugin { }, approvalCapability: synologyChatApprovalAuth, messaging: { + targetPrefixes: ["synology-chat", "synology_chat", "synology"], normalizeTarget: (target: string) => { const trimmed = target.trim(); if (!trimmed) { return undefined; } // Strip common prefixes - return trimmed.replace(/^synology[-_]?chat:/i, "").trim(); + return trimmed.replace(/^synology(?:[-_]?chat)?:/i, "").trim(); }, targetResolver: { looksLikeId: (id: string) => { @@ -252,7 +254,7 @@ export function createSynologyChatPlugin(): SynologyChatPlugin { return false; } // Synology Chat user IDs are numeric - return /^\d+$/.test(trimmed) || /^synology[-_]?chat:/i.test(trimmed); + return /^\d+$/.test(trimmed) || /^synology(?:[-_]?chat)?:/i.test(trimmed); }, hint: "", }, diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts index 7364389668e..df5692cd6aa 100644 --- a/extensions/synology-chat/src/setup-surface.ts +++ b/extensions/synology-chat/src/setup-surface.ts @@ -130,7 +130,7 @@ function validateWebhookPath(value: string): string | undefined { } function parseSynologyUserId(value: string): string | null { - const cleaned = value.replace(/^synology-chat:/i, "").trim(); + const cleaned = value.replace(/^synology(?:[-_]?chat)?:/i, "").trim(); return /^\d+$/.test(cleaned) ? cleaned : null; } diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 4b7474cfb9a..bf604b40287 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -691,6 +691,7 @@ export const telegramPlugin = createChatChannelPlugin({ }, }, messaging: { + targetPrefixes: ["telegram", "tg"], normalizeTarget: normalizeTelegramMessagingTarget, resolveInboundConversation: ({ to, conversationId, threadId }) => resolveTelegramInboundConversation({ to, conversationId, threadId }), diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 7c5e89f6135..5b101393232 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -96,6 +96,7 @@ export const tlonPlugin = createChatChannelPlugin({ }, doctor: tlonDoctor, messaging: { + targetPrefixes: ["tlon"], normalizeTarget: (target) => { const parsed = parseTlonTarget(target); if (!parsed) { diff --git a/extensions/whatsapp/src/channel-outbound.test.ts b/extensions/whatsapp/src/channel-outbound.test.ts index 1d74ca6c45b..270590b356e 100644 --- a/extensions/whatsapp/src/channel-outbound.test.ts +++ b/extensions/whatsapp/src/channel-outbound.test.ts @@ -101,6 +101,17 @@ describe("whatsappChannelOutbound", () => { }); }); + it("rejects non-WhatsApp provider-prefixed outbound targets", () => { + const result = whatsappChannelOutbound.resolveTarget?.({ + to: "telegram:1234567890", + allowFrom: [], + mode: undefined, + }); + + expect(result?.ok).toBe(false); + expect(hoisted.sendMessageWhatsApp).not.toHaveBeenCalled(); + }); + it("preserves indentation for payload delivery", async () => { await whatsappChannelOutbound.sendPayload!({ cfg: {}, diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 01e2c08d29a..ac423e5531d 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -111,6 +111,7 @@ export const whatsappPlugin: ChannelPlugin = }, }, messaging: { + targetPrefixes: ["whatsapp"], normalizeTarget: normalizeWhatsAppMessagingTarget, resolveOutboundSessionRoute: (params) => resolveWhatsAppOutboundSessionRoute(params), parseExplicitTarget: ({ raw }) => parseWhatsAppExplicitTarget(raw), diff --git a/extensions/whatsapp/src/normalize-target.ts b/extensions/whatsapp/src/normalize-target.ts index 26ce6da8ef3..4e3ec37df0a 100644 --- a/extensions/whatsapp/src/normalize-target.ts +++ b/extensions/whatsapp/src/normalize-target.ts @@ -4,6 +4,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim const WHATSAPP_USER_JID_RE = /^(\d+)(?::\d+)?@s\.whatsapp\.net$/i; const WHATSAPP_LEGACY_USER_JID_RE = /^(\d+)@c\.us$/i; const WHATSAPP_LID_RE = /^(\d+)@lid$/i; +const NON_WHATSAPP_PROVIDER_PREFIX_RE = /^[a-z][a-z0-9-]*:/i; function stripWhatsAppTargetPrefixes(value: string): string { let candidate = value.trim(); @@ -74,6 +75,9 @@ export function normalizeWhatsAppTarget(value: string): string | null { if (candidate.includes("@")) { return null; } + if (NON_WHATSAPP_PROVIDER_PREFIX_RE.test(candidate)) { + return null; + } const normalized = normalizeE164(candidate); return normalized.length > 1 ? normalized : null; } diff --git a/extensions/whatsapp/src/resolve-target.test.ts b/extensions/whatsapp/src/resolve-target.test.ts index afdf65fd4b6..37d5f3a7e58 100644 --- a/extensions/whatsapp/src/resolve-target.test.ts +++ b/extensions/whatsapp/src/resolve-target.test.ts @@ -42,6 +42,13 @@ describe("normalizeWhatsAppTarget", () => { expect(normalizeWhatsAppTarget("abc@s.whatsapp.net")).toBeNull(); }); + it("rejects non-WhatsApp provider-prefixed phone-like targets", () => { + expect(normalizeWhatsAppTarget("telegram:1234567890")).toBeNull(); + expect(normalizeWhatsAppTarget("tg:1234567890")).toBeNull(); + expect(normalizeWhatsAppTarget("sms:+15551234567")).toBeNull(); + expect(looksLikeWhatsAppTargetId("telegram:1234567890")).toBe(false); + }); + it("handles repeated prefixes", () => { expect(normalizeWhatsAppTarget("whatsapp:whatsapp:+1555")).toBe("+1555"); expect(normalizeWhatsAppTarget("group:group:120@g.us")).toBeNull(); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 439b5e81f46..0d4191f9542 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -196,6 +196,7 @@ export const zaloPlugin: ChannelPlugin = }, actions: zaloMessageActions, messaging: { + targetPrefixes: ["zalo", "zl"], normalizeTarget: normalizeZaloMessagingTarget, resolveOutboundSessionRoute: (params) => resolveZaloOutboundSessionRoute(params), targetResolver: { diff --git a/extensions/zalouser/src/channel.adapters.ts b/extensions/zalouser/src/channel.adapters.ts index 0277ae5236c..afe99f562cc 100644 --- a/extensions/zalouser/src/channel.adapters.ts +++ b/extensions/zalouser/src/channel.adapters.ts @@ -370,6 +370,7 @@ export const zalouserOutboundAdapter = { }; export const zalouserMessagingAdapter = { + targetPrefixes: ["zalouser", "zlu"], normalizeTarget: (raw: string) => normalizeZalouserTarget(raw), resolveOutboundSessionRoute: ( params: Parameters[0], diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 87050c36b7c..daecd345f92 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -475,6 +475,12 @@ export type ChannelThreadingToolContext = { /** Channel-owned messaging helpers for target parsing, routing, and payload shaping. */ export type ChannelMessagingAdapter = { + /** + * Provider prefixes accepted in explicit targets, including aliases not used + * as channel-selection aliases. Core uses these to reject cross-channel + * targets before plugin-specific normalization. + */ + targetPrefixes?: readonly string[]; normalizeTarget?: (raw: string) => string | undefined; defaultMarkdownTableMode?: MarkdownTableMode; normalizeExplicitSessionKey?: (params: { diff --git a/src/cron/delivery-plan.ts b/src/cron/delivery-plan.ts index e7e4100aa39..f2b5d1e2b1f 100644 --- a/src/cron/delivery-plan.ts +++ b/src/cron/delivery-plan.ts @@ -1,4 +1,5 @@ import type { CronFailureDestinationConfig } from "../config/types.cron.js"; +import { resolveTargetPrefixedChannel } from "../infra/outbound/channel-target-prefix.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -26,6 +27,20 @@ function normalizeChannel(value: unknown): CronMessageChannel | undefined { return trimmed as CronMessageChannel; } +function resolveAnnounceChannel(params: { + channel?: CronMessageChannel; + to?: string; +}): CronMessageChannel { + if (params.channel && params.channel !== "last") { + return params.channel; + } + return ( + (resolveTargetPrefixedChannel(params.to) as CronMessageChannel | undefined) ?? + params.channel ?? + "last" + ); +} + export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { const delivery = job.delivery; const hasDelivery = delivery && typeof delivery === "object"; @@ -56,7 +71,10 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { ); if (hasDelivery) { const resolvedMode = mode ?? "announce"; - const channel = resolvedMode === "announce" ? (deliveryChannel ?? "last") : deliveryChannel; + const channel = + resolvedMode === "announce" + ? resolveAnnounceChannel({ channel: deliveryChannel, to }) + : deliveryChannel; return { mode: resolvedMode, channel: resolvedMode === "webhook" ? undefined : channel, @@ -168,7 +186,7 @@ export function resolveFailureDestination( const result: CronFailureDeliveryPlan = { mode: resolvedMode, - channel: resolvedMode === "announce" ? (channel ?? "last") : undefined, + channel: resolvedMode === "announce" ? resolveAnnounceChannel({ channel, to }) : undefined, to, accountId, }; @@ -189,15 +207,17 @@ function isSameDeliveryTarget( return false; } - const primaryChannel = delivery.channel; - const primaryTo = delivery.to; - const primaryAccountId = delivery.accountId; + const primaryTo = normalizeOptionalString(delivery.to); + const primaryAccountId = normalizeOptionalString(delivery.accountId); if (failurePlan.mode === "webhook") { return primaryMode === "webhook" && primaryTo === failurePlan.to; } - const primaryChannelNormalized = primaryChannel ?? "last"; + const primaryChannelNormalized = resolveAnnounceChannel({ + channel: normalizeChannel(delivery.channel), + to: primaryTo, + }); const failureChannelNormalized = failurePlan.channel ?? "last"; return ( diff --git a/src/cron/delivery.test.ts b/src/cron/delivery.test.ts index c53097482e2..4fc5c3a6f74 100644 --- a/src/cron/delivery.test.ts +++ b/src/cron/delivery.test.ts @@ -1,8 +1,48 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { ChannelPlugin } from "../channels/plugins/types.public.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { resolveCronDeliveryPlan, resolveFailureDestination } from "./delivery-plan.js"; import { makeCronJob } from "./delivery.test-helpers.js"; +function createPrefixOnlyChannelPlugin( + id: string, + targetPrefixes?: readonly string[], +): ChannelPlugin { + return { + ...createChannelTestPluginBase({ id }), + messaging: targetPrefixes ? { targetPrefixes } : {}, + }; +} + +function setCronDeliveryTestRegistry( + plugins: Array<{ pluginId: string; plugin: ChannelPlugin }>, +): void { + setActivePluginRegistry( + createTestRegistry( + plugins.map((entry) => ({ + ...entry, + source: `test:${entry.pluginId}`, + })), + ), + ); +} + describe("resolveCronDeliveryPlan", () => { + beforeEach(() => { + setCronDeliveryTestRegistry([ + { + pluginId: "telegram", + plugin: createPrefixOnlyChannelPlugin("telegram", ["telegram", "tg"]), + }, + { pluginId: "slack", plugin: createPrefixOnlyChannelPlugin("slack", ["slack"]) }, + ]); + }); + + afterEach(() => { + resetPluginRuntimeStateForTest(); + }); + it("defaults to announce when delivery object has no mode", () => { const plan = resolveCronDeliveryPlan( makeCronJob({ @@ -86,9 +126,89 @@ describe("resolveCronDeliveryPlan", () => { expect(plan.to).toBe("-1001234567890"); expect(plan.threadId).toBe("99"); }); + + it("uses a provider-prefixed announce target as the channel when channel is last", () => { + const plan = resolveCronDeliveryPlan( + makeCronJob({ + delivery: { + mode: "announce", + channel: "last", + to: "telegram:123", + }, + }), + ); + expect(plan.mode).toBe("announce"); + expect(plan.channel).toBe("telegram"); + expect(plan.to).toBe("telegram:123"); + }); + + it("uses Synology Chat provider prefixes with underscores and short spelling", () => { + setCronDeliveryTestRegistry([ + { + pluginId: "synology-chat", + plugin: createPrefixOnlyChannelPlugin("synology-chat", [ + "synology-chat", + "synology_chat", + "synology", + ]), + }, + ]); + + for (const to of ["synology-chat:123", "synology_chat:123", "synology:123"]) { + const plan = resolveCronDeliveryPlan( + makeCronJob({ + delivery: { + mode: "announce", + channel: "last", + to, + }, + }), + ); + expect(plan.mode).toBe("announce"); + expect(plan.channel).toBe("synology-chat"); + expect(plan.to).toBe(to); + } + }); + + it("does not treat channel-owned service prefixes as provider selection", () => { + setCronDeliveryTestRegistry([ + { + pluginId: "bluebubbles", + plugin: createPrefixOnlyChannelPlugin("bluebubbles", ["bluebubbles"]), + }, + { pluginId: "imessage", plugin: createPrefixOnlyChannelPlugin("imessage") }, + ]); + + const plan = resolveCronDeliveryPlan( + makeCronJob({ + delivery: { + mode: "announce", + channel: "last", + to: "imessage:+15551234567", + }, + }), + ); + expect(plan.mode).toBe("announce"); + expect(plan.channel).toBe("last"); + expect(plan.to).toBe("imessage:+15551234567"); + }); }); describe("resolveFailureDestination", () => { + beforeEach(() => { + setCronDeliveryTestRegistry([ + { + pluginId: "telegram", + plugin: createPrefixOnlyChannelPlugin("telegram", ["telegram", "tg"]), + }, + { pluginId: "slack", plugin: createPrefixOnlyChannelPlugin("slack", ["slack"]) }, + ]); + }); + + afterEach(() => { + resetPluginRuntimeStateForTest(); + }); + it("merges global defaults with job-level overrides", () => { const plan = resolveFailureDestination( makeCronJob({ @@ -150,6 +270,24 @@ describe("resolveFailureDestination", () => { expect(plan).toBeNull(); }); + it("returns null when provider-prefixed failure destination matches a provider-prefixed primary target", () => { + const plan = resolveFailureDestination( + makeCronJob({ + delivery: { + mode: "announce", + channel: "last", + to: "telegram:123", + failureDestination: { + mode: "announce", + to: "telegram:123", + }, + }, + }), + undefined, + ); + expect(plan).toBeNull(); + }); + it("returns null when webhook failure destination matches the primary webhook target", () => { const plan = resolveFailureDestination( makeCronJob({ @@ -219,4 +357,27 @@ describe("resolveFailureDestination", () => { accountId: undefined, }); }); + + it("uses a provider-prefixed failure destination as the announce channel", () => { + const plan = resolveFailureDestination( + makeCronJob({ + delivery: { + mode: "announce", + channel: "telegram", + to: "111", + failureDestination: { + mode: "announce", + to: "slack:U123", + }, + }, + }), + undefined, + ); + expect(plan).toEqual({ + mode: "announce", + channel: "slack", + to: "slack:U123", + accountId: undefined, + }); + }); }); diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index bcd52567059..0d5bd53c4b0 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -143,14 +143,6 @@ beforeEach(() => { }, source: "test", }, - { - pluginId: "telegram", - plugin: createOutboundTestPlugin({ - id: "telegram", - outbound: createStubOutbound("Telegram"), - }), - source: "test", - }, ]), ); }); @@ -480,6 +472,47 @@ describe("resolveDeliveryTarget", () => { expect(result.error.message).toContain("Invalid delivery target: target normalizer exploded"); }); + it("returns an unresolved target when the shared prefix guard rejects the explicit target", async () => { + setMainSessionEntry(undefined); + const resolveTarget = vi.fn(() => ({ ok: true as const, to: "telegram:1234567890" })); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "alpha", + plugin: createOutboundTestPlugin({ + id: "alpha", + outbound: { + deliveryMode: "gateway", + resolveTarget, + }, + }), + source: "test", + }, + { + pluginId: "telegram", + plugin: createOutboundTestPlugin({ + id: "telegram", + outbound: createStubOutbound("Telegram"), + messaging: telegramMessagingForTest, + }), + source: "test", + }, + ]), + ); + + const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, { + channel: "alpha", + to: "telegram:1234567890", + }); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected invalid delivery target"); + } + expect(result.error.message).toContain("belongs to telegram, not alpha"); + expect(resolveTarget).not.toHaveBeenCalled(); + }); + it("selects correct binding when multiple agents have bindings", async () => { setMainSessionEntry(undefined); @@ -616,6 +649,18 @@ describe("resolveDeliveryTarget", () => { expect(result.error.message).toContain("requires target"); }); + it("uses provider-prefixed explicit target instead of fallback channel for delivery.channel=last", async () => { + setMainSessionEntry(undefined); + const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, { + channel: "last", + to: "telegram:1234567890", + }); + + expect(result.ok).toBe(true); + expect(result.channel).toBe("telegram"); + expect(result.to).toBe("1234567890"); + }); + it("returns an error when channel selection is ambiguous", async () => { setMainSessionEntry(undefined); vi.mocked(resolveMessageChannelSelection).mockRejectedValueOnce( diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index ebbf8d63046..eddaa1db12c 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -11,6 +11,10 @@ import { isInvalidCronSessionTargetIdError } from "../../cron/session-target.js" import type { CronDelivery, CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js"; import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { + resolveTargetPrefixedChannel, + validateTargetProviderPrefix, +} from "../../infra/outbound/channel-target-prefix.js"; import { listConfiguredAnnounceChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { @@ -66,20 +70,63 @@ function assertConfiguredAnnounceChannel(params: { throw new Error(`${params.field} must be one of: ${configuredChannels.join(", ")}`); } +function resolveAnnounceValidationChannel(params: { + channel?: string; + to?: string; +}): string | undefined { + if (params.channel && params.channel !== "last") { + return params.channel; + } + return resolveTargetPrefixedChannel(params.to) ?? params.channel; +} + +function assertCompatibleAnnounceTarget(params: { + channel?: string; + to?: string; + field: "delivery.channel" | "delivery.failureDestination.channel"; +}) { + if (!params.channel || params.channel === "last") { + return; + } + const error = validateTargetProviderPrefix({ + channel: params.channel, + to: params.to, + }); + if (error) { + throw new Error(`${params.field}: ${error.message}`); + } +} + function assertValidCronAnnounceDelivery(params: { cfg: OpenClawConfig; delivery?: CronDelivery }) { - if (params.delivery?.mode === "announce") { + if (params.delivery && (params.delivery.mode ?? "announce") === "announce") { + assertCompatibleAnnounceTarget({ + channel: params.delivery.channel, + to: params.delivery.to, + field: "delivery.channel", + }); assertConfiguredAnnounceChannel({ cfg: params.cfg, - channel: params.delivery.channel, + channel: resolveAnnounceValidationChannel({ + channel: params.delivery.channel, + to: params.delivery.to, + }), field: "delivery.channel", }); } const failureDestination = params.delivery?.failureDestination; if (failureDestination && (failureDestination.mode ?? "announce") === "announce") { + assertCompatibleAnnounceTarget({ + channel: failureDestination.channel, + to: failureDestination.to, + field: "delivery.failureDestination.channel", + }); assertConfiguredAnnounceChannel({ cfg: params.cfg, - channel: failureDestination.channel, + channel: resolveAnnounceValidationChannel({ + channel: failureDestination.channel, + to: failureDestination.to, + }), field: "delivery.failureDestination.channel", }); } diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 0333a266c50..3a1602c995d 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -1,6 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPlugin } from "../../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { CronJob } from "../../cron/types.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; const getRuntimeConfig = vi.hoisted(() => vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig), @@ -17,6 +23,53 @@ vi.mock("../../config/config.js", async () => { import { cronHandlers } from "./cron.js"; +function createPrefixOnlyChannelPlugin( + id: string, + targetPrefixes: readonly string[], + aliases?: readonly string[], +): ChannelPlugin { + const base = createChannelTestPluginBase({ id }); + return { + ...base, + meta: { + ...base.meta, + ...(aliases ? { aliases } : {}), + }, + messaging: { targetPrefixes }, + }; +} + +function setCronValidationTestRegistry(): void { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + plugin: createPrefixOnlyChannelPlugin("telegram", ["telegram", "tg"]), + source: "test:telegram", + }, + { + pluginId: "slack", + plugin: createPrefixOnlyChannelPlugin("slack", ["slack"]), + source: "test:slack", + }, + { + pluginId: "msteams", + plugin: createPrefixOnlyChannelPlugin("msteams", ["msteams", "teams"], ["teams"]), + source: "test:msteams", + }, + { + pluginId: "synology-chat", + plugin: createPrefixOnlyChannelPlugin("synology-chat", [ + "synology-chat", + "synology_chat", + "synology", + ]), + source: "test:synology-chat", + }, + ]), + ); +} + function createCronContext(currentJob?: CronJob) { return { cron: { @@ -80,6 +133,11 @@ function createCronJob(overrides: Partial = {}): CronJob { describe("cron method validation", () => { beforeEach(() => { getRuntimeConfig.mockReset().mockReturnValue({} as OpenClawConfig); + setCronValidationTestRegistry(); + }); + + afterEach(() => { + resetPluginRuntimeStateForTest(); }); it("accepts threadId on announce delivery add params", async () => { @@ -211,6 +269,195 @@ describe("cron method validation", () => { ); }); + it("accepts provider-prefixed announce target without delivery.channel when multiple channels are configured", async () => { + getRuntimeConfig.mockReturnValue({ + session: { + mainKey: "main", + }, + channels: { + telegram: { + botToken: "telegram-token", + }, + slack: { + botToken: "xoxb-slack-token", + appToken: "xapp-slack-token", + }, + }, + plugins: { + entries: { + telegram: { enabled: true }, + slack: { enabled: true }, + }, + }, + } as OpenClawConfig); + + const { context, respond } = await invokeCronAdd({ + name: "prefixed announce add", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "announce", to: "telegram:123" }, + }); + + expect(context.cron.add).toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith(true, { id: "cron-1" }, undefined); + }); + + it("rejects announce targets prefixed for a different explicit delivery channel", async () => { + getRuntimeConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "telegram-token", + }, + slack: { + botToken: "xoxb-slack-token", + appToken: "xapp-slack-token", + }, + }, + plugins: { + entries: { + telegram: { enabled: true }, + slack: { enabled: true }, + }, + }, + } as OpenClawConfig); + + const { context, respond } = await invokeCronAdd({ + name: "mismatched announce add", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "announce", channel: "slack", to: "telegram:123" }, + }); + + expect(context.cron.add).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("belongs to telegram, not slack"), + }), + ); + }); + + it("accepts provider-prefixed announce targets when delivery.channel uses a channel alias", async () => { + getRuntimeConfig.mockReturnValue({ + channels: { + msteams: { + botToken: "teams-token", + }, + }, + plugins: { + entries: { + msteams: { enabled: true }, + }, + }, + } as OpenClawConfig); + + for (const to of ["teams:19:meeting_abc@thread.tacv2", "msteams:19:meeting_abc@thread.tacv2"]) { + const { context, respond } = await invokeCronAdd({ + name: `aliased announce add ${to}`, + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + delivery: { + mode: "announce", + channel: "teams", + to, + }, + }); + + expect(context.cron.add).toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith(true, { id: "cron-1" }, undefined); + } + }); + + it("validates announce delivery patches that omit mode", async () => { + getRuntimeConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "telegram-token", + }, + slack: { + botToken: "xoxb-slack-token", + appToken: "xapp-slack-token", + }, + }, + plugins: { + entries: { + telegram: { enabled: true }, + slack: { enabled: true }, + }, + }, + } as OpenClawConfig); + + const { context, respond } = await invokeCronUpdate( + { + id: "cron-1", + patch: { + delivery: { channel: "slack", to: "telegram:123" }, + }, + }, + createCronJob({ + delivery: { mode: "announce", channel: "telegram", to: "123" }, + }), + ); + + expect(context.cron.update).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("belongs to telegram, not slack"), + }), + ); + }); + + it("rejects underscored provider prefixes for a different explicit delivery channel", async () => { + getRuntimeConfig.mockReturnValue({ + channels: { + slack: { + botToken: "xoxb-slack-token", + appToken: "xapp-slack-token", + }, + "synology-chat": { + token: "synology-token", + }, + }, + plugins: { + entries: { + slack: { enabled: true }, + "synology-chat": { enabled: true }, + }, + }, + } as OpenClawConfig); + + const { context, respond } = await invokeCronAdd({ + name: "underscored mismatch add", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + delivery: { mode: "announce", channel: "slack", to: "synology_chat:123" }, + }); + + expect(context.cron.add).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("belongs to synology-chat, not slack"), + }), + ); + }); + it("rejects ambiguous announce delivery on update when multiple channels are configured", async () => { getRuntimeConfig.mockReturnValue({ session: { diff --git a/src/infra/outbound/channel-target-prefix.ts b/src/infra/outbound/channel-target-prefix.ts new file mode 100644 index 00000000000..a60a0ea0794 --- /dev/null +++ b/src/infra/outbound/channel-target-prefix.ts @@ -0,0 +1,74 @@ +import { getActivePluginChannelRegistryFromState } from "../../plugins/runtime-channel-state.js"; +import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; +import { normalizeMessageChannel } from "../../utils/message-channel-core.js"; + +const TARGET_KIND_PREFIXES = new Set([ + "channel", + "conversation", + "dm", + "group", + "room", + "thread", + "user", +]); + +export type ChannelTargetProviderPrefix = { + prefix: string; + channel: string; +}; + +function resolvePluginTargetPrefix(prefix: string): string | undefined { + const normalizedPrefix = normalizeOptionalLowercaseString(prefix); + if (!normalizedPrefix) { + return undefined; + } + const registry = getActivePluginChannelRegistryFromState(); + for (const entry of registry?.channels ?? []) { + const plugin = entry.plugin; + const channelId = normalizeOptionalLowercaseString(plugin.id); + const candidates = plugin.messaging?.targetPrefixes ?? []; + if ( + channelId && + candidates.some( + (candidate) => normalizeOptionalLowercaseString(candidate) === normalizedPrefix, + ) + ) { + return channelId; + } + } + return undefined; +} + +export function resolveChannelTargetProviderPrefix( + raw?: string | null, +): ChannelTargetProviderPrefix | undefined { + const match = /^\s*([a-z][a-z0-9_-]*):/i.exec(raw ?? ""); + const prefix = normalizeOptionalLowercaseString(match?.[1]); + if (!prefix || TARGET_KIND_PREFIXES.has(prefix)) { + return undefined; + } + const channel = resolvePluginTargetPrefix(prefix); + return channel ? { prefix, channel } : undefined; +} + +export function resolveTargetPrefixedChannel(raw?: string | null): string | undefined { + return resolveChannelTargetProviderPrefix(raw)?.channel; +} + +export function validateTargetProviderPrefix(params: { + channel: string; + to?: string | null; +}): Error | undefined { + const selectedChannel = + normalizeMessageChannel(params.channel) ?? normalizeOptionalLowercaseString(params.channel); + if (!selectedChannel || selectedChannel === "last") { + return undefined; + } + const prefixed = resolveChannelTargetProviderPrefix(params.to); + if (!prefixed || prefixed.channel === selectedChannel) { + return undefined; + } + return new Error( + `Target prefix "${prefixed.prefix}:" belongs to ${prefixed.channel}, not ${selectedChannel}.`, + ); +} diff --git a/src/infra/outbound/targets-resolve-shared.ts b/src/infra/outbound/targets-resolve-shared.ts index aa26b005bf2..c58e43f360b 100644 --- a/src/infra/outbound/targets-resolve-shared.ts +++ b/src/infra/outbound/targets-resolve-shared.ts @@ -5,6 +5,7 @@ import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel-constants.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; +import { validateTargetProviderPrefix } from "./channel-target-prefix.js"; import { missingTargetError } from "./target-errors.js"; export type OutboundTargetResolution = { ok: true; to: string } | { ok: false; error: Error }; @@ -59,6 +60,13 @@ export function resolveOutboundTargetWithPlugin(params: { accountId: params.target.accountId ?? undefined, }) : undefined); + const targetPrefixError = validateTargetProviderPrefix({ + channel: params.target.channel, + to: effectiveTo, + }); + if (targetPrefixError) { + return { ok: false, error: targetPrefixError }; + } const resolveTarget = plugin.outbound?.resolveTarget; if (resolveTarget) { diff --git a/src/infra/outbound/targets-session.ts b/src/infra/outbound/targets-session.ts index 7b8272058e2..250cc12cd26 100644 --- a/src/infra/outbound/targets-session.ts +++ b/src/infra/outbound/targets-session.ts @@ -14,6 +14,7 @@ import type { DeliverableMessageChannel, GatewayMessageChannel, } from "../../utils/message-channel-normalize.js"; +import { resolveTargetPrefixedChannel } from "./channel-target-prefix.js"; export type SessionDeliveryTarget = { channel?: DeliverableMessageChannel; @@ -117,7 +118,14 @@ export function resolveSessionDeliveryTarget(params: { ? params.explicitTo.trim() : undefined; - let channel = requestedChannel === "last" ? lastChannel : requestedChannel; + const explicitPrefixedChannel = + requestedChannel === "last" ? resolveTargetPrefixedChannel(rawExplicitTo) : undefined; + let channel = + explicitPrefixedChannel && isDeliverableMessageChannel(explicitPrefixedChannel) + ? explicitPrefixedChannel + : requestedChannel === "last" + ? lastChannel + : requestedChannel; if (!channel && params.fallbackChannel && isDeliverableMessageChannel(params.fallbackChannel)) { channel = params.fallbackChannel; } diff --git a/src/infra/outbound/targets.shared-test.ts b/src/infra/outbound/targets.shared-test.ts index 410917dc729..e35a39357e6 100644 --- a/src/infra/outbound/targets.shared-test.ts +++ b/src/infra/outbound/targets.shared-test.ts @@ -85,6 +85,18 @@ export function runResolveOutboundTargetCoreTests(): void { } }); + it("rejects a target prefixed for a different channel before plugin normalization", () => { + const res = resolveOutboundTarget({ + channel: "alpha", + to: "beta:room-one", + mode: "explicit", + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error.message).toContain("belongs to beta, not alpha"); + } + }); + it("uses the plugin hint when a channel has outbound support but no target resolver", () => { setActivePluginRegistry( createTargetsTestRegistry([ diff --git a/src/infra/outbound/targets.test-helpers.ts b/src/infra/outbound/targets.test-helpers.ts index 583e8e48d6e..7a18921734d 100644 --- a/src/infra/outbound/targets.test-helpers.ts +++ b/src/infra/outbound/targets.test-helpers.ts @@ -75,6 +75,7 @@ function parseTelegramTargetForTest(raw: string): { } export const telegramMessagingForTest: ChannelMessagingAdapter = { + targetPrefixes: ["telegram", "tg"], parseExplicitTarget: ({ raw }) => { const target = parseTelegramTargetForTest(raw); return { @@ -90,6 +91,7 @@ export const telegramMessagingForTest: ChannelMessagingAdapter = { }; export const forumMessagingForTest: ChannelMessagingAdapter = { + targetPrefixes: ["forum"], parseExplicitTarget: ({ raw }) => { const target = parseForumTargetForTest(raw); return { @@ -151,6 +153,9 @@ export function createGenericTargetTestPlugin( sendText: async () => ({ channel: id, messageId: `${id}-msg` }), resolveTarget: createGenericResolveTarget(String(id), label), }, + messaging: { + targetPrefixes: [String(id)], + }, resolveDefaultTo: ({ cfg }) => readTestDefaultTo(cfg, String(id)), }); } diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index bd7896e7700..9fc7d09147b 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -205,6 +205,39 @@ describe("resolveSessionDeliveryTarget", () => { }); }); + it("uses an explicit provider-prefixed target before last-session channel fallback", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-prefixed", + updatedAt: 1, + lastChannel: "alpha", + lastTo: "room-one", + }, + requestedChannel: "last", + explicitTo: "beta:room-two", + }); + + expect(resolved.channel).toBe("beta"); + expect(resolved.to).toBe("beta:room-two"); + expect(resolved.lastChannel).toBe("alpha"); + }); + + it("keeps target-kind prefixes on the selected last-session channel", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-target-kind", + updatedAt: 1, + lastChannel: "alpha", + lastTo: "room-one", + }, + requestedChannel: "last", + explicitTo: "channel:room-two", + }); + + expect(resolved.channel).toBe("alpha"); + expect(resolved.to).toBe("channel:room-two"); + }); + it("allows mismatched lastTo when configured", () => { const resolved = resolveSessionDeliveryTarget({ entry: { diff --git a/src/plugins/channel-registry-state.types.ts b/src/plugins/channel-registry-state.types.ts index f2606a07cd0..16fe45eaddd 100644 --- a/src/plugins/channel-registry-state.types.ts +++ b/src/plugins/channel-registry-state.types.ts @@ -5,6 +5,9 @@ export type ActiveChannelPluginRuntimeShape = { markdownCapable?: boolean; order?: number; } | null; + messaging?: { + targetPrefixes?: readonly string[]; + } | null; capabilities?: { nativeCommands?: boolean; } | null;