diff --git a/src/cron/service/failure-alerts.account-routing.test.ts b/src/cron/service/failure-alerts.account-routing.test.ts index 3d61c1c7d951..3f27a8bcc9f7 100644 --- a/src/cron/service/failure-alerts.account-routing.test.ts +++ b/src/cron/service/failure-alerts.account-routing.test.ts @@ -9,25 +9,64 @@ import { resolveFailureAlert } from "./failure-alerts.js"; import { createCronServiceState, type DeferredCronNotifications } from "./state.js"; import { applyJobResult } from "./timer.js"; +function stripTestTargetPrefix(raw: string, prefixes: readonly string[]): string | undefined { + const target = raw + .trim() + .replace(new RegExp(`^(?:${prefixes.join("|")}):`, "i"), "") + .trim(); + return target || undefined; +} + +function normalizeDiscordTestTarget(raw: string): string | undefined { + const target = raw.trim().toLowerCase(); + if (!target) { + return undefined; + } + if (target.startsWith("discord:channel:")) { + return target.slice("discord:".length); + } + if (target.startsWith("discord:")) { + return `user:${target.slice("discord:".length)}`; + } + return /^(channel|user):/.test(target) ? target : `channel:${target}`; +} + describe("cron failure alert account routing", () => { beforeEach(() => { + const pluginSpecs = [ + { + id: "telegram", + aliases: [], + targetPrefixes: ["telegram", "tg"], + normalizeTarget: (raw: string) => { + const target = stripTestTargetPrefix(raw, ["telegram", "tg"]); + return target ? `telegram:${target}` : undefined; + }, + }, + { + id: "googlechat", + aliases: ["gchat", "google-chat"], + targetPrefixes: ["googlechat", "google-chat", "gchat"], + normalizeTarget: (raw: string) => + stripTestTargetPrefix(raw, ["googlechat", "google-chat", "gchat"]), + }, + { + id: "discord", + aliases: [], + targetPrefixes: ["discord"], + normalizeTarget: normalizeDiscordTestTarget, + }, + ]; setActivePluginRegistry( createTestRegistry( - [ - { id: "telegram", aliases: [], targetPrefixes: ["telegram", "tg"] }, - { - id: "googlechat", - aliases: ["gchat", "google-chat"], - targetPrefixes: ["googlechat", "google-chat", "gchat"], - }, - ].map(({ id, aliases, targetPrefixes }) => { + pluginSpecs.map(({ id, aliases, targetPrefixes, normalizeTarget }) => { const plugin = createChannelTestPluginBase({ id }); return { pluginId: id, plugin: { ...plugin, meta: { ...plugin.meta, aliases }, - messaging: { targetPrefixes }, + messaging: { targetPrefixes, normalizeTarget }, }, source: `test:${id}`, }; @@ -199,6 +238,19 @@ describe("cron failure alert account routing", () => { jobAlert: undefined, expected: { channel: "slack", to: undefined, accountId: undefined }, }, + { + name: "does not equate Discord user and channel targets with the same id", + globalAlert: { enabled: true, after: 1 }, + deliveryChannel: "discord", + deliveryTo: "1234567890", + jobAlert: { channel: "discord", to: "discord:1234567890" }, + expected: { + channel: "discord", + to: "discord:1234567890", + accountId: undefined, + threadId: undefined, + }, + }, { name: "does not inherit the primary account for a webhook", globalAlert: { @@ -309,6 +361,13 @@ describe("cron failure alert account routing", () => { failureAlert: { channel: "googlechat", to: "googlechat:RoomA" }, expectedChannel: "googlechat", }, + { + name: "plugin-normalized equivalent", + deliveryChannel: "discord", + deliveryTo: "1234567890", + failureAlert: { channel: "discord", to: "discord:channel:1234567890" }, + expectedChannel: "discord", + }, ])("keeps the primary account and topic on $name failure alerts", (testCase) => { const { failureAlert } = testCase; const sendCronFailureAlert = vi.fn(async () => undefined); diff --git a/src/cron/service/failure-alerts.ts b/src/cron/service/failure-alerts.ts index eff8805c7a0c..33e60c5f5856 100644 --- a/src/cron/service/failure-alerts.ts +++ b/src/cron/service/failure-alerts.ts @@ -3,10 +3,8 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/s import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { FailoverReason } from "../../agents/embedded-agent-helpers/types.js"; import { normalizeAnyChannelId } from "../../channels/registry-normalize.js"; -import { - resolveTargetPrefixedChannel, - stripTargetProviderPrefix, -} from "../../infra/outbound/channel-target-prefix.js"; +import { resolveTargetPrefixedChannel } from "../../infra/outbound/channel-target-prefix.js"; +import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import type { CronFailureNotificationDelivery, CronJob, CronMessageChannel } from "../types.js"; import type { CronServiceState, DeferredCronNotifications } from "./state.js"; @@ -53,11 +51,12 @@ function resolveFailureAlertChannel(channel: unknown, to?: string): CronMessageC } function normalizeFailureAlertRecipient(channel: CronMessageChannel, to: string): string { - if (resolveTargetPrefixedChannel(to) !== channel) { + try { + return normalizeTargetForProvider(channel, to) ?? to; + } catch { + // Invalid loaded targets are distinct routes; they must not block run finalization. return to; } - // Canonicalize loaded-provider aliases only; recipient/topic ids can be case-sensitive. - return stripTargetProviderPrefix(to, to.slice(0, to.indexOf(":"))); } function normalizeTo(input: unknown): string | undefined {