refactor(channels): unify target parsing

This commit is contained in:
Peter Steinberger
2026-01-18 00:31:37 +00:00
parent d593a809f0
commit 79a44d0da4
18 changed files with 274 additions and 195 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

56
src/channels/targets.ts Normal file
View File

@@ -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}:<id>).`);
}
return params.target.id;
}

View File

@@ -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", () => {

View File

@@ -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:<id> or a <@id> mention)");
}
return buildTarget("user", candidate, trimmed);
const id = ensureTargetId({
candidate,
pattern: /^\d+$/,
errorMessage: "Discord DMs require a user id (use user:<id> 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:<id>).");
}
return target.id;
return requireTargetKind({ platform: "Discord", target, kind: "channel" });
}

View File

@@ -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", () => {

View File

@@ -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:<id> 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:<id> 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:<id>)");
}
return buildTarget("channel", candidate, trimmed);
const id = ensureTargetId({
candidate,
pattern: /^[A-Z0-9]+$/i,
errorMessage: "Slack channels require a channel id (use channel:<id>)",
});
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:<id>).");
}
return target.id;
return requireTargetKind({ platform: "Slack", target, kind: "channel" });
}