refactor: make OutboundSendDeps dynamic with channel-ID keys (#45517)

* refactor: make OutboundSendDeps dynamic with channel-ID keys

Replace hardcoded per-channel send fields (sendTelegram, sendDiscord,
etc.) with a dynamic index-signature type keyed by channel ID. This
unblocks moving channel implementations to extensions without breaking
the outbound dispatch contract.

- OutboundSendDeps and CliDeps are now { [channelId: string]: unknown }
- Each outbound adapter resolves its send fn via bracket access with cast
- Lazy-loading preserved via createLazySender with module cache
- Delete 6 deps-send-*.runtime.ts one-liner re-export files
- Harden guardrail scan against deleted-but-tracked files


* fix: preserve outbound send-deps compatibility

* style: fix formatting issues (import order, extra bracket, trailing whitespace)



* fix: resolve type errors from dynamic OutboundSendDeps in tests and extension

* fix: remove unused OutboundSendDeps import from deliver.test-helpers
This commit is contained in:
scoootscooob 2026-03-14 02:42:21 -07:00 committed by GitHub
parent 0c926a2c5e
commit 7764f717e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 403 additions and 282 deletions

View File

@ -37,8 +37,13 @@ import {
type ChannelPlugin, type ChannelPlugin,
type ResolvedDiscordAccount, type ResolvedDiscordAccount,
} from "openclaw/plugin-sdk/discord"; } from "openclaw/plugin-sdk/discord";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
import { getDiscordRuntime } from "./runtime.js"; import { getDiscordRuntime } from "./runtime.js";
type DiscordSendFn = ReturnType<
typeof getDiscordRuntime
>["channel"]["discord"]["sendMessageDiscord"];
const meta = getChatChannelMeta("discord"); const meta = getChatChannelMeta("discord");
const discordMessageActions: ChannelMessageActionAdapter = { const discordMessageActions: ChannelMessageActionAdapter = {
@ -300,7 +305,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
pollMaxOptions: 10, pollMaxOptions: 10,
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => {
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const send =
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, { const result = await send(to, text, {
verbose: false, verbose: false,
cfg, cfg,
@ -321,7 +328,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
replyToId, replyToId,
silent, silent,
}) => { }) => {
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const send =
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, { const result = await send(to, text, {
verbose: false, verbose: false,
cfg, cfg,

View File

@ -29,6 +29,7 @@ import {
type ChannelPlugin, type ChannelPlugin,
type ResolvedIMessageAccount, type ResolvedIMessageAccount,
} from "openclaw/plugin-sdk/imessage"; } from "openclaw/plugin-sdk/imessage";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import { getIMessageRuntime } from "./runtime.js"; import { getIMessageRuntime } from "./runtime.js";
@ -59,11 +60,12 @@ async function sendIMessageOutbound(params: {
mediaUrl?: string; mediaUrl?: string;
mediaLocalRoots?: readonly string[]; mediaLocalRoots?: readonly string[];
accountId?: string; accountId?: string;
deps?: { sendIMessage?: IMessageSendFn }; deps?: { [channelId: string]: unknown };
replyToId?: string; replyToId?: string;
}) { }) {
const send = const send =
params.deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage; resolveOutboundSendDep<IMessageSendFn>(params.deps, "imessage") ??
getIMessageRuntime().channel.imessage.sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg, cfg: params.cfg,
resolveChannelLimitMb: ({ cfg, accountId }) => resolveChannelLimitMb: ({ cfg, accountId }) =>

View File

@ -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 = { const cfg = {
channels: { channels: {
matrix: { matrix: {
@ -96,7 +96,7 @@ describe("matrixOutbound cfg threading", () => {
}, },
}, },
} as OpenClawConfig; } as OpenClawConfig;
const sendMatrix = vi.fn(async () => ({ const matrix = vi.fn(async () => ({
messageId: "evt-injected", messageId: "evt-injected",
roomId: "!room:example", roomId: "!room:example",
})); }));
@ -105,13 +105,13 @@ describe("matrixOutbound cfg threading", () => {
cfg, cfg,
to: "room:!room:example", to: "room:!room:example",
text: "hello via deps", text: "hello via deps",
deps: { sendMatrix }, deps: { matrix },
accountId: "default", accountId: "default",
threadId: "$thread", threadId: "$thread",
replyToId: "$reply", replyToId: "$reply",
}); });
expect(sendMatrix).toHaveBeenCalledWith( expect(matrix).toHaveBeenCalledWith(
"room:!room:example", "room:!room:example",
"hello via deps", "hello via deps",
expect.objectContaining({ expect.objectContaining({

View File

@ -1,4 +1,5 @@
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/matrix"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/matrix";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
import { getMatrixRuntime } from "./runtime.js"; import { getMatrixRuntime } from "./runtime.js";
@ -8,7 +9,8 @@ export const matrixOutbound: ChannelOutboundAdapter = {
chunkerMode: "markdown", chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ cfg, to, text, deps, replyToId, threadId, accountId }) => { sendText: async ({ cfg, to, text, deps, replyToId, threadId, accountId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix; const send =
resolveOutboundSendDep<typeof sendMessageMatrix>(deps, "matrix") ?? sendMessageMatrix;
const resolvedThreadId = const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined; threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await send(to, text, { const result = await send(to, text, {
@ -24,7 +26,8 @@ export const matrixOutbound: ChannelOutboundAdapter = {
}; };
}, },
sendMedia: async ({ cfg, to, text, mediaUrl, deps, replyToId, threadId, accountId }) => { sendMedia: async ({ cfg, to, text, mediaUrl, deps, replyToId, threadId, accountId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix; const send =
resolveOutboundSendDep<typeof sendMessageMatrix>(deps, "matrix") ?? sendMessageMatrix;
const resolvedThreadId = const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined; threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await send(to, text, { const result = await send(to, text, {

View File

@ -1,4 +1,5 @@
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/msteams"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/msteams";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
import { createMSTeamsPollStoreFs } from "./polls.js"; import { createMSTeamsPollStoreFs } from "./polls.js";
import { getMSTeamsRuntime } from "./runtime.js"; import { getMSTeamsRuntime } from "./runtime.js";
import { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
@ -10,13 +11,24 @@ export const msteamsOutbound: ChannelOutboundAdapter = {
textChunkLimit: 4000, textChunkLimit: 4000,
pollMaxOptions: 12, pollMaxOptions: 12,
sendText: async ({ cfg, to, text, deps }) => { 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<SendFn>(deps, "msteams") ??
((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text); const result = await send(to, text);
return { channel: "msteams", ...result }; return { channel: "msteams", ...result };
}, },
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => { 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 = const send =
deps?.sendMSTeams ?? resolveOutboundSendDep<SendFn>(deps, "msteams") ??
((to, text, opts) => ((to, text, opts) =>
sendMessageMSTeams({ sendMessageMSTeams({
cfg, cfg,

View File

@ -30,6 +30,7 @@ import {
type ChannelPlugin, type ChannelPlugin,
type ResolvedSignalAccount, type ResolvedSignalAccount,
} from "openclaw/plugin-sdk/signal"; } from "openclaw/plugin-sdk/signal";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
import { getSignalRuntime } from "./runtime.js"; import { getSignalRuntime } from "./runtime.js";
const signalMessageActions: ChannelMessageActionAdapter = { const signalMessageActions: ChannelMessageActionAdapter = {
@ -84,9 +85,11 @@ async function sendSignalOutbound(params: {
mediaUrl?: string; mediaUrl?: string;
mediaLocalRoots?: readonly string[]; mediaLocalRoots?: readonly string[];
accountId?: string; accountId?: string;
deps?: { sendSignal?: SignalSendFn }; deps?: { [channelId: string]: unknown };
}) { }) {
const send = params.deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal; const send =
resolveOutboundSendDep<SignalSendFn>(params.deps, "signal") ??
getSignalRuntime().channel.signal.sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg, cfg: params.cfg,
resolveChannelLimitMb: ({ cfg, accountId }) => resolveChannelLimitMb: ({ cfg, accountId }) =>

View File

@ -38,6 +38,7 @@ import {
type ChannelPlugin, type ChannelPlugin,
type ResolvedSlackAccount, type ResolvedSlackAccount,
} from "openclaw/plugin-sdk/slack"; } from "openclaw/plugin-sdk/slack";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import { getSlackRuntime } from "./runtime.js"; import { getSlackRuntime } from "./runtime.js";
@ -77,11 +78,13 @@ type SlackSendFn = ReturnType<typeof getSlackRuntime>["channel"]["slack"]["sendM
function resolveSlackSendContext(params: { function resolveSlackSendContext(params: {
cfg: Parameters<typeof resolveSlackAccount>[0]["cfg"]; cfg: Parameters<typeof resolveSlackAccount>[0]["cfg"];
accountId?: string; accountId?: string;
deps?: { sendSlack?: SlackSendFn }; deps?: { [channelId: string]: unknown };
replyToId?: string | number | null; replyToId?: string | number | null;
threadId?: string | number | null; threadId?: string | number | null;
}) { }) {
const send = params.deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack; const send =
resolveOutboundSendDep<SlackSendFn>(params.deps, "slack") ??
getSlackRuntime().channel.slack.sendMessageSlack;
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const token = getTokenForOperation(account, "write"); const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim(); const botToken = account.botToken?.trim();

View File

@ -40,8 +40,16 @@ import {
type ResolvedTelegramAccount, type ResolvedTelegramAccount,
type TelegramProbe, type TelegramProbe,
} from "openclaw/plugin-sdk/telegram"; } from "openclaw/plugin-sdk/telegram";
import {
type OutboundSendDeps,
resolveOutboundSendDep,
} from "../../../src/infra/outbound/deliver.js";
import { getTelegramRuntime } from "./runtime.js"; import { getTelegramRuntime } from "./runtime.js";
type TelegramSendFn = ReturnType<
typeof getTelegramRuntime
>["channel"]["telegram"]["sendMessageTelegram"];
const meta = getChatChannelMeta("telegram"); const meta = getChatChannelMeta("telegram");
function findTelegramTokenOwnerAccountId(params: { function findTelegramTokenOwnerAccountId(params: {
@ -78,9 +86,6 @@ function formatDuplicateTelegramTokenReason(params: {
); );
} }
type TelegramSendFn = ReturnType<
typeof getTelegramRuntime
>["channel"]["telegram"]["sendMessageTelegram"];
type TelegramSendOptions = NonNullable<Parameters<TelegramSendFn>[2]>; type TelegramSendOptions = NonNullable<Parameters<TelegramSendFn>[2]>;
function buildTelegramSendOptions(params: { function buildTelegramSendOptions(params: {
@ -111,13 +116,14 @@ async function sendTelegramOutbound(params: {
mediaUrl?: string | null; mediaUrl?: string | null;
mediaLocalRoots?: readonly string[] | null; mediaLocalRoots?: readonly string[] | null;
accountId?: string | null; accountId?: string | null;
deps?: { sendTelegram?: TelegramSendFn }; deps?: OutboundSendDeps;
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
silent?: boolean | null; silent?: boolean | null;
}) { }) {
const send = const send =
params.deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; resolveOutboundSendDep<TelegramSendFn>(params.deps, "telegram") ??
getTelegramRuntime().channel.telegram.sendMessageTelegram;
return await send( return await send(
params.to, params.to,
params.text, params.text,
@ -381,7 +387,9 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
threadId, threadId,
silent, silent,
}) => { }) => {
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const send =
resolveOutboundSendDep<TelegramSendFn>(deps, "telegram") ??
getTelegramRuntime().channel.telegram.sendMessageTelegram;
const result = await sendTelegramPayloadMessages({ const result = await sendTelegramPayloadMessages({
send, send,
to, to,

View File

@ -8,6 +8,7 @@ import {
sendPollDiscord, sendPollDiscord,
sendWebhookMessageDiscord, sendWebhookMessageDiscord,
} from "../../../discord/send.js"; } from "../../../discord/send.js";
import { resolveOutboundSendDep } from "../../../infra/outbound/deliver.js";
import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; import type { OutboundIdentity } from "../../../infra/outbound/identity.js";
import { normalizeDiscordOutboundTarget } from "../normalize/discord.js"; import { normalizeDiscordOutboundTarget } from "../normalize/discord.js";
import type { ChannelOutboundAdapter } from "../types.js"; import type { ChannelOutboundAdapter } from "../types.js";
@ -100,7 +101,8 @@ export const discordOutbound: ChannelOutboundAdapter = {
return { channel: "discord", ...webhookResult }; return { channel: "discord", ...webhookResult };
} }
} }
const send = deps?.sendDiscord ?? sendMessageDiscord; const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to, threadId }); const target = resolveDiscordOutboundTarget({ to, threadId });
const result = await send(target, text, { const result = await send(target, text, {
verbose: false, verbose: false,
@ -123,7 +125,8 @@ export const discordOutbound: ChannelOutboundAdapter = {
threadId, threadId,
silent, silent,
}) => { }) => {
const send = deps?.sendDiscord ?? sendMessageDiscord; const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to, threadId }); const target = resolveDiscordOutboundTarget({ to, threadId });
const result = await send(target, text, { const result = await send(target, text, {
verbose: false, verbose: false,

View File

@ -22,7 +22,7 @@ describe("imessageOutbound", () => {
text: "hello", text: "hello",
accountId: "default", accountId: "default",
replyToId: "msg-123", replyToId: "msg-123",
deps: { sendIMessage }, deps: { imessage: sendIMessage },
}); });
expect(sendIMessage).toHaveBeenCalledWith( expect(sendIMessage).toHaveBeenCalledWith(
@ -50,7 +50,7 @@ describe("imessageOutbound", () => {
mediaLocalRoots: ["/tmp"], mediaLocalRoots: ["/tmp"],
accountId: "acct-1", accountId: "acct-1",
replyToId: "msg-456", replyToId: "msg-456",
deps: { sendIMessage }, deps: { imessage: sendIMessage },
}); });
expect(sendIMessage).toHaveBeenCalledWith( expect(sendIMessage).toHaveBeenCalledWith(

View File

@ -1,12 +1,14 @@
import { sendMessageIMessage } from "../../../imessage/send.js"; import { sendMessageIMessage } from "../../../imessage/send.js";
import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; import { resolveOutboundSendDep, type OutboundSendDeps } from "../../../infra/outbound/deliver.js";
import { import {
createScopedChannelMediaMaxBytesResolver, createScopedChannelMediaMaxBytesResolver,
createDirectTextMediaOutbound, createDirectTextMediaOutbound,
} from "./direct-text-media.js"; } from "./direct-text-media.js";
function resolveIMessageSender(deps: OutboundSendDeps | undefined) { function resolveIMessageSender(deps: OutboundSendDeps | undefined) {
return deps?.sendIMessage ?? sendMessageIMessage; return (
resolveOutboundSendDep<typeof sendMessageIMessage>(deps, "imessage") ?? sendMessageIMessage
);
} }
export const imessageOutbound = createDirectTextMediaOutbound({ export const imessageOutbound = createDirectTextMediaOutbound({

View File

@ -26,7 +26,7 @@ describe("signalOutbound", () => {
to: "+15555550123", to: "+15555550123",
text: "hello", text: "hello",
accountId: "work", accountId: "work",
deps: { sendSignal }, deps: { signal: sendSignal },
}); });
expect(sendSignal).toHaveBeenCalledWith( expect(sendSignal).toHaveBeenCalledWith(
@ -52,7 +52,7 @@ describe("signalOutbound", () => {
mediaUrl: "https://example.com/file.jpg", mediaUrl: "https://example.com/file.jpg",
mediaLocalRoots: ["/tmp/media"], mediaLocalRoots: ["/tmp/media"],
accountId: "default", accountId: "default",
deps: { sendSignal }, deps: { signal: sendSignal },
}); });
expect(sendSignal).toHaveBeenCalledWith( expect(sendSignal).toHaveBeenCalledWith(

View File

@ -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 { sendMessageSignal } from "../../../signal/send.js";
import { import {
createScopedChannelMediaMaxBytesResolver, createScopedChannelMediaMaxBytesResolver,
@ -6,7 +6,7 @@ import {
} from "./direct-text-media.js"; } from "./direct-text-media.js";
function resolveSignalSender(deps: OutboundSendDeps | undefined) { function resolveSignalSender(deps: OutboundSendDeps | undefined) {
return deps?.sendSignal ?? sendMessageSignal; return resolveOutboundSendDep<typeof sendMessageSignal>(deps, "signal") ?? sendMessageSignal;
} }
export const signalOutbound = createDirectTextMediaOutbound({ export const signalOutbound = createDirectTextMediaOutbound({

View File

@ -1,3 +1,4 @@
import { resolveOutboundSendDep } from "../../../infra/outbound/deliver.js";
import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; import type { OutboundIdentity } from "../../../infra/outbound/identity.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import { parseSlackBlocksInput } from "../../../slack/blocks-input.js"; import { parseSlackBlocksInput } from "../../../slack/blocks-input.js";
@ -56,12 +57,13 @@ async function sendSlackOutboundMessage(params: {
mediaLocalRoots?: readonly string[]; mediaLocalRoots?: readonly string[];
blocks?: NonNullable<Parameters<typeof sendMessageSlack>[2]>["blocks"]; blocks?: NonNullable<Parameters<typeof sendMessageSlack>[2]>["blocks"];
accountId?: string | null; accountId?: string | null;
deps?: { sendSlack?: typeof sendMessageSlack } | null; deps?: { [channelId: string]: unknown } | null;
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
identity?: OutboundIdentity; identity?: OutboundIdentity;
}) { }) {
const send = params.deps?.sendSlack ?? sendMessageSlack; const send =
resolveOutboundSendDep<typeof sendMessageSlack>(params.deps, "slack") ?? sendMessageSlack;
// Use threadId fallback so routed tool notifications stay in the Slack thread. // Use threadId fallback so routed tool notifications stay in the Slack thread.
const threadTs = const threadTs =
params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined); params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined);

View File

@ -15,7 +15,7 @@ describe("telegramOutbound", () => {
accountId: "work", accountId: "work",
replyToId: "44", replyToId: "44",
threadId: "55", threadId: "55",
deps: { sendTelegram }, deps: { telegram: sendTelegram },
}); });
expect(sendTelegram).toHaveBeenCalledWith( expect(sendTelegram).toHaveBeenCalledWith(
@ -43,7 +43,7 @@ describe("telegramOutbound", () => {
text: "<b>hello</b>", text: "<b>hello</b>",
accountId: "work", accountId: "work",
threadId: "12345:99", threadId: "12345:99",
deps: { sendTelegram }, deps: { telegram: sendTelegram },
}); });
expect(sendTelegram).toHaveBeenCalledWith( expect(sendTelegram).toHaveBeenCalledWith(
@ -70,7 +70,7 @@ describe("telegramOutbound", () => {
mediaUrl: "https://example.com/a.jpg", mediaUrl: "https://example.com/a.jpg",
mediaLocalRoots: ["/tmp/media"], mediaLocalRoots: ["/tmp/media"],
accountId: "default", accountId: "default",
deps: { sendTelegram }, deps: { telegram: sendTelegram },
}); });
expect(sendTelegram).toHaveBeenCalledWith( expect(sendTelegram).toHaveBeenCalledWith(
@ -112,7 +112,7 @@ describe("telegramOutbound", () => {
payload, payload,
mediaLocalRoots: ["/tmp/media"], mediaLocalRoots: ["/tmp/media"],
accountId: "default", accountId: "default",
deps: { sendTelegram }, deps: { telegram: sendTelegram },
}); });
expect(sendTelegram).toHaveBeenCalledTimes(2); expect(sendTelegram).toHaveBeenCalledTimes(2);

View File

@ -1,5 +1,5 @@
import type { ReplyPayload } from "../../../auto-reply/types.js"; 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 type { TelegramInlineButtons } from "../../../telegram/button-types.js";
import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js"; import { markdownToTelegramHtmlChunks } from "../../../telegram/format.js";
import { import {
@ -30,7 +30,9 @@ function resolveTelegramSendContext(params: {
accountId?: string; accountId?: string;
}; };
} { } {
const send = params.deps?.sendTelegram ?? sendMessageTelegram; const send =
resolveOutboundSendDep<typeof sendMessageTelegram>(params.deps, "telegram") ??
sendMessageTelegram;
return { return {
send, send,
baseOpts: { baseOpts: {

View File

@ -87,7 +87,7 @@ describe("telegramOutbound.sendPayload", () => {
}, },
}, },
}, },
deps: { sendTelegram }, deps: { telegram: sendTelegram },
}); });
expect(sendTelegram).toHaveBeenCalledTimes(1); expect(sendTelegram).toHaveBeenCalledTimes(1);
@ -121,7 +121,7 @@ describe("telegramOutbound.sendPayload", () => {
}, },
}, },
}, },
deps: { sendTelegram }, deps: { telegram: sendTelegram },
}); });
expect(sendTelegram).toHaveBeenCalledTimes(2); expect(sendTelegram).toHaveBeenCalledTimes(2);

View File

@ -1,3 +1,4 @@
import { resolveOutboundSendDep } from "../../infra/outbound/deliver.js";
import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js"; import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js";
import { escapeRegExp } from "../../utils.js"; import { escapeRegExp } from "../../utils.js";
import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js";
@ -66,7 +67,8 @@ export function createWhatsAppOutboundBase({
if (skipEmptyText && !normalizedText) { if (skipEmptyText && !normalizedText) {
return { channel: "whatsapp", messageId: "" }; return { channel: "whatsapp", messageId: "" };
} }
const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; const send =
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
const result = await send(to, normalizedText, { const result = await send(to, normalizedText, {
verbose: false, verbose: false,
cfg, cfg,
@ -85,7 +87,8 @@ export function createWhatsAppOutboundBase({
deps, deps,
gifPlayback, gifPlayback,
}) => { }) => {
const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; const send =
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
const result = await send(to, normalizeText(text), { const result = await send(to, normalizeText(text), {
verbose: false, verbose: false,
cfg, cfg,

View File

@ -1 +0,0 @@
export { sendMessageDiscord } from "../discord/send.js";

View File

@ -1 +0,0 @@
export { sendMessageIMessage } from "../imessage/send.js";

View File

@ -1 +0,0 @@
export { sendMessageSignal } from "../signal/send.js";

View File

@ -1 +0,0 @@
export { sendMessageSlack } from "../slack/send.js";

View File

@ -1 +0,0 @@
export { sendMessageTelegram } from "../telegram/send.js";

View File

@ -1 +0,0 @@
export { sendMessageWhatsApp } from "../channels/web/index.js";

View File

@ -74,9 +74,7 @@ describe("createDefaultDeps", () => {
expect(moduleLoads.signal).not.toHaveBeenCalled(); expect(moduleLoads.signal).not.toHaveBeenCalled();
expect(moduleLoads.imessage).not.toHaveBeenCalled(); expect(moduleLoads.imessage).not.toHaveBeenCalled();
const sendTelegram = deps.sendMessageTelegram as unknown as ( const sendTelegram = deps["telegram"] as (...args: unknown[]) => Promise<unknown>;
...args: unknown[]
) => Promise<unknown>;
await sendTelegram("chat", "hello", { verbose: false }); await sendTelegram("chat", "hello", { verbose: false });
expect(moduleLoads.telegram).toHaveBeenCalledTimes(1); expect(moduleLoads.telegram).toHaveBeenCalledTimes(1);
@ -86,9 +84,7 @@ describe("createDefaultDeps", () => {
it("reuses module cache after first dynamic import", async () => { it("reuses module cache after first dynamic import", async () => {
const deps = createDefaultDeps(); const deps = createDefaultDeps();
const sendDiscord = deps.sendMessageDiscord as unknown as ( const sendDiscord = deps["discord"] as (...args: unknown[]) => Promise<unknown>;
...args: unknown[]
) => Promise<unknown>;
await sendDiscord("channel", "first", { verbose: false }); await sendDiscord("channel", "first", { verbose: false });
await sendDiscord("channel", "second", { verbose: false }); await sendDiscord("channel", "second", { verbose: false });

View File

@ -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 { 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"; import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js";
export type CliDeps = { /**
sendMessageWhatsApp: typeof sendMessageWhatsApp; * Lazy-loaded per-channel send functions, keyed by channel ID.
sendMessageTelegram: typeof sendMessageTelegram; * Values are proxy functions that dynamically import the real module on first use.
sendMessageDiscord: typeof sendMessageDiscord; */
sendMessageSlack: typeof sendMessageSlack; export type CliDeps = { [channelId: string]: unknown };
sendMessageSignal: typeof sendMessageSignal;
sendMessageIMessage: typeof sendMessageIMessage;
};
let whatsappSenderRuntimePromise: Promise<typeof import("./deps-send-whatsapp.runtime.js")> | null = // Per-channel module caches for lazy loading.
null; const senderCache = new Map<string, Promise<Record<string, unknown>>>();
let telegramSenderRuntimePromise: Promise<typeof import("./deps-send-telegram.runtime.js")> | null =
null;
let discordSenderRuntimePromise: Promise<typeof import("./deps-send-discord.runtime.js")> | null =
null;
let slackSenderRuntimePromise: Promise<typeof import("./deps-send-slack.runtime.js")> | null = null;
let signalSenderRuntimePromise: Promise<typeof import("./deps-send-signal.runtime.js")> | null =
null;
let imessageSenderRuntimePromise: Promise<typeof import("./deps-send-imessage.runtime.js")> | null =
null;
function loadWhatsAppSenderRuntime() { /**
whatsappSenderRuntimePromise ??= import("./deps-send-whatsapp.runtime.js"); * Create a lazy-loading send function proxy for a channel.
return whatsappSenderRuntimePromise; * The channel's module is loaded on first call and cached for reuse.
} */
function createLazySender(
function loadTelegramSenderRuntime() { channelId: string,
telegramSenderRuntimePromise ??= import("./deps-send-telegram.runtime.js"); loader: () => Promise<Record<string, unknown>>,
return telegramSenderRuntimePromise; exportName: string,
} ): (...args: unknown[]) => Promise<unknown> {
return async (...args: unknown[]) => {
function loadDiscordSenderRuntime() { let cached = senderCache.get(channelId);
discordSenderRuntimePromise ??= import("./deps-send-discord.runtime.js"); if (!cached) {
return discordSenderRuntimePromise; cached = loader();
} senderCache.set(channelId, cached);
}
function loadSlackSenderRuntime() { const mod = await cached;
slackSenderRuntimePromise ??= import("./deps-send-slack.runtime.js"); const fn = mod[exportName] as (...a: unknown[]) => Promise<unknown>;
return slackSenderRuntimePromise; return await fn(...args);
} };
function loadSignalSenderRuntime() {
signalSenderRuntimePromise ??= import("./deps-send-signal.runtime.js");
return signalSenderRuntimePromise;
}
function loadIMessageSenderRuntime() {
imessageSenderRuntimePromise ??= import("./deps-send-imessage.runtime.js");
return imessageSenderRuntimePromise;
} }
export function createDefaultDeps(): CliDeps { export function createDefaultDeps(): CliDeps {
return { return {
sendMessageWhatsApp: async (...args) => { whatsapp: createLazySender(
const { sendMessageWhatsApp } = await loadWhatsAppSenderRuntime(); "whatsapp",
return await sendMessageWhatsApp(...args); () => import("../channels/web/index.js") as Promise<Record<string, unknown>>,
}, "sendMessageWhatsApp",
sendMessageTelegram: async (...args) => { ),
const { sendMessageTelegram } = await loadTelegramSenderRuntime(); telegram: createLazySender(
return await sendMessageTelegram(...args); "telegram",
}, () => import("../telegram/send.js") as Promise<Record<string, unknown>>,
sendMessageDiscord: async (...args) => { "sendMessageTelegram",
const { sendMessageDiscord } = await loadDiscordSenderRuntime(); ),
return await sendMessageDiscord(...args); discord: createLazySender(
}, "discord",
sendMessageSlack: async (...args) => { () => import("../discord/send.js") as Promise<Record<string, unknown>>,
const { sendMessageSlack } = await loadSlackSenderRuntime(); "sendMessageDiscord",
return await sendMessageSlack(...args); ),
}, slack: createLazySender(
sendMessageSignal: async (...args) => { "slack",
const { sendMessageSignal } = await loadSignalSenderRuntime(); () => import("../slack/send.js") as Promise<Record<string, unknown>>,
return await sendMessageSignal(...args); "sendMessageSlack",
}, ),
sendMessageIMessage: async (...args) => { signal: createLazySender(
const { sendMessageIMessage } = await loadIMessageSenderRuntime(); "signal",
return await sendMessageIMessage(...args); () => import("../signal/send.js") as Promise<Record<string, unknown>>,
}, "sendMessageSignal",
),
imessage: createLazySender(
"imessage",
() => import("../imessage/send.js") as Promise<Record<string, unknown>>,
"sendMessageIMessage",
),
}; };
} }

View File

@ -4,7 +4,7 @@ import {
type CliOutboundSendSource, type CliOutboundSendSource,
} from "./outbound-send-mapping.js"; } from "./outbound-send-mapping.js";
export type CliDeps = Required<CliOutboundSendSource>; export type CliDeps = CliOutboundSendSource;
export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps {
return createOutboundSendDepsFromCliSource(deps); return createOutboundSendDepsFromCliSource(deps);

View File

@ -1,29 +1,32 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js";
createOutboundSendDepsFromCliSource,
type CliOutboundSendSource,
} from "./outbound-send-mapping.js";
describe("createOutboundSendDepsFromCliSource", () => { describe("createOutboundSendDepsFromCliSource", () => {
it("maps CLI send deps to outbound send deps", () => { it("adds legacy aliases for channel-keyed send deps", () => {
const deps: CliOutboundSendSource = { const deps = {
sendMessageWhatsApp: vi.fn() as CliOutboundSendSource["sendMessageWhatsApp"], whatsapp: vi.fn(),
sendMessageTelegram: vi.fn() as CliOutboundSendSource["sendMessageTelegram"], telegram: vi.fn(),
sendMessageDiscord: vi.fn() as CliOutboundSendSource["sendMessageDiscord"], discord: vi.fn(),
sendMessageSlack: vi.fn() as CliOutboundSendSource["sendMessageSlack"], slack: vi.fn(),
sendMessageSignal: vi.fn() as CliOutboundSendSource["sendMessageSignal"], signal: vi.fn(),
sendMessageIMessage: vi.fn() as CliOutboundSendSource["sendMessageIMessage"], imessage: vi.fn(),
}; };
const outbound = createOutboundSendDepsFromCliSource(deps); const outbound = createOutboundSendDepsFromCliSource(deps);
expect(outbound).toEqual({ expect(outbound).toEqual({
sendWhatsApp: deps.sendMessageWhatsApp, whatsapp: deps.whatsapp,
sendTelegram: deps.sendMessageTelegram, telegram: deps.telegram,
sendDiscord: deps.sendMessageDiscord, discord: deps.discord,
sendSlack: deps.sendMessageSlack, slack: deps.slack,
sendSignal: deps.sendMessageSignal, signal: deps.signal,
sendIMessage: deps.sendMessageIMessage, imessage: deps.imessage,
sendWhatsApp: deps.whatsapp,
sendTelegram: deps.telegram,
sendDiscord: deps.discord,
sendSlack: deps.slack,
sendSignal: deps.signal,
sendIMessage: deps.imessage,
}); });
}); });
}); });

View File

@ -1,22 +1,49 @@
import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
export type CliOutboundSendSource = { /**
sendMessageWhatsApp: OutboundSendDeps["sendWhatsApp"]; * CLI-internal send function sources, keyed by channel ID.
sendMessageTelegram: OutboundSendDeps["sendTelegram"]; * Each value is a lazily-loaded send function for that channel.
sendMessageDiscord: OutboundSendDeps["sendDiscord"]; */
sendMessageSlack: OutboundSendDeps["sendSlack"]; export type CliOutboundSendSource = { [channelId: string]: unknown };
sendMessageSignal: OutboundSendDeps["sendSignal"];
sendMessageIMessage: OutboundSendDeps["sendIMessage"];
};
// 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 { export function createOutboundSendDepsFromCliSource(deps: CliOutboundSendSource): OutboundSendDeps {
return { const outbound: OutboundSendDeps = { ...deps };
sendWhatsApp: deps.sendMessageWhatsApp,
sendTelegram: deps.sendMessageTelegram, for (const [legacySourceKey, channelId] of Object.entries(LEGACY_SOURCE_TO_CHANNEL)) {
sendDiscord: deps.sendMessageDiscord, const sourceValue = deps[legacySourceKey];
sendSlack: deps.sendMessageSlack, if (sourceValue !== undefined && outbound[channelId] === undefined) {
sendSignal: deps.sendMessageSignal, outbound[channelId] = sourceValue;
sendIMessage: deps.sendMessageIMessage, }
}; }
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;
} }

View File

@ -218,16 +218,7 @@ async function expectDefaultThinkLevel(params: {
function createTelegramOutboundPlugin() { function createTelegramOutboundPlugin() {
const sendWithTelegram = async ( const sendWithTelegram = async (
ctx: { ctx: {
deps?: { deps?: { [channelId: string]: unknown };
sendTelegram?: (
to: string,
text: string,
opts: Record<string, unknown>,
) => Promise<{
messageId: string;
chatId: string;
}>;
};
to: string; to: string;
text: string; text: string;
accountId?: string | null; accountId?: string | null;
@ -235,7 +226,13 @@ function createTelegramOutboundPlugin() {
}, },
mediaUrl?: string, mediaUrl?: string,
) => { ) => {
const sendTelegram = ctx.deps?.sendTelegram; const sendTelegram = ctx.deps?.["telegram"] as
| ((
to: string,
text: string,
opts: Record<string, unknown>,
) => Promise<{ messageId: string; chatId: string }>)
| undefined;
if (!sendTelegram) { if (!sendTelegram) {
throw new Error("sendTelegram dependency missing"); throw new Error("sendTelegram dependency missing");
} }

View File

@ -162,6 +162,8 @@ describe("runCronIsolatedAgentTurn", () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const { storePath, deps } = await createTelegramDeliveryFixture(home); const { storePath, deps } = await createTelegramDeliveryFixture(home);
vi.mocked(runSubagentAnnounceFlow).mockClear();
vi.mocked(deps.sendMessageTelegram as (...args: unknown[]) => unknown).mockClear();
mockEmbeddedAgentPayloads([{ text: "HEARTBEAT_OK 🦞" }]); mockEmbeddedAgentPayloads([{ text: "HEARTBEAT_OK 🦞" }]);
const cfg = makeCfg(home, storePath); 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({ const deleteRes = await runCronIsolatedAgentTurn({
cfg, cfg,
deps, deps,

View File

@ -51,18 +51,21 @@ beforeAll(async () => {
const whatsappOutbound: ChannelOutboundAdapter = { const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
sendText: async ({ deps, to, text }) => { sendText: async ({ deps, to, text }) => {
if (!deps?.sendWhatsApp) { if (!deps?.["whatsapp"]) {
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) {
throw new Error("Missing sendWhatsApp dep"); throw new Error("Missing sendWhatsApp dep");
} }
return { return {
channel: "whatsapp", 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 })),
}; };
}, },
}; };

View File

@ -118,7 +118,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
agentId: "main", agentId: "main",
reason: params.reason, reason: params.reason,
deps: { deps: {
sendTelegram, telegram: sendTelegram,
}, },
}); });
const calledCtx = (getReplySpy.mock.calls[0]?.[0] ?? null) as { const calledCtx = (getReplySpy.mock.calls[0]?.[0] ?? null) as {

View File

@ -48,9 +48,7 @@ describe("runHeartbeatOnce ack handling", () => {
} = {}, } = {},
) { ) {
return { return {
...(params.sendWhatsApp ...(params.sendWhatsApp ? { whatsapp: params.sendWhatsApp as unknown } : {}),
? { sendWhatsApp: params.sendWhatsApp as unknown as HeartbeatDeps["sendWhatsApp"] }
: {}),
getQueueSize: params.getQueueSize ?? (() => 0), getQueueSize: params.getQueueSize ?? (() => 0),
nowMs: params.nowMs ?? (() => 0), nowMs: params.nowMs ?? (() => 0),
webAuthExists: params.webAuthExists ?? (async () => true), webAuthExists: params.webAuthExists ?? (async () => true),
@ -66,9 +64,7 @@ describe("runHeartbeatOnce ack handling", () => {
} = {}, } = {},
) { ) {
return { return {
...(params.sendTelegram ...(params.sendTelegram ? { telegram: params.sendTelegram as unknown } : {}),
? { sendTelegram: params.sendTelegram as unknown as HeartbeatDeps["sendTelegram"] }
: {}),
getQueueSize: params.getQueueSize ?? (() => 0), getQueueSize: params.getQueueSize ?? (() => 0),
nowMs: params.nowMs ?? (() => 0), nowMs: params.nowMs ?? (() => 0),
} satisfies HeartbeatDeps; } satisfies HeartbeatDeps;

View File

@ -59,20 +59,20 @@ beforeAll(async () => {
outbound: { outbound: {
deliveryMode: "direct", deliveryMode: "direct",
sendText: async ({ to, text, deps, accountId }) => { sendText: async ({ to, text, deps, accountId }) => {
if (!deps?.sendTelegram) { if (!deps?.["telegram"]) {
throw new Error("sendTelegram missing"); throw new Error("sendTelegram missing");
} }
const res = await deps.sendTelegram(to, text, { const res = await (deps["telegram"] as Function)(to, text, {
verbose: false, verbose: false,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
}); });
return { channel: "telegram", messageId: res.messageId, chatId: res.chatId }; return { channel: "telegram", messageId: res.messageId, chatId: res.chatId };
}, },
sendMedia: async ({ to, text, mediaUrl, deps, accountId }) => { sendMedia: async ({ to, text, mediaUrl, deps, accountId }) => {
if (!deps?.sendTelegram) { if (!deps?.["telegram"]) {
throw new Error("sendTelegram missing"); throw new Error("sendTelegram missing");
} }
const res = await deps.sendTelegram(to, text, { const res = await (deps["telegram"] as Function)(to, text, {
verbose: false, verbose: false,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
mediaUrl, mediaUrl,
@ -468,10 +468,14 @@ describe("resolveHeartbeatSenderContext", () => {
describe("runHeartbeatOnce", () => { describe("runHeartbeatOnce", () => {
const createHeartbeatDeps = ( const createHeartbeatDeps = (
sendWhatsApp: NonNullable<HeartbeatDeps["sendWhatsApp"]>, sendWhatsApp: (
to: string,
text: string,
opts?: unknown,
) => Promise<{ messageId: string; toJid: string }>,
nowMs = 0, nowMs = 0,
): HeartbeatDeps => ({ ): HeartbeatDeps => ({
sendWhatsApp, whatsapp: sendWhatsApp,
getQueueSize: () => 0, getQueueSize: () => 0,
nowMs: () => nowMs, nowMs: () => nowMs,
webAuthExists: async () => true, webAuthExists: async () => true,
@ -547,10 +551,18 @@ describe("runHeartbeatOnce", () => {
); );
replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]); replySpy.mockResolvedValue([{ text: "Let me check..." }, { text: "Final alert" }]);
const sendWhatsApp = vi.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>().mockResolvedValue({ const sendWhatsApp = vi
messageId: "m1", .fn<
toJid: "jid", (
}); to: string,
text: string,
opts?: unknown,
) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
await runHeartbeatOnce({ await runHeartbeatOnce({
cfg, cfg,
@ -604,10 +616,18 @@ describe("runHeartbeatOnce", () => {
}), }),
); );
replySpy.mockResolvedValue([{ text: "Final alert" }]); replySpy.mockResolvedValue([{ text: "Final alert" }]);
const sendWhatsApp = vi.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>().mockResolvedValue({ const sendWhatsApp = vi
messageId: "m1", .fn<
toJid: "jid", (
}); to: string,
text: string,
opts?: unknown,
) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
await runHeartbeatOnce({ await runHeartbeatOnce({
cfg, cfg,
agentId: "ops", agentId: "ops",
@ -682,10 +702,18 @@ describe("runHeartbeatOnce", () => {
); );
replySpy.mockResolvedValue([{ text: "Final alert" }]); replySpy.mockResolvedValue([{ text: "Final alert" }]);
const sendWhatsApp = vi.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>().mockResolvedValue({ const sendWhatsApp = vi
messageId: "m1", .fn<
toJid: "jid", (
}); to: string,
text: string,
opts?: unknown,
) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
const result = await runHeartbeatOnce({ const result = await runHeartbeatOnce({
cfg, cfg,
agentId, agentId,
@ -799,7 +827,13 @@ describe("runHeartbeatOnce", () => {
replySpy.mockClear(); replySpy.mockClear();
replySpy.mockResolvedValue([{ text: testCase.message }]); replySpy.mockResolvedValue([{ text: testCase.message }]);
const sendWhatsApp = vi const sendWhatsApp = vi
.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>() .fn<
(
to: string,
text: string,
opts?: unknown,
) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" }); .mockResolvedValue({ messageId: "m1", toJid: "jid" });
await runHeartbeatOnce({ await runHeartbeatOnce({
@ -863,7 +897,13 @@ describe("runHeartbeatOnce", () => {
replySpy.mockResolvedValue([{ text: "Final alert" }]); replySpy.mockResolvedValue([{ text: "Final alert" }]);
const sendWhatsApp = vi const sendWhatsApp = vi
.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>() .fn<
(
to: string,
text: string,
opts?: unknown,
) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" }); .mockResolvedValue({ messageId: "m1", toJid: "jid" });
await runHeartbeatOnce({ await runHeartbeatOnce({
@ -935,7 +975,13 @@ describe("runHeartbeatOnce", () => {
replySpy.mockClear(); replySpy.mockClear();
replySpy.mockResolvedValue(testCase.replies); replySpy.mockResolvedValue(testCase.replies);
const sendWhatsApp = vi const sendWhatsApp = vi
.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>() .fn<
(
to: string,
text: string,
opts?: unknown,
) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" }); .mockResolvedValue({ messageId: "m1", toJid: "jid" });
await runHeartbeatOnce({ await runHeartbeatOnce({
@ -990,10 +1036,18 @@ describe("runHeartbeatOnce", () => {
); );
replySpy.mockResolvedValue({ text: "Hello from heartbeat" }); replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
const sendWhatsApp = vi.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>().mockResolvedValue({ const sendWhatsApp = vi
messageId: "m1", .fn<
toJid: "jid", (
}); to: string,
text: string,
opts?: unknown,
) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
await runHeartbeatOnce({ await runHeartbeatOnce({
cfg, cfg,
@ -1073,7 +1127,9 @@ describe("runHeartbeatOnce", () => {
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
replySpy.mockResolvedValue({ text: params.replyText ?? "Checked logs and PRs" }); replySpy.mockResolvedValue({ text: params.replyText ?? "Checked logs and PRs" });
const sendWhatsApp = vi const sendWhatsApp = vi
.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>() .fn<
(to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" }); .mockResolvedValue({ messageId: "m1", toJid: "jid" });
const res = await runHeartbeatOnce({ const res = await runHeartbeatOnce({
cfg, cfg,
@ -1239,7 +1295,9 @@ describe("runHeartbeatOnce", () => {
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
replySpy.mockResolvedValue({ text: "Handled internally" }); replySpy.mockResolvedValue({ text: "Handled internally" });
const sendWhatsApp = vi const sendWhatsApp = vi
.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>() .fn<
(to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" }); .mockResolvedValue({ messageId: "m1", toJid: "jid" });
try { try {
@ -1292,7 +1350,9 @@ describe("runHeartbeatOnce", () => {
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
replySpy.mockResolvedValue({ text: "Handled internally" }); replySpy.mockResolvedValue({ text: "Handled internally" });
const sendWhatsApp = vi const sendWhatsApp = vi
.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>() .fn<
(to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" }); .mockResolvedValue({ messageId: "m1", toJid: "jid" });
try { try {

View File

@ -47,7 +47,7 @@ describe("runHeartbeatOnce", () => {
await runHeartbeatOnce({ await runHeartbeatOnce({
cfg, cfg,
deps: { deps: {
sendSlack, slack: sendSlack,
getQueueSize: () => 0, getQueueSize: () => 0,
nowMs: () => 0, nowMs: () => 0,
}, },

View File

@ -7,11 +7,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import type { import type { DeliverOutboundPayloadsParams, OutboundDeliveryResult } from "./deliver.js";
DeliverOutboundPayloadsParams,
OutboundDeliveryResult,
OutboundSendDeps,
} from "./deliver.js";
type DeliverMockState = { type DeliverMockState = {
sessions: { sessions: {
@ -215,7 +211,9 @@ export async function runChunkedWhatsAppDelivery(params: {
mirror?: DeliverOutboundPayloadsParams["mirror"]; mirror?: DeliverOutboundPayloadsParams["mirror"];
}) { }) {
const sendWhatsApp = vi const sendWhatsApp = vi
.fn<NonNullable<OutboundSendDeps["sendWhatsApp"]>>() .fn<
(to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" }) .mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" }); .mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {

View File

@ -17,7 +17,6 @@ import {
appendAssistantMessageToSessionTranscript, appendAssistantMessageToSessionTranscript,
resolveMirroredTranscriptText, resolveMirroredTranscriptText,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import type { sendMessageDiscord } from "../../discord/send.js";
import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; import { fireAndForgetHook } from "../../hooks/fire-and-forget.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { import {
@ -26,15 +25,11 @@ import {
toPluginMessageContext, toPluginMessageContext,
toPluginMessageSentEvent, toPluginMessageSentEvent,
} from "../../hooks/message-hook-mappers.js"; } from "../../hooks/message-hook-mappers.js";
import type { sendMessageIMessage } from "../../imessage/send.js";
import { createSubsystemLogger } from "../../logging/subsystem.js"; import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js"; import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
import { sendMessageSignal } from "../../signal/send.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 { throwIfAborted } from "./abort.js";
import { ackDelivery, enqueueDelivery, failDelivery } from "./delivery-queue.js"; import { ackDelivery, enqueueDelivery, failDelivery } from "./delivery-queue.js";
import type { OutboundIdentity } from "./identity.js"; import type { OutboundIdentity } from "./identity.js";
@ -51,33 +46,48 @@ export { normalizeOutboundPayloads } from "./payloads.js";
const log = createSubsystemLogger("outbound/deliver"); const log = createSubsystemLogger("outbound/deliver");
const TELEGRAM_TEXT_LIMIT = 4096; const TELEGRAM_TEXT_LIMIT = 4096;
type SendMatrixMessage = ( type LegacyOutboundSendDeps = {
to: string, sendWhatsApp?: unknown;
text: string, sendTelegram?: unknown;
opts?: { sendDiscord?: unknown;
cfg?: OpenClawConfig; sendSlack?: unknown;
mediaUrl?: string; sendSignal?: unknown;
replyToId?: string; sendIMessage?: unknown;
threadId?: string; sendMatrix?: unknown;
timeoutMs?: number; sendMSTeams?: unknown;
},
) => 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 }>;
}; };
/**
* 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<string, keyof LegacyOutboundSendDeps>;
export function resolveOutboundSendDep<T>(
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 = { export type OutboundDeliveryResult = {
channel: Exclude<OutboundChannel, "none">; channel: Exclude<OutboundChannel, "none">;
messageId: string; messageId: string;
@ -527,7 +537,8 @@ async function deliverOutboundPayloadsCore(
const accountId = params.accountId; const accountId = params.accountId;
const deps = params.deps; const deps = params.deps;
const abortSignal = params.abortSignal; const abortSignal = params.abortSignal;
const sendSignal = params.deps?.sendSignal ?? sendMessageSignal; const sendSignal =
resolveOutboundSendDep<typeof sendMessageSignal>(params.deps, "signal") ?? sendMessageSignal;
const mediaLocalRoots = getAgentScopedMediaLocalRoots( const mediaLocalRoots = getAgentScopedMediaLocalRoots(
cfg, cfg,
params.session?.agentId ?? params.mirror?.agentId, params.session?.agentId ?? params.mirror?.agentId,

View File

@ -304,7 +304,9 @@ const emptyRegistry = createTestRegistry([]);
const createMSTeamsOutbound = (opts?: { includePoll?: boolean }): ChannelOutboundAdapter => ({ const createMSTeamsOutbound = (opts?: { includePoll?: boolean }): ChannelOutboundAdapter => ({
deliveryMode: "direct", deliveryMode: "direct",
sendText: async ({ deps, to, text }) => { 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) { if (!send) {
throw new Error("sendMSTeams missing"); throw new Error("sendMSTeams missing");
} }
@ -312,7 +314,9 @@ const createMSTeamsOutbound = (opts?: { includePoll?: boolean }): ChannelOutboun
return { channel: "msteams", ...result }; return { channel: "msteams", ...result };
}, },
sendMedia: async ({ deps, to, text, mediaUrl }) => { 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) { if (!send) {
throw new Error("sendMSTeams missing"); throw new Error("sendMSTeams missing");
} }

View File

@ -50,7 +50,13 @@ async function readRuntimeSourceFiles(
if (!absolutePath) { if (!absolutePath) {
continue; 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] = { output[index] = {
relativePath: path.relative(repoRoot, absolutePath), relativePath: path.relative(repoRoot, absolutePath),
source, source,

View File

@ -48,22 +48,7 @@ const [
installProcessWarningFilter(); installProcessWarningFilter();
const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => {
switch (id) { return deps?.[id] as ((...args: unknown[]) => Promise<unknown>) | undefined;
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;
}
}; };
const createStubOutbound = ( const createStubOutbound = (
@ -75,7 +60,9 @@ const createStubOutbound = (
const send = pickSendFn(id, deps); const send = pickSendFn(id, deps);
if (send) { if (send) {
// oxlint-disable-next-line typescript/no-explicit-any // 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, ...result };
} }
return { channel: id, messageId: "test" }; return { channel: id, messageId: "test" };
@ -84,7 +71,9 @@ const createStubOutbound = (
const send = pickSendFn(id, deps); const send = pickSendFn(id, deps);
if (send) { if (send) {
// oxlint-disable-next-line typescript/no-explicit-any // 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, ...result };
} }
return { channel: id, messageId: "test" }; return { channel: id, messageId: "test" };