fix: guard provider-prefixed delivery targets

This commit is contained in:
Peter Steinberger
2026-05-02 05:29:55 +01:00
parent 2218ce46fe
commit 43121fb096
44 changed files with 753 additions and 25 deletions

View File

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

View File

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

View File

@@ -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:<id>`, `user:<id>`). 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:<id>`, `user:<id>`, `imessage:<handle>`, and `sms:<number>` 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.

View File

@@ -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:<id>`, `user:<id>`, `room:<id>`, `thread:<id>`, `imessage:<handle>`, and `sms:<number>` stay inside the selected channel's grammar. They do not select the provider by themselves.
## Session key shapes (examples)
Direct messages collapse to the agents **main** session by default:

View File

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

View File

@@ -35,6 +35,8 @@ Run `openclaw cron --help` for the full command surface. See [Cron jobs](/automa
`openclaw cron list` and `openclaw cron show <job-id>` 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.
<Note>
Isolated `cron add` jobs default to `--announce` delivery. Use `--no-deliver` to keep output internal. `--deliver` remains as a deprecated alias for `--announce`.
</Note>

View File

@@ -129,6 +129,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
},
},
messaging: {
targetPrefixes: ["bluebubbles"],
normalizeTarget: normalizeBlueBubblesMessagingTarget,
inferTargetChatType: ({ to }) => inferBlueBubblesTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params),

View File

@@ -218,6 +218,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
],
},
messaging: {
targetPrefixes: ["discord"],
normalizeTarget: normalizeDiscordMessagingTarget,
resolveInboundConversation: ({ from, to, conversationId, isGroup }) =>
resolveDiscordInboundConversation({ from, to, conversationId, isGroup }),

View File

@@ -1154,6 +1154,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
setup: feishuSetupAdapter,
setupWizard: feishuSetupWizard,
messaging: {
targetPrefixes: ["feishu", "lark"],
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
const directId = parseFeishuDirectConversationId(conversationId);

View File

@@ -144,6 +144,7 @@ export const googlechatPlugin = createChatChannelPlugin({
},
groups: googlechatGroupsAdapter,
messaging: {
targetPrefixes: ["googlechat", "google-chat", "gchat"],
normalizeTarget: normalizeGoogleChatTarget,
targetResolver: {
looksLikeId: (raw, normalized) => {

View File

@@ -233,6 +233,7 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
},
},
messaging: {
targetPrefixes: ["irc"],
normalizeTarget: normalizeIrcMessagingTarget,
targetResolver: {
looksLikeId: looksLikeIrcTargetId,

View File

@@ -42,6 +42,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
resolveRequireMention: resolveLineGroupRequireMention,
},
messaging: {
targetPrefixes: ["line"],
normalizeTarget: (target) => {
const trimmed = target.trim();
if (!trimmed) {

View File

@@ -376,6 +376,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
}).map(projectMatrixConversationBinding),
},
messaging: {
targetPrefixes: ["matrix"],
normalizeTarget: normalizeMatrixMessagingTarget,
resolveInboundConversation: ({ to, conversationId, threadId }) =>
resolveMatrixInboundConversation({ to, conversationId, threadId }),

View File

@@ -306,6 +306,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
(await loadMattermostChannelRuntime()).listMattermostDirectoryPeers(params),
}),
messaging: {
targetPrefixes: ["mattermost"],
defaultMarkdownTableMode: "off",
normalizeTarget: normalizeMattermostMessagingTarget,
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {

View File

@@ -450,6 +450,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
},
setup: msteamsSetupAdapter,
messaging: {
targetPrefixes: ["msteams", "teams"],
normalizeTarget: normalizeMSTeamsMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveMSTeamsOutboundSessionRoute(params),
targetResolver: {

View File

@@ -119,6 +119,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy,
},
messaging: {
targetPrefixes: ["nextcloud-talk", "nc-talk", "nc"],
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveNextcloudTalkOutboundSessionRoute(params),
targetResolver: {

View File

@@ -118,6 +118,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = createChatChanne
}),
},
messaging: {
targetPrefixes: ["nostr"],
normalizeTarget: (target) => {
// Strip nostr: prefix if present
const cleaned = target.trim().replace(/^nostr:/i, "");

View File

@@ -99,6 +99,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
},
approvalCapability: getQQBotApprovalCapability(),
messaging: {
targetPrefixes: ["qqbot"],
/** Normalize common QQ Bot target formats into the canonical qqbot:... form. */
normalizeTarget: coreNormalizeTarget,
targetResolver: {

View File

@@ -270,6 +270,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
},
},
messaging: {
targetPrefixes: ["signal"],
normalizeTarget: normalizeSignalMessagingTarget,
parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw),
inferTargetChatType: ({ to }) => inferSignalTargetChatType(to),

View File

@@ -385,6 +385,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
resolveToolPolicy: resolveSlackGroupToolPolicy,
},
messaging: {
targetPrefixes: ["slack"],
normalizeTarget: normalizeSlackMessagingTarget,
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
const parent = parentConversationId?.trim();

View File

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

View File

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

View File

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

View File

@@ -691,6 +691,7 @@ export const telegramPlugin = createChatChannelPlugin({
},
},
messaging: {
targetPrefixes: ["telegram", "tg"],
normalizeTarget: normalizeTelegramMessagingTarget,
resolveInboundConversation: ({ to, conversationId, threadId }) =>
resolveTelegramInboundConversation({ to, conversationId, threadId }),

View File

@@ -96,6 +96,7 @@ export const tlonPlugin = createChatChannelPlugin({
},
doctor: tlonDoctor,
messaging: {
targetPrefixes: ["tlon"],
normalizeTarget: (target) => {
const parsed = parseTlonTarget(target);
if (!parsed) {

View File

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

View File

@@ -111,6 +111,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
},
},
messaging: {
targetPrefixes: ["whatsapp"],
normalizeTarget: normalizeWhatsAppMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveWhatsAppOutboundSessionRoute(params),
parseExplicitTarget: ({ raw }) => parseWhatsAppExplicitTarget(raw),

View File

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

View File

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

View File

@@ -196,6 +196,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount, ZaloProbeResult> =
},
actions: zaloMessageActions,
messaging: {
targetPrefixes: ["zalo", "zl"],
normalizeTarget: normalizeZaloMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveZaloOutboundSessionRoute(params),
targetResolver: {

View File

@@ -370,6 +370,7 @@ export const zalouserOutboundAdapter = {
};
export const zalouserMessagingAdapter = {
targetPrefixes: ["zalouser", "zlu"],
normalizeTarget: (raw: string) => normalizeZalouserTarget(raw),
resolveOutboundSessionRoute: (
params: Parameters<typeof resolveZalouserOutboundSessionRoute>[0],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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> = {}): 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: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,9 @@ export type ActiveChannelPluginRuntimeShape = {
markdownCapable?: boolean;
order?: number;
} | null;
messaging?: {
targetPrefixes?: readonly string[];
} | null;
capabilities?: {
nativeCommands?: boolean;
} | null;