diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index c6852a63469..c910e56342d 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -37,8 +37,13 @@ import { type ChannelPlugin, type ResolvedDiscordAccount, } from "openclaw/plugin-sdk/discord"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { getDiscordRuntime } from "./runtime.js"; +type DiscordSendFn = ReturnType< + typeof getDiscordRuntime +>["channel"]["discord"]["sendMessageDiscord"]; + const meta = getChatChannelMeta("discord"); const discordMessageActions: ChannelMessageActionAdapter = { @@ -300,7 +305,9 @@ export const discordPlugin: ChannelPlugin = { pollMaxOptions: 10, resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { - const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, cfg, @@ -321,7 +328,9 @@ export const discordPlugin: ChannelPlugin = { replyToId, silent, }) => { - const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, cfg, diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 17023599eb1..2394f80ec62 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -29,6 +29,7 @@ import { type ChannelPlugin, type ResolvedIMessageAccount, } from "openclaw/plugin-sdk/imessage"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getIMessageRuntime } from "./runtime.js"; @@ -59,11 +60,12 @@ async function sendIMessageOutbound(params: { mediaUrl?: string; mediaLocalRoots?: readonly string[]; accountId?: string; - deps?: { sendIMessage?: IMessageSendFn }; + deps?: { [channelId: string]: unknown }; replyToId?: string; }) { const send = - params.deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage; + resolveOutboundSendDep(params.deps, "imessage") ?? + getIMessageRuntime().channel.imessage.sendMessageIMessage; const maxBytes = resolveChannelMediaMaxBytes({ cfg: params.cfg, resolveChannelLimitMb: ({ cfg, accountId }) => diff --git a/extensions/matrix/src/outbound.test.ts b/extensions/matrix/src/outbound.test.ts index e0b62c1c00b..081c5572837 100644 --- a/extensions/matrix/src/outbound.test.ts +++ b/extensions/matrix/src/outbound.test.ts @@ -88,7 +88,7 @@ describe("matrixOutbound cfg threading", () => { ); }); - it("passes resolved cfg through injected deps.sendMatrix", async () => { + it("passes resolved cfg through injected deps.matrix", async () => { const cfg = { channels: { matrix: { @@ -96,7 +96,7 @@ describe("matrixOutbound cfg threading", () => { }, }, } as OpenClawConfig; - const sendMatrix = vi.fn(async () => ({ + const matrix = vi.fn(async () => ({ messageId: "evt-injected", roomId: "!room:example", })); @@ -105,13 +105,13 @@ describe("matrixOutbound cfg threading", () => { cfg, to: "room:!room:example", text: "hello via deps", - deps: { sendMatrix }, + deps: { matrix }, accountId: "default", threadId: "$thread", replyToId: "$reply", }); - expect(sendMatrix).toHaveBeenCalledWith( + expect(matrix).toHaveBeenCalledWith( "room:!room:example", "hello via deps", expect.objectContaining({ diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index be4f8d3426d..1018fd0c2e5 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -1,4 +1,5 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/matrix"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; import { getMatrixRuntime } from "./runtime.js"; @@ -8,7 +9,8 @@ export const matrixOutbound: ChannelOutboundAdapter = { chunkerMode: "markdown", textChunkLimit: 4000, sendText: async ({ cfg, to, text, deps, replyToId, threadId, accountId }) => { - const send = deps?.sendMatrix ?? sendMessageMatrix; + const send = + resolveOutboundSendDep(deps, "matrix") ?? sendMessageMatrix; const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await send(to, text, { @@ -24,7 +26,8 @@ export const matrixOutbound: ChannelOutboundAdapter = { }; }, sendMedia: async ({ cfg, to, text, mediaUrl, deps, replyToId, threadId, accountId }) => { - const send = deps?.sendMatrix ?? sendMessageMatrix; + const send = + resolveOutboundSendDep(deps, "matrix") ?? sendMessageMatrix; const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await send(to, text, { diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 9f3f55c6414..4241e166872 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -1,4 +1,5 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/msteams"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { createMSTeamsPollStoreFs } from "./polls.js"; import { getMSTeamsRuntime } from "./runtime.js"; import { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; @@ -10,13 +11,24 @@ export const msteamsOutbound: ChannelOutboundAdapter = { textChunkLimit: 4000, pollMaxOptions: 12, sendText: async ({ cfg, to, text, deps }) => { - const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text })); + type SendFn = ( + to: string, + text: string, + ) => Promise<{ messageId: string; conversationId: string }>; + const send = + resolveOutboundSendDep(deps, "msteams") ?? + ((to, text) => sendMessageMSTeams({ cfg, to, text })); const result = await send(to, text); return { channel: "msteams", ...result }; }, sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => { + type SendFn = ( + to: string, + text: string, + opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] }, + ) => Promise<{ messageId: string; conversationId: string }>; const send = - deps?.sendMSTeams ?? + resolveOutboundSendDep(deps, "msteams") ?? ((to, text, opts) => sendMessageMSTeams({ cfg, diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 89dfb8c9a48..f763f0c6769 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -30,6 +30,7 @@ import { type ChannelPlugin, type ResolvedSignalAccount, } from "openclaw/plugin-sdk/signal"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { getSignalRuntime } from "./runtime.js"; const signalMessageActions: ChannelMessageActionAdapter = { @@ -84,9 +85,11 @@ async function sendSignalOutbound(params: { mediaUrl?: string; mediaLocalRoots?: readonly string[]; accountId?: string; - deps?: { sendSignal?: SignalSendFn }; + deps?: { [channelId: string]: unknown }; }) { - const send = params.deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal; + const send = + resolveOutboundSendDep(params.deps, "signal") ?? + getSignalRuntime().channel.signal.sendMessageSignal; const maxBytes = resolveChannelMediaMaxBytes({ cfg: params.cfg, resolveChannelLimitMb: ({ cfg, accountId }) => diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 17209b6e4d1..d288963efc6 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -38,6 +38,7 @@ import { type ChannelPlugin, type ResolvedSlackAccount, } from "openclaw/plugin-sdk/slack"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getSlackRuntime } from "./runtime.js"; @@ -77,11 +78,13 @@ type SlackSendFn = ReturnType["channel"]["slack"]["sendM function resolveSlackSendContext(params: { cfg: Parameters[0]["cfg"]; accountId?: string; - deps?: { sendSlack?: SlackSendFn }; + deps?: { [channelId: string]: unknown }; replyToId?: string | number | null; threadId?: string | number | null; }) { - const send = params.deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; + const send = + resolveOutboundSendDep(params.deps, "slack") ?? + getSlackRuntime().channel.slack.sendMessageSlack; const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); const token = getTokenForOperation(account, "write"); const botToken = account.botToken?.trim(); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 20d012c9dda..b13e33859f9 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -40,8 +40,16 @@ import { type ResolvedTelegramAccount, type TelegramProbe, } from "openclaw/plugin-sdk/telegram"; +import { + type OutboundSendDeps, + resolveOutboundSendDep, +} from "../../../src/infra/outbound/deliver.js"; import { getTelegramRuntime } from "./runtime.js"; +type TelegramSendFn = ReturnType< + typeof getTelegramRuntime +>["channel"]["telegram"]["sendMessageTelegram"]; + const meta = getChatChannelMeta("telegram"); function findTelegramTokenOwnerAccountId(params: { @@ -78,9 +86,6 @@ function formatDuplicateTelegramTokenReason(params: { ); } -type TelegramSendFn = ReturnType< - typeof getTelegramRuntime ->["channel"]["telegram"]["sendMessageTelegram"]; type TelegramSendOptions = NonNullable[2]>; function buildTelegramSendOptions(params: { @@ -111,13 +116,14 @@ async function sendTelegramOutbound(params: { mediaUrl?: string | null; mediaLocalRoots?: readonly string[] | null; accountId?: string | null; - deps?: { sendTelegram?: TelegramSendFn }; + deps?: OutboundSendDeps; replyToId?: string | null; threadId?: string | number | null; silent?: boolean | null; }) { const send = - params.deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; + resolveOutboundSendDep(params.deps, "telegram") ?? + getTelegramRuntime().channel.telegram.sendMessageTelegram; return await send( params.to, params.text, @@ -381,7 +387,9 @@ export const telegramPlugin: ChannelPlugin { - const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; + const send = + resolveOutboundSendDep(deps, "telegram") ?? + getTelegramRuntime().channel.telegram.sendMessageTelegram; const result = await sendTelegramPayloadMessages({ send, to, diff --git a/src/channels/plugins/outbound/discord.ts b/src/channels/plugins/outbound/discord.ts index b88f3cc09ef..706ac866a2e 100644 --- a/src/channels/plugins/outbound/discord.ts +++ b/src/channels/plugins/outbound/discord.ts @@ -8,6 +8,7 @@ import { sendPollDiscord, sendWebhookMessageDiscord, } from "../../../discord/send.js"; +import { resolveOutboundSendDep } from "../../../infra/outbound/deliver.js"; import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; import { normalizeDiscordOutboundTarget } from "../normalize/discord.js"; import type { ChannelOutboundAdapter } from "../types.js"; @@ -100,7 +101,8 @@ export const discordOutbound: ChannelOutboundAdapter = { return { channel: "discord", ...webhookResult }; } } - const send = deps?.sendDiscord ?? sendMessageDiscord; + const send = + resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; const target = resolveDiscordOutboundTarget({ to, threadId }); const result = await send(target, text, { verbose: false, @@ -123,7 +125,8 @@ export const discordOutbound: ChannelOutboundAdapter = { threadId, silent, }) => { - const send = deps?.sendDiscord ?? sendMessageDiscord; + const send = + resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; const target = resolveDiscordOutboundTarget({ to, threadId }); const result = await send(target, text, { verbose: false, diff --git a/src/channels/plugins/outbound/imessage.test.ts b/src/channels/plugins/outbound/imessage.test.ts index 7ebcc853793..b42b5a954c8 100644 --- a/src/channels/plugins/outbound/imessage.test.ts +++ b/src/channels/plugins/outbound/imessage.test.ts @@ -22,7 +22,7 @@ describe("imessageOutbound", () => { text: "hello", accountId: "default", replyToId: "msg-123", - deps: { sendIMessage }, + deps: { imessage: sendIMessage }, }); expect(sendIMessage).toHaveBeenCalledWith( @@ -50,7 +50,7 @@ describe("imessageOutbound", () => { mediaLocalRoots: ["/tmp"], accountId: "acct-1", replyToId: "msg-456", - deps: { sendIMessage }, + deps: { imessage: sendIMessage }, }); expect(sendIMessage).toHaveBeenCalledWith( diff --git a/src/channels/plugins/outbound/imessage.ts b/src/channels/plugins/outbound/imessage.ts index 20c92754d28..f321b0cb936 100644 --- a/src/channels/plugins/outbound/imessage.ts +++ b/src/channels/plugins/outbound/imessage.ts @@ -1,12 +1,14 @@ import { sendMessageIMessage } from "../../../imessage/send.js"; -import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js"; import { createScopedChannelMediaMaxBytesResolver, createDirectTextMediaOutbound, } from "./direct-text-media.js"; function resolveIMessageSender(deps: OutboundSendDeps | undefined) { - return deps?.sendIMessage ?? sendMessageIMessage; + return ( + resolveOutboundSendDep(deps, "imessage") ?? sendMessageIMessage + ); } export const imessageOutbound = createDirectTextMediaOutbound({ diff --git a/src/channels/plugins/outbound/signal.test.ts b/src/channels/plugins/outbound/signal.test.ts index 6d1d0bd0606..9848c558965 100644 --- a/src/channels/plugins/outbound/signal.test.ts +++ b/src/channels/plugins/outbound/signal.test.ts @@ -26,7 +26,7 @@ describe("signalOutbound", () => { to: "+15555550123", text: "hello", accountId: "work", - deps: { sendSignal }, + deps: { signal: sendSignal }, }); expect(sendSignal).toHaveBeenCalledWith( @@ -52,7 +52,7 @@ describe("signalOutbound", () => { mediaUrl: "https://example.com/file.jpg", mediaLocalRoots: ["/tmp/media"], accountId: "default", - deps: { sendSignal }, + deps: { signal: sendSignal }, }); expect(sendSignal).toHaveBeenCalledWith( diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index 0ebf8e57670..f5ee80788ad 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -1,4 +1,4 @@ -import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js"; import { sendMessageSignal } from "../../../signal/send.js"; import { createScopedChannelMediaMaxBytesResolver, @@ -6,7 +6,7 @@ import { } from "./direct-text-media.js"; function resolveSignalSender(deps: OutboundSendDeps | undefined) { - return deps?.sendSignal ?? sendMessageSignal; + return resolveOutboundSendDep(deps, "signal") ?? sendMessageSignal; } export const signalOutbound = createDirectTextMediaOutbound({ diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index 96ff7b1b0cb..12a5604f811 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -1,3 +1,4 @@ +import { resolveOutboundSendDep } from "../../../infra/outbound/deliver.js"; import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import { parseSlackBlocksInput } from "../../../slack/blocks-input.js"; @@ -56,12 +57,13 @@ async function sendSlackOutboundMessage(params: { mediaLocalRoots?: readonly string[]; blocks?: NonNullable[2]>["blocks"]; accountId?: string | null; - deps?: { sendSlack?: typeof sendMessageSlack } | null; + deps?: { [channelId: string]: unknown } | null; replyToId?: string | null; threadId?: string | number | null; identity?: OutboundIdentity; }) { - const send = params.deps?.sendSlack ?? sendMessageSlack; + const send = + resolveOutboundSendDep(params.deps, "slack") ?? sendMessageSlack; // Use threadId fallback so routed tool notifications stay in the Slack thread. const threadTs = params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined); diff --git a/src/channels/plugins/outbound/telegram.test.ts b/src/channels/plugins/outbound/telegram.test.ts index df81947fa5d..f464858d7f1 100644 --- a/src/channels/plugins/outbound/telegram.test.ts +++ b/src/channels/plugins/outbound/telegram.test.ts @@ -15,7 +15,7 @@ describe("telegramOutbound", () => { accountId: "work", replyToId: "44", threadId: "55", - deps: { sendTelegram }, + deps: { telegram: sendTelegram }, }); expect(sendTelegram).toHaveBeenCalledWith( @@ -43,7 +43,7 @@ describe("telegramOutbound", () => { text: "hello", accountId: "work", threadId: "12345:99", - deps: { sendTelegram }, + deps: { telegram: sendTelegram }, }); expect(sendTelegram).toHaveBeenCalledWith( @@ -70,7 +70,7 @@ describe("telegramOutbound", () => { mediaUrl: "https://example.com/a.jpg", mediaLocalRoots: ["/tmp/media"], accountId: "default", - deps: { sendTelegram }, + deps: { telegram: sendTelegram }, }); expect(sendTelegram).toHaveBeenCalledWith( @@ -112,7 +112,7 @@ describe("telegramOutbound", () => { payload, mediaLocalRoots: ["/tmp/media"], accountId: "default", - deps: { sendTelegram }, + deps: { telegram: sendTelegram }, }); expect(sendTelegram).toHaveBeenCalledTimes(2); diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index c96a44a7047..ad1e9176235 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -1,5 +1,5 @@ import type { ReplyPayload } from "../../../auto-reply/types.js"; -import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; +import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js"; import type { TelegramInlineButtons } from "../../../telegram/button-types.js"; import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js"; import { @@ -30,7 +30,9 @@ function resolveTelegramSendContext(params: { accountId?: string; }; } { - const send = params.deps?.sendTelegram ?? sendMessageTelegram; + const send = + resolveOutboundSendDep(params.deps, "telegram") ?? + sendMessageTelegram; return { send, baseOpts: { diff --git a/src/channels/plugins/plugins-channel.test.ts b/src/channels/plugins/plugins-channel.test.ts index e6f0e800a03..37fea7e032d 100644 --- a/src/channels/plugins/plugins-channel.test.ts +++ b/src/channels/plugins/plugins-channel.test.ts @@ -87,7 +87,7 @@ describe("telegramOutbound.sendPayload", () => { }, }, }, - deps: { sendTelegram }, + deps: { telegram: sendTelegram }, }); expect(sendTelegram).toHaveBeenCalledTimes(1); @@ -121,7 +121,7 @@ describe("telegramOutbound.sendPayload", () => { }, }, }, - deps: { sendTelegram }, + deps: { telegram: sendTelegram }, }); expect(sendTelegram).toHaveBeenCalledTimes(2); diff --git a/src/channels/plugins/whatsapp-shared.ts b/src/channels/plugins/whatsapp-shared.ts index 1174dff7c73..99c94aead1d 100644 --- a/src/channels/plugins/whatsapp-shared.ts +++ b/src/channels/plugins/whatsapp-shared.ts @@ -1,3 +1,4 @@ +import { resolveOutboundSendDep } from "../../infra/outbound/deliver.js"; import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js"; import { escapeRegExp } from "../../utils.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; @@ -66,7 +67,8 @@ export function createWhatsAppOutboundBase({ if (skipEmptyText && !normalizedText) { return { channel: "whatsapp", messageId: "" }; } - const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; const result = await send(to, normalizedText, { verbose: false, cfg, @@ -85,7 +87,8 @@ export function createWhatsAppOutboundBase({ deps, gifPlayback, }) => { - const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; const result = await send(to, normalizeText(text), { verbose: false, cfg, diff --git a/src/cli/deps-send-discord.runtime.ts b/src/cli/deps-send-discord.runtime.ts deleted file mode 100644 index e451b4fccb6..00000000000 --- a/src/cli/deps-send-discord.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageDiscord } from "../discord/send.js"; diff --git a/src/cli/deps-send-imessage.runtime.ts b/src/cli/deps-send-imessage.runtime.ts deleted file mode 100644 index 502d0c116bd..00000000000 --- a/src/cli/deps-send-imessage.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageIMessage } from "../imessage/send.js"; diff --git a/src/cli/deps-send-signal.runtime.ts b/src/cli/deps-send-signal.runtime.ts deleted file mode 100644 index f19755b8cf0..00000000000 --- a/src/cli/deps-send-signal.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageSignal } from "../signal/send.js"; diff --git a/src/cli/deps-send-slack.runtime.ts b/src/cli/deps-send-slack.runtime.ts deleted file mode 100644 index 039ffb20645..00000000000 --- a/src/cli/deps-send-slack.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageSlack } from "../slack/send.js"; diff --git a/src/cli/deps-send-telegram.runtime.ts b/src/cli/deps-send-telegram.runtime.ts deleted file mode 100644 index 8a052a3cf75..00000000000 --- a/src/cli/deps-send-telegram.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageTelegram } from "../telegram/send.js"; diff --git a/src/cli/deps-send-whatsapp.runtime.ts b/src/cli/deps-send-whatsapp.runtime.ts deleted file mode 100644 index e0ae02b3882..00000000000 --- a/src/cli/deps-send-whatsapp.runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { sendMessageWhatsApp } from "../channels/web/index.js"; diff --git a/src/cli/deps.test.ts b/src/cli/deps.test.ts index 3cba4d63ad8..644a8abd2c2 100644 --- a/src/cli/deps.test.ts +++ b/src/cli/deps.test.ts @@ -74,9 +74,7 @@ describe("createDefaultDeps", () => { expect(moduleLoads.signal).not.toHaveBeenCalled(); expect(moduleLoads.imessage).not.toHaveBeenCalled(); - const sendTelegram = deps.sendMessageTelegram as unknown as ( - ...args: unknown[] - ) => Promise; + const sendTelegram = deps["telegram"] as (...args: unknown[]) => Promise; await sendTelegram("chat", "hello", { verbose: false }); expect(moduleLoads.telegram).toHaveBeenCalledTimes(1); @@ -86,9 +84,7 @@ describe("createDefaultDeps", () => { it("reuses module cache after first dynamic import", async () => { const deps = createDefaultDeps(); - const sendDiscord = deps.sendMessageDiscord as unknown as ( - ...args: unknown[] - ) => Promise; + const sendDiscord = deps["discord"] as (...args: unknown[]) => Promise; await sendDiscord("channel", "first", { verbose: false }); await sendDiscord("channel", "second", { verbose: false }); diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 478f3862146..07b608639cc 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,89 +1,68 @@ -import type { sendMessageWhatsApp } from "../channels/web/index.js"; -import type { sendMessageDiscord } from "../discord/send.js"; -import type { sendMessageIMessage } from "../imessage/send.js"; import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; -import type { sendMessageSignal } from "../signal/send.js"; -import type { sendMessageSlack } from "../slack/send.js"; -import type { sendMessageTelegram } from "../telegram/send.js"; import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js"; -export type CliDeps = { - sendMessageWhatsApp: typeof sendMessageWhatsApp; - sendMessageTelegram: typeof sendMessageTelegram; - sendMessageDiscord: typeof sendMessageDiscord; - sendMessageSlack: typeof sendMessageSlack; - sendMessageSignal: typeof sendMessageSignal; - sendMessageIMessage: typeof sendMessageIMessage; -}; +/** + * Lazy-loaded per-channel send functions, keyed by channel ID. + * Values are proxy functions that dynamically import the real module on first use. + */ +export type CliDeps = { [channelId: string]: unknown }; -let whatsappSenderRuntimePromise: Promise | null = - null; -let telegramSenderRuntimePromise: Promise | null = - null; -let discordSenderRuntimePromise: Promise | null = - null; -let slackSenderRuntimePromise: Promise | null = null; -let signalSenderRuntimePromise: Promise | null = - null; -let imessageSenderRuntimePromise: Promise | null = - null; +// Per-channel module caches for lazy loading. +const senderCache = new Map>>(); -function loadWhatsAppSenderRuntime() { - whatsappSenderRuntimePromise ??= import("./deps-send-whatsapp.runtime.js"); - return whatsappSenderRuntimePromise; -} - -function loadTelegramSenderRuntime() { - telegramSenderRuntimePromise ??= import("./deps-send-telegram.runtime.js"); - return telegramSenderRuntimePromise; -} - -function loadDiscordSenderRuntime() { - discordSenderRuntimePromise ??= import("./deps-send-discord.runtime.js"); - return discordSenderRuntimePromise; -} - -function loadSlackSenderRuntime() { - slackSenderRuntimePromise ??= import("./deps-send-slack.runtime.js"); - return slackSenderRuntimePromise; -} - -function loadSignalSenderRuntime() { - signalSenderRuntimePromise ??= import("./deps-send-signal.runtime.js"); - return signalSenderRuntimePromise; -} - -function loadIMessageSenderRuntime() { - imessageSenderRuntimePromise ??= import("./deps-send-imessage.runtime.js"); - return imessageSenderRuntimePromise; +/** + * Create a lazy-loading send function proxy for a channel. + * The channel's module is loaded on first call and cached for reuse. + */ +function createLazySender( + channelId: string, + loader: () => Promise>, + exportName: string, +): (...args: unknown[]) => Promise { + return async (...args: unknown[]) => { + let cached = senderCache.get(channelId); + if (!cached) { + cached = loader(); + senderCache.set(channelId, cached); + } + const mod = await cached; + const fn = mod[exportName] as (...a: unknown[]) => Promise; + return await fn(...args); + }; } export function createDefaultDeps(): CliDeps { return { - sendMessageWhatsApp: async (...args) => { - const { sendMessageWhatsApp } = await loadWhatsAppSenderRuntime(); - return await sendMessageWhatsApp(...args); - }, - sendMessageTelegram: async (...args) => { - const { sendMessageTelegram } = await loadTelegramSenderRuntime(); - return await sendMessageTelegram(...args); - }, - sendMessageDiscord: async (...args) => { - const { sendMessageDiscord } = await loadDiscordSenderRuntime(); - return await sendMessageDiscord(...args); - }, - sendMessageSlack: async (...args) => { - const { sendMessageSlack } = await loadSlackSenderRuntime(); - return await sendMessageSlack(...args); - }, - sendMessageSignal: async (...args) => { - const { sendMessageSignal } = await loadSignalSenderRuntime(); - return await sendMessageSignal(...args); - }, - sendMessageIMessage: async (...args) => { - const { sendMessageIMessage } = await loadIMessageSenderRuntime(); - return await sendMessageIMessage(...args); - }, + whatsapp: createLazySender( + "whatsapp", + () => import("../channels/web/index.js") as Promise>, + "sendMessageWhatsApp", + ), + telegram: createLazySender( + "telegram", + () => import("../telegram/send.js") as Promise>, + "sendMessageTelegram", + ), + discord: createLazySender( + "discord", + () => import("../discord/send.js") as Promise>, + "sendMessageDiscord", + ), + slack: createLazySender( + "slack", + () => import("../slack/send.js") as Promise>, + "sendMessageSlack", + ), + signal: createLazySender( + "signal", + () => import("../signal/send.js") as Promise>, + "sendMessageSignal", + ), + imessage: createLazySender( + "imessage", + () => import("../imessage/send.js") as Promise>, + "sendMessageIMessage", + ), }; } diff --git a/src/cli/outbound-send-deps.ts b/src/cli/outbound-send-deps.ts index 81d7211bf9f..6969ec0b0f0 100644 --- a/src/cli/outbound-send-deps.ts +++ b/src/cli/outbound-send-deps.ts @@ -4,7 +4,7 @@ import { type CliOutboundSendSource, } from "./outbound-send-mapping.js"; -export type CliDeps = Required; +export type CliDeps = CliOutboundSendSource; export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { return createOutboundSendDepsFromCliSource(deps); diff --git a/src/cli/outbound-send-mapping.test.ts b/src/cli/outbound-send-mapping.test.ts index 0b31e21b299..4d68d9ce249 100644 --- a/src/cli/outbound-send-mapping.test.ts +++ b/src/cli/outbound-send-mapping.test.ts @@ -1,29 +1,32 @@ import { describe, expect, it, vi } from "vitest"; -import { - createOutboundSendDepsFromCliSource, - type CliOutboundSendSource, -} from "./outbound-send-mapping.js"; +import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js"; describe("createOutboundSendDepsFromCliSource", () => { - it("maps CLI send deps to outbound send deps", () => { - const deps: CliOutboundSendSource = { - sendMessageWhatsApp: vi.fn() as CliOutboundSendSource["sendMessageWhatsApp"], - sendMessageTelegram: vi.fn() as CliOutboundSendSource["sendMessageTelegram"], - sendMessageDiscord: vi.fn() as CliOutboundSendSource["sendMessageDiscord"], - sendMessageSlack: vi.fn() as CliOutboundSendSource["sendMessageSlack"], - sendMessageSignal: vi.fn() as CliOutboundSendSource["sendMessageSignal"], - sendMessageIMessage: vi.fn() as CliOutboundSendSource["sendMessageIMessage"], + it("adds legacy aliases for channel-keyed send deps", () => { + const deps = { + whatsapp: vi.fn(), + telegram: vi.fn(), + discord: vi.fn(), + slack: vi.fn(), + signal: vi.fn(), + imessage: vi.fn(), }; const outbound = createOutboundSendDepsFromCliSource(deps); expect(outbound).toEqual({ - sendWhatsApp: deps.sendMessageWhatsApp, - sendTelegram: deps.sendMessageTelegram, - sendDiscord: deps.sendMessageDiscord, - sendSlack: deps.sendMessageSlack, - sendSignal: deps.sendMessageSignal, - sendIMessage: deps.sendMessageIMessage, + whatsapp: deps.whatsapp, + telegram: deps.telegram, + discord: deps.discord, + slack: deps.slack, + signal: deps.signal, + imessage: deps.imessage, + sendWhatsApp: deps.whatsapp, + sendTelegram: deps.telegram, + sendDiscord: deps.discord, + sendSlack: deps.slack, + sendSignal: deps.signal, + sendIMessage: deps.imessage, }); }); }); diff --git a/src/cli/outbound-send-mapping.ts b/src/cli/outbound-send-mapping.ts index cf220084e3b..9233d984f21 100644 --- a/src/cli/outbound-send-mapping.ts +++ b/src/cli/outbound-send-mapping.ts @@ -1,22 +1,49 @@ import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; -export type CliOutboundSendSource = { - sendMessageWhatsApp: OutboundSendDeps["sendWhatsApp"]; - sendMessageTelegram: OutboundSendDeps["sendTelegram"]; - sendMessageDiscord: OutboundSendDeps["sendDiscord"]; - sendMessageSlack: OutboundSendDeps["sendSlack"]; - sendMessageSignal: OutboundSendDeps["sendSignal"]; - sendMessageIMessage: OutboundSendDeps["sendIMessage"]; -}; +/** + * CLI-internal send function sources, keyed by channel ID. + * Each value is a lazily-loaded send function for that channel. + */ +export type CliOutboundSendSource = { [channelId: string]: unknown }; -// Provider docking: extend this mapping when adding new outbound send deps. +const LEGACY_SOURCE_TO_CHANNEL = { + sendMessageWhatsApp: "whatsapp", + sendMessageTelegram: "telegram", + sendMessageDiscord: "discord", + sendMessageSlack: "slack", + sendMessageSignal: "signal", + sendMessageIMessage: "imessage", +} as const; + +const CHANNEL_TO_LEGACY_DEP_KEY = { + whatsapp: "sendWhatsApp", + telegram: "sendTelegram", + discord: "sendDiscord", + slack: "sendSlack", + signal: "sendSignal", + imessage: "sendIMessage", +} as const; + +/** + * Pass CLI send sources through as-is — both CliOutboundSendSource and + * OutboundSendDeps are now channel-ID-keyed records. + */ export function createOutboundSendDepsFromCliSource(deps: CliOutboundSendSource): OutboundSendDeps { - return { - sendWhatsApp: deps.sendMessageWhatsApp, - sendTelegram: deps.sendMessageTelegram, - sendDiscord: deps.sendMessageDiscord, - sendSlack: deps.sendMessageSlack, - sendSignal: deps.sendMessageSignal, - sendIMessage: deps.sendMessageIMessage, - }; + const outbound: OutboundSendDeps = { ...deps }; + + for (const [legacySourceKey, channelId] of Object.entries(LEGACY_SOURCE_TO_CHANNEL)) { + const sourceValue = deps[legacySourceKey]; + if (sourceValue !== undefined && outbound[channelId] === undefined) { + outbound[channelId] = sourceValue; + } + } + + for (const [channelId, legacyDepKey] of Object.entries(CHANNEL_TO_LEGACY_DEP_KEY)) { + const sourceValue = outbound[channelId]; + if (sourceValue !== undefined && outbound[legacyDepKey] === undefined) { + outbound[legacyDepKey] = sourceValue; + } + } + + return outbound; } diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index baa58df2ef1..5b4fc2c9040 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -218,16 +218,7 @@ async function expectDefaultThinkLevel(params: { function createTelegramOutboundPlugin() { const sendWithTelegram = async ( ctx: { - deps?: { - sendTelegram?: ( - to: string, - text: string, - opts: Record, - ) => Promise<{ - messageId: string; - chatId: string; - }>; - }; + deps?: { [channelId: string]: unknown }; to: string; text: string; accountId?: string | null; @@ -235,7 +226,13 @@ function createTelegramOutboundPlugin() { }, mediaUrl?: string, ) => { - const sendTelegram = ctx.deps?.sendTelegram; + const sendTelegram = ctx.deps?.["telegram"] as + | (( + to: string, + text: string, + opts: Record, + ) => Promise<{ messageId: string; chatId: string }>) + | undefined; if (!sendTelegram) { throw new Error("sendTelegram dependency missing"); } diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index 8ea21bffefe..5678b75e4f7 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -162,6 +162,8 @@ describe("runCronIsolatedAgentTurn", () => { await withTempHome(async (home) => { const { storePath, deps } = await createTelegramDeliveryFixture(home); + vi.mocked(runSubagentAnnounceFlow).mockClear(); + vi.mocked(deps.sendMessageTelegram as (...args: unknown[]) => unknown).mockClear(); mockEmbeddedAgentPayloads([{ text: "HEARTBEAT_OK 🦞" }]); const cfg = makeCfg(home, storePath); @@ -215,6 +217,10 @@ describe("runCronIsolatedAgentTurn", () => { }, }; + vi.mocked(deps.sendMessageTelegram as (...args: unknown[]) => unknown).mockClear(); + vi.mocked(runSubagentAnnounceFlow).mockClear(); + vi.mocked(callGateway).mockClear(); + const deleteRes = await runCronIsolatedAgentTurn({ cfg, deps, diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index 6b95ff62d25..ef461ce4a7a 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -51,18 +51,21 @@ beforeAll(async () => { const whatsappOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", sendText: async ({ deps, to, text }) => { - if (!deps?.sendWhatsApp) { - throw new Error("Missing sendWhatsApp dep"); - } - return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, { verbose: false })) }; - }, - sendMedia: async ({ deps, to, text, mediaUrl }) => { - if (!deps?.sendWhatsApp) { + if (!deps?.["whatsapp"]) { throw new Error("Missing sendWhatsApp dep"); } return { channel: "whatsapp", - ...(await deps.sendWhatsApp(to, text, { verbose: false, mediaUrl })), + ...(await (deps["whatsapp"] as Function)(to, text, { verbose: false })), + }; + }, + sendMedia: async ({ deps, to, text, mediaUrl }) => { + if (!deps?.["whatsapp"]) { + throw new Error("Missing sendWhatsApp dep"); + } + return { + channel: "whatsapp", + ...(await (deps["whatsapp"] as Function)(to, text, { verbose: false, mediaUrl })), }; }, }; diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 648acf1813c..f215b8313d1 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -118,7 +118,7 @@ describe("Ghost reminder bug (issue #13317)", () => { agentId: "main", reason: params.reason, deps: { - sendTelegram, + telegram: sendTelegram, }, }); const calledCtx = (getReplySpy.mock.calls[0]?.[0] ?? null) as { diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index d0f4fd19bd7..fcc3f7556ae 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -48,9 +48,7 @@ describe("runHeartbeatOnce ack handling", () => { } = {}, ) { return { - ...(params.sendWhatsApp - ? { sendWhatsApp: params.sendWhatsApp as unknown as HeartbeatDeps["sendWhatsApp"] } - : {}), + ...(params.sendWhatsApp ? { whatsapp: params.sendWhatsApp as unknown } : {}), getQueueSize: params.getQueueSize ?? (() => 0), nowMs: params.nowMs ?? (() => 0), webAuthExists: params.webAuthExists ?? (async () => true), @@ -66,9 +64,7 @@ describe("runHeartbeatOnce ack handling", () => { } = {}, ) { return { - ...(params.sendTelegram - ? { sendTelegram: params.sendTelegram as unknown as HeartbeatDeps["sendTelegram"] } - : {}), + ...(params.sendTelegram ? { telegram: params.sendTelegram as unknown } : {}), getQueueSize: params.getQueueSize ?? (() => 0), nowMs: params.nowMs ?? (() => 0), } satisfies HeartbeatDeps; diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 2ac6a8be0f3..dc28784870a 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -59,20 +59,20 @@ beforeAll(async () => { outbound: { deliveryMode: "direct", sendText: async ({ to, text, deps, accountId }) => { - if (!deps?.sendTelegram) { + if (!deps?.["telegram"]) { throw new Error("sendTelegram missing"); } - const res = await deps.sendTelegram(to, text, { + const res = await (deps["telegram"] as Function)(to, text, { verbose: false, accountId: accountId ?? undefined, }); return { channel: "telegram", messageId: res.messageId, chatId: res.chatId }; }, sendMedia: async ({ to, text, mediaUrl, deps, accountId }) => { - if (!deps?.sendTelegram) { + if (!deps?.["telegram"]) { throw new Error("sendTelegram missing"); } - const res = await deps.sendTelegram(to, text, { + const res = await (deps["telegram"] as Function)(to, text, { verbose: false, accountId: accountId ?? undefined, mediaUrl, @@ -468,10 +468,14 @@ describe("resolveHeartbeatSenderContext", () => { describe("runHeartbeatOnce", () => { const createHeartbeatDeps = ( - sendWhatsApp: NonNullable, + sendWhatsApp: ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }>, nowMs = 0, ): HeartbeatDeps => ({ - sendWhatsApp, + whatsapp: sendWhatsApp, getQueueSize: () => 0, nowMs: () => nowMs, webAuthExists: async () => true, @@ -547,10 +551,18 @@ describe("runHeartbeatOnce", () => { ); replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]); - const sendWhatsApp = vi.fn>().mockResolvedValue({ - messageId: "m1", - toJid: "jid", - }); + const sendWhatsApp = vi + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() + .mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); await runHeartbeatOnce({ cfg, @@ -604,10 +616,18 @@ describe("runHeartbeatOnce", () => { }), ); replySpy.mockResolvedValue([{ text: "Final alert" }]); - const sendWhatsApp = vi.fn>().mockResolvedValue({ - messageId: "m1", - toJid: "jid", - }); + const sendWhatsApp = vi + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() + .mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); await runHeartbeatOnce({ cfg, agentId: "ops", @@ -682,10 +702,18 @@ describe("runHeartbeatOnce", () => { ); replySpy.mockResolvedValue([{ text: "Final alert" }]); - const sendWhatsApp = vi.fn>().mockResolvedValue({ - messageId: "m1", - toJid: "jid", - }); + const sendWhatsApp = vi + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() + .mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); const result = await runHeartbeatOnce({ cfg, agentId, @@ -799,7 +827,13 @@ describe("runHeartbeatOnce", () => { replySpy.mockClear(); replySpy.mockResolvedValue([{ text: testCase.message }]); const sendWhatsApp = vi - .fn>() + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); await runHeartbeatOnce({ @@ -863,7 +897,13 @@ describe("runHeartbeatOnce", () => { replySpy.mockResolvedValue([{ text: "Final alert" }]); const sendWhatsApp = vi - .fn>() + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); await runHeartbeatOnce({ @@ -935,7 +975,13 @@ describe("runHeartbeatOnce", () => { replySpy.mockClear(); replySpy.mockResolvedValue(testCase.replies); const sendWhatsApp = vi - .fn>() + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); await runHeartbeatOnce({ @@ -990,10 +1036,18 @@ describe("runHeartbeatOnce", () => { ); replySpy.mockResolvedValue({ text: "Hello from heartbeat" }); - const sendWhatsApp = vi.fn>().mockResolvedValue({ - messageId: "m1", - toJid: "jid", - }); + const sendWhatsApp = vi + .fn< + ( + to: string, + text: string, + opts?: unknown, + ) => Promise<{ messageId: string; toJid: string }> + >() + .mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); await runHeartbeatOnce({ cfg, @@ -1073,7 +1127,9 @@ describe("runHeartbeatOnce", () => { const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); replySpy.mockResolvedValue({ text: params.replyText ?? "Checked logs and PRs" }); const sendWhatsApp = vi - .fn>() + .fn< + (to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); const res = await runHeartbeatOnce({ cfg, @@ -1239,7 +1295,9 @@ describe("runHeartbeatOnce", () => { const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); replySpy.mockResolvedValue({ text: "Handled internally" }); const sendWhatsApp = vi - .fn>() + .fn< + (to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); try { @@ -1292,7 +1350,9 @@ describe("runHeartbeatOnce", () => { const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); replySpy.mockResolvedValue({ text: "Handled internally" }); const sendWhatsApp = vi - .fn>() + .fn< + (to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); try { diff --git a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts index 71a190c844b..352dbd1c84c 100644 --- a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts +++ b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts @@ -47,7 +47,7 @@ describe("runHeartbeatOnce", () => { await runHeartbeatOnce({ cfg, deps: { - sendSlack, + slack: sendSlack, getQueueSize: () => 0, nowMs: () => 0, }, diff --git a/src/infra/outbound/deliver.test-helpers.ts b/src/infra/outbound/deliver.test-helpers.ts index e043e8ef84e..e5e8eaf5392 100644 --- a/src/infra/outbound/deliver.test-helpers.ts +++ b/src/infra/outbound/deliver.test-helpers.ts @@ -7,11 +7,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; -import type { - DeliverOutboundPayloadsParams, - OutboundDeliveryResult, - OutboundSendDeps, -} from "./deliver.js"; +import type { DeliverOutboundPayloadsParams, OutboundDeliveryResult } from "./deliver.js"; type DeliverMockState = { sessions: { @@ -215,7 +211,9 @@ export async function runChunkedWhatsAppDelivery(params: { mirror?: DeliverOutboundPayloadsParams["mirror"]; }) { const sendWhatsApp = vi - .fn>() + .fn< + (to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }> + >() .mockResolvedValueOnce({ messageId: "w1", toJid: "jid" }) .mockResolvedValueOnce({ messageId: "w2", toJid: "jid" }); const cfg: OpenClawConfig = { diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index bd2bb85d2e7..8649b063768 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -17,7 +17,6 @@ import { appendAssistantMessageToSessionTranscript, resolveMirroredTranscriptText, } from "../../config/sessions.js"; -import type { sendMessageDiscord } from "../../discord/send.js"; import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { @@ -26,15 +25,11 @@ import { toPluginMessageContext, toPluginMessageSentEvent, } from "../../hooks/message-hook-mappers.js"; -import type { sendMessageIMessage } from "../../imessage/send.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js"; import { sendMessageSignal } from "../../signal/send.js"; -import type { sendMessageSlack } from "../../slack/send.js"; -import type { sendMessageTelegram } from "../../telegram/send.js"; -import type { sendMessageWhatsApp } from "../../web/outbound.js"; import { throwIfAborted } from "./abort.js"; import { ackDelivery, enqueueDelivery, failDelivery } from "./delivery-queue.js"; import type { OutboundIdentity } from "./identity.js"; @@ -51,33 +46,48 @@ export { normalizeOutboundPayloads } from "./payloads.js"; const log = createSubsystemLogger("outbound/deliver"); const TELEGRAM_TEXT_LIMIT = 4096; -type SendMatrixMessage = ( - to: string, - text: string, - opts?: { - cfg?: OpenClawConfig; - mediaUrl?: string; - replyToId?: string; - threadId?: string; - timeoutMs?: number; - }, -) => Promise<{ messageId: string; roomId: string }>; - -export type OutboundSendDeps = { - sendWhatsApp?: typeof sendMessageWhatsApp; - sendTelegram?: typeof sendMessageTelegram; - sendDiscord?: typeof sendMessageDiscord; - sendSlack?: typeof sendMessageSlack; - sendSignal?: typeof sendMessageSignal; - sendIMessage?: typeof sendMessageIMessage; - sendMatrix?: SendMatrixMessage; - sendMSTeams?: ( - to: string, - text: string, - opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] }, - ) => Promise<{ messageId: string; conversationId: string }>; +type LegacyOutboundSendDeps = { + sendWhatsApp?: unknown; + sendTelegram?: unknown; + sendDiscord?: unknown; + sendSlack?: unknown; + sendSignal?: unknown; + sendIMessage?: unknown; + sendMatrix?: unknown; + sendMSTeams?: unknown; }; +/** + * Dynamic bag of per-channel send functions, keyed by channel ID. + * Each outbound adapter resolves its own function from this record and + * falls back to a direct import when the key is absent. + */ +export type OutboundSendDeps = LegacyOutboundSendDeps & { [channelId: string]: unknown }; + +const LEGACY_SEND_DEP_KEYS = { + whatsapp: "sendWhatsApp", + telegram: "sendTelegram", + discord: "sendDiscord", + slack: "sendSlack", + signal: "sendSignal", + imessage: "sendIMessage", + matrix: "sendMatrix", + msteams: "sendMSTeams", +} as const satisfies Record; + +export function resolveOutboundSendDep( + deps: OutboundSendDeps | null | undefined, + channelId: keyof typeof LEGACY_SEND_DEP_KEYS, +): T | undefined { + const dynamic = deps?.[channelId]; + if (dynamic !== undefined) { + return dynamic as T; + } + const legacyKey = LEGACY_SEND_DEP_KEYS[channelId]; + const legacy = deps?.[legacyKey]; + return legacy as T | undefined; +} + export type OutboundDeliveryResult = { channel: Exclude; messageId: string; @@ -527,7 +537,8 @@ async function deliverOutboundPayloadsCore( const accountId = params.accountId; const deps = params.deps; const abortSignal = params.abortSignal; - const sendSignal = params.deps?.sendSignal ?? sendMessageSignal; + const sendSignal = + resolveOutboundSendDep(params.deps, "signal") ?? sendMessageSignal; const mediaLocalRoots = getAgentScopedMediaLocalRoots( cfg, params.session?.agentId ?? params.mirror?.agentId, diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index 257d2ec94d6..6d89ac5ab91 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -304,7 +304,9 @@ const emptyRegistry = createTestRegistry([]); const createMSTeamsOutbound = (opts?: { includePoll?: boolean }): ChannelOutboundAdapter => ({ deliveryMode: "direct", sendText: async ({ deps, to, text }) => { - const send = deps?.sendMSTeams; + const send = deps?.sendMSTeams as + | ((to: string, text: string, opts?: unknown) => Promise<{ messageId: string }>) + | undefined; if (!send) { throw new Error("sendMSTeams missing"); } @@ -312,7 +314,9 @@ const createMSTeamsOutbound = (opts?: { includePoll?: boolean }): ChannelOutboun return { channel: "msteams", ...result }; }, sendMedia: async ({ deps, to, text, mediaUrl }) => { - const send = deps?.sendMSTeams; + const send = deps?.sendMSTeams as + | ((to: string, text: string, opts?: unknown) => Promise<{ messageId: string }>) + | undefined; if (!send) { throw new Error("sendMSTeams missing"); } diff --git a/src/test-utils/runtime-source-guardrail-scan.ts b/src/test-utils/runtime-source-guardrail-scan.ts index f5ef1b2100b..1e41fce3d3f 100644 --- a/src/test-utils/runtime-source-guardrail-scan.ts +++ b/src/test-utils/runtime-source-guardrail-scan.ts @@ -50,7 +50,13 @@ async function readRuntimeSourceFiles( if (!absolutePath) { continue; } - const source = await fs.readFile(absolutePath, "utf8"); + let source: string; + try { + source = await fs.readFile(absolutePath, "utf8"); + } catch { + // File tracked by git but deleted on disk (e.g. pending deletion). + continue; + } output[index] = { relativePath: path.relative(repoRoot, absolutePath), source, diff --git a/test/setup.ts b/test/setup.ts index 659956cc2c8..f0e1bdc4549 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -48,22 +48,7 @@ const [ installProcessWarningFilter(); const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { - switch (id) { - case "discord": - return deps?.sendDiscord; - case "slack": - return deps?.sendSlack; - case "telegram": - return deps?.sendTelegram; - case "whatsapp": - return deps?.sendWhatsApp; - case "signal": - return deps?.sendSignal; - case "imessage": - return deps?.sendIMessage; - default: - return undefined; - } + return deps?.[id] as ((...args: unknown[]) => Promise) | undefined; }; const createStubOutbound = ( @@ -75,7 +60,9 @@ const createStubOutbound = ( const send = pickSendFn(id, deps); if (send) { // oxlint-disable-next-line typescript/no-explicit-any - const result = await send(to, text, { verbose: false } as any); + const result = (await send(to, text, { verbose: false } as any)) as { + messageId: string; + }; return { channel: id, ...result }; } return { channel: id, messageId: "test" }; @@ -84,7 +71,9 @@ const createStubOutbound = ( const send = pickSendFn(id, deps); if (send) { // oxlint-disable-next-line typescript/no-explicit-any - const result = await send(to, text, { verbose: false, mediaUrl } as any); + const result = (await send(to, text, { verbose: false, mediaUrl } as any)) as { + messageId: string; + }; return { channel: id, ...result }; } return { channel: id, messageId: "test" };