From 331141ad77e3495a9960aa35572129d65de3d829 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 06:03:19 +0000 Subject: [PATCH] refactor: centralize message target resolution Co-authored-by: Thinh Dinh --- CHANGELOG.md | 1 + extensions/matrix/src/channel.ts | 7 ++ extensions/matrix/src/outbound.ts | 11 --- extensions/msteams/src/channel.ts | 7 ++ extensions/msteams/src/outbound.ts | 11 --- extensions/zalo/src/channel.ts | 17 ++--- extensions/zalouser/src/channel.ts | 17 ++--- src/channels/plugins/discord.ts | 18 ++--- src/channels/plugins/imessage.ts | 21 +++-- src/channels/plugins/normalize-target.ts | 42 ++++++++++ src/channels/plugins/outbound/discord.ts | 11 --- src/channels/plugins/outbound/imessage.ts | 11 --- src/channels/plugins/outbound/signal.ts | 11 --- src/channels/plugins/outbound/slack.ts | 11 --- src/channels/plugins/outbound/telegram.ts | 11 --- src/channels/plugins/signal.ts | 18 ++--- src/channels/plugins/slack.ts | 15 +--- src/channels/plugins/telegram.ts | 18 ++--- src/channels/plugins/types.core.ts | 7 ++ src/channels/plugins/whatsapp.ts | 7 +- src/commands/message-format.ts | 6 +- src/commands/message.test.ts | 2 +- src/infra/outbound/outbound-policy.ts | 9 ++- src/infra/outbound/target-resolver.ts | 93 +++++++++++++++-------- src/infra/outbound/targets.ts | 4 +- 25 files changed, 192 insertions(+), 194 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37931f6e5..8f99c56b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow. - **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan. - **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`. +- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups. ### Changes - Tools: improve `web_fetch` extraction using Readability (with fallback). diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index e8baf8aa9..e0752cf70 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -164,6 +164,13 @@ export const matrixPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeMatrixMessagingTarget, + looksLikeTargetId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^(matrix:)?[!#@]/i.test(trimmed)) return true; + return trimmed.includes(":"); + }, + targetHint: "", }, directory: { self: async () => null, diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 6aabde56b..954685eec 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -1,22 +1,11 @@ import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js"; import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; -import { missingTargetError } from "../../../src/infra/outbound/target-errors.js"; export const matrixOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkMarkdownText, textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("Matrix", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ to, text, deps, replyToId, threadId }) => { const send = deps?.sendMatrix ?? sendMessageMatrix; const resolvedThreadId = diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index bf315cdc8..a41dcff7a 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -133,6 +133,13 @@ export const msteamsPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeMSTeamsMessagingTarget, + looksLikeTargetId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^(conversation:|user:)/i.test(trimmed)) return true; + return trimmed.includes("@thread"); + }, + targetHint: "", }, directory: { self: async () => null, diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 8d4ccef41..b9c7ba9fb 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -3,23 +3,12 @@ import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types import { createMSTeamsPollStoreFs } from "./polls.js"; import { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; -import { missingTargetError } from "../../../src/infra/outbound/target-errors.js"; export const msteamsOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkMarkdownText, textChunkLimit: 4000, pollMaxOptions: 12, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("MS Teams", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ cfg, to, text, deps }) => { const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text })); const result = await send(to, text); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 070b9d38c..0104437b7 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -21,7 +21,6 @@ import { import { collectZaloStatusIssues } from "./status-issues.js"; import type { CoreConfig } from "./types.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js"; -import { missingTargetError } from "../../../src/infra/outbound/target-errors.js"; const meta = { id: "zalo", @@ -151,6 +150,12 @@ export const zaloPlugin: ChannelPlugin = { actions: zaloMessageActions, messaging: { normalizeTarget: normalizeZaloMessagingTarget, + looksLikeTargetId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) return false; + return /^\d{3,}$/.test(trimmed); + }, + targetHint: "", }, directory: { self: async () => null, @@ -280,16 +285,6 @@ export const zaloPlugin: ChannelPlugin = { return chunks; }, textChunkLimit: 2000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("Zalo", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ to, text, accountId, cfg }) => { const result = await sendMessageZalo(to, text, { accountId: accountId ?? undefined, diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index d3178219a..ac581d1f2 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -16,7 +16,6 @@ import { import { zalouserOnboardingAdapter } from "./onboarding.js"; import { sendMessageZalouser } from "./send.js"; import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js"; -import { missingTargetError } from "../../../src/infra/outbound/target-errors.js"; import { DEFAULT_ACCOUNT_ID, type CoreConfig, @@ -219,6 +218,12 @@ export const zalouserPlugin: ChannelPlugin = { if (!trimmed) return undefined; return trimmed.replace(/^(zalouser|zlu):/i, ""); }, + looksLikeTargetId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) return false; + return /^\d{3,}$/.test(trimmed); + }, + targetHint: "", }, directory: { self: async ({ cfg, accountId, runtime }) => { @@ -374,16 +379,6 @@ export const zalouserPlugin: ChannelPlugin = { return chunks; }, textChunkLimit: 2000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("Zalouser", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ to, text, accountId, cfg }) => { const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }); const result = await sendMessageZalouser(to, text, { profile: account.profile }); diff --git a/src/channels/plugins/discord.ts b/src/channels/plugins/discord.ts index 97a90561b..91eb7ea9a 100644 --- a/src/channels/plugins/discord.ts +++ b/src/channels/plugins/discord.ts @@ -27,7 +27,10 @@ import { } from "./config-helpers.js"; import { resolveDiscordGroupRequireMention } from "./group-mentions.js"; import { formatPairingApproveHint } from "./helpers.js"; -import { normalizeDiscordMessagingTarget } from "./normalize-target.js"; +import { + looksLikeDiscordTargetId, + normalizeDiscordMessagingTarget, +} from "./normalize-target.js"; import { discordOnboardingAdapter } from "./onboarding/discord.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { @@ -36,7 +39,6 @@ import { } from "./setup-helpers.js"; import { collectDiscordStatusIssues } from "./status-issues/discord.js"; import type { ChannelPlugin } from "./types.js"; -import { missingTargetError } from "../../infra/outbound/target-errors.js"; const meta = getChatChannelMeta("discord"); @@ -178,6 +180,8 @@ export const discordPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeDiscordMessagingTarget, + looksLikeTargetId: looksLikeDiscordTargetId, + targetHint: "", }, directory: { self: async () => null, @@ -345,16 +349,6 @@ export const discordPlugin: ChannelPlugin = { chunker: null, textChunkLimit: 2000, pollMaxOptions: 10, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("Discord", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ to, text, accountId, deps, replyToId }) => { const send = deps?.sendDiscord ?? sendMessageDiscord; const result = await send(to, text, { diff --git a/src/channels/plugins/imessage.ts b/src/channels/plugins/imessage.ts index 47420d0b1..a8ed5e80c 100644 --- a/src/channels/plugins/imessage.ts +++ b/src/channels/plugins/imessage.ts @@ -25,7 +25,6 @@ import { migrateBaseNameToDefaultAccount, } from "./setup-helpers.js"; import type { ChannelPlugin } from "./types.js"; -import { missingTargetError } from "../../infra/outbound/target-errors.js"; const meta = getChatChannelMeta("imessage"); @@ -107,6 +106,16 @@ export const imessagePlugin: ChannelPlugin = { groups: { resolveRequireMention: resolveIMessageGroupRequireMention, }, + messaging: { + looksLikeTargetId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^(imessage:|chat_id:)/i.test(trimmed)) return true; + if (trimmed.includes("@")) return true; + return /^\+?\d{3,}$/.test(trimmed); + }, + targetHint: "", + }, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => @@ -173,16 +182,6 @@ export const imessagePlugin: ChannelPlugin = { deliveryMode: "direct", chunker: chunkText, textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("iMessage", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ cfg, to, text, accountId, deps }) => { const send = deps?.sendIMessage ?? sendMessageIMessage; const maxBytes = resolveChannelMediaMaxBytes({ diff --git a/src/channels/plugins/normalize-target.ts b/src/channels/plugins/normalize-target.ts index 814deda1f..9d93cbd8a 100644 --- a/src/channels/plugins/normalize-target.ts +++ b/src/channels/plugins/normalize-target.ts @@ -32,6 +32,16 @@ export function normalizeSlackMessagingTarget(raw: string): string | undefined { return `channel:${trimmed}`.toLowerCase(); } +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|group):/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 { const trimmed = raw.trim(); if (!trimmed) return undefined; @@ -60,6 +70,15 @@ export function normalizeDiscordMessagingTarget(raw: string): string | undefined return `channel:${trimmed}`.toLowerCase(); } +export function looksLikeDiscordTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^<@!?\d+>$/.test(trimmed)) return true; + if (/^(user|channel|group|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; @@ -80,6 +99,14 @@ export function normalizeTelegramMessagingTarget(raw: string): string | undefine return `telegram:${normalized}`.toLowerCase(); } +export function looksLikeTelegramTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return false; + if (/^(telegram|tg|group):/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; @@ -104,8 +131,23 @@ export function normalizeSignalMessagingTarget(raw: string): string | 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/outbound/discord.ts b/src/channels/plugins/outbound/discord.ts index 2209576f5..b3dca39e8 100644 --- a/src/channels/plugins/outbound/discord.ts +++ b/src/channels/plugins/outbound/discord.ts @@ -1,22 +1,11 @@ import { sendMessageDiscord, sendPollDiscord } from "../../../discord/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; -import { missingTargetError } from "../../../infra/outbound/target-errors.js"; export const discordOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, textChunkLimit: 2000, pollMaxOptions: 10, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("Discord", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ to, text, accountId, deps, replyToId }) => { const send = deps?.sendDiscord ?? sendMessageDiscord; const result = await send(to, text, { diff --git a/src/channels/plugins/outbound/imessage.ts b/src/channels/plugins/outbound/imessage.ts index b3e8a9280..3e415d6bb 100644 --- a/src/channels/plugins/outbound/imessage.ts +++ b/src/channels/plugins/outbound/imessage.ts @@ -2,22 +2,11 @@ import { chunkText } from "../../../auto-reply/chunk.js"; import { sendMessageIMessage } from "../../../imessage/send.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js"; import type { ChannelOutboundAdapter } from "../types.js"; -import { missingTargetError } from "../../../infra/outbound/target-errors.js"; export const imessageOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkText, textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("iMessage", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ cfg, to, text, accountId, deps }) => { const send = deps?.sendIMessage ?? sendMessageIMessage; const maxBytes = resolveChannelMediaMaxBytes({ diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index c7515b332..0939e7b6b 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -2,22 +2,11 @@ import { chunkText } from "../../../auto-reply/chunk.js"; import { sendMessageSignal } from "../../../signal/send.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js"; import type { ChannelOutboundAdapter } from "../types.js"; -import { missingTargetError } from "../../../infra/outbound/target-errors.js"; export const signalOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: chunkText, textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("Signal", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ cfg, to, text, accountId, deps }) => { const send = deps?.sendSignal ?? sendMessageSignal; const maxBytes = resolveChannelMediaMaxBytes({ diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index 00de3a188..b359bfaca 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -1,21 +1,10 @@ import { sendMessageSlack } from "../../../slack/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; -import { missingTargetError } from "../../../infra/outbound/target-errors.js"; export const slackOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("Slack", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ to, text, accountId, deps, replyToId }) => { const send = deps?.sendSlack ?? sendMessageSlack; const result = await send(to, text, { diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index f6e773edd..8cf4d6946 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -1,7 +1,6 @@ import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js"; import { sendMessageTelegram } from "../../../telegram/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; -import { missingTargetError } from "../../../infra/outbound/target-errors.js"; function parseReplyToMessageId(replyToId?: string | null) { if (!replyToId) return undefined; @@ -23,16 +22,6 @@ export const telegramOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: markdownToTelegramHtmlChunks, textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("Telegram", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { const send = deps?.sendTelegram ?? sendMessageTelegram; const replyToMessageId = parseReplyToMessageId(replyToId); diff --git a/src/channels/plugins/signal.ts b/src/channels/plugins/signal.ts index 67791ee88..2e48684cc 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 { normalizeSignalMessagingTarget } from "./normalize-target.js"; +import { + looksLikeSignalTargetId, + normalizeSignalMessagingTarget, +} from "./normalize-target.js"; import { signalOnboardingAdapter } from "./onboarding/signal.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { @@ -26,7 +29,6 @@ import { migrateBaseNameToDefaultAccount, } from "./setup-helpers.js"; import type { ChannelPlugin } from "./types.js"; -import { missingTargetError } from "../../infra/outbound/target-errors.js"; const meta = getChatChannelMeta("signal"); @@ -116,6 +118,8 @@ export const signalPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeSignalMessagingTarget, + looksLikeTargetId: looksLikeSignalTargetId, + targetHint: "", }, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), @@ -197,16 +201,6 @@ export const signalPlugin: ChannelPlugin = { deliveryMode: "direct", chunker: chunkText, textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("Signal", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ cfg, to, text, accountId, deps }) => { const send = deps?.sendSignal ?? sendMessageSignal; const maxBytes = resolveChannelMediaMaxBytes({ diff --git a/src/channels/plugins/slack.ts b/src/channels/plugins/slack.ts index 90b37e385..5ebdf7d81 100644 --- a/src/channels/plugins/slack.ts +++ b/src/channels/plugins/slack.ts @@ -21,7 +21,7 @@ import { } from "./config-helpers.js"; import { resolveSlackGroupRequireMention } from "./group-mentions.js"; import { formatPairingApproveHint } from "./helpers.js"; -import { normalizeSlackMessagingTarget } from "./normalize-target.js"; +import { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./normalize-target.js"; import { slackOnboardingAdapter } from "./onboarding/slack.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { @@ -29,7 +29,6 @@ import { migrateBaseNameToDefaultAccount, } from "./setup-helpers.js"; import type { ChannelMessageActionName, ChannelPlugin } from "./types.js"; -import { missingTargetError } from "../../infra/outbound/target-errors.js"; const meta = getChatChannelMeta("slack"); @@ -205,6 +204,8 @@ export const slackPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeSlackMessagingTarget, + looksLikeTargetId: looksLikeSlackTargetId, + targetHint: "", }, directory: { self: async () => null, @@ -526,16 +527,6 @@ export const slackPlugin: ChannelPlugin = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("Slack", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => { const send = deps?.sendSlack ?? sendMessageSlack; const account = resolveSlackAccount({ cfg, accountId }); diff --git a/src/channels/plugins/telegram.ts b/src/channels/plugins/telegram.ts index 24dae1803..2fd417b6a 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 { normalizeTelegramMessagingTarget } from "./normalize-target.js"; +import { + looksLikeTelegramTargetId, + normalizeTelegramMessagingTarget, +} from "./normalize-target.js"; import { telegramOnboardingAdapter } from "./onboarding/telegram.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { @@ -35,7 +38,6 @@ import { } from "./setup-helpers.js"; import { collectTelegramStatusIssues } from "./status-issues/telegram.js"; import type { ChannelPlugin } from "./types.js"; -import { missingTargetError } from "../../infra/outbound/target-errors.js"; const meta = getChatChannelMeta("telegram"); @@ -158,6 +160,8 @@ export const telegramPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeTelegramMessagingTarget, + looksLikeTargetId: looksLikeTelegramTargetId, + targetHint: "", }, directory: { self: async () => null, @@ -281,16 +285,6 @@ export const telegramPlugin: ChannelPlugin = { deliveryMode: "direct", chunker: chunkMarkdownText, textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: missingTargetError("Telegram", ""), - }; - } - return { ok: true, to: trimmed }; - }, sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { const send = deps?.sendTelegram ?? sendMessageTelegram; const replyToMessageId = parseReplyToMessageId(replyToId); diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 6adee27b5..6e88f7b11 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -215,6 +215,13 @@ export type ChannelThreadingToolContext = { export type ChannelMessagingAdapter = { normalizeTarget?: (raw: string) => string | undefined; + looksLikeTargetId?: (raw: string, normalized?: string) => boolean; + formatTargetDisplay?: (params: { + target: string; + display?: string; + kind?: ChannelDirectoryEntryKind; + }) => string; + targetHint?: string; }; export type ChannelDirectoryEntryKind = "user" | "group" | "channel"; diff --git a/src/channels/plugins/whatsapp.ts b/src/channels/plugins/whatsapp.ts index 79b915f92..fa8777325 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 { normalizeWhatsAppMessagingTarget } from "./normalize-target.js"; +import { + looksLikeWhatsAppTargetId, + normalizeWhatsAppMessagingTarget, +} from "./normalize-target.js"; import { whatsappOnboardingAdapter } from "./onboarding/whatsapp.js"; import { applyAccountNameToChannelSection, @@ -219,6 +222,8 @@ export const whatsappPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeWhatsAppMessagingTarget, + looksLikeTargetId: looksLikeWhatsAppTargetId, + targetHint: "", }, directory: { self: async ({ cfg, accountId }) => { diff --git a/src/commands/message-format.ts b/src/commands/message-format.ts index 25dc069dc..769e3b13a 100644 --- a/src/commands/message-format.ts +++ b/src/commands/message-format.ts @@ -2,6 +2,7 @@ import { getChannelPlugin } from "../channels/plugins/index.js"; import type { ChannelId, ChannelMessageActionName } from "../channels/plugins/types.js"; import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; import { formatGatewaySummary, formatOutboundDeliverySummary } from "../infra/outbound/format.js"; +import { formatTargetDisplay } from "../infra/outbound/target-resolver.js"; import type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js"; import { renderTable } from "../terminal/table.js"; import { isRich, theme } from "../terminal/theme.js"; @@ -242,7 +243,10 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] { const results = result.payload.results ?? []; const rows = results.map((entry) => ({ Channel: resolveChannelLabel(entry.channel), - Target: shortenText(entry.to, 36), + Target: shortenText( + formatTargetDisplay({ channel: entry.channel, target: entry.to }), + 36, + ), Status: entry.ok ? "ok" : "error", Error: entry.ok ? "" : shortenText(entry.error ?? "unknown error", 48), })); diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index e18736653..cbba3bd19 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -88,7 +88,7 @@ describe("messageCommand", () => { const deps = makeDeps(); await messageCommand( { - target: "123", + target: "123456", message: "hi", }, deps, diff --git a/src/infra/outbound/outbound-policy.ts b/src/infra/outbound/outbound-policy.ts index f99e0826a..19961729f 100644 --- a/src/infra/outbound/outbound-policy.ts +++ b/src/infra/outbound/outbound-policy.ts @@ -6,7 +6,7 @@ import type { } from "../../channels/plugins/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { getChannelMessageAdapter } from "./channel-adapters.js"; -import { lookupDirectoryDisplay } from "./target-resolver.js"; +import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js"; export type CrossContextDecoration = { prefix: string; @@ -125,7 +125,12 @@ export async function buildCrossContextDecoration(params: { targetId: params.toolContext.currentChannelId, accountId: params.accountId ?? undefined, })) ?? params.toolContext.currentChannelId; - const originLabel = currentName.startsWith("#") ? currentName : `#${currentName}`; + const originLabel = formatTargetDisplay({ + channel: params.channel, + target: params.toolContext.currentChannelId, + display: currentName, + kind: "group", + }); const prefixTemplate = markerConfig?.prefix ?? "[from {channel}] "; const suffixTemplate = markerConfig?.suffix ?? ""; const prefix = prefixTemplate.replaceAll("{channel}", originLabel); diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 9e861d224..760e64791 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -62,6 +62,52 @@ function stripTargetPrefixes(value: string): string { .trim(); } +export function formatTargetDisplay(params: { + channel: ChannelId; + target: string; + display?: string; + kind?: ChannelDirectoryEntryKind; +}): string { + const plugin = getChannelPlugin(params.channel); + if (plugin?.messaging?.formatTargetDisplay) { + return plugin.messaging.formatTargetDisplay({ + target: params.target, + display: params.display, + kind: params.kind, + }); + } + + const trimmedTarget = params.target.trim(); + const lowered = trimmedTarget.toLowerCase(); + const display = params.display?.trim(); + const kind = + params.kind ?? + (lowered.startsWith("user:") + ? "user" + : lowered.startsWith("channel:") || lowered.startsWith("group:") + ? "group" + : undefined); + + if (display) { + if (display.startsWith("#") || display.startsWith("@")) return display; + if (kind === "user") return `@${display}`; + if (kind === "group" || kind === "channel") return `#${display}`; + return display; + } + + if (!trimmedTarget) return trimmedTarget; + if (trimmedTarget.startsWith("#") || trimmedTarget.startsWith("@")) return trimmedTarget; + + const withoutPrefix = trimmedTarget.replace(/^telegram:/i, ""); + if (/^(channel|group):/i.test(withoutPrefix)) { + return `#${withoutPrefix.replace(/^(channel|group):/i, "")}`; + } + if (/^user:/i.test(withoutPrefix)) { + return `@${withoutPrefix.replace(/^user:/i, "")}`; + } + return withoutPrefix; +} + function preserveTargetCase(channel: ChannelId, raw: string, normalized: string): string { if (channel !== "slack") return normalized; const trimmed = raw.trim(); @@ -114,35 +160,22 @@ function resolveMatch(params: { return { kind: "ambiguous" as const, entries: matches }; } -function looksLikeId(channel: ChannelId, normalized: string): boolean { - if (!normalized) return false; - const raw = normalized.trim(); - switch (channel) { - case "discord": { - const candidate = stripTargetPrefixes(raw); - return /^\d{6,}$/.test(candidate); - } - case "slack": { - const candidate = stripTargetPrefixes(raw); - return /^[A-Z0-9]{8,}$/i.test(candidate); - } - case "msteams": { - return /^conversation:/i.test(raw) || /^user:/i.test(raw) || raw.includes("@thread"); - } - case "telegram": { - return /^telegram:/i.test(raw) || raw.startsWith("@"); - } - case "whatsapp": { - const candidate = stripTargetPrefixes(raw); - return ( - /@/i.test(candidate) || - /^\+?\d{3,}$/.test(candidate) || - candidate.toLowerCase().endsWith("@g.us") - ); - } - default: - return Boolean(raw); - } +function looksLikeTargetId(params: { + channel: ChannelId; + raw: string; + normalized: string; +}): boolean { + const raw = params.raw.trim(); + if (!raw) return false; + const plugin = getChannelPlugin(params.channel); + const lookup = plugin?.messaging?.looksLikeTargetId; + if (lookup) return lookup(raw, params.normalized); + if (/^(channel|group|user):/i.test(raw)) return true; + if (/^[@#]/.test(raw)) return true; + if (/^\+?\d{6,}$/.test(raw)) return true; + if (raw.includes("@thread")) return true; + if (/^(conversation|user):/i.test(raw)) return true; + return false; } async function listDirectoryEntries(params: { @@ -245,7 +278,7 @@ export async function resolveMessagingTarget(params: { } const kind = detectTargetKind(raw, params.preferredKind); const normalized = normalizeTargetForProvider(params.channel, raw) ?? raw; - if (looksLikeId(params.channel, normalized)) { + if (looksLikeTargetId({ channel: params.channel, raw, normalized })) { const directTarget = preserveTargetCase(params.channel, raw, normalized); return { ok: true, diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 8302f4c99..93ea835ac 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -13,6 +13,7 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { missingTargetError } from "./target-errors.js"; export type OutboundChannel = DeliverableMessageChannel | "none"; @@ -145,9 +146,10 @@ export function resolveOutboundTarget(params: { if (trimmed) { return { ok: true, to: trimmed }; } + const hint = plugin.messaging?.targetHint; return { ok: false, - error: new Error(`Delivering to ${plugin.meta.label} requires a destination`), + error: missingTargetError(plugin.meta.label ?? params.channel, hint), }; }