test: decouple outbound tests from bundled plugins

This commit is contained in:
Peter Steinberger
2026-04-20 22:44:06 +01:00
parent 27c52f8062
commit b7e5d9a96e
4 changed files with 237 additions and 732 deletions

View File

@@ -89,4 +89,15 @@ describe("whatsappOutbound sendPayload", () => {
expect(result).toEqual({ channel: "whatsapp", messageId: "" });
expect(sendWhatsApp).not.toHaveBeenCalled();
});
it("sanitizes HTML-only text to whitespace-only payload", () => {
expect(
whatsappOutbound
.sanitizeText?.({
text: "<br><br>",
payload: { text: "<br><br>" },
})
?.trim(),
).toBe("");
});
});

View File

@@ -1,239 +0,0 @@
import { vi } from "vitest";
import { createIMessageTestPlugin } from "../../../test/helpers/channels/imessage-test-plugin.js";
import {
imessageOutboundForTest,
signalOutbound,
whatsappOutbound,
} from "../../../test/helpers/infra/deliver-test-outbounds.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
releasePinnedPluginChannelRegistry,
setActivePluginRegistry,
} from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import type { DeliverOutboundPayloadsParams, OutboundDeliveryResult } from "./deliver.js";
type DeliverMockState = {
sessions: {
appendAssistantMessageToSessionTranscript: (...args: unknown[]) => Promise<{
ok: boolean;
sessionFile: string;
}>;
};
hooks: {
runner: {
hasHooks: (...args: unknown[]) => boolean;
runMessageSent: (...args: unknown[]) => Promise<void>;
};
};
internalHooks: {
createInternalHookEvent: typeof createInternalHookEventPayload;
triggerInternalHook: (...args: unknown[]) => Promise<void>;
};
queue: {
enqueueDelivery: (...args: unknown[]) => Promise<string>;
ackDelivery: (...args: unknown[]) => Promise<void>;
failDelivery: (...args: unknown[]) => Promise<void>;
};
log: {
warn: (...args: unknown[]) => void;
};
};
export const deliverMocks: DeliverMockState = {
sessions: {
appendAssistantMessageToSessionTranscript: async () => ({ ok: true, sessionFile: "x" }),
},
hooks: {
runner: {
hasHooks: () => false,
runMessageSent: async () => {},
},
},
internalHooks: {
createInternalHookEvent: createInternalHookEventPayload,
triggerInternalHook: async () => {},
},
queue: {
enqueueDelivery: async () => "mock-queue-id",
ackDelivery: async () => {},
failDelivery: async () => {},
},
log: {
warn: () => {},
},
};
const _mocks = vi.hoisted(() => ({
appendAssistantMessageToSessionTranscript: vi.fn(async () =>
deliverMocks.sessions.appendAssistantMessageToSessionTranscript(),
),
}));
const _hookMocks = vi.hoisted(() => ({
runner: {
hasHooks: vi.fn(() => deliverMocks.hooks.runner.hasHooks()),
runMessageSent: vi.fn(
async (...args: unknown[]) => await deliverMocks.hooks.runner.runMessageSent(...args),
),
},
}));
const _internalHookMocks = vi.hoisted(() => ({
createInternalHookEvent: vi.fn((...args: Parameters<typeof createInternalHookEventPayload>) =>
deliverMocks.internalHooks.createInternalHookEvent(...args),
),
triggerInternalHook: vi.fn(
async (...args: unknown[]) => await deliverMocks.internalHooks.triggerInternalHook(...args),
),
}));
const _queueMocks = vi.hoisted(() => ({
enqueueDelivery: vi.fn(
async (...args: unknown[]) => await deliverMocks.queue.enqueueDelivery(...args),
),
ackDelivery: vi.fn(async (...args: unknown[]) => await deliverMocks.queue.ackDelivery(...args)),
failDelivery: vi.fn(async (...args: unknown[]) => await deliverMocks.queue.failDelivery(...args)),
}));
const _logMocks = vi.hoisted(() => ({
warn: vi.fn((...args: unknown[]) => deliverMocks.log.warn(...args)),
}));
export const mocks = _mocks;
export const hookMocks = _hookMocks;
export const internalHookMocks = _internalHookMocks;
export const queueMocks = _queueMocks;
export const logMocks = _logMocks;
vi.mock("../../config/sessions/transcript.runtime.js", async () => {
const actual = await vi.importActual<
typeof import("../../config/sessions/transcript.runtime.js")
>("../../config/sessions/transcript.runtime.js");
return {
...actual,
appendAssistantMessageToSessionTranscript: _mocks.appendAssistantMessageToSessionTranscript,
};
});
vi.mock("../../config/sessions/transcript.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions/transcript.js")>(
"../../config/sessions/transcript.js",
);
return {
...actual,
appendAssistantMessageToSessionTranscript: _mocks.appendAssistantMessageToSessionTranscript,
};
});
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => _hookMocks.runner,
}));
vi.mock("../../hooks/internal-hooks.js", () => ({
createInternalHookEvent: _internalHookMocks.createInternalHookEvent,
triggerInternalHook: _internalHookMocks.triggerInternalHook,
}));
vi.mock("./delivery-queue.js", () => ({
enqueueDelivery: _queueMocks.enqueueDelivery,
ackDelivery: _queueMocks.ackDelivery,
failDelivery: _queueMocks.failDelivery,
}));
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => {
const makeLogger = () => ({
warn: _logMocks.warn,
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
child: vi.fn(() => makeLogger()),
});
return makeLogger();
},
}));
export const whatsappChunkConfig: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 4000 } },
};
export const defaultRegistry = createTestRegistry([
{
pluginId: "signal",
source: "test",
plugin: createOutboundTestPlugin({
id: "signal",
outbound: signalOutbound,
}),
},
{
pluginId: "whatsapp",
source: "test",
plugin: createOutboundTestPlugin({
id: "whatsapp",
outbound: whatsappOutbound,
}),
},
{
pluginId: "imessage",
source: "test",
plugin: createIMessageTestPlugin({ outbound: imessageOutboundForTest }),
},
]);
export const emptyRegistry = createTestRegistry([]);
export function resetDeliverTestState() {
releasePinnedPluginChannelRegistry();
setActivePluginRegistry(defaultRegistry);
deliverMocks.hooks.runner.hasHooks = () => false;
deliverMocks.hooks.runner.runMessageSent = async () => {};
deliverMocks.internalHooks.createInternalHookEvent = createInternalHookEventPayload;
deliverMocks.internalHooks.triggerInternalHook = async () => {};
deliverMocks.queue.enqueueDelivery = async () => "mock-queue-id";
deliverMocks.queue.ackDelivery = async () => {};
deliverMocks.queue.failDelivery = async () => {};
deliverMocks.log.warn = () => {};
deliverMocks.sessions.appendAssistantMessageToSessionTranscript = async () => ({
ok: true,
sessionFile: "x",
});
}
export function clearDeliverTestRegistry() {
releasePinnedPluginChannelRegistry();
setActivePluginRegistry(emptyRegistry);
}
export function resetDeliverTestMocks(params?: { includeSessionMocks?: boolean }) {
hookMocks.runner.hasHooks.mockClear();
hookMocks.runner.runMessageSent.mockClear();
internalHookMocks.createInternalHookEvent.mockClear();
internalHookMocks.triggerInternalHook.mockClear();
queueMocks.enqueueDelivery.mockClear();
queueMocks.ackDelivery.mockClear();
queueMocks.failDelivery.mockClear();
logMocks.warn.mockClear();
if (params?.includeSessionMocks) {
mocks.appendAssistantMessageToSessionTranscript.mockClear();
}
}
export async function runChunkedWhatsAppDelivery(params: {
deliverOutboundPayloads: (
params: DeliverOutboundPayloadsParams,
) => Promise<OutboundDeliveryResult[]>;
mirror?: DeliverOutboundPayloadsParams["mirror"];
}) {
const sendWhatsApp = vi
.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 = {
channels: { whatsapp: { textChunkLimit: 2 } },
};
const results = await params.deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "abcd" }],
deps: { whatsapp: sendWhatsApp },
...(params.mirror ? { mirror: params.mirror } : {}),
});
return { sendWhatsApp, results };
}

