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 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<ResolvedDiscordAccount> = {
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<DiscordSendFn>(deps, "discord") ??
getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
cfg,
@ -321,7 +328,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
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, {
verbose: false,
cfg,

View File

@ -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<IMessageSendFn>(params.deps, "imessage") ??
getIMessageRuntime().channel.imessage.sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
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 = {
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({

View File

@ -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<typeof sendMessageMatrix>(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<typeof sendMessageMatrix>(deps, "matrix") ?? sendMessageMatrix;
const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await send(to, text, {

View File

@ -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<SendFn>(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<SendFn>(deps, "msteams") ??
((to, text, opts) =>
sendMessageMSTeams({
cfg,

View File

@ -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<SignalSendFn>(params.deps, "signal") ??
getSignalRuntime().channel.signal.sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>

View File

@ -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<typeof getSlackRuntime>["channel"]["slack"]["sendM
function resolveSlackSendContext(params: {
cfg: Parameters<typeof resolveSlackAccount>[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<SlackSendFn>(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();

View File

@ -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<Parameters<TelegramSendFn>[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<TelegramSendFn>(params.deps, "telegram") ??
getTelegramRuntime().channel.telegram.sendMessageTelegram;
return await send(
params.to,
params.text,
@ -381,7 +387,9 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
threadId,
silent,
}) => {
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
const send =
resolveOutboundSendDep<TelegramSendFn>(deps, "telegram") ??
getTelegramRuntime().channel.telegram.sendMessageTelegram;
const result = await sendTelegramPayloadMessages({
send,
to,

View File

@ -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<typeof sendMessageDiscord>(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<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to, threadId });
const result = await send(target, text, {
verbose: false,

View File

@ -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(

View File

@ -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<typeof sendMessageIMessage>(deps, "imessage") ?? sendMessageIMessage
);
}
export const imessageOutbound = createDirectTextMediaOutbound({

View File

@ -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(

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

View File

@ -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: "<b>hello</b>",
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);

View File

@ -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<typeof sendMessageTelegram>(params.deps, "telegram") ??
sendMessageTelegram;
return {
send,
baseOpts: {

View File

@ -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);

View File

@ -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<WhatsAppSendMessage>(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<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
const result = await send(to, normalizeText(text), {
verbose: false,
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.imessage).not.toHaveBeenCalled();
const sendTelegram = deps.sendMessageTelegram as unknown as (
...args: unknown[]
) => Promise<unknown>;
const sendTelegram = deps["telegram"] as (...args: unknown[]) => Promise<unknown>;
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<unknown>;
const sendDiscord = deps["discord"] as (...args: unknown[]) => Promise<unknown>;
await sendDiscord("channel", "first", { 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 { 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<typeof import("./deps-send-whatsapp.runtime.js")> | null =
null;
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;
// Per-channel module caches for lazy loading.
const senderCache = new Map<string, Promise<Record<string, unknown>>>();
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<Record<string, unknown>>,
exportName: string,
): (...args: unknown[]) => Promise<unknown> {
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<unknown>;
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<Record<string, unknown>>,
"sendMessageWhatsApp",
),
telegram: createLazySender(
"telegram",
() => import("../telegram/send.js") as Promise<Record<string, unknown>>,
"sendMessageTelegram",
),
discord: createLazySender(
"discord",
() => import("../discord/send.js") as Promise<Record<string, unknown>>,
"sendMessageDiscord",
),
slack: createLazySender(
"slack",
() => import("../slack/send.js") as Promise<Record<string, unknown>>,
"sendMessageSlack",
),
signal: createLazySender(
"signal",
() => 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,
} from "./outbound-send-mapping.js";
export type CliDeps = Required<CliOutboundSendSource>;
export type CliDeps = CliOutboundSendSource;
export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps {
return createOutboundSendDepsFromCliSource(deps);

View File

@ -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,
});
});
});

View File

@ -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;
}

View File

@ -218,16 +218,7 @@ async function expectDefaultThinkLevel(params: {
function createTelegramOutboundPlugin() {
const sendWithTelegram = async (
ctx: {
deps?: {
sendTelegram?: (
to: string,
text: string,
opts: Record<string, unknown>,
) => 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<string, unknown>,
) => Promise<{ messageId: string; chatId: string }>)
| undefined;
if (!sendTelegram) {
throw new Error("sendTelegram dependency missing");
}

View File

@ -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,

View File

@ -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 })),
};
},
};

View File

@ -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 {

View File

@ -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;

View File

@ -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<HeartbeatDeps["sendWhatsApp"]>,
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<NonNullable<HeartbeatDeps["sendWhatsApp"]>>().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<NonNullable<HeartbeatDeps["sendWhatsApp"]>>().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<NonNullable<HeartbeatDeps["sendWhatsApp"]>>().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<NonNullable<HeartbeatDeps["sendWhatsApp"]>>()
.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<NonNullable<HeartbeatDeps["sendWhatsApp"]>>()
.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<NonNullable<HeartbeatDeps["sendWhatsApp"]>>()
.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<NonNullable<HeartbeatDeps["sendWhatsApp"]>>().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<NonNullable<HeartbeatDeps["sendWhatsApp"]>>()
.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<NonNullable<HeartbeatDeps["sendWhatsApp"]>>()
.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<NonNullable<HeartbeatDeps["sendWhatsApp"]>>()
.fn<
(to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
try {

View File

@ -47,7 +47,7 @@ describe("runHeartbeatOnce", () => {
await runHeartbeatOnce({
cfg,
deps: {
sendSlack,
slack: sendSlack,
getQueueSize: () => 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 { 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<NonNullable<OutboundSendDeps["sendWhatsApp"]>>()
.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 = {

View File

@ -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<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 = {
channel: Exclude<OutboundChannel, "none">;
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<typeof sendMessageSignal>(params.deps, "signal") ?? sendMessageSignal;
const mediaLocalRoots = getAgentScopedMediaLocalRoots(
cfg,
params.session?.agentId ?? params.mirror?.agentId,

View File

@ -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");
}

View File

@ -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,

View File

@ -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<unknown>) | 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" };