diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 54fbb09a2..6aabde56b 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -1,6 +1,7 @@ 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", @@ -11,7 +12,7 @@ export const matrixOutbound: ChannelOutboundAdapter = { if (!trimmed) { return { ok: false, - error: new Error("Delivering to Matrix requires target "), + error: missingTargetError("Matrix", ""), }; } return { ok: true, to: trimmed }; diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 9e46fafbb..8d4ccef41 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -3,6 +3,7 @@ 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", @@ -14,9 +15,7 @@ export const msteamsOutbound: ChannelOutboundAdapter = { if (!trimmed) { return { ok: false, - error: new Error( - "Delivering to MS Teams requires target ", - ), + error: missingTargetError("MS Teams", ""), }; } return { ok: true, to: trimmed }; diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 5742ec7e8..070b9d38c 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -21,6 +21,7 @@ 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", @@ -185,7 +186,7 @@ export const zaloPlugin: ChannelPlugin = { return "ZALO_BOT_TOKEN can only be used for the default account."; } if (!input.useEnv && !input.token && !input.tokenFile) { - return "Zalo requires targetken or --token-file (or --use-env)."; + return "Zalo requires token or --token-file (or --use-env)."; } return null; }, @@ -284,7 +285,7 @@ export const zaloPlugin: ChannelPlugin = { if (!trimmed) { return { ok: false, - error: new Error("Delivering to Zalo requires target "), + error: missingTargetError("Zalo", ""), }; } return { ok: true, to: trimmed }; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index fd4b92ac6..9c71b1481 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -17,6 +17,7 @@ import { zalouserOnboardingAdapter } from "./onboarding.js"; import { sendMessageZalouser } from "./send.js"; import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js"; import { +import { missingTargetError } from "../../../src/infra/outbound/target-errors.js"; DEFAULT_ACCOUNT_ID, type CoreConfig, type ZalouserConfig, @@ -378,7 +379,7 @@ export const zalouserPlugin: ChannelPlugin = { if (!trimmed) { return { ok: false, - error: new Error("Delivering to Zalouser requires target "), + error: missingTargetError("Zalouser", ""), }; } return { ok: true, to: trimmed }; diff --git a/src/channels/plugins/discord.ts b/src/channels/plugins/discord.ts index 5f726f292..0a65cc007 100644 --- a/src/channels/plugins/discord.ts +++ b/src/channels/plugins/discord.ts @@ -35,6 +35,7 @@ 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"); @@ -253,7 +254,7 @@ export const discordPlugin: ChannelPlugin = { return "DISCORD_BOT_TOKEN can only be used for the default account."; } if (!input.useEnv && !input.token) { - return "Discord requires targetken (or --use-env)."; + return "Discord requires token (or --use-env)."; } return null; }, @@ -314,7 +315,7 @@ export const discordPlugin: ChannelPlugin = { if (!trimmed) { return { ok: false, - error: new Error("Delivering to Discord requires target "), + error: missingTargetError("Discord", ""), }; } return { ok: true, to: trimmed }; diff --git a/src/channels/plugins/imessage.ts b/src/channels/plugins/imessage.ts index 0a52ffa36..47420d0b1 100644 --- a/src/channels/plugins/imessage.ts +++ b/src/channels/plugins/imessage.ts @@ -25,6 +25,7 @@ import { migrateBaseNameToDefaultAccount, } from "./setup-helpers.js"; import type { ChannelPlugin } from "./types.js"; +import { missingTargetError } from "../../infra/outbound/target-errors.js"; const meta = getChatChannelMeta("imessage"); @@ -177,7 +178,7 @@ export const imessagePlugin: ChannelPlugin = { if (!trimmed) { return { ok: false, - error: new Error("Delivering to iMessage requires target "), + error: missingTargetError("iMessage", ""), }; } return { ok: true, to: trimmed }; diff --git a/src/channels/plugins/outbound/discord.ts b/src/channels/plugins/outbound/discord.ts index 7f76b6c72..2209576f5 100644 --- a/src/channels/plugins/outbound/discord.ts +++ b/src/channels/plugins/outbound/discord.ts @@ -1,5 +1,6 @@ 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", @@ -11,7 +12,7 @@ export const discordOutbound: ChannelOutboundAdapter = { if (!trimmed) { return { ok: false, - error: new Error("Delivering to Discord requires target "), + error: missingTargetError("Discord", ""), }; } return { ok: true, to: trimmed }; diff --git a/src/channels/plugins/outbound/imessage.ts b/src/channels/plugins/outbound/imessage.ts index 89389b230..b3e8a9280 100644 --- a/src/channels/plugins/outbound/imessage.ts +++ b/src/channels/plugins/outbound/imessage.ts @@ -2,6 +2,7 @@ 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", @@ -12,7 +13,7 @@ export const imessageOutbound: ChannelOutboundAdapter = { if (!trimmed) { return { ok: false, - error: new Error("Delivering to iMessage requires target "), + error: missingTargetError("iMessage", ""), }; } return { ok: true, to: trimmed }; diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index 366b66231..c7515b332 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -2,6 +2,7 @@ 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", @@ -12,9 +13,7 @@ export const signalOutbound: ChannelOutboundAdapter = { if (!trimmed) { return { ok: false, - error: new Error( - "Delivering to Signal requires target ", - ), + error: missingTargetError("Signal", ""), }; } return { ok: true, to: trimmed }; diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index 63fc2e540..00de3a188 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -1,5 +1,6 @@ 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", @@ -10,7 +11,7 @@ export const slackOutbound: ChannelOutboundAdapter = { if (!trimmed) { return { ok: false, - error: new Error("Delivering to Slack requires target "), + error: missingTargetError("Slack", ""), }; } return { ok: true, to: trimmed }; diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index cb6f5529d..f6e773edd 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -1,6 +1,7 @@ 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; @@ -27,7 +28,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { if (!trimmed) { return { ok: false, - error: new Error("Delivering to Telegram requires target "), + error: missingTargetError("Telegram", ""), }; } return { ok: true, to: trimmed }; diff --git a/src/channels/plugins/outbound/whatsapp.ts b/src/channels/plugins/outbound/whatsapp.ts index 495c0bdaa..f9ff350fc 100644 --- a/src/channels/plugins/outbound/whatsapp.ts +++ b/src/channels/plugins/outbound/whatsapp.ts @@ -3,6 +3,7 @@ import { shouldLogVerbose } from "../../../globals.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "../../../web/outbound.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../../whatsapp/normalize.js"; import type { ChannelOutboundAdapter } from "../types.js"; +import { missingTargetError } from "../../../infra/outbound/target-errors.js"; export const whatsappOutbound: ChannelOutboundAdapter = { deliveryMode: "gateway", @@ -26,8 +27,9 @@ export const whatsappOutbound: ChannelOutboundAdapter = { } return { ok: false, - error: new Error( - "Delivering to WhatsApp requires target or channels.whatsapp.allowFrom[0]", + error: missingTargetError( + "WhatsApp", + " or channels.whatsapp.allowFrom[0]", ), }; } @@ -51,8 +53,9 @@ export const whatsappOutbound: ChannelOutboundAdapter = { } return { ok: false, - error: new Error( - "Delivering to WhatsApp requires target or channels.whatsapp.allowFrom[0]", + error: missingTargetError( + "WhatsApp", + " or channels.whatsapp.allowFrom[0]", ), }; }, diff --git a/src/channels/plugins/signal.ts b/src/channels/plugins/signal.ts index 7692a038d..167d2c48c 100644 --- a/src/channels/plugins/signal.ts +++ b/src/channels/plugins/signal.ts @@ -26,6 +26,7 @@ import { migrateBaseNameToDefaultAccount, } from "./setup-helpers.js"; import type { ChannelPlugin } from "./types.js"; +import { missingTargetError } from "../../infra/outbound/target-errors.js"; const meta = getChatChannelMeta("signal"); @@ -201,8 +202,9 @@ export const signalPlugin: ChannelPlugin = { if (!trimmed) { return { ok: false, - error: new Error( - "Delivering to Signal requires target ", + error: missingTargetError( + "Signal", + "", ), }; } diff --git a/src/channels/plugins/slack.ts b/src/channels/plugins/slack.ts index c82cea5a2..9bf3db690 100644 --- a/src/channels/plugins/slack.ts +++ b/src/channels/plugins/slack.ts @@ -28,6 +28,7 @@ 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"); @@ -497,7 +498,7 @@ export const slackPlugin: ChannelPlugin = { if (!trimmed) { return { ok: false, - error: new Error("Delivering to Slack requires target "), + error: missingTargetError("Slack", ""), }; } return { ok: true, to: trimmed }; diff --git a/src/channels/plugins/telegram.ts b/src/channels/plugins/telegram.ts index 941c73d9a..24dae1803 100644 --- a/src/channels/plugins/telegram.ts +++ b/src/channels/plugins/telegram.ts @@ -35,6 +35,7 @@ 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"); @@ -215,7 +216,7 @@ export const telegramPlugin: ChannelPlugin = { return "TELEGRAM_BOT_TOKEN can only be used for the default account."; } if (!input.useEnv && !input.token && !input.tokenFile) { - return "Telegram requires targetken or --token-file (or --use-env)."; + return "Telegram requires token or --token-file (or --use-env)."; } return null; }, @@ -285,7 +286,7 @@ export const telegramPlugin: ChannelPlugin = { if (!trimmed) { return { ok: false, - error: new Error("Delivering to Telegram requires target "), + error: missingTargetError("Telegram", ""), }; } return { ok: true, to: trimmed }; diff --git a/src/channels/plugins/whatsapp.ts b/src/channels/plugins/whatsapp.ts index 0c4171c79..9759d64b2 100644 --- a/src/channels/plugins/whatsapp.ts +++ b/src/channels/plugins/whatsapp.ts @@ -35,6 +35,7 @@ import { import { collectWhatsAppStatusIssues } from "./status-issues/whatsapp.js"; import type { ChannelMessageActionName, ChannelPlugin } from "./types.js"; import { resolveWhatsAppHeartbeatRecipients } from "./whatsapp-heartbeat.js"; +import { missingTargetError } from "../../infra/outbound/target-errors.js"; const meta = getChatChannelMeta("whatsapp"); @@ -315,8 +316,9 @@ export const whatsappPlugin: ChannelPlugin = { } return { ok: false, - error: new Error( - "Delivering to WhatsApp requires target or channels.whatsapp.allowFrom[0]", + error: missingTargetError( + "WhatsApp", + " or channels.whatsapp.allowFrom[0]", ), }; } @@ -340,9 +342,7 @@ export const whatsappPlugin: ChannelPlugin = { } return { ok: false, - error: new Error( - "Delivering to WhatsApp requires target or channels.whatsapp.allowFrom[0]", - ), + error: missingTargetError("WhatsApp", " or channels.whatsapp.allowFrom[0]"), }; }, sendText: async ({ to, text, accountId, deps, gifPlayback }) => { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 87d66705c..b5ba043ce 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -150,6 +150,36 @@ function applyCrossContextMessageDecoration({ return applied.message; } +async function maybeApplyCrossContextMarker(params: { + cfg: ClawdbotConfig; + channel: ChannelId; + action: ChannelMessageActionName; + target: string; + toolContext?: ChannelThreadingToolContext; + accountId?: string | null; + args: Record; + message: string; + preferEmbeds: boolean; +}): Promise { + if (!shouldApplyCrossContextMarker(params.action) || !params.toolContext) { + return params.message; + } + const decoration = await buildCrossContextDecoration({ + cfg: params.cfg, + channel: params.channel, + target: params.target, + toolContext: params.toolContext, + accountId: params.accountId ?? undefined, + }); + if (!decoration) return params.message; + return applyCrossContextMessageDecoration({ + params: params.args, + message: params.message, + decoration, + preferEmbeds: params.preferEmbeds, + }); +} + function readBooleanParam(params: Record, key: string): boolean | undefined { const raw = params[key]; if (typeof raw === "boolean") return raw; @@ -339,24 +369,17 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise ({ + listGroups: vi.fn(), + listGroupsLive: vi.fn(), + getChannelPlugin: vi.fn(), +})); + +vi.mock("../../channels/plugins/index.js", () => ({ + getChannelPlugin: (...args: unknown[]) => mocks.getChannelPlugin(...args), +})); + +describe("resolveMessagingTarget (directory fallback)", () => { + const cfg = {} as ClawdbotConfig; + + beforeEach(() => { + mocks.listGroups.mockReset(); + mocks.listGroupsLive.mockReset(); + mocks.getChannelPlugin.mockReset(); + resetDirectoryCache(); + mocks.getChannelPlugin.mockReturnValue({ + directory: { + listGroups: mocks.listGroups, + listGroupsLive: mocks.listGroupsLive, + }, + }); + }); + + it("uses live directory fallback and caches the result", async () => { + const entry: ChannelDirectoryEntry = { id: "123456789", name: "support" }; + mocks.listGroups.mockResolvedValue([]); + mocks.listGroupsLive.mockResolvedValue([entry]); + + const first = await resolveMessagingTarget({ + cfg, + channel: "discord", + input: "support", + }); + + expect(first.ok).toBe(true); + if (first.ok) { + expect(first.target.source).toBe("directory"); + expect(first.target.to).toBe("123456789"); + } + expect(mocks.listGroups).toHaveBeenCalledTimes(1); + expect(mocks.listGroupsLive).toHaveBeenCalledTimes(1); + + const second = await resolveMessagingTarget({ + cfg, + channel: "discord", + input: "support", + }); + + expect(second.ok).toBe(true); + expect(mocks.listGroups).toHaveBeenCalledTimes(1); + expect(mocks.listGroupsLive).toHaveBeenCalledTimes(1); + }); + + it("skips directory lookup for direct ids", async () => { + const result = await resolveMessagingTarget({ + cfg, + channel: "discord", + input: "123456789", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.target.source).toBe("normalized"); + expect(result.target.to).toBe("123456789"); + } + expect(mocks.listGroups).not.toHaveBeenCalled(); + expect(mocks.listGroupsLive).not.toHaveBeenCalled(); + }); +}); diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index e493b6bed..9e861d224 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -227,6 +227,7 @@ async function getDirectoryEntries(params: { source: "live", }); directoryCache.set(liveKey, liveEntries, params.cfg); + directoryCache.set(cacheKey, liveEntries, params.cfg); return liveEntries; }