View File

@@ -1,11 +1,7 @@
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createIMessageTestPlugin } from "../../../test/helpers/channels/imessage-test-plugin.js";
import {
imessageOutboundForTest,
signalOutbound,
whatsappOutbound,
} from "../../../test/helpers/infra/deliver-test-outbounds.js";
import { chunkText } from "../../auto-reply/chunk.js";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import * as mediaCapabilityModule from "../../media/read-capability.js";
import { createHookRunner } from "../../plugins/hooks.js";
@@ -93,84 +89,139 @@ type DeliverModule = typeof import("./deliver.js");
let deliverOutboundPayloads: DeliverModule["deliverOutboundPayloads"];
let normalizeOutboundPayloads: DeliverModule["normalizeOutboundPayloads"];
const whatsappChunkConfig: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 4000 } },
const matrixChunkConfig: OpenClawConfig = {
channels: { matrix: { textChunkLimit: 4000 } } as OpenClawConfig["channels"],
};
const expectedPreferredTmpRoot = resolvePreferredOpenClawTmpDir();
type DeliverOutboundArgs = Parameters<DeliverModule["deliverOutboundPayloads"]>[0];
type DeliverOutboundPayload = DeliverOutboundArgs["payloads"][number];
type MatrixSendFn = (
to: string,
text: string,
options?: Record<string, unknown>,
) => Promise<{ messageId: string } & Record<string, unknown>>;
async function deliverWhatsAppPayload(params: {
sendWhatsApp: NonNullable<
NonNullable<Parameters<DeliverModule["deliverOutboundPayloads"]>[0]["deps"]>["whatsapp"]
>;
function resolveMatrixSender(deps: DeliverOutboundArgs["deps"]): MatrixSendFn {
const sender = deps?.matrix;
if (typeof sender !== "function") {
throw new Error("missing matrix sender");
}
return sender as MatrixSendFn;
}
function withMatrixChannel(result: Awaited<ReturnType<MatrixSendFn>>) {
return {
channel: "matrix" as const,
...result,
};
}
const matrixOutboundForTest: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
sanitizeText: ({ text }) => (text === "<br>" || text === "<br><br>" ? "" : text),
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) =>
withMatrixChannel(
await resolveMatrixSender(deps)(to, text, {
cfg,
accountId: accountId ?? undefined,
gifPlayback,
}),
),
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
gifPlayback,
}) =>
withMatrixChannel(
await resolveMatrixSender(deps)(to, text, {
cfg,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId: accountId ?? undefined,
gifPlayback,
}),
),
};
async function deliverMatrixPayload(params: {
sendMatrix: MatrixSendFn;
payload: DeliverOutboundPayload;
cfg?: OpenClawConfig;
}) {
return deliverOutboundPayloads({
cfg: params.cfg ?? whatsappChunkConfig,
channel: "whatsapp",
to: "+1555",
cfg: params.cfg ?? matrixChunkConfig,
channel: "matrix",
to: "!room:example",
payloads: [params.payload],
deps: { whatsapp: params.sendWhatsApp },
deps: { matrix: params.sendMatrix },
});
}
async function runChunkedWhatsAppDelivery(params?: {
async function runChunkedMatrixDelivery(params?: {
mirror?: Parameters<typeof deliverOutboundPayloads>[0]["mirror"];
}) {
const sendWhatsApp = vi
const sendMatrix = vi
.fn()
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
.mockResolvedValueOnce({ messageId: "m1", roomId: "!room:example" })
.mockResolvedValueOnce({ messageId: "m2", roomId: "!room:example" });
const cfg: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 2 } },
channels: { matrix: { textChunkLimit: 2 } } as OpenClawConfig["channels"],
};
const results = await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "abcd" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
...(params?.mirror ? { mirror: params.mirror } : {}),
});
return { sendWhatsApp, results };
return { sendMatrix, results };
}
async function deliverSingleWhatsAppForHookTest(params?: { sessionKey?: string }) {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
async function deliverSingleMatrixForHookTest(params?: { sessionKey?: string }) {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
await deliverOutboundPayloads({
cfg: whatsappChunkConfig,
channel: "whatsapp",
to: "+1555",
cfg: matrixChunkConfig,
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hello" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
...(params?.sessionKey ? { session: { key: params.sessionKey } } : {}),
});
}
async function runBestEffortPartialFailureDelivery() {
const sendWhatsApp = vi
const sendMatrix = vi
.fn()
.mockRejectedValueOnce(new Error("fail"))
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
.mockResolvedValueOnce({ messageId: "m2", roomId: "!room:example" });
const onError = vi.fn();
const cfg: OpenClawConfig = {};
const results = await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "a" }, { text: "b" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
bestEffort: true,
onError,
});
return { sendWhatsApp, onError, results };
return { sendMatrix, onError, results };
}
function expectSuccessfulWhatsAppInternalHookPayload(
function expectSuccessfulMatrixInternalHookPayload(
expected: Partial<{
content: string;
messageId: string;
@@ -179,10 +230,10 @@ function expectSuccessfulWhatsAppInternalHookPayload(
}>,
) {
return expect.objectContaining({
to: "+1555",
to: "!room:example",
success: true,
channelId: "whatsapp",
conversationId: "+1555",
channelId: "matrix",
conversationId: "!room:example",
...expected,
});
}
@@ -224,23 +275,23 @@ describe("deliverOutboundPayloads", () => {
mediaCapabilityModule,
"resolveAgentScopedOutboundMediaAccess",
);
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
await deliverOutboundPayloads({
cfg: {},
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hello", mediaUrl: "file:///tmp/policy.png" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
session: {
key: "agent:main:whatsapp:group:ops",
key: "agent:main:matrix:room:ops",
requesterSenderId: "attacker",
},
});
expect(resolveMediaAccessSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:whatsapp:group:ops",
sessionKey: "agent:main:matrix:room:ops",
messageProvider: undefined,
requesterSenderId: "attacker",
}),
@@ -253,17 +304,17 @@ describe("deliverOutboundPayloads", () => {
mediaCapabilityModule,
"resolveAgentScopedOutboundMediaAccess",
);
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w2", toJid: "jid" });
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m2", roomId: "!room:example" });
await deliverOutboundPayloads({
cfg: {},
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hello", mediaUrl: "file:///tmp/policy.png" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
session: {
key: "agent:main:whatsapp:group:ops",
requesterSenderId: "id:whatsapp:123",
key: "agent:main:matrix:room:ops",
requesterSenderId: "id:matrix:123",
requesterSenderName: "Alice",
requesterSenderUsername: "alice_u",
requesterSenderE164: "+15551234567",
@@ -272,7 +323,7 @@ describe("deliverOutboundPayloads", () => {
expect(resolveMediaAccessSpy).toHaveBeenCalledWith(
expect.objectContaining({
requesterSenderId: "id:whatsapp:123",
requesterSenderId: "id:matrix:123",
requesterSenderName: "Alice",
requesterSenderUsername: "alice_u",
requesterSenderE164: "+15551234567",
@@ -286,17 +337,17 @@ describe("deliverOutboundPayloads", () => {
mediaCapabilityModule,
"resolveAgentScopedOutboundMediaAccess",
);
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w3", toJid: "jid" });
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m3", roomId: "!room:example" });
await deliverOutboundPayloads({
cfg: {},
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
accountId: "destination-account",
payloads: [{ text: "hello", mediaUrl: "file:///tmp/policy.png" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
session: {
key: "agent:main:whatsapp:group:ops",
key: "agent:main:matrix:room:ops",
requesterAccountId: "source-account",
requesterSenderId: "attacker",
},
@@ -304,7 +355,7 @@ describe("deliverOutboundPayloads", () => {
expect(resolveMediaAccessSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:whatsapp:group:ops",
sessionKey: "agent:main:matrix:room:ops",
accountId: "source-account",
requesterSenderId: "attacker",
}),
@@ -317,16 +368,16 @@ describe("deliverOutboundPayloads", () => {
mediaCapabilityModule,
"resolveAgentScopedOutboundMediaAccess",
);
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w4", toJid: "jid" });
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m4", roomId: "!room:example" });
await deliverOutboundPayloads({
cfg: {},
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hello" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
session: {
key: "agent:main:whatsapp:group:ops",
key: "agent:main:matrix:room:ops",
requesterSenderId: "attacker",
},
});
@@ -473,19 +524,19 @@ describe("deliverOutboundPayloads", () => {
expect(sendMedia).not.toHaveBeenCalled();
});
it("includes OpenClaw tmp root in signal mediaLocalRoots", async () => {
const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 });
it("includes OpenClaw tmp root in plugin mediaLocalRoots", async () => {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-media", roomId: "!room" });
await deliverOutboundPayloads({
cfg: { channels: { signal: {} } },
channel: "signal",
to: "+1555",
cfg: { channels: { matrix: {} } } as OpenClawConfig,
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }],
deps: { sendSignal },
deps: { matrix: sendMatrix },
});
expect(sendSignal).toHaveBeenCalledWith(
"+1555",
expect(sendMatrix).toHaveBeenCalledWith(
"!room:example",
"hi",
expect.objectContaining({
mediaLocalRoots: expect.arrayContaining([expectedPreferredTmpRoot]),
@@ -493,18 +544,18 @@ describe("deliverOutboundPayloads", () => {
);
});
it("sends telegram media to an explicit target once instead of fanning out over allowFrom", async () => {
const sendMedia = vi.fn().mockResolvedValue({ channel: "telegram", messageId: "t1" });
it("sends plugin media to an explicit target once instead of fanning out over allowFrom", async () => {
const sendMedia = vi.fn().mockResolvedValue({ channel: "matrix", messageId: "m1" });
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "telegram",
id: "matrix",
outbound: {
deliveryMode: "direct",
sendText: vi.fn().mockResolvedValue({ channel: "telegram", messageId: "text-1" }),
sendText: vi.fn().mockResolvedValue({ channel: "matrix", messageId: "text-1" }),
sendMedia,
},
}),
@@ -515,14 +566,13 @@ describe("deliverOutboundPayloads", () => {
await deliverOutboundPayloads({
cfg: {
channels: {
telegram: {
botToken: "tok",
matrix: {
allowFrom: ["111", "222", "333"],
},
},
} as OpenClawConfig["channels"],
},
channel: "telegram",
to: "123",
channel: "matrix",
to: "!explicit:example",
payloads: [{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }],
skipQueue: true,
});
@@ -530,7 +580,7 @@ describe("deliverOutboundPayloads", () => {
expect(sendMedia).toHaveBeenCalledTimes(1);
expect(sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
to: "123",
to: "!explicit:example",
text: "HEARTBEAT_OK",
mediaUrl: "https://example.com/img.png",
accountId: undefined,
@@ -581,104 +631,66 @@ describe("deliverOutboundPayloads", () => {
);
});
it("includes OpenClaw tmp root in whatsapp mediaLocalRoots", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
it("chunks plugin text and returns all results", async () => {
const { sendMatrix, results } = await runChunkedMatrixDelivery();
await deliverOutboundPayloads({
cfg: whatsappChunkConfig,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }],
deps: { whatsapp: sendWhatsApp },
});
expect(sendWhatsApp).toHaveBeenCalledWith(
"+1555",
"hi",
expect.objectContaining({
mediaLocalRoots: expect.arrayContaining([expectedPreferredTmpRoot]),
}),
);
expect(sendMatrix).toHaveBeenCalledTimes(2);
expect(results.map((r) => r.messageId)).toEqual(["m1", "m2"]);
});
it("includes OpenClaw tmp root in imessage mediaLocalRoots", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1", chatId: "chat-1" });
await deliverOutboundPayloads({
cfg: {},
channel: "imessage",
to: "imessage:+15551234567",
payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }],
deps: { imessage: sendIMessage },
});
expect(sendIMessage).toHaveBeenCalledWith(
"imessage:+15551234567",
"hi",
expect.objectContaining({
mediaLocalRoots: expect.arrayContaining([expectedPreferredTmpRoot]),
}),
);
});
it("chunks WhatsApp text and returns all results", async () => {
const { sendWhatsApp, results } = await runChunkedWhatsAppDelivery();
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(results.map((r) => r.messageId)).toEqual(["w1", "w2"]);
});
it("respects newline chunk mode for WhatsApp", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
it("respects newline chunk mode for plugin text", async () => {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
const cfg: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 4000, chunkMode: "newline" } },
channels: {
matrix: { textChunkLimit: 4000, chunkMode: "newline" },
} as OpenClawConfig["channels"],
};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "Line one\n\nLine two" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
});
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
expect(sendMatrix).toHaveBeenCalledTimes(2);
expect(sendMatrix).toHaveBeenNthCalledWith(
1,
"+1555",
"!room:example",
"Line one",
expect.objectContaining({ verbose: false }),
expect.objectContaining({ cfg }),
);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
expect(sendMatrix).toHaveBeenNthCalledWith(
2,
"+1555",
"!room:example",
"Line two",
expect.objectContaining({ verbose: false }),
expect.objectContaining({ cfg }),
);
});
it("drops HTML-only WhatsApp text payloads after sanitization", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const results = await deliverWhatsAppPayload({
sendWhatsApp,
it("drops text payloads after adapter sanitization removes all content", async () => {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
const results = await deliverMatrixPayload({
sendMatrix,
payload: { text: "<br><br>" },
});
expect(sendWhatsApp).not.toHaveBeenCalled();
expect(sendMatrix).not.toHaveBeenCalled();
expect(results).toEqual([]);
});
it("drops non-WhatsApp HTML-only text payloads after sanitization", async () => {
const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", toJid: "jid" });
it("drops plugin HTML-only text payloads after sanitization", async () => {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
const results = await deliverOutboundPayloads({
cfg: {},
channel: "signal",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "<br>" }],
deps: { sendSignal },
deps: { matrix: sendMatrix },
});
expect(sendSignal).not.toHaveBeenCalled();
expect(sendMatrix).not.toHaveBeenCalled();
expect(results).toEqual([]);
});
@@ -730,14 +742,14 @@ describe("deliverOutboundPayloads", () => {
expect(chunker).toHaveBeenNthCalledWith(1, text, 4000);
});
it("passes config through for iMessage media sends so the channel runtime can resolve limits", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1" });
it("passes config through for plugin media sends", async () => {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-media", roomId: "!room" });
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "imessage",
pluginId: "matrix",
source: "test",
plugin: createIMessageTestPlugin(),
plugin: createOutboundTestPlugin({ id: "matrix", outbound: matrixOutboundForTest }),
},
]),
);
@@ -747,17 +759,17 @@ describe("deliverOutboundPayloads", () => {
await deliverOutboundPayloads({
cfg,
channel: "imessage",
to: "chat_id:42",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hello", mediaUrls: ["https://example.com/a.png"] }],
deps: { imessage: sendIMessage },
deps: { matrix: sendMatrix },
});
expect(sendIMessage).toHaveBeenCalledWith(
"chat_id:42",
expect(sendMatrix).toHaveBeenCalledWith(
"!room:example",
"hello",
expect.objectContaining({
config: cfg,
cfg,
mediaUrl: "https://example.com/a.png",
}),
);
@@ -776,74 +788,74 @@ describe("deliverOutboundPayloads", () => {
});
it("continues on errors when bestEffort is enabled", async () => {
const { sendWhatsApp, onError, results } = await runBestEffortPartialFailureDelivery();
const { sendMatrix, onError, results } = await runBestEffortPartialFailureDelivery();
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(sendMatrix).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledTimes(1);
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
expect(results).toEqual([{ channel: "matrix", messageId: "m2", roomId: "!room:example" }]);
});
it("emits internal message:sent hook with success=true for chunked payload delivery", async () => {
const { sendWhatsApp } = await runChunkedWhatsAppDelivery({
const { sendMatrix } = await runChunkedMatrixDelivery({
mirror: {
sessionKey: "agent:main:main",
isGroup: true,
groupId: "whatsapp:group:123",
groupId: "matrix:room:123",
},
});
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(sendMatrix).toHaveBeenCalledTimes(2);
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1);
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
"message",
"sent",
"agent:main:main",
expectSuccessfulWhatsAppInternalHookPayload({
expectSuccessfulMatrixInternalHookPayload({
content: "abcd",
messageId: "w2",
messageId: "m2",
isGroup: true,
groupId: "whatsapp:group:123",
groupId: "matrix:room:123",
}),
);
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
});
it("does not emit internal message:sent hook when neither mirror nor sessionKey is provided", async () => {
await deliverSingleWhatsAppForHookTest();
await deliverSingleMatrixForHookTest();
expect(internalHookMocks.createInternalHookEvent).not.toHaveBeenCalled();
expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled();
});
it("emits internal message:sent hook when sessionKey is provided without mirror", async () => {
await deliverSingleWhatsAppForHookTest({ sessionKey: "agent:main:main" });
await deliverSingleMatrixForHookTest({ sessionKey: "agent:main:main" });
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1);
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
"message",
"sent",
"agent:main:main",
expectSuccessfulWhatsAppInternalHookPayload({ content: "hello", messageId: "w1" }),
expectSuccessfulMatrixInternalHookPayload({ content: "hello", messageId: "m1" }),
);
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
});
it("warns when session.agentId is set without a session key", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
hookMocks.runner.hasHooks.mockReturnValue(true);
await deliverOutboundPayloads({
cfg: whatsappChunkConfig,
channel: "whatsapp",
to: "+1555",
cfg: matrixChunkConfig,
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hello" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
session: { agentId: "agent-main" },
});
expect(logMocks.warn).toHaveBeenCalledWith(
"deliverOutboundPayloads: session.agentId present without session key; internal message:sent hook will be skipped",
expect.objectContaining({ channel: "whatsapp", to: "+1555", agentId: "agent-main" }),
expect.objectContaining({ channel: "matrix", to: "!room:example", agentId: "agent-main" }),
);
});
@@ -862,7 +874,7 @@ describe("deliverOutboundPayloads", () => {
});
it("writes raw payloads to the queue before normalization", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w-raw", toJid: "jid" });
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-raw", roomId: "!room:example" });
const rawPayloads: DeliverOutboundPayload[] = [
{ text: "NO_REPLY" },
{ text: '{"action":"NO_REPLY"}' },
@@ -871,11 +883,11 @@ describe("deliverOutboundPayloads", () => {
];
await deliverOutboundPayloads({
cfg: whatsappChunkConfig,
channel: "whatsapp",
to: "+1555",
cfg: matrixChunkConfig,
channel: "matrix",
to: "!room:example",
payloads: rawPayloads,
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
});
expect(queueMocks.enqueueDelivery).toHaveBeenCalledTimes(1);
@@ -892,7 +904,7 @@ describe("deliverOutboundPayloads", () => {
});
it("acks the queue entry when delivery is aborted", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
const abortController = new AbortController();
abortController.abort();
const cfg: OpenClawConfig = {};
@@ -900,30 +912,30 @@ describe("deliverOutboundPayloads", () => {
await expect(
deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "a" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
abortSignal: abortController.signal,
}),
).rejects.toThrow("Operation aborted");
expect(queueMocks.ackDelivery).toHaveBeenCalledWith("mock-queue-id");
expect(queueMocks.failDelivery).not.toHaveBeenCalled();
expect(sendWhatsApp).not.toHaveBeenCalled();
expect(sendMatrix).not.toHaveBeenCalled();
});
it("passes normalized payload to onError", async () => {
const sendWhatsApp = vi.fn().mockRejectedValue(new Error("boom"));
const sendMatrix = vi.fn().mockRejectedValue(new Error("boom"));
const onError = vi.fn();
const cfg: OpenClawConfig = {};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hi", mediaUrl: "https://x.test/a.jpg" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
bestEffort: true,
onError,
});
@@ -977,19 +989,19 @@ describe("deliverOutboundPayloads", () => {
it("emits message_sent success for text-only deliveries", async () => {
hookMocks.runner.hasHooks.mockReturnValue(true);
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
await deliverOutboundPayloads({
cfg: {},
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hello" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
});
expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith(
expect.objectContaining({ to: "+1555", content: "hello", success: true }),
expect.objectContaining({ channelId: "whatsapp" }),
expect.objectContaining({ to: "!room:example", content: "hello", success: true }),
expect.objectContaining({ channelId: "matrix" }),
);
});
@@ -1019,19 +1031,19 @@ describe("deliverOutboundPayloads", () => {
realRunner.runMessageSending(event as never, ctx as never),
);
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
await deliverOutboundPayloads({
cfg: {},
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hello" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
});
expect(hookMocks.runner.runMessageSending).toHaveBeenCalledTimes(1);
expect(high).toHaveBeenCalledTimes(1);
expect(low).not.toHaveBeenCalled();
expect(sendWhatsApp).not.toHaveBeenCalled();
expect(sendMatrix).not.toHaveBeenCalled();
expect(hookMocks.runner.runMessageSent).not.toHaveBeenCalled();
});
@@ -1066,7 +1078,7 @@ describe("deliverOutboundPayloads", () => {
);
});
it("preserves channelData-only payloads with empty text for non-WhatsApp sendPayload channels", async () => {
it("preserves channelData-only payloads with empty text for sendPayload channels", async () => {
const sendPayload = vi.fn().mockResolvedValue({ channel: "line", messageId: "ln-1" });
const sendText = vi.fn();
const sendMedia = vi.fn();
@@ -1229,26 +1241,26 @@ describe("deliverOutboundPayloads", () => {
it("emits message_sent failure when delivery errors", async () => {
hookMocks.runner.hasHooks.mockReturnValue(true);
const sendWhatsApp = vi.fn().mockRejectedValue(new Error("downstream failed"));
const sendMatrix = vi.fn().mockRejectedValue(new Error("downstream failed"));
await expect(
deliverOutboundPayloads({
cfg: {},
channel: "whatsapp",
to: "+1555",
channel: "matrix",
to: "!room:example",
payloads: [{ text: "hi" }],
deps: { whatsapp: sendWhatsApp },
deps: { matrix: sendMatrix },
}),
).rejects.toThrow("downstream failed");
expect(hookMocks.runner.runMessageSent).toHaveBeenCalledWith(
expect.objectContaining({
to: "+1555",
to: "!room:example",
content: "hi",
success: false,
error: "downstream failed",
}),
expect.objectContaining({ channelId: "whatsapp" }),
expect.objectContaining({ channelId: "matrix" }),
);
});
});
@@ -1256,18 +1268,8 @@ describe("deliverOutboundPayloads", () => {
const emptyRegistry = createTestRegistry([]);
const defaultRegistry = createTestRegistry([
{
pluginId: "signal",
plugin: createOutboundTestPlugin({ id: "signal", outbound: signalOutbound }),
source: "test",
},
{
pluginId: "whatsapp",
plugin: createOutboundTestPlugin({ id: "whatsapp", outbound: whatsappOutbound }),
source: "test",
},
{
pluginId: "imessage",
plugin: createIMessageTestPlugin({ outbound: imessageOutboundForTest }),
pluginId: "matrix",
plugin: createOutboundTestPlugin({ id: "matrix", outbound: matrixOutboundForTest }),
source: "test",
},
]);

View File

@@ -1,269 +0,0 @@
import { chunkMarkdownTextWithMode, chunkText } from "../../../src/auto-reply/chunk.js";
import { resolveChannelMediaMaxBytes } from "../../../src/channels/plugins/media-limits.js";
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
resolveOutboundSendDep,
type OutboundSendDeps,
} from "../../../src/infra/outbound/send-deps.js";
import { sanitizeForPlainText } from "../../../src/plugin-sdk/outbound-runtime.js";
type SignalSendFn = (
to: string,
text: string,
options?: Record<string, unknown>,
) => Promise<{ messageId: string } & Record<string, unknown>>;
const MB = 1024 * 1024;
function resolveSignalMaxBytes(cfg: OpenClawConfig, accountId?: string): number | undefined {
const signalCfg = cfg.channels?.signal as
| {
mediaMaxMb?: number;
accounts?: Record<string, { mediaMaxMb?: number }>;
}
| undefined;
const accountMb = accountId ? signalCfg?.accounts?.[accountId]?.mediaMaxMb : undefined;
const mediaMaxMb = accountMb ?? signalCfg?.mediaMaxMb;
return typeof mediaMaxMb === "number" ? mediaMaxMb * MB : undefined;
}
function resolveSignalSender(deps: OutboundSendDeps | undefined): SignalSendFn {
const sender = resolveOutboundSendDep<SignalSendFn>(deps, "signal");
if (!sender) {
throw new Error("missing sendSignal dep");
}
return sender;
}
function resolveSignalTextChunkLimit(cfg: OpenClawConfig, accountId?: string | null): number {
const signalCfg = cfg.channels?.signal as
| {
textChunkLimit?: number;
accounts?: Record<string, { textChunkLimit?: number }>;
}
| undefined;
const accountLimit = accountId ? signalCfg?.accounts?.[accountId]?.textChunkLimit : undefined;
if (typeof accountLimit === "number") {
return accountLimit;
}
return typeof signalCfg?.textChunkLimit === "number" ? signalCfg.textChunkLimit : 4000;
}
function withSignalChannel(result: Awaited<ReturnType<SignalSendFn>>) {
return {
channel: "signal" as const,
...result,
};
}
export const signalOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
const limit = resolveSignalTextChunkLimit(cfg, accountId);
const chunks = chunkMarkdownTextWithMode(text, limit, "length");
const outputChunks = chunks.length === 0 && text ? [text] : chunks;
const results = [];
for (const chunk of outputChunks) {
abortSignal?.throwIfAborted();
results.push(
withSignalChannel(
await send(to, chunk, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
textMode: "plain",
textStyles: [],
}),
),
);
}
return results;
},
sendFormattedMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
abortSignal,
}) => {
abortSignal?.throwIfAborted();
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
return withSignalChannel(
await send(to, text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
textMode: "plain",
textStyles: [],
mediaLocalRoots,
mediaReadFile,
}),
);
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
return withSignalChannel(
await send(to, text, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
}),
);
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
}) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined);
return withSignalChannel(
await send(to, text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
mediaLocalRoots,
mediaReadFile,
}),
);
},
};
type WhatsAppSendFn = (
to: string,
text: string,
options?: Record<string, unknown>,
) => Promise<{ messageId: string } & Record<string, unknown>>;
function resolveWhatsAppSender(deps: OutboundSendDeps | undefined): WhatsAppSendFn {
const sender = resolveOutboundSendDep<WhatsAppSendFn>(deps, "whatsapp");
if (!sender) {
throw new Error("missing whatsapp dep");
}
return sender;
}
function withWhatsAppChannel(result: Awaited<ReturnType<WhatsAppSendFn>>) {
return {
channel: "whatsapp" as const,
...result,
};
}
export const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "gateway",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const send = resolveWhatsAppSender(deps);
return withWhatsAppChannel(
await send(to, text, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
gifPlayback,
}),
);
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
gifPlayback,
}) => {
const send = resolveWhatsAppSender(deps);
return withWhatsAppChannel(
await send(to, text, {
verbose: false,
cfg,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId: accountId ?? undefined,
gifPlayback,
}),
);
},
};
function resolveIMessageSender(deps: OutboundSendDeps | undefined) {
const sender = resolveOutboundSendDep<
(
to: string,
text: string,
options?: Record<string, unknown>,
) => Promise<{ messageId: string; chatId?: string }>
>(deps, "imessage");
if (!sender) {
throw new Error("missing sendIMessage dep");
}
return sender;
}
function withIMessageChannel(
result: Awaited<ReturnType<ReturnType<typeof resolveIMessageSender>>>,
) {
return {
channel: "imessage" as const,
...result,
};
}
function resolveIMessageMaxBytes(
cfg: OpenClawConfig,
accountId?: string | null,
): number | undefined {
return resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
}
export const imessageOutboundForTest: ChannelOutboundAdapter = {
deliveryMode: "direct",
sanitizeText: ({ text }) => text,
sendText: async ({ to, text, accountId, deps }) =>
withIMessageChannel(
await resolveIMessageSender(deps)(to, text, {
accountId: accountId ?? undefined,
}),
),
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, mediaReadFile, accountId, deps }) =>
withIMessageChannel(
await resolveIMessageSender(deps)(to, text, {
mediaUrl,
mediaLocalRoots,
mediaReadFile,
maxBytes: resolveIMessageMaxBytes(cfg, accountId),
accountId: accountId ?? undefined,
}),
),
};