diff --git a/src/channels/plugins/directory-config.ts b/src/channels/plugins/directory-config.ts index ba7ac32e5..3e25ecad0 100644 --- a/src/channels/plugins/directory-config.ts +++ b/src/channels/plugins/directory-config.ts @@ -4,7 +4,7 @@ import { resolveSlackAccount } from "../../slack/accounts.js"; import { resolveDiscordAccount } from "../../discord/accounts.js"; import { resolveTelegramAccount } from "../../telegram/accounts.js"; import { resolveWhatsAppAccount } from "../../web/accounts.js"; -import { normalizeSlackMessagingTarget } from "./normalize-target.js"; +import { normalizeSlackMessagingTarget } from "./normalize/slack.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; export type DirectoryConfigParams = { diff --git a/src/channels/plugins/discord.ts b/src/channels/plugins/discord.ts index 254c764ba..eb9a65a8f 100644 --- a/src/channels/plugins/discord.ts +++ b/src/channels/plugins/discord.ts @@ -26,7 +26,10 @@ import { } from "./config-helpers.js"; import { resolveDiscordGroupRequireMention } from "./group-mentions.js"; import { formatPairingApproveHint } from "./helpers.js"; -import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget } from "./normalize-target.js"; +import { + looksLikeDiscordTargetId, + normalizeDiscordMessagingTarget, +} from "./normalize/discord.js"; import { discordOnboardingAdapter } from "./onboarding/discord.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { diff --git a/src/channels/plugins/normalize-target.ts b/src/channels/plugins/normalize-target.ts deleted file mode 100644 index 267cafaed..000000000 --- a/src/channels/plugins/normalize-target.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { parseDiscordTarget } from "../../discord/targets.js"; -import { parseSlackTarget } from "../../slack/targets.js"; -import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; - -export function normalizeSlackMessagingTarget(raw: string): string | undefined { - const target = parseSlackTarget(raw, { defaultKind: "channel" }); - return target?.normalized; -} - -export function looksLikeSlackTargetId(raw: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) return false; - if (/^<@([A-Z0-9]+)>$/i.test(trimmed)) return true; - if (/^(user|channel):/i.test(trimmed)) return true; - if (/^slack:/i.test(trimmed)) return true; - if (/^[@#]/.test(trimmed)) return true; - return /^[CUWGD][A-Z0-9]{8,}$/i.test(trimmed); -} - -export function normalizeDiscordMessagingTarget(raw: string): string | undefined { - // Default bare IDs to channels so routing is stable across tool actions. - const target = parseDiscordTarget(raw, { defaultKind: "channel" }); - return target?.normalized; -} - -export function looksLikeDiscordTargetId(raw: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) return false; - if (/^<@!?\d+>$/.test(trimmed)) return true; - if (/^(user|channel|discord):/i.test(trimmed)) return true; - if (/^\d{6,}$/.test(trimmed)) return true; - return false; -} - -export function normalizeTelegramMessagingTarget(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) return undefined; - let normalized = trimmed; - if (normalized.startsWith("telegram:")) { - normalized = normalized.slice("telegram:".length).trim(); - } else if (normalized.startsWith("tg:")) { - normalized = normalized.slice("tg:".length).trim(); - } - if (!normalized) return undefined; - const tmeMatch = - /^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ?? - /^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized); - if (tmeMatch?.[1]) normalized = `@${tmeMatch[1]}`; - if (!normalized) return undefined; - return `telegram:${normalized}`.toLowerCase(); -} - -export function looksLikeTelegramTargetId(raw: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) return false; - if (/^(telegram|tg):/i.test(trimmed)) return true; - if (trimmed.startsWith("@")) return true; - return /^-?\d{6,}$/.test(trimmed); -} - -export function normalizeSignalMessagingTarget(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) return undefined; - let normalized = trimmed; - if (normalized.toLowerCase().startsWith("signal:")) { - normalized = normalized.slice("signal:".length).trim(); - } - if (!normalized) return undefined; - const lower = normalized.toLowerCase(); - if (lower.startsWith("group:")) { - const id = normalized.slice("group:".length).trim(); - return id ? `group:${id}`.toLowerCase() : undefined; - } - if (lower.startsWith("username:")) { - const id = normalized.slice("username:".length).trim(); - return id ? `username:${id}`.toLowerCase() : undefined; - } - if (lower.startsWith("u:")) { - const id = normalized.slice("u:".length).trim(); - return id ? `username:${id}`.toLowerCase() : undefined; - } - return normalized.toLowerCase(); -} - -export function looksLikeSignalTargetId(raw: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) return false; - if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true; - return /^\+?\d{3,}$/.test(trimmed); -} - -export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) return undefined; - return normalizeWhatsAppTarget(trimmed) ?? undefined; -} - -export function looksLikeWhatsAppTargetId(raw: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) return false; - if (/^whatsapp:/i.test(trimmed)) return true; - if (trimmed.includes("@")) return true; - return /^\+?\d{3,}$/.test(trimmed); -} diff --git a/src/channels/plugins/normalize/discord.ts b/src/channels/plugins/normalize/discord.ts new file mode 100644 index 000000000..b701e5da8 --- /dev/null +++ b/src/channels/plugins/normalize/discord.ts @@ -0,0 +1,16 @@ +import { parseDiscordTarget } from "../../../discord/targets.js"; + +export function normalizeDiscordMessagingTarget(raw: string): string | undefined { + // Default bare IDs to channels so routing is stable across tool actions. + const target = parseDiscordTarget(raw, { defaultKind: "channel" }); + return target?.normalized; +} + +export function looksLikeDiscordTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^<@!?\d+>$/.test(trimmed)) return true; + if (/^(user|channel|discord):/i.test(trimmed)) return true; + if (/^\d{6,}$/.test(trimmed)) return true; + return false; +} diff --git a/src/channels/plugins/normalize/signal.ts b/src/channels/plugins/normalize/signal.ts new file mode 100644 index 000000000..00e03443a --- /dev/null +++ b/src/channels/plugins/normalize/signal.ts @@ -0,0 +1,30 @@ +export function normalizeSignalMessagingTarget(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + let normalized = trimmed; + if (normalized.toLowerCase().startsWith("signal:")) { + normalized = normalized.slice("signal:".length).trim(); + } + if (!normalized) return undefined; + const lower = normalized.toLowerCase(); + if (lower.startsWith("group:")) { + const id = normalized.slice("group:".length).trim(); + return id ? `group:${id}`.toLowerCase() : undefined; + } + if (lower.startsWith("username:")) { + const id = normalized.slice("username:".length).trim(); + return id ? `username:${id}`.toLowerCase() : undefined; + } + if (lower.startsWith("u:")) { + const id = normalized.slice("u:".length).trim(); + return id ? `username:${id}`.toLowerCase() : undefined; + } + return normalized.toLowerCase(); +} + +export function looksLikeSignalTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true; + return /^\+?\d{3,}$/.test(trimmed); +} diff --git a/src/channels/plugins/normalize/slack.ts b/src/channels/plugins/normalize/slack.ts new file mode 100644 index 000000000..876785e7d --- /dev/null +++ b/src/channels/plugins/normalize/slack.ts @@ -0,0 +1,16 @@ +import { parseSlackTarget } from "../../../slack/targets.js"; + +export function normalizeSlackMessagingTarget(raw: string): string | undefined { + const target = parseSlackTarget(raw, { defaultKind: "channel" }); + return target?.normalized; +} + +export function looksLikeSlackTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^<@([A-Z0-9]+)>$/i.test(trimmed)) return true; + if (/^(user|channel):/i.test(trimmed)) return true; + if (/^slack:/i.test(trimmed)) return true; + if (/^[@#]/.test(trimmed)) return true; + return /^[CUWGD][A-Z0-9]{8,}$/i.test(trimmed); +} diff --git a/src/channels/plugins/normalize/telegram.ts b/src/channels/plugins/normalize/telegram.ts new file mode 100644 index 000000000..ea5531ce1 --- /dev/null +++ b/src/channels/plugins/normalize/telegram.ts @@ -0,0 +1,25 @@ +export function normalizeTelegramMessagingTarget(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + let normalized = trimmed; + if (normalized.startsWith("telegram:")) { + normalized = normalized.slice("telegram:".length).trim(); + } else if (normalized.startsWith("tg:")) { + normalized = normalized.slice("tg:".length).trim(); + } + if (!normalized) return undefined; + const tmeMatch = + /^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ?? + /^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized); + if (tmeMatch?.[1]) normalized = `@${tmeMatch[1]}`; + if (!normalized) return undefined; + return `telegram:${normalized}`.toLowerCase(); +} + +export function looksLikeTelegramTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^(telegram|tg):/i.test(trimmed)) return true; + if (trimmed.startsWith("@")) return true; + return /^-?\d{6,}$/.test(trimmed); +} diff --git a/src/channels/plugins/normalize/whatsapp.ts b/src/channels/plugins/normalize/whatsapp.ts new file mode 100644 index 000000000..1f4a95167 --- /dev/null +++ b/src/channels/plugins/normalize/whatsapp.ts @@ -0,0 +1,15 @@ +import { normalizeWhatsAppTarget } from "../../../whatsapp/normalize.js"; + +export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + return normalizeWhatsAppTarget(trimmed) ?? undefined; +} + +export function looksLikeWhatsAppTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^whatsapp:/i.test(trimmed)) return true; + if (trimmed.includes("@")) return true; + return /^\+?\d{3,}$/.test(trimmed); +} diff --git a/src/channels/plugins/signal.ts b/src/channels/plugins/signal.ts index 5f0a27fb5..c404c63bb 100644 --- a/src/channels/plugins/signal.ts +++ b/src/channels/plugins/signal.ts @@ -18,7 +18,10 @@ import { } from "./config-helpers.js"; import { formatPairingApproveHint } from "./helpers.js"; import { resolveChannelMediaMaxBytes } from "./media-limits.js"; -import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize-target.js"; +import { + looksLikeSignalTargetId, + normalizeSignalMessagingTarget, +} from "./normalize/signal.js"; import { signalOnboardingAdapter } from "./onboarding/signal.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { diff --git a/src/channels/plugins/slack.ts b/src/channels/plugins/slack.ts index d3d8a60ee..dd70421d3 100644 --- a/src/channels/plugins/slack.ts +++ b/src/channels/plugins/slack.ts @@ -20,7 +20,7 @@ import { } from "./config-helpers.js"; import { resolveSlackGroupRequireMention } from "./group-mentions.js"; import { formatPairingApproveHint } from "./helpers.js"; -import { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./normalize-target.js"; +import { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./normalize/slack.js"; import { slackOnboardingAdapter } from "./onboarding/slack.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { diff --git a/src/channels/plugins/telegram.ts b/src/channels/plugins/telegram.ts index 6e1b7fb16..9595cd8f6 100644 --- a/src/channels/plugins/telegram.ts +++ b/src/channels/plugins/telegram.ts @@ -26,7 +26,10 @@ import { } from "./config-helpers.js"; import { resolveTelegramGroupRequireMention } from "./group-mentions.js"; import { formatPairingApproveHint } from "./helpers.js"; -import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize-target.js"; +import { + looksLikeTelegramTargetId, + normalizeTelegramMessagingTarget, +} from "./normalize/telegram.js"; import { telegramOnboardingAdapter } from "./onboarding/telegram.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { diff --git a/src/channels/plugins/whatsapp.ts b/src/channels/plugins/whatsapp.ts index 3ee1179ed..91afada7f 100644 --- a/src/channels/plugins/whatsapp.ts +++ b/src/channels/plugins/whatsapp.ts @@ -26,7 +26,10 @@ import { buildChannelConfigSchema } from "./config-schema.js"; import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js"; import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js"; import { formatPairingApproveHint } from "./helpers.js"; -import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize-target.js"; +import { + looksLikeWhatsAppTargetId, + normalizeWhatsAppMessagingTarget, +} from "./normalize/whatsapp.js"; import { whatsappOnboardingAdapter } from "./onboarding/whatsapp.js"; import { applyAccountNameToChannelSection, diff --git a/src/channels/targets.test.ts b/src/channels/targets.test.ts new file mode 100644 index 000000000..25a91fef5 --- /dev/null +++ b/src/channels/targets.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { buildMessagingTarget, ensureTargetId, requireTargetKind } from "./targets.js"; + +describe("ensureTargetId", () => { + it("returns the candidate when it matches", () => { + expect( + ensureTargetId({ + candidate: "U123", + pattern: /^[A-Z0-9]+$/i, + errorMessage: "bad", + }), + ).toBe("U123"); + }); + + it("throws with the provided message on mismatch", () => { + expect(() => + ensureTargetId({ + candidate: "not-ok", + pattern: /^[A-Z0-9]+$/i, + errorMessage: "Bad target", + }), + ).toThrow(/Bad target/); + }); +}); + +describe("requireTargetKind", () => { + it("returns the target id when the kind matches", () => { + const target = buildMessagingTarget("channel", "C123", "C123"); + expect(requireTargetKind({ platform: "Slack", target, kind: "channel" })).toBe("C123"); + }); + + it("throws when the kind is missing or mismatched", () => { + expect(() => requireTargetKind({ platform: "Slack", target: undefined, kind: "channel" })).toThrow( + /Slack channel id is required/, + ); + const target = buildMessagingTarget("user", "U123", "U123"); + expect(() => requireTargetKind({ platform: "Slack", target, kind: "channel" })).toThrow( + /Slack channel id is required/, + ); + }); +}); diff --git a/src/channels/targets.ts b/src/channels/targets.ts new file mode 100644 index 000000000..77ab755b7 --- /dev/null +++ b/src/channels/targets.ts @@ -0,0 +1,56 @@ +export type MessagingTargetKind = "user" | "channel"; + +export type MessagingTarget = { + kind: MessagingTargetKind; + id: string; + raw: string; + normalized: string; +}; + +export type MessagingTargetParseOptions = { + defaultKind?: MessagingTargetKind; + ambiguousMessage?: string; +}; + +export function normalizeTargetId(kind: MessagingTargetKind, id: string): string { + return `${kind}:${id}`.toLowerCase(); +} + +export function buildMessagingTarget( + kind: MessagingTargetKind, + id: string, + raw: string, +): MessagingTarget { + return { + kind, + id, + raw, + normalized: normalizeTargetId(kind, id), + }; +} + +export function ensureTargetId(params: { + candidate: string; + pattern: RegExp; + errorMessage: string; +}): string { + if (!params.pattern.test(params.candidate)) { + throw new Error(params.errorMessage); + } + return params.candidate; +} + +export function requireTargetKind(params: { + platform: string; + target: MessagingTarget | undefined; + kind: MessagingTargetKind; +}): string { + const kindLabel = params.kind; + if (!params.target) { + throw new Error(`${params.platform} ${kindLabel} id is required.`); + } + if (params.target.kind !== params.kind) { + throw new Error(`${params.platform} ${kindLabel} id is required (use ${kindLabel}:).`); + } + return params.target.id; +} diff --git a/src/discord/targets.test.ts b/src/discord/targets.test.ts index 6e83984fd..3eee1eb1e 100644 --- a/src/discord/targets.test.ts +++ b/src/discord/targets.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { normalizeDiscordMessagingTarget } from "../channels/plugins/normalize-target.js"; +import { normalizeDiscordMessagingTarget } from "../channels/plugins/normalize/discord.js"; import { parseDiscordTarget, resolveDiscordChannelId } from "./targets.js"; describe("parseDiscordTarget", () => { diff --git a/src/discord/targets.ts b/src/discord/targets.ts index 6c5145576..3a3c93ec8 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -1,29 +1,17 @@ -export type DiscordTargetKind = "user" | "channel"; +import { + buildMessagingTarget, + ensureTargetId, + requireTargetKind, + type MessagingTarget, + type MessagingTargetKind, + type MessagingTargetParseOptions, +} from "../channels/targets.js"; -export type DiscordTarget = { - kind: DiscordTargetKind; - id: string; - raw: string; - normalized: string; -}; +export type DiscordTargetKind = MessagingTargetKind; -type DiscordTargetParseOptions = { - defaultKind?: DiscordTargetKind; - ambiguousMessage?: string; -}; +export type DiscordTarget = MessagingTarget; -function normalizeTargetId(kind: DiscordTargetKind, id: string) { - return `${kind}:${id}`.toLowerCase(); -} - -function buildTarget(kind: DiscordTargetKind, id: string, raw: string): DiscordTarget { - return { - kind, - id, - raw, - normalized: normalizeTargetId(kind, id), - }; -} +type DiscordTargetParseOptions = MessagingTargetParseOptions; export function parseDiscordTarget( raw: string, @@ -33,46 +21,42 @@ export function parseDiscordTarget( if (!trimmed) return undefined; const mentionMatch = trimmed.match(/^<@!?(\d+)>$/); if (mentionMatch) { - return buildTarget("user", mentionMatch[1], trimmed); + return buildMessagingTarget("user", mentionMatch[1], trimmed); } if (trimmed.startsWith("user:")) { const id = trimmed.slice("user:".length).trim(); - return id ? buildTarget("user", id, trimmed) : undefined; + return id ? buildMessagingTarget("user", id, trimmed) : undefined; } if (trimmed.startsWith("channel:")) { const id = trimmed.slice("channel:".length).trim(); - return id ? buildTarget("channel", id, trimmed) : undefined; + return id ? buildMessagingTarget("channel", id, trimmed) : undefined; } if (trimmed.startsWith("discord:")) { const id = trimmed.slice("discord:".length).trim(); - return id ? buildTarget("user", id, trimmed) : undefined; + return id ? buildMessagingTarget("user", id, trimmed) : undefined; } if (trimmed.startsWith("@")) { const candidate = trimmed.slice(1).trim(); - if (!/^\d+$/.test(candidate)) { - throw new Error("Discord DMs require a user id (use user: or a <@id> mention)"); - } - return buildTarget("user", candidate, trimmed); + const id = ensureTargetId({ + candidate, + pattern: /^\d+$/, + errorMessage: "Discord DMs require a user id (use user: or a <@id> mention)", + }); + return buildMessagingTarget("user", id, trimmed); } if (/^\d+$/.test(trimmed)) { if (options.defaultKind) { - return buildTarget(options.defaultKind, trimmed, trimmed); + return buildMessagingTarget(options.defaultKind, trimmed, trimmed); } throw new Error( options.ambiguousMessage ?? `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`, ); } - return buildTarget("channel", trimmed, trimmed); + return buildMessagingTarget("channel", trimmed, trimmed); } export function resolveDiscordChannelId(raw: string): string { const target = parseDiscordTarget(raw, { defaultKind: "channel" }); - if (!target) { - throw new Error("Discord channel id is required."); - } - if (target.kind !== "channel") { - throw new Error("Discord channel id is required (use channel:)."); - } - return target.id; + return requireTargetKind({ platform: "Discord", target, kind: "channel" }); } diff --git a/src/slack/targets.test.ts b/src/slack/targets.test.ts index c25d2ab1f..5b5cfe849 100644 --- a/src/slack/targets.test.ts +++ b/src/slack/targets.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { normalizeSlackMessagingTarget } from "../channels/plugins/normalize-target.js"; +import { normalizeSlackMessagingTarget } from "../channels/plugins/normalize/slack.js"; import { parseSlackTarget, resolveSlackChannelId } from "./targets.js"; describe("parseSlackTarget", () => { diff --git a/src/slack/targets.ts b/src/slack/targets.ts index 9ec8939d3..5701a16e2 100644 --- a/src/slack/targets.ts +++ b/src/slack/targets.ts @@ -1,28 +1,17 @@ -export type SlackTargetKind = "user" | "channel"; +import { + buildMessagingTarget, + ensureTargetId, + requireTargetKind, + type MessagingTarget, + type MessagingTargetKind, + type MessagingTargetParseOptions, +} from "../channels/targets.js"; -export type SlackTarget = { - kind: SlackTargetKind; - id: string; - raw: string; - normalized: string; -}; +export type SlackTargetKind = MessagingTargetKind; -type SlackTargetParseOptions = { - defaultKind?: SlackTargetKind; -}; +export type SlackTarget = MessagingTarget; -function normalizeTargetId(kind: SlackTargetKind, id: string) { - return `${kind}:${id}`.toLowerCase(); -} - -function buildTarget(kind: SlackTargetKind, id: string, raw: string): SlackTarget { - return { - kind, - id, - raw, - normalized: normalizeTargetId(kind, id), - }; -} +type SlackTargetParseOptions = MessagingTargetParseOptions; export function parseSlackTarget( raw: string, @@ -32,47 +21,45 @@ export function parseSlackTarget( if (!trimmed) return undefined; const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i); if (mentionMatch) { - return buildTarget("user", mentionMatch[1], trimmed); + return buildMessagingTarget("user", mentionMatch[1], trimmed); } if (trimmed.startsWith("user:")) { const id = trimmed.slice("user:".length).trim(); - return id ? buildTarget("user", id, trimmed) : undefined; + return id ? buildMessagingTarget("user", id, trimmed) : undefined; } if (trimmed.startsWith("channel:")) { const id = trimmed.slice("channel:".length).trim(); - return id ? buildTarget("channel", id, trimmed) : undefined; + return id ? buildMessagingTarget("channel", id, trimmed) : undefined; } if (trimmed.startsWith("slack:")) { const id = trimmed.slice("slack:".length).trim(); - return id ? buildTarget("user", id, trimmed) : undefined; + return id ? buildMessagingTarget("user", id, trimmed) : undefined; } if (trimmed.startsWith("@")) { const candidate = trimmed.slice(1).trim(); - if (!/^[A-Z0-9]+$/i.test(candidate)) { - throw new Error("Slack DMs require a user id (use user: or <@id>)"); - } - return buildTarget("user", candidate, trimmed); + const id = ensureTargetId({ + candidate, + pattern: /^[A-Z0-9]+$/i, + errorMessage: "Slack DMs require a user id (use user: or <@id>)", + }); + return buildMessagingTarget("user", id, trimmed); } if (trimmed.startsWith("#")) { const candidate = trimmed.slice(1).trim(); - if (!/^[A-Z0-9]+$/i.test(candidate)) { - throw new Error("Slack channels require a channel id (use channel:)"); - } - return buildTarget("channel", candidate, trimmed); + const id = ensureTargetId({ + candidate, + pattern: /^[A-Z0-9]+$/i, + errorMessage: "Slack channels require a channel id (use channel:)", + }); + return buildMessagingTarget("channel", id, trimmed); } if (options.defaultKind) { - return buildTarget(options.defaultKind, trimmed, trimmed); + return buildMessagingTarget(options.defaultKind, trimmed, trimmed); } - return buildTarget("channel", trimmed, trimmed); + return buildMessagingTarget("channel", trimmed, trimmed); } export function resolveSlackChannelId(raw: string): string { const target = parseSlackTarget(raw, { defaultKind: "channel" }); - if (!target) { - throw new Error("Slack channel id is required."); - } - if (target.kind !== "channel") { - throw new Error("Slack channel id is required (use channel:)."); - } - return target.id; + return requireTargetKind({ platform: "Slack", target, kind: "channel" }); }