From 3238bd78d9d593d48e7b49fe9d655554f13f9163 Mon Sep 17 00:00:00 2001 From: Latitude Bot Date: Fri, 13 Feb 2026 07:09:49 -0500 Subject: [PATCH] fix(discord): normalize bare numeric IDs in outbound target resolution Bare numeric Discord IDs (e.g. '1470130713209602050') in cron delivery.to caused 'Ambiguous Discord recipient' errors and silent delivery failures. Adds normalizeDiscordOutboundTarget() to the existing Discord normalize module (channels/plugins/normalize/discord.ts) alongside normalizeDiscordMessagingTarget. Defaults bare numeric IDs to 'channel:', matching existing behavior. Both the Discord extension plugin and standalone outbound adapter use the shared helper via a one-liner resolveTarget. Fixes #14753. Related: #13927 --- extensions/discord/src/channel.ts | 2 ++ src/channels/plugins/normalize/discord.ts | 23 ++++++++++++ src/channels/plugins/outbound/discord.test.ts | 35 +++++++++++++++++++ src/channels/plugins/outbound/discord.ts | 2 ++ src/plugin-sdk/index.ts | 1 + 5 files changed, 63 insertions(+) create mode 100644 src/channels/plugins/outbound/discord.test.ts diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index f8fc9576e6..4db082e32e 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -16,6 +16,7 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeDiscordMessagingTarget, + normalizeDiscordOutboundTarget, PAIRING_APPROVED_MESSAGE, resolveDiscordAccount, resolveDefaultDiscordAccountId, @@ -291,6 +292,7 @@ export const discordPlugin: ChannelPlugin = { chunker: null, textChunkLimit: 2000, pollMaxOptions: 10, + resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), sendText: async ({ to, text, accountId, deps, replyToId, silent }) => { const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { diff --git a/src/channels/plugins/normalize/discord.ts b/src/channels/plugins/normalize/discord.ts index 43c3f67dda..1885582500 100644 --- a/src/channels/plugins/normalize/discord.ts +++ b/src/channels/plugins/normalize/discord.ts @@ -6,6 +6,29 @@ export function normalizeDiscordMessagingTarget(raw: string): string | undefined return target?.normalized; } +/** + * Normalize a Discord outbound target for delivery. Bare numeric IDs are + * prefixed with "channel:" to avoid the ambiguous-target error in + * parseDiscordTarget. All other formats pass through unchanged. + */ +export function normalizeDiscordOutboundTarget( + to?: string, +): { ok: true; to: string } | { ok: false; error: Error } { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + 'Discord recipient is required. Use "channel:" for channels or "user:" for DMs.', + ), + }; + } + if (/^\d+$/.test(trimmed)) { + return { ok: true, to: `channel:${trimmed}` }; + } + return { ok: true, to: trimmed }; +} + export function looksLikeDiscordTargetId(raw: string): boolean { const trimmed = raw.trim(); if (!trimmed) { diff --git a/src/channels/plugins/outbound/discord.test.ts b/src/channels/plugins/outbound/discord.test.ts new file mode 100644 index 0000000000..dc80bd18ed --- /dev/null +++ b/src/channels/plugins/outbound/discord.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { normalizeDiscordOutboundTarget } from "../normalize/discord.js"; + +describe("normalizeDiscordOutboundTarget", () => { + it("normalizes bare numeric IDs to channel: prefix", () => { + expect(normalizeDiscordOutboundTarget("1470130713209602050")).toEqual({ + ok: true, + to: "channel:1470130713209602050", + }); + }); + + it("passes through channel: prefixed targets", () => { + expect(normalizeDiscordOutboundTarget("channel:123")).toEqual({ ok: true, to: "channel:123" }); + }); + + it("passes through user: prefixed targets", () => { + expect(normalizeDiscordOutboundTarget("user:123")).toEqual({ ok: true, to: "user:123" }); + }); + + it("passes through channel name strings", () => { + expect(normalizeDiscordOutboundTarget("general")).toEqual({ ok: true, to: "general" }); + }); + + it("returns error for empty target", () => { + expect(normalizeDiscordOutboundTarget("").ok).toBe(false); + }); + + it("returns error for undefined target", () => { + expect(normalizeDiscordOutboundTarget(undefined).ok).toBe(false); + }); + + it("trims whitespace", () => { + expect(normalizeDiscordOutboundTarget(" 123 ")).toEqual({ ok: true, to: "channel:123" }); + }); +}); diff --git a/src/channels/plugins/outbound/discord.ts b/src/channels/plugins/outbound/discord.ts index 37affea5e0..82fbdd757b 100644 --- a/src/channels/plugins/outbound/discord.ts +++ b/src/channels/plugins/outbound/discord.ts @@ -1,11 +1,13 @@ import type { ChannelOutboundAdapter } from "../types.js"; import { sendMessageDiscord, sendPollDiscord } from "../../../discord/send.js"; +import { normalizeDiscordOutboundTarget } from "../normalize/discord.js"; export const discordOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, textChunkLimit: 2000, pollMaxOptions: 10, + resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), sendText: async ({ to, text, accountId, deps, replyToId, silent }) => { const send = deps?.sendDiscord ?? sendMessageDiscord; const result = await send(to, text, { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index e415f44189..bd0a06036f 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -328,6 +328,7 @@ export { discordOnboardingAdapter } from "../channels/plugins/onboarding/discord export { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, + normalizeDiscordOutboundTarget, } from "../channels/plugins/normalize/discord.js"; export { collectDiscordStatusIssues } from "../channels/plugins/status-issues/discord.js";