refactor: centralize message target resolution

Co-authored-by: Thinh Dinh <tobalsan@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-17 06:03:19 +00:00
parent c7ae5100fa
commit 331141ad77
25 changed files with 192 additions and 194 deletions

View File

@@ -13,6 +13,7 @@
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow. - **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:** `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:** 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 ### Changes
- Tools: improve `web_fetch` extraction using Readability (with fallback). - Tools: improve `web_fetch` extraction using Readability (with fallback).

View File

@@ -164,6 +164,13 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
}, },
messaging: { messaging: {
normalizeTarget: normalizeMatrixMessagingTarget, normalizeTarget: normalizeMatrixMessagingTarget,
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
return trimmed.includes(":");
},
targetHint: "<room|alias|user>",
}, },
directory: { directory: {
self: async () => null, self: async () => null,

View File

@@ -1,22 +1,11 @@
import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js"; import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
import { missingTargetError } from "../../../src/infra/outbound/target-errors.js";
export const matrixOutbound: ChannelOutboundAdapter = { export const matrixOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkMarkdownText, chunker: chunkMarkdownText,
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("Matrix", "<room|alias|user>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, deps, replyToId, threadId }) => { sendText: async ({ to, text, deps, replyToId, threadId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix; const send = deps?.sendMatrix ?? sendMessageMatrix;
const resolvedThreadId = const resolvedThreadId =

View File

@@ -133,6 +133,13 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
}, },
messaging: { messaging: {
normalizeTarget: normalizeMSTeamsMessagingTarget, 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: "<conversationId|user:ID|conversation:ID>",
}, },
directory: { directory: {
self: async () => null, self: async () => null,

View File

@@ -3,23 +3,12 @@ import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types
import { createMSTeamsPollStoreFs } from "./polls.js"; import { createMSTeamsPollStoreFs } from "./polls.js";
import { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
import { missingTargetError } from "../../../src/infra/outbound/target-errors.js";
export const msteamsOutbound: ChannelOutboundAdapter = { export const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkMarkdownText, chunker: chunkMarkdownText,
textChunkLimit: 4000, textChunkLimit: 4000,
pollMaxOptions: 12, pollMaxOptions: 12,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("MS Teams", "<conversationId|user:ID|conversation:ID>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, deps }) => { sendText: async ({ cfg, to, text, deps }) => {
const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text })); const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text); const result = await send(to, text);

View File

@@ -21,7 +21,6 @@ import {
import { collectZaloStatusIssues } from "./status-issues.js"; import { collectZaloStatusIssues } from "./status-issues.js";
import type { CoreConfig } from "./types.js"; import type { CoreConfig } from "./types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
import { missingTargetError } from "../../../src/infra/outbound/target-errors.js";
const meta = { const meta = {
id: "zalo", id: "zalo",
@@ -151,6 +150,12 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
actions: zaloMessageActions, actions: zaloMessageActions,
messaging: { messaging: {
normalizeTarget: normalizeZaloMessagingTarget, normalizeTarget: normalizeZaloMessagingTarget,
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
return /^\d{3,}$/.test(trimmed);
},
targetHint: "<chatId>",
}, },
directory: { directory: {
self: async () => null, self: async () => null,
@@ -280,16 +285,6 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
return chunks; return chunks;
}, },
textChunkLimit: 2000, textChunkLimit: 2000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("Zalo", "<chatId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, cfg }) => { sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, { const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined, accountId: accountId ?? undefined,

View File

@@ -16,7 +16,6 @@ import {
import { zalouserOnboardingAdapter } from "./onboarding.js"; import { zalouserOnboardingAdapter } from "./onboarding.js";
import { sendMessageZalouser } from "./send.js"; import { sendMessageZalouser } from "./send.js";
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js"; import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
import { missingTargetError } from "../../../src/infra/outbound/target-errors.js";
import { import {
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
type CoreConfig, type CoreConfig,
@@ -219,6 +218,12 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
if (!trimmed) return undefined; if (!trimmed) return undefined;
return trimmed.replace(/^(zalouser|zlu):/i, ""); return trimmed.replace(/^(zalouser|zlu):/i, "");
}, },
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
return /^\d{3,}$/.test(trimmed);
},
targetHint: "<threadId>",
}, },
directory: { directory: {
self: async ({ cfg, accountId, runtime }) => { self: async ({ cfg, accountId, runtime }) => {
@@ -374,16 +379,6 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
return chunks; return chunks;
}, },
textChunkLimit: 2000, textChunkLimit: 2000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("Zalouser", "<threadId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, cfg }) => { sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }); const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await sendMessageZalouser(to, text, { profile: account.profile }); const result = await sendMessageZalouser(to, text, { profile: account.profile });

View File

@@ -27,7 +27,10 @@ import {
} from "./config-helpers.js"; } from "./config-helpers.js";
import { resolveDiscordGroupRequireMention } from "./group-mentions.js"; import { resolveDiscordGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.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 { discordOnboardingAdapter } from "./onboarding/discord.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import { import {
@@ -36,7 +39,6 @@ import {
} from "./setup-helpers.js"; } from "./setup-helpers.js";
import { collectDiscordStatusIssues } from "./status-issues/discord.js"; import { collectDiscordStatusIssues } from "./status-issues/discord.js";
import type { ChannelPlugin } from "./types.js"; import type { ChannelPlugin } from "./types.js";
import { missingTargetError } from "../../infra/outbound/target-errors.js";
const meta = getChatChannelMeta("discord"); const meta = getChatChannelMeta("discord");
@@ -178,6 +180,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
}, },
messaging: { messaging: {
normalizeTarget: normalizeDiscordMessagingTarget, normalizeTarget: normalizeDiscordMessagingTarget,
looksLikeTargetId: looksLikeDiscordTargetId,
targetHint: "<channelId|user:ID|channel:ID>",
}, },
directory: { directory: {
self: async () => null, self: async () => null,
@@ -345,16 +349,6 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
chunker: null, chunker: null,
textChunkLimit: 2000, textChunkLimit: 2000,
pollMaxOptions: 10, pollMaxOptions: 10,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("Discord", "<channelId|user:ID|channel:ID>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => { sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord; const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, { const result = await send(to, text, {

View File

@@ -25,7 +25,6 @@ import {
migrateBaseNameToDefaultAccount, migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js"; } from "./setup-helpers.js";
import type { ChannelPlugin } from "./types.js"; import type { ChannelPlugin } from "./types.js";
import { missingTargetError } from "../../infra/outbound/target-errors.js";
const meta = getChatChannelMeta("imessage"); const meta = getChatChannelMeta("imessage");
@@ -107,6 +106,16 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
groups: { groups: {
resolveRequireMention: resolveIMessageGroupRequireMention, 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: "<handle|chat_id:ID>",
},
setup: { setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) => applyAccountName: ({ cfg, accountId, name }) =>
@@ -173,16 +182,6 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkText, chunker: chunkText,
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("iMessage", "<handle|chat_id:ID>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, deps }) => { sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage; const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({

View File

@@ -32,6 +32,16 @@ export function normalizeSlackMessagingTarget(raw: string): string | undefined {
return `channel:${trimmed}`.toLowerCase(); 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 { export function normalizeDiscordMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return undefined; if (!trimmed) return undefined;
@@ -60,6 +70,15 @@ export function normalizeDiscordMessagingTarget(raw: string): string | undefined
return `channel:${trimmed}`.toLowerCase(); 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 { export function normalizeTelegramMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return undefined; if (!trimmed) return undefined;
@@ -80,6 +99,14 @@ export function normalizeTelegramMessagingTarget(raw: string): string | undefine
return `telegram:${normalized}`.toLowerCase(); 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 { export function normalizeSignalMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return undefined; if (!trimmed) return undefined;
@@ -104,8 +131,23 @@ export function normalizeSignalMessagingTarget(raw: string): string | undefined
return normalized.toLowerCase(); 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 { export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return undefined; if (!trimmed) return undefined;
return normalizeWhatsAppTarget(trimmed) ?? 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

@@ -1,22 +1,11 @@
import { sendMessageDiscord, sendPollDiscord } from "../../../discord/send.js"; import { sendMessageDiscord, sendPollDiscord } from "../../../discord/send.js";
import type { ChannelOutboundAdapter } from "../types.js"; import type { ChannelOutboundAdapter } from "../types.js";
import { missingTargetError } from "../../../infra/outbound/target-errors.js";
export const discordOutbound: ChannelOutboundAdapter = { export const discordOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: null, chunker: null,
textChunkLimit: 2000, textChunkLimit: 2000,
pollMaxOptions: 10, pollMaxOptions: 10,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("Discord", "<channelId|user:ID|channel:ID>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => { sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord; const send = deps?.sendDiscord ?? sendMessageDiscord;
const result = await send(to, text, { const result = await send(to, text, {

View File

@@ -2,22 +2,11 @@ import { chunkText } from "../../../auto-reply/chunk.js";
import { sendMessageIMessage } from "../../../imessage/send.js"; import { sendMessageIMessage } from "../../../imessage/send.js";
import { resolveChannelMediaMaxBytes } from "../media-limits.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js";
import type { ChannelOutboundAdapter } from "../types.js"; import type { ChannelOutboundAdapter } from "../types.js";
import { missingTargetError } from "../../../infra/outbound/target-errors.js";
export const imessageOutbound: ChannelOutboundAdapter = { export const imessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkText, chunker: chunkText,
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("iMessage", "<handle|chat_id:ID>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, deps }) => { sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage; const send = deps?.sendIMessage ?? sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({

View File

@@ -2,22 +2,11 @@ import { chunkText } from "../../../auto-reply/chunk.js";
import { sendMessageSignal } from "../../../signal/send.js"; import { sendMessageSignal } from "../../../signal/send.js";
import { resolveChannelMediaMaxBytes } from "../media-limits.js"; import { resolveChannelMediaMaxBytes } from "../media-limits.js";
import type { ChannelOutboundAdapter } from "../types.js"; import type { ChannelOutboundAdapter } from "../types.js";
import { missingTargetError } from "../../../infra/outbound/target-errors.js";
export const signalOutbound: ChannelOutboundAdapter = { export const signalOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkText, chunker: chunkText,
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("Signal", "<E.164|group:ID|signal:group:ID|signal:+E.164>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, deps }) => { sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal; const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({

View File

@@ -1,21 +1,10 @@
import { sendMessageSlack } from "../../../slack/send.js"; import { sendMessageSlack } from "../../../slack/send.js";
import type { ChannelOutboundAdapter } from "../types.js"; import type { ChannelOutboundAdapter } from "../types.js";
import { missingTargetError } from "../../../infra/outbound/target-errors.js";
export const slackOutbound: ChannelOutboundAdapter = { export const slackOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: null, chunker: null,
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("Slack", "<channelId|user:ID|channel:ID>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => { sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send = deps?.sendSlack ?? sendMessageSlack; const send = deps?.sendSlack ?? sendMessageSlack;
const result = await send(to, text, { const result = await send(to, text, {

View File

@@ -1,7 +1,6 @@
import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js"; import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js";
import { sendMessageTelegram } from "../../../telegram/send.js"; import { sendMessageTelegram } from "../../../telegram/send.js";
import type { ChannelOutboundAdapter } from "../types.js"; import type { ChannelOutboundAdapter } from "../types.js";
import { missingTargetError } from "../../../infra/outbound/target-errors.js";
function parseReplyToMessageId(replyToId?: string | null) { function parseReplyToMessageId(replyToId?: string | null) {
if (!replyToId) return undefined; if (!replyToId) return undefined;
@@ -23,16 +22,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks, chunker: markdownToTelegramHtmlChunks,
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("Telegram", "<chatId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendTelegram ?? sendMessageTelegram; const send = deps?.sendTelegram ?? sendMessageTelegram;
const replyToMessageId = parseReplyToMessageId(replyToId); const replyToMessageId = parseReplyToMessageId(replyToId);

View File

@@ -18,7 +18,10 @@ import {
} from "./config-helpers.js"; } from "./config-helpers.js";
import { formatPairingApproveHint } from "./helpers.js"; import { formatPairingApproveHint } from "./helpers.js";
import { resolveChannelMediaMaxBytes } from "./media-limits.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 { signalOnboardingAdapter } from "./onboarding/signal.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import { import {
@@ -26,7 +29,6 @@ import {
migrateBaseNameToDefaultAccount, migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js"; } from "./setup-helpers.js";
import type { ChannelPlugin } from "./types.js"; import type { ChannelPlugin } from "./types.js";
import { missingTargetError } from "../../infra/outbound/target-errors.js";
const meta = getChatChannelMeta("signal"); const meta = getChatChannelMeta("signal");
@@ -116,6 +118,8 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
}, },
messaging: { messaging: {
normalizeTarget: normalizeSignalMessagingTarget, normalizeTarget: normalizeSignalMessagingTarget,
looksLikeTargetId: looksLikeSignalTargetId,
targetHint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
}, },
setup: { setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
@@ -197,16 +201,6 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkText, chunker: chunkText,
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("Signal", "<E.164|group:ID|signal:group:ID|signal:+E.164>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId, deps }) => { sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal; const send = deps?.sendSignal ?? sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({

View File

@@ -21,7 +21,7 @@ import {
} from "./config-helpers.js"; } from "./config-helpers.js";
import { resolveSlackGroupRequireMention } from "./group-mentions.js"; import { resolveSlackGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.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 { slackOnboardingAdapter } from "./onboarding/slack.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import { import {
@@ -29,7 +29,6 @@ import {
migrateBaseNameToDefaultAccount, migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js"; } from "./setup-helpers.js";
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js"; import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
import { missingTargetError } from "../../infra/outbound/target-errors.js";
const meta = getChatChannelMeta("slack"); const meta = getChatChannelMeta("slack");
@@ -205,6 +204,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
}, },
messaging: { messaging: {
normalizeTarget: normalizeSlackMessagingTarget, normalizeTarget: normalizeSlackMessagingTarget,
looksLikeTargetId: looksLikeSlackTargetId,
targetHint: "<channelId|user:ID|channel:ID>",
}, },
directory: { directory: {
self: async () => null, self: async () => null,
@@ -526,16 +527,6 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: null, chunker: null,
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("Slack", "<channelId|user:ID|channel:ID>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => { sendText: async ({ to, text, accountId, deps, replyToId, cfg }) => {
const send = deps?.sendSlack ?? sendMessageSlack; const send = deps?.sendSlack ?? sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId }); const account = resolveSlackAccount({ cfg, accountId });

View File

@@ -26,7 +26,10 @@ import {
} from "./config-helpers.js"; } from "./config-helpers.js";
import { resolveTelegramGroupRequireMention } from "./group-mentions.js"; import { resolveTelegramGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.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 { telegramOnboardingAdapter } from "./onboarding/telegram.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import { import {
@@ -35,7 +38,6 @@ import {
} from "./setup-helpers.js"; } from "./setup-helpers.js";
import { collectTelegramStatusIssues } from "./status-issues/telegram.js"; import { collectTelegramStatusIssues } from "./status-issues/telegram.js";
import type { ChannelPlugin } from "./types.js"; import type { ChannelPlugin } from "./types.js";
import { missingTargetError } from "../../infra/outbound/target-errors.js";
const meta = getChatChannelMeta("telegram"); const meta = getChatChannelMeta("telegram");
@@ -158,6 +160,8 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
}, },
messaging: { messaging: {
normalizeTarget: normalizeTelegramMessagingTarget, normalizeTarget: normalizeTelegramMessagingTarget,
looksLikeTargetId: looksLikeTelegramTargetId,
targetHint: "<chatId>",
}, },
directory: { directory: {
self: async () => null, self: async () => null,
@@ -281,16 +285,6 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkMarkdownText, chunker: chunkMarkdownText,
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: missingTargetError("Telegram", "<chatId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendTelegram ?? sendMessageTelegram; const send = deps?.sendTelegram ?? sendMessageTelegram;
const replyToMessageId = parseReplyToMessageId(replyToId); const replyToMessageId = parseReplyToMessageId(replyToId);

View File

@@ -215,6 +215,13 @@ export type ChannelThreadingToolContext = {
export type ChannelMessagingAdapter = { export type ChannelMessagingAdapter = {
normalizeTarget?: (raw: string) => string | undefined; 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"; export type ChannelDirectoryEntryKind = "user" | "group" | "channel";

View File

@@ -26,7 +26,10 @@ import { buildChannelConfigSchema } from "./config-schema.js";
import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js"; import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js";
import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js"; import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js";
import { formatPairingApproveHint } from "./helpers.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 { whatsappOnboardingAdapter } from "./onboarding/whatsapp.js";
import { import {
applyAccountNameToChannelSection, applyAccountNameToChannelSection,
@@ -219,6 +222,8 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
}, },
messaging: { messaging: {
normalizeTarget: normalizeWhatsAppMessagingTarget, normalizeTarget: normalizeWhatsAppMessagingTarget,
looksLikeTargetId: looksLikeWhatsAppTargetId,
targetHint: "<E.164|group JID>",
}, },
directory: { directory: {
self: async ({ cfg, accountId }) => { self: async ({ cfg, accountId }) => {

View File

@@ -2,6 +2,7 @@ import { getChannelPlugin } from "../channels/plugins/index.js";
import type { ChannelId, ChannelMessageActionName } from "../channels/plugins/types.js"; import type { ChannelId, ChannelMessageActionName } from "../channels/plugins/types.js";
import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
import { formatGatewaySummary, formatOutboundDeliverySummary } from "../infra/outbound/format.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 type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js";
import { renderTable } from "../terminal/table.js"; import { renderTable } from "../terminal/table.js";
import { isRich, theme } from "../terminal/theme.js"; import { isRich, theme } from "../terminal/theme.js";
@@ -242,7 +243,10 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
const results = result.payload.results ?? []; const results = result.payload.results ?? [];
const rows = results.map((entry) => ({ const rows = results.map((entry) => ({
Channel: resolveChannelLabel(entry.channel), Channel: resolveChannelLabel(entry.channel),
Target: shortenText(entry.to, 36), Target: shortenText(
formatTargetDisplay({ channel: entry.channel, target: entry.to }),
36,
),
Status: entry.ok ? "ok" : "error", Status: entry.ok ? "ok" : "error",
Error: entry.ok ? "" : shortenText(entry.error ?? "unknown error", 48), Error: entry.ok ? "" : shortenText(entry.error ?? "unknown error", 48),
})); }));

View File

@@ -88,7 +88,7 @@ describe("messageCommand", () => {
const deps = makeDeps(); const deps = makeDeps();
await messageCommand( await messageCommand(
{ {
target: "123", target: "123456",
message: "hi", message: "hi",
}, },
deps, deps,

View File

@@ -6,7 +6,7 @@ import type {
} from "../../channels/plugins/types.js"; } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { getChannelMessageAdapter } from "./channel-adapters.js"; import { getChannelMessageAdapter } from "./channel-adapters.js";
import { lookupDirectoryDisplay } from "./target-resolver.js"; import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js";
export type CrossContextDecoration = { export type CrossContextDecoration = {
prefix: string; prefix: string;
@@ -125,7 +125,12 @@ export async function buildCrossContextDecoration(params: {
targetId: params.toolContext.currentChannelId, targetId: params.toolContext.currentChannelId,
accountId: params.accountId ?? undefined, accountId: params.accountId ?? undefined,
})) ?? params.toolContext.currentChannelId; })) ?? 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 prefixTemplate = markerConfig?.prefix ?? "[from {channel}] ";
const suffixTemplate = markerConfig?.suffix ?? ""; const suffixTemplate = markerConfig?.suffix ?? "";
const prefix = prefixTemplate.replaceAll("{channel}", originLabel); const prefix = prefixTemplate.replaceAll("{channel}", originLabel);

View File

@@ -62,6 +62,52 @@ function stripTargetPrefixes(value: string): string {
.trim(); .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 { function preserveTargetCase(channel: ChannelId, raw: string, normalized: string): string {
if (channel !== "slack") return normalized; if (channel !== "slack") return normalized;
const trimmed = raw.trim(); const trimmed = raw.trim();
@@ -114,35 +160,22 @@ function resolveMatch(params: {
return { kind: "ambiguous" as const, entries: matches }; return { kind: "ambiguous" as const, entries: matches };
} }
function looksLikeId(channel: ChannelId, normalized: string): boolean { function looksLikeTargetId(params: {
if (!normalized) return false; channel: ChannelId;
const raw = normalized.trim(); raw: string;
switch (channel) { normalized: string;
case "discord": { }): boolean {
const candidate = stripTargetPrefixes(raw); const raw = params.raw.trim();
return /^\d{6,}$/.test(candidate); if (!raw) return false;
} const plugin = getChannelPlugin(params.channel);
case "slack": { const lookup = plugin?.messaging?.looksLikeTargetId;
const candidate = stripTargetPrefixes(raw); if (lookup) return lookup(raw, params.normalized);
return /^[A-Z0-9]{8,}$/i.test(candidate); if (/^(channel|group|user):/i.test(raw)) return true;
} if (/^[@#]/.test(raw)) return true;
case "msteams": { if (/^\+?\d{6,}$/.test(raw)) return true;
return /^conversation:/i.test(raw) || /^user:/i.test(raw) || raw.includes("@thread"); if (raw.includes("@thread")) return true;
} if (/^(conversation|user):/i.test(raw)) return true;
case "telegram": { return false;
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);
}
} }
async function listDirectoryEntries(params: { async function listDirectoryEntries(params: {
@@ -245,7 +278,7 @@ export async function resolveMessagingTarget(params: {
} }
const kind = detectTargetKind(raw, params.preferredKind); const kind = detectTargetKind(raw, params.preferredKind);
const normalized = normalizeTargetForProvider(params.channel, raw) ?? raw; 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); const directTarget = preserveTargetCase(params.channel, raw, normalized);
return { return {
ok: true, ok: true,

View File

@@ -13,6 +13,7 @@ import {
isDeliverableMessageChannel, isDeliverableMessageChannel,
normalizeMessageChannel, normalizeMessageChannel,
} from "../../utils/message-channel.js"; } from "../../utils/message-channel.js";
import { missingTargetError } from "./target-errors.js";
export type OutboundChannel = DeliverableMessageChannel | "none"; export type OutboundChannel = DeliverableMessageChannel | "none";
@@ -145,9 +146,10 @@ export function resolveOutboundTarget(params: {
if (trimmed) { if (trimmed) {
return { ok: true, to: trimmed }; return { ok: true, to: trimmed };
} }
const hint = plugin.messaging?.targetHint;
return { return {
ok: false, ok: false,
error: new Error(`Delivering to ${plugin.meta.label} requires a destination`), error: missingTargetError(plugin.meta.label ?? params.channel, hint),
}; };
} }