mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:10:43 +00:00
test: decouple outbound tests from bundled plugins
This commit is contained in:
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
Reference in New Issue
Block a user