mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 15:18:58 +00:00
fix: guard provider-prefixed delivery targets
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 agent’s **main** session by default:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -129,6 +129,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["bluebubbles"],
|
||||
normalizeTarget: normalizeBlueBubblesMessagingTarget,
|
||||
inferTargetChatType: ({ to }) => inferBlueBubblesTargetChatType(to),
|
||||
resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -144,6 +144,7 @@ export const googlechatPlugin = createChatChannelPlugin({
|
||||
},
|
||||
groups: googlechatGroupsAdapter,
|
||||
messaging: {
|
||||
targetPrefixes: ["googlechat", "google-chat", "gchat"],
|
||||
normalizeTarget: normalizeGoogleChatTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw, normalized) => {
|
||||
|
||||
@@ -233,6 +233,7 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["irc"],
|
||||
normalizeTarget: normalizeIrcMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeIrcTargetId,
|
||||
|
||||
@@ -42,6 +42,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
||||
resolveRequireMention: resolveLineGroupRequireMention,
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["line"],
|
||||
normalizeTarget: (target) => {
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -306,6 +306,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
(await loadMattermostChannelRuntime()).listMattermostDirectoryPeers(params),
|
||||
}),
|
||||
messaging: {
|
||||
targetPrefixes: ["mattermost"],
|
||||
defaultMarkdownTableMode: "off",
|
||||
normalizeTarget: normalizeMattermostMessagingTarget,
|
||||
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
|
||||
|
||||
@@ -450,6 +450,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
|
||||
},
|
||||
setup: msteamsSetupAdapter,
|
||||
messaging: {
|
||||
targetPrefixes: ["msteams", "teams"],
|
||||
normalizeTarget: normalizeMSTeamsMessagingTarget,
|
||||
resolveOutboundSessionRoute: (params) => resolveMSTeamsOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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, "");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -270,6 +270,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["signal"],
|
||||
normalizeTarget: normalizeSignalMessagingTarget,
|
||||
parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => inferSignalTargetChatType(to),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -691,6 +691,7 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["telegram", "tg"],
|
||||
normalizeTarget: normalizeTelegramMessagingTarget,
|
||||
resolveInboundConversation: ({ to, conversationId, threadId }) =>
|
||||
resolveTelegramInboundConversation({ to, conversationId, threadId }),
|
||||
|
||||
@@ -96,6 +96,7 @@ export const tlonPlugin = createChatChannelPlugin({
|
||||
},
|
||||
doctor: tlonDoctor,
|
||||
messaging: {
|
||||
targetPrefixes: ["tlon"],
|
||||
normalizeTarget: (target) => {
|
||||
const parsed = parseTlonTarget(target);
|
||||
if (!parsed) {
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -111,6 +111,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["whatsapp"],
|
||||
normalizeTarget: normalizeWhatsAppMessagingTarget,
|
||||
resolveOutboundSessionRoute: (params) => resolveWhatsAppOutboundSessionRoute(params),
|
||||
parseExplicitTarget: ({ raw }) => parseWhatsAppExplicitTarget(raw),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -196,6 +196,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount, ZaloProbeResult> =
|
||||
},
|
||||
actions: zaloMessageActions,
|
||||
messaging: {
|
||||
targetPrefixes: ["zalo", "zl"],
|
||||
normalizeTarget: normalizeZaloMessagingTarget,
|
||||
resolveOutboundSessionRoute: (params) => resolveZaloOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
|
||||
@@ -370,6 +370,7 @@ export const zalouserOutboundAdapter = {
|
||||
};
|
||||
|
||||
export const zalouserMessagingAdapter = {
|
||||
targetPrefixes: ["zalouser", "zlu"],
|
||||
normalizeTarget: (raw: string) => normalizeZalouserTarget(raw),
|
||||
resolveOutboundSessionRoute: (
|
||||
params: Parameters<typeof resolveZalouserOutboundSessionRoute>[0],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
74
src/infra/outbound/channel-target-prefix.ts
Normal file
74
src/infra/outbound/channel-target-prefix.ts
Normal 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}.`,
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -5,6 +5,9 @@ export type ActiveChannelPluginRuntimeShape = {
|
||||
markdownCapable?: boolean;
|
||||
order?: number;
|
||||
} | null;
|
||||
messaging?: {
|
||||
targetPrefixes?: readonly string[];
|
||||
} | null;
|
||||
capabilities?: {
|
||||
nativeCommands?: boolean;
|
||||
} | null;
|
||||
|
||||
Reference in New Issue
Block a user