openclaw-vainplex/src/infra/outbound/deliver.test.ts
plum-dawg c96ffa7186
feat: Add Line plugin (#1630)
* feat: add LINE plugin (#1630) (thanks @plum-dawg)

* feat: complete LINE plugin (#1630) (thanks @plum-dawg)

* chore: drop line plugin node_modules (#1630) (thanks @plum-dawg)

* test: mock /context report in commands test (#1630) (thanks @plum-dawg)

* test: limit macOS CI workers to avoid OOM (#1630) (thanks @plum-dawg)

* test: reduce macOS CI vitest workers (#1630) (thanks @plum-dawg)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-25 12:22:36 +00:00

384 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { signalOutbound } from "../../channels/plugins/outbound/signal.js";
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js";
import { markdownToSignalTextChunks } from "../../signal/format.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createIMessageTestPlugin,
createOutboundTestPlugin,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
const mocks = vi.hoisted(() => ({
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
}));
vi.mock("../../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
"../../config/sessions.js",
);
return {
...actual,
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
};
});
const { deliverOutboundPayloads, normalizeOutboundPayloads } = await import("./deliver.js");
describe("deliverOutboundPayloads", () => {
beforeEach(() => {
setActivePluginRegistry(defaultRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("chunks telegram markdown and passes through accountId", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: ClawdbotConfig = {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
process.env.TELEGRAM_BOT_TOKEN = "";
try {
const results = await deliverOutboundPayloads({
cfg,
channel: "telegram",
to: "123",
payloads: [{ text: "abcd" }],
deps: { sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledTimes(2);
for (const call of sendTelegram.mock.calls) {
expect(call[2]).toEqual(
expect.objectContaining({ accountId: undefined, verbose: false, textMode: "html" }),
);
}
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({ channel: "telegram", chatId: "c1" });
} finally {
if (prevTelegramToken === undefined) {
delete process.env.TELEGRAM_BOT_TOKEN;
} else {
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
}
}
});
it("passes explicit accountId to sendTelegram", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: ClawdbotConfig = {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
await deliverOutboundPayloads({
cfg,
channel: "telegram",
to: "123",
accountId: "default",
payloads: [{ text: "hi" }],
deps: { sendTelegram },
});
expect(sendTelegram).toHaveBeenCalledWith(
"123",
"hi",
expect.objectContaining({ accountId: "default", verbose: false, textMode: "html" }),
);
});
it("uses signal media maxBytes from config", async () => {
const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 });
const cfg: ClawdbotConfig = { channels: { signal: { mediaMaxMb: 2 } } };
const results = await deliverOutboundPayloads({
cfg,
channel: "signal",
to: "+1555",
payloads: [{ text: "hi", mediaUrl: "https://x.test/a.jpg" }],
deps: { sendSignal },
});
expect(sendSignal).toHaveBeenCalledWith(
"+1555",
"hi",
expect.objectContaining({
mediaUrl: "https://x.test/a.jpg",
maxBytes: 2 * 1024 * 1024,
textMode: "plain",
textStyles: [],
}),
);
expect(results[0]).toMatchObject({ channel: "signal", messageId: "s1" });
});
it("chunks Signal markdown using the format-first chunker", async () => {
const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 });
const cfg: ClawdbotConfig = {
channels: { signal: { textChunkLimit: 20 } },
};
const text = `Intro\\n\\n\`\`\`\`md\\n${"y".repeat(60)}\\n\`\`\`\\n\\nOutro`;
const expectedChunks = markdownToSignalTextChunks(text, 20);
await deliverOutboundPayloads({
cfg,
channel: "signal",
to: "+1555",
payloads: [{ text }],
deps: { sendSignal },
});
expect(sendSignal).toHaveBeenCalledTimes(expectedChunks.length);
expectedChunks.forEach((chunk, index) => {
expect(sendSignal).toHaveBeenNthCalledWith(
index + 1,
"+1555",
chunk.text,
expect.objectContaining({
accountId: undefined,
textMode: "plain",
textStyles: chunk.styles,
}),
);
});
});
it("chunks WhatsApp text and returns all results", async () => {
const sendWhatsApp = vi
.fn()
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
const cfg: ClawdbotConfig = {
channels: { whatsapp: { textChunkLimit: 2 } },
};
const results = await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "abcd" }],
deps: { sendWhatsApp },
});
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" });
const cfg: ClawdbotConfig = {
channels: { whatsapp: { textChunkLimit: 4000, chunkMode: "newline" } },
};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "Line one\n\nLine two" }],
deps: { sendWhatsApp },
});
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
1,
"+1555",
"Line one",
expect.objectContaining({ verbose: false }),
);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
2,
"+1555",
"\nLine two",
expect.objectContaining({ verbose: false }),
);
});
it("preserves fenced blocks for markdown chunkers in newline mode", async () => {
const chunker = vi.fn((text: string) => (text ? [text] : []));
const sendText = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
channel: "matrix" as const,
messageId: text,
roomId: "r1",
}));
const sendMedia = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
channel: "matrix" as const,
messageId: text,
roomId: "r1",
}));
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: {
deliveryMode: "direct",
chunker,
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText,
sendMedia,
},
}),
},
]),
);
const cfg: ClawdbotConfig = {
channels: { matrix: { textChunkLimit: 4000, chunkMode: "newline" } },
};
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
await deliverOutboundPayloads({
cfg,
channel: "matrix",
to: "!room",
payloads: [{ text }],
});
expect(chunker).toHaveBeenCalledTimes(2);
expect(chunker).toHaveBeenNthCalledWith(1, "```js\nconst a = 1;\nconst b = 2;\n```", 4000);
expect(chunker).toHaveBeenNthCalledWith(2, "After", 4000);
});
it("uses iMessage media maxBytes from agent fallback", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1" });
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "imessage",
source: "test",
plugin: createIMessageTestPlugin(),
},
]),
);
const cfg: ClawdbotConfig = {
agents: { defaults: { mediaMaxMb: 3 } },
};
await deliverOutboundPayloads({
cfg,
channel: "imessage",
to: "chat_id:42",
payloads: [{ text: "hello" }],
deps: { sendIMessage },
});
expect(sendIMessage).toHaveBeenCalledWith(
"chat_id:42",
"hello",
expect.objectContaining({ maxBytes: 3 * 1024 * 1024 }),
);
});
it("normalizes payloads and drops empty entries", () => {
const normalized = normalizeOutboundPayloads([
{ text: "hi" },
{ text: "MEDIA:https://x.test/a.jpg" },
{ text: " ", mediaUrls: [] },
]);
expect(normalized).toEqual([
{ text: "hi", mediaUrls: [] },
{ text: "", mediaUrls: ["https://x.test/a.jpg"] },
]);
});
it("continues on errors when bestEffort is enabled", async () => {
const sendWhatsApp = vi
.fn()
.mockRejectedValueOnce(new Error("fail"))
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
const onError = vi.fn();
const cfg: ClawdbotConfig = {};
const results = await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "a" }, { text: "b" }],
deps: { sendWhatsApp },
bestEffort: true,
onError,
});
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledTimes(1);
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
});
it("passes normalized payload to onError", async () => {
const sendWhatsApp = vi.fn().mockRejectedValue(new Error("boom"));
const onError = vi.fn();
const cfg: ClawdbotConfig = {};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "hi", mediaUrl: "https://x.test/a.jpg" }],
deps: { sendWhatsApp },
bestEffort: true,
onError,
});
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ text: "hi", mediaUrls: ["https://x.test/a.jpg"] }),
);
});
it("mirrors delivered output when mirror options are provided", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: ClawdbotConfig = {
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
};
mocks.appendAssistantMessageToSessionTranscript.mockClear();
await deliverOutboundPayloads({
cfg,
channel: "telegram",
to: "123",
payloads: [{ text: "caption", mediaUrl: "https://example.com/files/report.pdf?sig=1" }],
deps: { sendTelegram },
mirror: {
sessionKey: "agent:main:main",
text: "caption",
mediaUrls: ["https://example.com/files/report.pdf?sig=1"],
},
});
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith(
expect.objectContaining({ text: "report.pdf" }),
);
});
});
const emptyRegistry = createTestRegistry([]);
const defaultRegistry = createTestRegistry([
{
pluginId: "telegram",
plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }),
source: "test",
},
{
pluginId: "signal",
plugin: createOutboundTestPlugin({ id: "signal", outbound: signalOutbound }),
source: "test",
},
{
pluginId: "whatsapp",
plugin: createOutboundTestPlugin({ id: "whatsapp", outbound: whatsappOutbound }),
source: "test",
},
{
pluginId: "imessage",
plugin: createIMessageTestPlugin(),
source: "test",
},
]);