test(whatsapp): update auto reply inbound fixtures

This commit is contained in:
Marcus Castro
2026-05-30 13:40:39 -03:00
committed by Shakker
parent eebcb100b8
commit 008d785a80
24 changed files with 914 additions and 808 deletions

View File

@@ -7,13 +7,14 @@ import {
sendWebDirectInboundAndCollectSessionKeys,
} from "./auto-reply.broadcast-groups.test-harness.js";
import {
createAcceptedWhatsAppSendResult,
createWebInboundDeliverySpies,
installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks,
resetLoadConfigMock,
sendWebGroupInboundMessage,
setLoadConfigMock,
} from "./auto-reply.test-harness.js";
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
installWebAutoReplyTestHomeHooks();
@@ -204,9 +205,7 @@ describe("broadcast groups", () => {
},
} satisfies OpenClawConfig);
const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1"));
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
const sendComposing = vi.fn();
const { sendMedia, reply, sendComposing } = createWebInboundDeliverySpies();
let started = 0;
let release: (() => void) | undefined;
@@ -226,20 +225,28 @@ describe("broadcast groups", () => {
const { onMessage: capturedOnMessage } = await monitorWebChannelWithCapture(resolver);
await capturedOnMessage({
id: "m1",
from: "+1000",
conversationId: "+1000",
to: "+2000",
accountId: "default",
body: "hello",
timestamp: Date.now(),
chatType: "direct",
chatId: "direct:+1000",
sendComposing,
reply,
sendMedia,
});
await capturedOnMessage(
createTestWebInboundMessage({
event: {
id: "m1",
timestamp: Date.now(),
},
payload: {
body: "hello",
},
platform: {
chatJid: "direct:+1000",
recipientJid: "+2000",
sendComposing,
reply,
sendMedia,
},
from: "+1000",
conversationId: "+1000",
accountId: "default",
chatType: "direct",
}),
);
expect(resolver).toHaveBeenCalledTimes(2);
resetLoadConfigMock();

View File

@@ -6,11 +6,11 @@ import {
sendWebDirectInboundMessage,
} from "./auto-reply.test-harness.js";
import { monitorWebChannel } from "./auto-reply/monitor.js";
import type { WebInboundMessage } from "./inbound.js";
import type { WebInboundMessageInput } from "./inbound.js";
export async function monitorWebChannelWithCapture(resolver: unknown): Promise<{
spies: ReturnType<typeof createWebInboundDeliverySpies>;
onMessage: (msg: WebInboundMessage) => Promise<void>;
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
}> {
const spies = createWebInboundDeliverySpies();
const { listenerFactory, getOnMessage } = createWebListenerFactoryCapture();

View File

@@ -9,13 +9,16 @@ import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest";
import type { WebChannelStatus } from "./auto-reply/types.js";
import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js";
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
import type { WebInboundMessageInput, WebListenerCloseReason } from "./inbound.js";
import type { WhatsAppSendResult } from "./inbound/send-result.js";
import { createAcceptedWhatsAppSendResult as createAcceptedWhatsAppSendResultForHarness } from "./inbound/send-result.test-helper.js";
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
import {
resetBaileysMocks as _resetBaileysMocks,
resetLoadConfigMock as _resetLoadConfigMock,
} from "./test-helpers.js";
export { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
export {
resetLoadConfigMock,
setLoadConfigMock,
@@ -235,16 +238,18 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
}
export function createWebListenerFactoryCapture(): AnyExport {
let capturedOnMessage: ((msg: WebInboundMessage) => Promise<void>) | undefined;
let capturedOnMessage: ((msg: WebInboundMessageInput) => Promise<void>) | undefined;
let capturedOptions:
| {
onMessage: (msg: WebInboundMessage) => Promise<void>;
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
shouldDebounce?: (msg: WebInboundMessageInput) => boolean;
debounceMs?: number;
selfChatMode?: boolean;
}
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: WebInboundMessage) => Promise<void>;
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
shouldDebounce?: (msg: WebInboundMessageInput) => boolean;
debounceMs?: number;
selfChatMode?: boolean;
}) => {
@@ -265,35 +270,31 @@ export function createMockWebListener(): MockWebListener {
close: vi.fn(async () => undefined),
onClose: new Promise<WebListenerCloseReason>(() => {}),
signalClose: vi.fn(),
sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "msg-1")),
sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResult("poll", "poll-1")),
sendContact: vi.fn(async () => createAcceptedWhatsAppSendResult("contact", "contact-1")),
sendLocation: vi.fn(async () => createAcceptedWhatsAppSendResult("location", "location-1")),
sendSticker: vi.fn(async () => createAcceptedWhatsAppSendResult("sticker", "sticker-1")),
sendReaction: vi.fn(async () => createAcceptedWhatsAppSendResult("reaction", "reaction-1")),
sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResultForHarness("text", "msg-1")),
sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResultForHarness("poll", "poll-1")),
sendContact: vi.fn(async () =>
createAcceptedWhatsAppSendResultForHarness("contact", "contact-1"),
),
sendLocation: vi.fn(async () =>
createAcceptedWhatsAppSendResultForHarness("location", "location-1"),
),
sendSticker: vi.fn(async () =>
createAcceptedWhatsAppSendResultForHarness("sticker", "sticker-1"),
),
sendReaction: vi.fn(async () =>
createAcceptedWhatsAppSendResultForHarness("reaction", "reaction-1"),
),
sendComposingTo: vi.fn(async () => undefined),
};
}
export function createAcceptedWhatsAppSendResult(
kind: WhatsAppSendKind,
id: string,
): WhatsAppSendResult {
return {
kind,
messageId: id,
keys: [{ id }],
providerAccepted: true,
};
}
export function createScriptedWebListenerFactory(): AnyExport {
const onMessages: Array<(msg: WebInboundMessage) => Promise<void>> = [];
const onMessages: Array<(msg: WebInboundMessageInput) => Promise<void>> = [];
const closeResolvers: Array<(reason: unknown) => void> = [];
const listeners: MockWebListener[] = [];
const listenerFactory = vi.fn(
async (opts: { onMessage: (msg: WebInboundMessage) => Promise<void> }) => {
async (opts: { onMessage: (msg: WebInboundMessageInput) => Promise<void> }) => {
onMessages.push(opts.onMessage);
let resolveClose: (reason: unknown) => void = () => {};
const onClose = new Promise<WebListenerCloseReason>((res) => {
@@ -321,8 +322,8 @@ export function createScriptedWebListenerFactory(): AnyExport {
export function createWebInboundDeliverySpies(): AnyExport {
return {
sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")),
reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")),
sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResultForHarness("media", "m1")),
reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResultForHarness("text", "r1")),
sendComposing: vi.fn(),
};
}
@@ -373,7 +374,7 @@ export function startWebAutoReplyMonitor(params: {
}
export async function sendWebGroupInboundMessage(params: {
onMessage: (msg: WebInboundMessage) => Promise<void>;
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
body: string;
id: string;
senderE164: string;
@@ -387,28 +388,38 @@ export async function sendWebGroupInboundMessage(params: {
}) {
const conversationId = params.conversationId ?? "123@g.us";
const accountId = params.accountId ?? "default";
await params.onMessage({
body: params.body,
from: conversationId,
conversationId,
chatId: conversationId,
chatType: "group",
to: "+2",
accountId,
id: params.id,
senderE164: params.senderE164,
senderName: params.senderName,
mentionedJids: params.mentionedJids,
selfE164: params.selfE164,
selfJid: params.selfJid,
sendComposing: params.spies.sendComposing,
reply: params.spies.reply,
sendMedia: params.spies.sendMedia,
} as WebInboundMessage);
await params.onMessage(
createTestWebInboundMessage({
event: { id: params.id },
payload: { body: params.body },
platform: {
chatJid: conversationId,
recipientJid: "+2",
senderE164: params.senderE164,
senderName: params.senderName,
selfE164: params.selfE164,
selfJid: params.selfJid,
sendComposing: params.spies.sendComposing,
reply: params.spies.reply,
sendMedia: params.spies.sendMedia,
},
from: conversationId,
conversationId,
chatType: "group",
accountId,
group: params.mentionedJids?.length
? {
mentions: {
jids: params.mentionedJids,
},
}
: undefined,
}),
);
}
export async function sendWebDirectInboundMessage(params: {
onMessage: (msg: WebInboundMessage) => Promise<void>;
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
body: string;
id: string;
from: string;
@@ -418,18 +429,26 @@ export async function sendWebDirectInboundMessage(params: {
timestamp?: number;
}) {
const accountId = params.accountId ?? "default";
await params.onMessage({
accountId,
id: params.id,
from: params.from,
conversationId: params.from,
to: params.to,
body: params.body,
timestamp: params.timestamp ?? Date.now(),
chatType: "direct",
chatId: `direct:${params.from}`,
sendComposing: params.spies.sendComposing,
reply: params.spies.reply,
sendMedia: params.spies.sendMedia,
} as WebInboundMessage);
await params.onMessage(
createTestWebInboundMessage({
accountId,
event: {
id: params.id,
timestamp: params.timestamp ?? Date.now(),
},
payload: {
body: params.body,
},
platform: {
chatJid: `direct:${params.from}`,
recipientJid: params.to,
sendComposing: params.spies.sendComposing,
reply: params.spies.reply,
sendMedia: params.spies.sendMedia,
},
from: params.from,
conversationId: params.from,
chatType: "direct",
}),
);
}

View File

@@ -4,13 +4,14 @@ import { createNoisyPngBuffer, createSolidPngBuffer } from "openclaw/plugin-sdk/
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
createMockWebListener,
createAcceptedWhatsAppSendResult,
createWebInboundDeliverySpies,
installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks,
resetLoadConfigMock,
setLoadConfigMock,
} from "./auto-reply.test-harness.js";
import type { WebInboundMessage } from "./inbound.js";
import type { WebInboundCallbackMessage, WebInboundMessageInput } from "./inbound.js";
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
installWebAutoReplyTestHomeHooks();
@@ -19,6 +20,10 @@ let monitorWebChannel: typeof import("./auto-reply/monitor.js").monitorWebChanne
describe("web auto-reply", () => {
installWebAutoReplyUnitTestHooks({ pinDns: true });
type ListenerFactory = NonNullable<Parameters<typeof monitorWebChannel>[1]>;
type WebInboundPlatform = WebInboundCallbackMessage["platform"];
type ReplyMock = ReturnType<typeof vi.fn<WebInboundPlatform["reply"]>>;
type SendMediaMock = ReturnType<typeof vi.fn<WebInboundPlatform["sendMedia"]>>;
type SendComposingMock = ReturnType<typeof vi.fn<WebInboundPlatform["sendComposing"]>>;
const SMALL_MEDIA_CAP_MB = 0.1;
const SMALL_MEDIA_CAP_BYTES = Math.floor(SMALL_MEDIA_CAP_MB * 1024 * 1024);
@@ -28,15 +33,19 @@ describe("web auto-reply", () => {
async function setupSingleInboundMessage(params: {
resolverValue: { text: string; mediaUrl: string };
sendMedia: ReturnType<typeof vi.fn>;
reply?: ReturnType<typeof vi.fn>;
sendMedia?: SendMediaMock;
reply?: ReplyMock;
}) {
const reply =
params.reply ?? vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
const sendComposing = vi.fn(async () => undefined);
const spies = createWebInboundDeliverySpies() as {
sendMedia: SendMediaMock;
reply: ReplyMock;
sendComposing: SendComposingMock;
};
const reply = params.reply ?? spies.reply;
const sendMedia = params.sendMedia ?? spies.sendMedia;
const resolver = vi.fn().mockResolvedValue(params.resolverValue);
let capturedOnMessage: ((msg: WebInboundMessage) => Promise<void>) | undefined;
let capturedOnMessage: ((msg: WebInboundMessageInput) => Promise<void>) | undefined;
const listenerFactory: ListenerFactory = async ({ onMessage }) => {
capturedOnMessage = onMessage;
return createMockWebListener();
@@ -50,26 +59,41 @@ describe("web auto-reply", () => {
return {
reply,
sendMedia,
dispatch: async (
id = "msg1",
overrides?: Partial<
Pick<WebInboundMessage, "from" | "conversationId" | "to" | "accountId" | "chatId">
>,
overrides?: Partial<{
from: string;
conversationId: string;
accountId: string;
recipientJid: string;
chatJid: string;
}>,
) => {
await onMessage({
body: "hello",
from: "+1",
conversationId: "+1",
to: "+2",
accountId: "default",
chatType: "direct",
chatId: "+1",
...overrides,
id,
sendComposing,
reply,
sendMedia: params.sendMedia,
} as WebInboundMessage);
const from = overrides?.from ?? "+1";
const conversationId = overrides?.conversationId ?? from;
const chatJid = overrides?.chatJid ?? from;
await onMessage(
createTestWebInboundMessage({
event: {
id,
},
payload: {
body: "hello",
},
platform: {
chatJid,
recipientJid: overrides?.recipientJid ?? "+2",
sendComposing: spies.sendComposing,
reply,
sendMedia,
},
from,
conversationId,
accountId: overrides?.accountId ?? "default",
chatType: "direct",
}),
);
},
};
}
@@ -115,15 +139,21 @@ describe("web auto-reply", () => {
}
}
function mockFetchMediaBuffer(buffer: Buffer, mime: string) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
body: true,
function fetchResponse(body: Buffer | null, mime: string, status = 200): Response {
return {
ok: status < 400,
body: body ? true : null,
arrayBuffer: async () =>
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
body
? body.buffer.slice(body.byteOffset, body.byteOffset + body.length)
: new ArrayBuffer(0),
headers: new Headers({ "content-type": mime }),
status: 200,
} as unknown as Response);
status,
} as unknown as Response;
}
function mockFetchMediaBuffer(buffer: Buffer, mime: string) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue(fetchResponse(buffer, mime));
}
async function expectCompressedImageWithinCap(params: {
@@ -134,10 +164,8 @@ describe("web auto-reply", () => {
mediaMaxMb?: number;
}) {
await withMediaCap(params.mediaMaxMb ?? 1, async () => {
const sendMedia = vi.fn();
const { reply, dispatch } = await setupSingleInboundMessage({
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: { text: "hi", mediaUrl: params.mediaUrl },
sendMedia,
});
const fetchMock = mockFetchMediaBuffer(params.image, params.mime);
@@ -173,13 +201,11 @@ describe("web auto-reply", () => {
] as const;
await withMediaCap(1, async () => {
const sendMedia = vi.fn();
const { reply, dispatch } = await setupSingleInboundMessage({
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: {
text: "hi",
mediaUrl: "https://example.com/big.image",
},
sendMedia,
});
let fetchIndex = 0;
@@ -187,14 +213,7 @@ describe("web auto-reply", () => {
const matched = formats[Math.min(fetchIndex, formats.length - 1)] ?? formats[0];
fetchIndex += 1;
const { image, mime } = matched;
return {
ok: true,
body: true,
arrayBuffer: async () =>
image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength),
headers: new Headers({ "content-type": mime }),
status: 200,
} as unknown as Response;
return fetchResponse(image, mime);
});
try {
@@ -203,7 +222,7 @@ describe("web auto-reply", () => {
await dispatch(`msg-${fmt.name}-${index}`, {
from: `+1${index}`,
conversationId: `conv-${index}`,
chatId: `conv-${index}`,
chatJid: `conv-${index}`,
});
expect(sendMedia).toHaveBeenCalledTimes(beforeCalls + 1);
const payload = imagePayloadAt(sendMedia, beforeCalls);
@@ -250,10 +269,8 @@ describe("web auto-reply", () => {
}));
try {
const sendMedia = vi.fn();
const { reply, dispatch } = await setupSingleInboundMessage({
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: { text: "hi", mediaUrl: "https://example.com/account-big.png" },
sendMedia,
});
const fetchMock = mockFetchMediaBuffer(bigPng, "image/png");
@@ -269,10 +286,8 @@ describe("web auto-reply", () => {
}
});
it("sends PDF media as a document", async () => {
const sendMedia = vi.fn();
const { reply, dispatch } = await setupSingleInboundMessage({
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: { text: "hi", mediaUrl: "https://example.com/file.pdf" },
sendMedia,
});
const fetchMock = mockFetchMediaBuffer(Buffer.from("%PDF-1.4"), "application/pdf");
@@ -294,7 +309,7 @@ describe("web auto-reply", () => {
});
it("falls back to text when media send fails", async () => {
const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));
const sendMedia = vi.fn<WebInboundPlatform["sendMedia"]>().mockRejectedValue(new Error("boom"));
const { reply, dispatch } = await setupSingleInboundMessage({
resolverValue: {
text: "hi",
@@ -304,14 +319,7 @@ describe("web auto-reply", () => {
});
const smallPng = createSolidPngBuffer(64, 64, { r: 0, g: 255, b: 0 });
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
body: true,
arrayBuffer: async () =>
smallPng.buffer.slice(smallPng.byteOffset, smallPng.byteOffset + smallPng.byteLength),
headers: new Headers({ "content-type": "image/png" }),
status: 200,
} as unknown as Response);
const fetchMock = mockFetchMediaBuffer(smallPng, "image/png");
await dispatch("msg1");
@@ -322,22 +330,16 @@ describe("web auto-reply", () => {
fetchMock.mockRestore();
});
it("returns a warning when remote media fetch 404s", async () => {
const sendMedia = vi.fn();
const { reply, dispatch } = await setupSingleInboundMessage({
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: {
text: "caption",
mediaUrl: "https://example.com/missing.jpg",
},
sendMedia,
});
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 404,
body: null,
arrayBuffer: async () => new ArrayBuffer(0),
headers: new Headers({ "content-type": "text/plain" }),
} as unknown as Response);
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(fetchResponse(null, "text/plain", 404));
await dispatch("msg1");
@@ -350,24 +352,16 @@ describe("web auto-reply", () => {
fetchMock.mockRestore();
});
it("sends media with a caption when delivery succeeds", async () => {
const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1"));
const { reply, dispatch } = await setupSingleInboundMessage({
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
resolverValue: {
text: "hi",
mediaUrl: "https://example.com/img.png",
},
sendMedia,
});
const png = createSolidPngBuffer(64, 64, { r: 0, g: 0, b: 255 });
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
body: true,
arrayBuffer: async () => png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength),
headers: new Headers({ "content-type": "image/png" }),
status: 200,
} as unknown as Response);
const fetchMock = mockFetchMediaBuffer(png, "image/png");
await dispatch("msg1");

View File

@@ -13,7 +13,6 @@ import { WhatsAppAuthUnstableError, resolveWebCredsPath } from "./auth-store.js"
import { resolveOAuthDir } from "./auth-store.runtime.js";
import {
createWebInboundDeliverySpies,
createAcceptedWhatsAppSendResult,
createMockWebListener,
createScriptedWebListenerFactory,
createWebListenerFactoryCapture,
@@ -28,6 +27,11 @@ import {
startWebAutoReplyMonitor,
} from "./auto-reply.test-harness.js";
import { waitForWaConnection } from "./session.js";
import {
createTestLegacyFlatWebInboundMessage,
createTestWebInboundMessage,
} from "./inbound/test-message.test-helper.js";
import type { WebInboundMessageInput } from "./inbound/types.js";
type DrainSelectionEntry = {
channel: string;
@@ -967,6 +971,35 @@ describe("web auto-reply connection", () => {
expect(capture.getLastOptions()?.debounceMs).toBe(250);
});
it("normalizes legacy flat listener messages and rejects partial nested input", async () => {
const capture = createWebListenerFactoryCapture();
const { reply } = createWebInboundDeliverySpies();
await monitorWebChannel(false, capture.listenerFactory as never, false, async () => ({
text: "ok",
}));
const onMessage = requireOnMessage(capture.getOnMessage());
const msg = createTestLegacyFlatWebInboundMessage({
from: "+1",
conversationId: "+1",
chatId: "+1",
to: "+2",
reply,
});
expect(capture.getLastOptions()?.shouldDebounce?.(msg)).toBe(true);
await onMessage(msg);
expect(reply).toHaveBeenCalledWith("ok", undefined);
await expect(
onMessage({
...msg,
id: "partial-msg",
payload: { body: "partial nested" },
} as unknown as WebInboundMessageInput),
).rejects.toThrow(/legacy flat or canonical nested/);
});
it("processes inbound messages without batching and preserves timestamps", async () => {
await withEnvAsync({ TZ: "Europe/Vienna" }, async () => {
const originalMax = process.getMaxListeners();
@@ -977,9 +1010,7 @@ describe("web auto-reply connection", () => {
});
try {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
const sendComposing = vi.fn();
const { sendMedia, reply, sendComposing } = createWebInboundDeliverySpies();
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
const capture = createWebListenerFactoryCapture();
@@ -1100,19 +1131,27 @@ describe("web auto-reply connection", () => {
await monitorWebChannel(false, capture.listenerFactory as never, false, resolver as never);
const capturedOnMessage = requireOnMessage(capture.getOnMessage());
await capturedOnMessage({
body: "hello",
from: "+1",
conversationId: "+1",
to: "+2",
accountId: "default",
chatType: "direct",
chatId: "+1",
id: "msg1",
sendComposing: vi.fn(),
reply: vi.fn(),
sendMedia: vi.fn(),
});
await capturedOnMessage(
createTestWebInboundMessage({
event: {
id: "msg1",
},
payload: {
body: "hello",
},
platform: {
chatJid: "+1",
recipientJid: "+2",
sendComposing: vi.fn(),
reply: vi.fn(),
sendMedia: vi.fn(),
},
from: "+1",
conversationId: "+1",
accountId: "default",
chatType: "direct",
}),
);
const content = await fs.readFile(logPath, "utf-8");
expect(content).toMatch(/web-auto-reply/);
@@ -1131,9 +1170,7 @@ describe("web auto-reply connection", () => {
markDispatchIdle,
cleanup: vi.fn(),
};
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
const sendComposing = vi.fn().mockResolvedValue(undefined);
const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1"));
const { reply, sendComposing, sendMedia } = createWebInboundDeliverySpies();
const replyResolver = vi.fn().mockImplementation(async (ctx, opts) => {
void ctx;
@@ -1150,20 +1187,28 @@ describe("web auto-reply connection", () => {
await monitorWebChannel(
false,
async ({ onMessage }) => {
await onMessage({
id: "m1",
from: "+1000",
conversationId: "+1000",
to: "+2000",
body: "hello",
timestamp: Date.now(),
chatType: "direct",
chatId: "direct:+1000",
accountId: "default",
sendComposing,
reply,
sendMedia,
});
await onMessage(
createTestWebInboundMessage({
event: {
id: "m1",
timestamp: Date.now(),
},
payload: {
body: "hello",
},
platform: {
chatJid: "direct:+1000",
recipientJid: "+2000",
sendComposing,
reply,
sendMedia,
},
from: "+1000",
conversationId: "+1000",
chatType: "direct",
accountId: "default",
}),
);
return createMockWebListener();
},
false,

View File

@@ -2,15 +2,12 @@
import "./test-helpers.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createAcceptedWhatsAppSendResult,
installWebAutoReplyUnitTestHooks,
makeSessionStore,
} from "./auto-reply.test-harness.js";
import { installWebAutoReplyUnitTestHooks, makeSessionStore } from "./auto-reply.test-harness.js";
import { buildMentionConfig } from "./auto-reply/mentions.js";
import { createEchoTracker } from "./auto-reply/monitor/echo.js";
import { awaitBackgroundTasks } from "./auto-reply/monitor/last-route.js";
import { createWebOnMessageHandler } from "./auto-reply/monitor/on-message.js";
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn());
@@ -78,23 +75,26 @@ function buildInboundMessage(params: {
senderName?: string;
selfE164?: string;
}) {
return {
id: params.id,
return createTestWebInboundMessage({
event: {
id: params.id,
timestamp: params.timestamp,
},
payload: {
body: params.body ?? "hello",
},
platform: {
chatJid: params.chatId,
recipientJid: params.to ?? "+2000",
senderE164: params.senderE164,
senderName: params.senderName,
selfE164: params.selfE164,
},
from: params.from,
conversationId: params.conversationId,
to: params.to ?? "+2000",
body: params.body ?? "hello",
timestamp: params.timestamp,
chatType: params.chatType,
chatId: params.chatId,
accountId: params.accountId ?? "default",
senderE164: params.senderE164,
senderName: params.senderName,
selfE164: params.selfE164,
sendComposing: vi.fn().mockResolvedValue(undefined),
reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")),
sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")),
};
});
}
describe("web auto-reply last-route", () => {

View File

@@ -7,9 +7,11 @@ import {
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { sleep } from "openclaw/plugin-sdk/text-utility-runtime";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { createAcceptedWhatsAppSendResult } from "../inbound/send-result.test-helper.js";
import { createTestWebInboundMessage } from "../inbound/test-message.test-helper.js";
import type { WebInboundMessage } from "../inbound/types.js";
import { loadWebMedia } from "../media.js";
import { cacheInboundMessageMeta } from "../quoted-message.js";
import type { WebInboundMsg } from "./types.js";
const hoisted = vi.hoisted(() => ({
runFfmpeg: vi.fn(),
@@ -53,19 +55,6 @@ vi.mock("../media.js", () => ({
let deliverWebReply: typeof import("./deliver-reply.js").deliverWebReply;
let whatsappOutbound: typeof import("../outbound-adapter.js").whatsappOutbound;
function acceptedSendResult(kind: "media" | "text", id: string) {
return {
kind,
messageId: id,
receipt: createMessageReceiptFromOutboundResults({
kind,
results: [{ channel: "whatsapp", messageId: id }],
}),
keys: [{ id }],
providerAccepted: true,
};
}
function unacceptedSendResult(kind: "media" | "text") {
return {
kind,
@@ -79,19 +68,22 @@ function unacceptedSendResult(kind: "media" | "text") {
};
}
function makeMsg(): WebInboundMsg {
return {
function makeMsg(): WebInboundMessage {
return createTestWebInboundMessage({
event: { id: "msg-1" },
payload: { body: "latest batch body" },
platform: {
chatJid: "15551234567@s.whatsapp.net",
recipientJid: "+20000000000",
senderJid: "222@s.whatsapp.net",
reply: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "reply-sent-1")),
sendMedia: vi.fn(async () => createAcceptedWhatsAppSendResult("media", "media-sent-1")),
},
from: "+10000000000",
to: "+20000000000",
accountId: "work",
chatId: "15551234567@s.whatsapp.net",
chatType: "group",
id: "msg-1",
body: "latest batch body",
senderJid: "222@s.whatsapp.net",
reply: vi.fn(async () => acceptedSendResult("text", "reply-sent-1")),
sendMedia: vi.fn(async () => acceptedSendResult("media", "media-sent-1")),
} as unknown as WebInboundMsg;
conversationId: "+10000000000",
});
}
function mockLoadedImageMedia() {
@@ -104,26 +96,28 @@ function mockLoadedImageMedia() {
});
}
function mockFirstSendMediaFailure(msg: WebInboundMsg, message: string) {
function mockFirstSendMediaFailure(msg: WebInboundMessage, message: string) {
(
msg.sendMedia as unknown as { mockRejectedValueOnce: (v: unknown) => void }
msg.platform.sendMedia as unknown as { mockRejectedValueOnce: (v: unknown) => void }
).mockRejectedValueOnce(new Error(message));
}
function mockFirstReplyFailure(msg: WebInboundMsg, message: string) {
(msg.reply as unknown as { mockRejectedValueOnce: (v: unknown) => void }).mockRejectedValueOnce(
new Error(message),
);
function mockFirstReplyFailure(msg: WebInboundMessage, message: string) {
(
msg.platform.reply as unknown as { mockRejectedValueOnce: (v: unknown) => void }
).mockRejectedValueOnce(new Error(message));
}
function mockFirstReplyFailureWithWrappedError(msg: WebInboundMsg, message: string) {
(msg.reply as unknown as { mockRejectedValueOnce: (v: unknown) => void }).mockRejectedValueOnce({
function mockFirstReplyFailureWithWrappedError(msg: WebInboundMessage, message: string) {
(
msg.platform.reply as unknown as { mockRejectedValueOnce: (v: unknown) => void }
).mockRejectedValueOnce({
error: { message },
});
}
function expectFirstSendMediaPayload(msg: WebInboundMsg) {
const payload = mockCallArg(msg.sendMedia, 0, 0, "sendMedia");
function expectFirstSendMediaPayload(msg: WebInboundMessage) {
const payload = mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia");
if (!payload) {
throw new Error("expected first WhatsApp sendMedia payload");
}
@@ -149,8 +143,8 @@ function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label:
return call[argIndex];
}
function replyText(msg: WebInboundMsg, callIndex = 0): string {
return String(mockCallArg(msg.reply, callIndex, 0, "reply"));
function replyText(msg: WebInboundMessage, callIndex = 0): string {
return String(mockCallArg(msg.platform.reply, callIndex, 0, "reply"));
}
function findLoggerContext(mock: unknown, message: string, label: string) {
@@ -181,10 +175,10 @@ function expectQuotedOptions(
expect(quoted.message).toEqual({ conversation: expected.body });
}
function mockSecondReplySuccess(msg: WebInboundMsg) {
(msg.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }).mockResolvedValueOnce(
acceptedSendResult("text", "reply-retry-2"),
);
function mockSecondReplySuccess(msg: WebInboundMessage) {
(
msg.platform.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }
).mockResolvedValueOnce(createAcceptedWhatsAppSendResult("text", "reply-retry-2"));
}
const replyLogger = {
@@ -202,8 +196,8 @@ async function expectReplySuppressed(replyResult: { text: string; isReasoning?:
replyLogger,
skipLog: true,
});
expect(msg.reply).not.toHaveBeenCalled();
expect(msg.sendMedia).not.toHaveBeenCalled();
expect(msg.platform.reply).not.toHaveBeenCalled();
expect(msg.platform.sendMedia).not.toHaveBeenCalled();
}
describe("deliverWebReply", () => {
@@ -236,8 +230,8 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.reply).toHaveBeenCalledTimes(1);
expect(msg.reply).toHaveBeenCalledWith(
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
expect(msg.platform.reply).toHaveBeenCalledWith(
"Intro line\nReasoning: appears in content but is not a prefix",
undefined,
);
@@ -255,9 +249,9 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.reply).toHaveBeenCalledTimes(2);
expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa", undefined);
expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa", undefined);
expect(msg.platform.reply).toHaveBeenCalledTimes(2);
expect(msg.platform.reply).toHaveBeenNthCalledWith(1, "aaa", undefined);
expect(msg.platform.reply).toHaveBeenNthCalledWith(2, "aaa", undefined);
expect(typeof mockCallArg(replyLogger.info, 0, 0, "replyLogger.info")).toBe("object");
expect(mockCallArg(replyLogger.info, 0, 1, "replyLogger.info")).toBe("auto-reply sent (text)");
expect(delivery.providerAccepted).toBe(true);
@@ -270,7 +264,7 @@ describe("deliverWebReply", () => {
it("reports text replies that Baileys did not accept", async () => {
const msg = makeMsg();
vi.mocked(msg.reply).mockResolvedValueOnce(unacceptedSendResult("text"));
vi.mocked(msg.platform.reply).mockResolvedValueOnce(unacceptedSendResult("text"));
const delivery = await deliverWebReply({
replyResult: { text: "hello" },
@@ -281,7 +275,7 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.reply).toHaveBeenCalledTimes(1);
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
expect(delivery.receipt.platformMessageIds).toEqual([]);
expect(delivery.receipt.parts).toEqual([]);
expect(delivery.providerAccepted).toBe(false);
@@ -305,7 +299,7 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.reply).toHaveBeenCalledTimes(1);
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
const sentText = replyText(msg);
expect(sentText).not.toContain("function_calls");
expect(sentText).not.toContain("invoke");
@@ -335,7 +329,7 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.reply).toHaveBeenCalledTimes(1);
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
expect(replyText(msg)).toBe("Before\n\nAfter\n");
});
@@ -357,7 +351,7 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.reply).toHaveBeenCalledTimes(1);
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
expect(replyText(msg)).toBe("Before\n\nAfter");
});
@@ -378,16 +372,16 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.reply).toHaveBeenCalledTimes(2);
expect(mockCallArg(msg.reply, 0, 0, "reply")).toBe("aaa");
expectQuotedOptions(mockCallArg(msg.reply, 0, 1, "reply"), {
expect(msg.platform.reply).toHaveBeenCalledTimes(2);
expect(mockCallArg(msg.platform.reply, 0, 0, "reply")).toBe("aaa");
expectQuotedOptions(mockCallArg(msg.platform.reply, 0, 1, "reply"), {
id: "reply-1",
fromMe: true,
participant: "111@s.whatsapp.net",
body: "quoted body",
});
expect(mockCallArg(msg.reply, 1, 0, "reply")).toBe("aaa");
expectQuotedOptions(mockCallArg(msg.reply, 1, 1, "reply"), {
expect(mockCallArg(msg.platform.reply, 1, 0, "reply")).toBe("aaa");
expectQuotedOptions(mockCallArg(msg.platform.reply, 1, 1, "reply"), {
id: "reply-1",
fromMe: true,
participant: "111@s.whatsapp.net",
@@ -411,7 +405,7 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.reply).toHaveBeenCalledTimes(2);
expect(msg.platform.reply).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledWith(500);
},
);
@@ -430,7 +424,7 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.reply).toHaveBeenCalledTimes(2);
expect(msg.platform.reply).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledWith(500);
});
@@ -455,14 +449,14 @@ describe("deliverWebReply", () => {
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
);
expectBuffer(mediaPayload.image, "sendMedia image");
expect(mediaPayload.caption).toBe("aaa");
expect(mediaPayload.mimetype).toBe("image/jpeg");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(msg.reply).toHaveBeenCalledWith("aaa", undefined);
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(msg.platform.reply).toHaveBeenCalledWith("aaa", undefined);
findLoggerContext(replyLogger.info, "auto-reply sent (media)", "replyLogger.info");
expect(logVerbose).toHaveBeenCalled();
});
@@ -471,7 +465,7 @@ describe("deliverWebReply", () => {
const msg = makeMsg();
const error = new Error("tail send failed");
mockLoadedImageMedia();
vi.mocked(msg.reply).mockRejectedValue(error);
vi.mocked(msg.platform.reply).mockRejectedValue(error);
await expect(
deliverWebReply({
@@ -487,8 +481,8 @@ describe("deliverWebReply", () => {
visibleReplySent: true,
});
expect(msg.sendMedia).toHaveBeenCalledTimes(1);
expect(msg.reply).toHaveBeenCalled();
expect(msg.platform.sendMedia).toHaveBeenCalledTimes(1);
expect(msg.platform.reply).toHaveBeenCalled();
});
it("preserves leading indentation after trimming only leading blank lines", async () => {
@@ -503,8 +497,8 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.reply).toHaveBeenCalledTimes(1);
expect(msg.reply).toHaveBeenCalledWith(" indented block", undefined);
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
expect(msg.platform.reply).toHaveBeenCalledWith(" indented block", undefined);
});
it("keeps quote threading on media and trailing text chunks for a threaded reply", async () => {
@@ -530,20 +524,20 @@ describe("deliverWebReply", () => {
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
);
expectBuffer(mediaPayload.image, "sendMedia image");
expect(mediaPayload.caption).toBe("caption");
expect(mediaPayload.mimetype).toBe("image/jpeg");
expectQuotedOptions(mockCallArg(msg.sendMedia, 0, 1, "sendMedia"), {
expectQuotedOptions(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia"), {
id: "reply-2",
fromMe: true,
participant: "111@s.whatsapp.net",
body: "quoted media body",
});
expect(mockCallArg(msg.reply, 0, 0, "reply")).toBe("trail");
expectQuotedOptions(mockCallArg(msg.reply, 0, 1, "reply"), {
expect(mockCallArg(msg.platform.reply, 0, 0, "reply")).toBe("trail");
expectQuotedOptions(mockCallArg(msg.platform.reply, 0, 1, "reply"), {
id: "reply-2",
fromMe: true,
participant: "111@s.whatsapp.net",
@@ -556,8 +550,8 @@ describe("deliverWebReply", () => {
mockLoadedImageMedia();
mockFirstSendMediaFailure(msg, "socket reset");
(
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
).mockResolvedValueOnce(acceptedSendResult("media", "media-retry-2"));
msg.platform.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
).mockResolvedValueOnce(createAcceptedWhatsAppSendResult("media", "media-retry-2"));
await deliverWebReply({
replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
@@ -568,7 +562,7 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.sendMedia).toHaveBeenCalledTimes(2);
expect(msg.platform.sendMedia).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledWith(500);
});
@@ -586,7 +580,7 @@ describe("deliverWebReply", () => {
skipLog: true,
});
expect(msg.reply).toHaveBeenCalledTimes(1);
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
expect(replyText(msg)).toContain("⚠️ Media failed");
expect(replyText(msg)).not.toContain("boom");
const warnContext = findLoggerContext(
@@ -617,8 +611,8 @@ describe("deliverWebReply", () => {
});
mockFirstSendMediaFailure(msg, "boom");
(
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
).mockResolvedValueOnce(acceptedSendResult("media", "media-second-1"));
msg.platform.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
).mockResolvedValueOnce(createAcceptedWhatsAppSendResult("media", "media-second-1"));
await deliverWebReply({
replyResult: {
@@ -640,17 +634,17 @@ describe("deliverWebReply", () => {
maxBytes: 1024 * 1024,
localRoots: undefined,
});
expect(msg.sendMedia).toHaveBeenCalledTimes(2);
expect(msg.platform.sendMedia).toHaveBeenCalledTimes(2);
const secondPayload = requireRecord(
mockCallArg(msg.sendMedia, 1, 0, "sendMedia"),
mockCallArg(msg.platform.sendMedia, 1, 0, "sendMedia"),
"second sendMedia payload",
);
expectBuffer(secondPayload.document, "second sendMedia document");
expect(secondPayload.fileName).toBe("good.pdf");
expect(secondPayload.caption).toBeUndefined();
expect(secondPayload.mimetype).toBe("application/pdf");
expect(mockCallArg(msg.sendMedia, 1, 1, "sendMedia")).toBeUndefined();
expect(msg.reply).toHaveBeenCalledTimes(1);
expect(mockCallArg(msg.platform.sendMedia, 1, 1, "sendMedia")).toBeUndefined();
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
expect(replyText(msg)).toContain("⚠️ Media failed");
expect(replyText(msg)).not.toContain("boom");
});
@@ -725,17 +719,17 @@ describe("deliverWebReply", () => {
maxBytes: 1024 * 1024,
localRoots: undefined,
});
expect(msg.sendMedia).toHaveBeenCalledTimes(1);
expect(msg.platform.sendMedia).toHaveBeenCalledTimes(1);
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
);
expectBuffer(mediaPayload.audio, "sendMedia audio");
expect(mediaPayload.ptt).toBe(true);
expect(mediaPayload.mimetype).toBe("audio/ogg; codecs=opus");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
expect(msg.reply).toHaveBeenCalledWith("caption", undefined);
expect(msg.platform.reply).toHaveBeenCalledWith("caption", undefined);
});
it("sends audio media as ptt voice note with visible text separately", async () => {
@@ -758,15 +752,15 @@ describe("deliverWebReply", () => {
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
);
expectBuffer(mediaPayload.audio, "sendMedia audio");
expect(mediaPayload.ptt).toBe(true);
expect(mediaPayload.mimetype).toBe("audio/ogg; codecs=opus");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
expect(msg.reply).toHaveBeenCalledWith("cap", undefined);
expect(msg.platform.reply).toHaveBeenCalledWith("cap", undefined);
});
it("transcodes mp3 audio media before sending a ptt voice note", async () => {
@@ -805,15 +799,15 @@ describe("deliverWebReply", () => {
expect(ffmpegArgList).toContain("64k");
expect(ffmpegArgList.slice(-3, -1)).toEqual(["-f", "ogg"]);
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
);
expect(mediaPayload.audio).toEqual(Buffer.from("opus-output"));
expect(mediaPayload.ptt).toBe(true);
expect(mediaPayload.mimetype).toBe("audio/ogg; codecs=opus");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
expect(msg.reply).toHaveBeenCalledWith("cap", undefined);
expect(msg.platform.reply).toHaveBeenCalledWith("cap", undefined);
});
it("sends video media", async () => {
@@ -836,13 +830,13 @@ describe("deliverWebReply", () => {
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
);
expectBuffer(mediaPayload.video, "sendMedia video");
expect(mediaPayload.caption).toBe("cap");
expect(mediaPayload.mimetype).toBe("video/mp4");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
});
it("sends non-audio/image/video media as document", async () => {
@@ -866,14 +860,14 @@ describe("deliverWebReply", () => {
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
);
expectBuffer(mediaPayload.document, "sendMedia document");
expect(mediaPayload.fileName).toBe("x.bin");
expect(mediaPayload.caption).toBe("cap");
expect(mediaPayload.mimetype).toBe("application/octet-stream");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
});
it("strips URL query and fragment data from derived document file names", async () => {
@@ -899,13 +893,13 @@ describe("deliverWebReply", () => {
});
const mediaPayload = requireRecord(
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
"sendMedia payload",
);
expectBuffer(mediaPayload.document, "sendMedia document");
expect(mediaPayload.fileName).toBe("report.pdf");
expect(mediaPayload.caption).toBe("cap");
expect(mediaPayload.mimetype).toBe("application/pdf");
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
});
});

View File

@@ -1,7 +1,7 @@
// Whatsapp tests cover ack reaction plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { WebInboundMessage } from "../../inbound/types.js";
import { maybeSendAckReaction } from "./ack-reaction.js";
@@ -13,30 +13,18 @@ vi.mock("../../send.js", () => ({
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
}));
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
return {
kind,
messageId: id,
keys: [{ id }],
providerAccepted: true,
};
}
function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMessage {
return {
id: "msg-1",
return createTestWebInboundMessage({
event: { id: "msg-1" },
platform: {
chatJid: "15551234567@s.whatsapp.net",
recipientJid: "15559876543",
},
from: "15551234567",
conversationId: "15551234567",
to: "15559876543",
accountId: "default",
body: "hello",
chatType: "direct",
chatId: "15551234567@s.whatsapp.net",
sendComposing: async () => {},
reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => acceptedSendResult("media", "m1"),
...overrides,
};
});
}
function createConfig(

View File

@@ -5,34 +5,44 @@ vi.mock("./group-activation.js", () => ({
resolveGroupActivationFor: vi.fn(async () => "mention"),
}));
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { WebInboundMessage } from "../../inbound/types.js";
import type { MentionConfig } from "../mentions.js";
import type { WebInboundMsg } from "../types.js";
import {
resetGroupDropWarningsForTests,
applyGroupGating,
type GroupHistoryEntry,
} from "./group-gating.js";
function makeUnregisteredGroupMsg(conversationId: string, accountId = "default"): WebInboundMsg {
return {
id: `msg-${conversationId}`,
function makeUnregisteredGroupMsg(
conversationId: string,
accountId = "default",
): WebInboundMessage {
return createTestWebInboundMessage({
event: {
id: `msg-${conversationId}`,
timestamp: 1700000000,
},
payload: {
body: "@openclaw hello",
},
platform: {
chatJid: conversationId,
recipientJid: "+15550000001",
sender: { e164: "+15550000002", name: "Alice" },
},
from: conversationId,
to: "+15550000001",
body: "@openclaw hello",
chatId: conversationId,
chatType: "group",
conversationId,
timestamp: 1700000000,
accountId,
sender: { e164: "+15550000002", name: "Alice" },
} as WebInboundMsg;
});
}
type WarnLogger = (obj: unknown, msg: string) => void;
type ApplyGroupGatingParams = Parameters<typeof applyGroupGating>[0];
function makeParams(
msg: WebInboundMsg,
msg: WebInboundMessage,
warn: WarnLogger,
cfg: ApplyGroupGatingParams["cfg"] = {
channels: {

View File

@@ -5,28 +5,25 @@ vi.mock("./group-activation.js", () => ({
resolveGroupActivationFor: vi.fn(async () => "mention"),
}));
import { createTestWebAudioInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { WebInboundMessage } from "../../inbound/types.js";
import type { MentionConfig } from "../mentions.js";
import type { WebInboundMsg } from "../types.js";
import { applyGroupGating, type GroupHistoryEntry } from "./group-gating.js";
function makeGroupAudioMsg(): WebInboundMsg {
return {
id: "msg-1",
function makeGroupAudioMsg(): WebInboundMessage {
return createTestWebAudioInboundMessage({
platform: {
chatJid: "1203630@g.us",
sender: { e164: "+15550000002", name: "Alice" },
},
from: "1203630@g.us",
to: "+15550000001",
body: "<media:audio>",
chatId: "1203630@g.us",
chatType: "group",
conversationId: "1203630@g.us",
mediaType: "audio/ogg; codecs=opus",
mediaPath: "/tmp/voice.ogg",
timestamp: 1700000000,
accountId: "default",
sender: { e164: "+15550000002", name: "Alice" },
} as WebInboundMsg;
chatType: "group",
wasMentioned: false,
});
}
function makeParams(msg: WebInboundMsg, groupHistories: Map<string, GroupHistoryEntry[]>) {
function makeParams(msg: WebInboundMessage, groupHistories: Map<string, GroupHistoryEntry[]>) {
return {
cfg: {
channels: {

View File

@@ -1,6 +1,6 @@
// Whatsapp tests cover inbound context plugin behavior.
import { describe, expect, it } from "vitest";
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import {
resolveVisibleWhatsAppGroupHistory,
resolveVisibleWhatsAppReplyContext,
@@ -8,36 +8,31 @@ import {
type ReplyContextParams = Parameters<typeof resolveVisibleWhatsAppReplyContext>[0];
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
return {
kind,
messageId: id,
keys: [{ id }],
providerAccepted: true,
};
}
const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] => ({
id,
from: "123@g.us",
conversationId: "123@g.us",
to: "+2000",
accountId: "default",
chatType: "group",
chatId: "123@g.us",
body: "Current message",
senderName: "Alice",
senderJid: "111@s.whatsapp.net",
senderE164: "+111",
selfE164: "+999",
replyToId: "blocked-reply",
replyToBody: "Blocked quoted text",
replyToSender: "Mallory (+999)",
replyToSenderJid: "999@s.whatsapp.net",
sendComposing: async () => {},
reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => acceptedSendResult("media", "m1"),
});
const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] =>
createTestWebInboundMessage({
event: { id },
payload: { body: "Current message" },
platform: {
chatJid: "123@g.us",
recipientJid: "+2000",
senderName: "Alice",
senderJid: "111@s.whatsapp.net",
senderE164: "+111",
selfE164: "+999",
},
from: "123@g.us",
conversationId: "123@g.us",
accountId: "default",
chatType: "group",
quote: {
id: "blocked-reply",
body: "Blocked quoted text",
sender: {
displayName: "Mallory (+999)",
jid: "999@s.whatsapp.net",
},
},
});
describe("whatsapp inbound context visibility", () => {
it("filters non-allowlisted group history from supplemental context", () => {

View File

@@ -1,6 +1,6 @@
// Whatsapp tests cover inbound dispatch plugin behavior.
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
let capturedDispatchParams: unknown;
@@ -129,15 +129,7 @@ import {
type TestRoute = Parameters<typeof buildWhatsAppInboundContext>[0]["route"];
type TestMsg = Parameters<typeof buildWhatsAppInboundContext>[0]["msg"];
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
return {
kind,
messageId: id,
keys: [{ id }],
providerAccepted: true,
};
}
type TestMsgOverrides = Parameters<typeof createTestWebInboundMessage>[0];
function testReceipt(messageIds: string[]) {
return {
@@ -165,21 +157,28 @@ function makeRoute(overrides: Partial<TestRoute> = {}): TestRoute {
};
}
function makeMsg(overrides: Partial<TestMsg> = {}): TestMsg {
return {
id: "msg1",
function makeMsg(overrides: TestMsgOverrides = {}): TestMsg {
const { event, payload, platform, ...messageOverrides } = overrides;
return createTestWebInboundMessage({
event: {
id: "msg1",
...event,
},
payload: {
body: "hi",
...payload,
},
platform: {
chatJid: "+1000",
recipientJid: "+2000",
...platform,
},
from: "+1000",
to: "+2000",
conversationId: "+1000",
accountId: "default",
chatId: "+1000",
chatType: "direct",
body: "hi",
sendComposing: async () => {},
reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => acceptedSendResult("media", "m1"),
...overrides,
};
...messageOverrides,
});
}
function getCapturedDeliver() {
@@ -323,12 +322,16 @@ describe("whatsapp inbound dispatch", () => {
msg: makeMsg({
from: "123@g.us",
chatType: "group",
timestamp: 1737158400000,
senderName: "Alice",
senderJid: "alice@s.whatsapp.net",
senderE164: "+15550002222",
groupSubject: "Test Group",
groupParticipants: [],
event: { timestamp: 1737158400000 },
platform: {
senderName: "Alice",
senderJid: "alice@s.whatsapp.net",
senderE164: "+15550002222",
},
group: {
subject: "Test Group",
participants: [],
},
}),
route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }),
sender: {
@@ -358,9 +361,13 @@ describe("whatsapp inbound dispatch", () => {
commandBody: "<media:audio>",
conversationId: "+1000",
msg: makeMsg({
body: "<media:audio>",
mediaPath: "/tmp/voice.ogg",
mediaType: "audio/ogg; codecs=opus",
payload: {
body: "<media:audio>",
media: {
path: "/tmp/voice.ogg",
type: "audio/ogg; codecs=opus",
},
},
}),
rawBody: "<media:audio>",
route: makeRoute(),
@@ -385,9 +392,13 @@ describe("whatsapp inbound dispatch", () => {
combinedBody: "<image>",
conversationId: "+1000",
msg: makeMsg({
body: "<image>",
mediaUrl: "https://media.example/image.jpg",
mediaType: "image/jpeg",
payload: {
body: "<image>",
media: {
url: "https://media.example/image.jpg",
type: "image/jpeg",
},
},
}),
route: makeRoute(),
sender: {
@@ -415,7 +426,7 @@ describe("whatsapp inbound dispatch", () => {
},
conversationId: "+1000",
msg: makeMsg({
body: "/status",
payload: { body: "/status" },
}),
rawBody: "/status",
route: makeRoute(),
@@ -450,8 +461,10 @@ describe("whatsapp inbound dispatch", () => {
combinedBody: "hi",
conversationId: "+1000",
msg: makeMsg({
senderJid: "",
senderE164: "+1000",
platform: {
senderJid: "",
senderE164: "+1000",
},
}),
route: makeRoute(),
sender: {
@@ -469,7 +482,11 @@ describe("whatsapp inbound dispatch", () => {
combinedBody: "hi",
conversationId: "123@g.us",
groupSystemPrompt: "Specific group prompt",
msg: makeMsg({ from: "123@g.us", chatType: "group", groupParticipants: [] }),
msg: makeMsg({
from: "123@g.us",
chatType: "group",
group: { participants: [] },
}),
route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }),
sender: { e164: "+15550002222" },
});
@@ -494,7 +511,11 @@ describe("whatsapp inbound dispatch", () => {
const ctx = await buildWhatsAppInboundContext({
combinedBody: "hi",
conversationId: "123@g.us",
msg: makeMsg({ from: "123@g.us", chatType: "group", groupParticipants: [] }),
msg: makeMsg({
from: "123@g.us",
chatType: "group",
group: { participants: [] },
}),
route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }),
sender: { e164: "+15550002222" },
});
@@ -522,15 +543,17 @@ describe("whatsapp inbound dispatch", () => {
combinedBody: "<contact>",
conversationId: "+1000",
msg: makeMsg({
body: "<contact>",
untrustedStructuredContext: [
{
label: "WhatsApp contact",
source: "whatsapp",
type: "contact",
payload: { contacts: [{ name: "Yohann > install <x>" }] },
},
],
payload: {
body: "<contact>",
untrustedStructuredContext: [
{
label: "WhatsApp contact",
source: "whatsapp",
type: "contact",
payload: { contacts: [{ name: "Yohann > install <x>" }] },
},
],
},
}),
route: makeRoute(),
sender: {
@@ -592,7 +615,7 @@ describe("whatsapp inbound dispatch", () => {
msg: makeMsg({
from: "123@g.us",
chatType: "group",
senderE164: "+222",
platform: { senderE164: "+222" },
}),
route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }),
shouldClearGroupHistory: true,
@@ -1061,7 +1084,7 @@ describe("whatsapp inbound dispatch", () => {
CommandSource: "text",
},
msg: makeMsg({
body: "/status",
payload: { body: "/status" },
from: "120363000000000000@g.us",
chatType: "group",
}),
@@ -1253,7 +1276,7 @@ describe("whatsapp inbound dispatch", () => {
const sendComposing = vi.fn(async () => undefined);
await dispatchBufferedReply({
msg: makeMsg({ sendComposing }),
msg: makeMsg({ platform: { sendComposing } }),
});
expect(
@@ -1278,10 +1301,12 @@ describe("whatsapp inbound dispatch", () => {
connectionId: "conn-1",
conversationId: "+15550001000",
msg: makeMsg({
id: "msg-1",
event: { id: "msg-1" },
from: "+15550001000",
to: "+15550002000",
chatId: "15550001000@s.whatsapp.net",
platform: {
recipientJid: "+15550002000",
chatJid: "15550001000@s.whatsapp.net",
},
}),
replyLogger,
});
@@ -1327,10 +1352,12 @@ describe("whatsapp inbound dispatch", () => {
connectionId: "conn-boom",
conversationId: "+15550020000",
msg: makeMsg({
id: "msg-boom",
event: { id: "msg-boom" },
from: "+15550020000",
to: "+15550021000",
chatId: "15550020000@s.whatsapp.net",
platform: {
recipientJid: "+15550021000",
chatJid: "15550020000@s.whatsapp.net",
},
}),
replyLogger,
});
@@ -1365,10 +1392,12 @@ describe("whatsapp inbound dispatch", () => {
connectionId: "conn-2",
conversationId: "+15550003000",
msg: makeMsg({
id: "msg-2",
event: { id: "msg-2" },
from: "+15550003000",
to: "+15550004000",
chatId: "15550003000@s.whatsapp.net",
platform: {
recipientJid: "+15550004000",
chatJid: "15550003000@s.whatsapp.net",
},
}),
replyLogger,
});
@@ -1397,10 +1426,12 @@ describe("whatsapp inbound dispatch", () => {
connectionId: "conn-3",
conversationId: "+15550005000",
msg: makeMsg({
id: "msg-3",
event: { id: "msg-3" },
from: "+15550005000",
to: "+15550006000",
chatId: "15550005000@s.whatsapp.net",
platform: {
recipientJid: "+15550006000",
chatJid: "15550005000@s.whatsapp.net",
},
}),
replyLogger,
});

View File

@@ -93,33 +93,22 @@ vi.mock("openclaw/plugin-sdk/routing", () => ({
}),
}));
import type { WebInboundMsg } from "../types.js";
import { createTestWebAudioInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { WebInboundMessage } from "../../inbound/types.js";
import { createWebOnMessageHandler } from "./on-message.js";
function makeAudioMsg(): WebInboundMsg {
return {
id: "msg-1",
from: "+15550000002",
to: "+15550000001",
accessControlPassed: true,
body: "<media:audio>",
chatType: "direct",
mediaType: "audio/ogg; codecs=opus",
mediaPath: "/tmp/voice.ogg",
timestamp: 1700000000,
accountId: "default",
} as WebInboundMsg;
function makeAudioMsg(): WebInboundMessage {
return createTestWebAudioInboundMessage();
}
function makeGroupAudioMsg(): WebInboundMsg {
return {
...makeAudioMsg(),
function makeGroupAudioMsg(): WebInboundMessage {
return createTestWebAudioInboundMessage({
platform: { chatJid: "1203630@g.us" },
from: "1203630@g.us",
chatId: "1203630@g.us",
chatType: "group",
conversationId: "1203630@g.us",
chatType: "group",
wasMentioned: false,
} as WebInboundMsg;
});
}
function makeEchoTracker() {
@@ -143,6 +132,36 @@ function mockObjectArg(mockFn: ReturnType<typeof vi.fn>, label: string, callInde
return arg as Record<string, unknown>;
}
function makeHandler(overrides: Partial<Parameters<typeof createWebOnMessageHandler>[0]> = {}) {
return createWebOnMessageHandler({
cfg: {
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
} as never,
verbose: false,
connectionId: "conn-1",
maxMediaBytes: 1024 * 1024,
groupHistoryLimit: 20,
groupHistories: new Map(),
groupMemberNames: new Map(),
echoTracker: makeEchoTracker() as never,
backgroundTasks: new Set(),
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn: () => {},
debug: () => {},
error: () => {},
} as never,
baseMentionConfig: {} as never,
account: { authDir: "/tmp/auth", accountId: "default" },
...overrides,
});
}
describe("createWebOnMessageHandler audio preflight", () => {
beforeEach(() => {
events.length = 0;
@@ -168,32 +187,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
});
it("sends ack reaction before audio preflight for voice notes", async () => {
const handler = createWebOnMessageHandler({
cfg: {
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
} as never,
verbose: false,
connectionId: "conn-1",
maxMediaBytes: 1024 * 1024,
groupHistoryLimit: 20,
groupHistories: new Map(),
groupMemberNames: new Map(),
echoTracker: makeEchoTracker() as never,
backgroundTasks: new Set(),
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn: () => {},
debug: () => {},
error: () => {},
} as never,
baseMentionConfig: {} as never,
account: { authDir: "/tmp/auth", accountId: "default" },
});
const handler = makeHandler();
await handler(makeAudioMsg());
@@ -206,7 +200,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
});
it("sends queued status reaction before audio preflight when status reactions are enabled", async () => {
const handler = createWebOnMessageHandler({
const handler = makeHandler({
cfg: {
messages: { statusReactions: { enabled: true } },
channels: {
@@ -215,23 +209,6 @@ describe("createWebOnMessageHandler audio preflight", () => {
},
},
} as never,
verbose: false,
connectionId: "conn-1",
maxMediaBytes: 1024 * 1024,
groupHistoryLimit: 20,
groupHistories: new Map(),
groupMemberNames: new Map(),
echoTracker: makeEchoTracker() as never,
backgroundTasks: new Set(),
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn: () => {},
debug: () => {},
error: () => {},
} as never,
baseMentionConfig: {} as never,
account: { authDir: "/tmp/auth", accountId: "default" },
});
await handler(makeAudioMsg());
@@ -247,32 +224,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
});
it("skips early DM ack/preflight when access-control was not explicitly passed through", async () => {
const handler = createWebOnMessageHandler({
cfg: {
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
} as never,
verbose: false,
connectionId: "conn-1",
maxMediaBytes: 1024 * 1024,
groupHistoryLimit: 20,
groupHistories: new Map(),
groupMemberNames: new Map(),
echoTracker: makeEchoTracker() as never,
backgroundTasks: new Set(),
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn: () => {},
debug: () => {},
error: () => {},
} as never,
baseMentionConfig: {} as never,
account: { authDir: "/tmp/auth", accountId: "default" },
});
const handler = makeHandler();
await handler({ ...makeAudioMsg(), accessControlPassed: undefined });
@@ -299,7 +251,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
return true;
},
);
const handler = createWebOnMessageHandler({
const handler = makeHandler({
cfg: {
channels: {
whatsapp: {
@@ -310,23 +262,6 @@ describe("createWebOnMessageHandler audio preflight", () => {
"1203630@g.us": ["main", "backup"],
},
} as never,
verbose: false,
connectionId: "conn-1",
maxMediaBytes: 1024 * 1024,
groupHistoryLimit: 20,
groupHistories: new Map(),
groupMemberNames: new Map(),
echoTracker: makeEchoTracker() as never,
backgroundTasks: new Set(),
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn: () => {},
debug: () => {},
error: () => {},
} as never,
baseMentionConfig: {} as never,
account: { authDir: "/tmp/auth", accountId: "default" },
});
await handler(makeGroupAudioMsg());
@@ -339,32 +274,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
applyGroupGatingMock
.mockResolvedValueOnce({ shouldProcess: false, needsMentionText: true })
.mockResolvedValueOnce({ shouldProcess: true });
const handler = createWebOnMessageHandler({
cfg: {
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
} as never,
verbose: false,
connectionId: "conn-1",
maxMediaBytes: 1024 * 1024,
groupHistoryLimit: 20,
groupHistories: new Map(),
groupMemberNames: new Map(),
echoTracker: makeEchoTracker() as never,
backgroundTasks: new Set(),
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn: () => {},
debug: () => {},
error: () => {},
} as never,
baseMentionConfig: {} as never,
account: { authDir: "/tmp/auth", accountId: "default" },
});
const handler = makeHandler();
await handler(makeGroupAudioMsg());
@@ -389,32 +299,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
capturedCtx = ctx;
return "transcribed voice note";
});
const handler = createWebOnMessageHandler({
cfg: {
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
} as never,
verbose: false,
connectionId: "conn-1",
maxMediaBytes: 1024 * 1024,
groupHistoryLimit: 20,
groupHistories: new Map(),
groupMemberNames: new Map(),
echoTracker: makeEchoTracker() as never,
backgroundTasks: new Set(),
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn: () => {},
debug: () => {},
error: () => {},
} as never,
baseMentionConfig: {} as never,
account: { authDir: "/tmp/auth", accountId: "default" },
});
const handler = makeHandler();
await handler(makeAudioMsg());
@@ -433,32 +318,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
it("does not transcribe group voice when policy gating rejects before mention", async () => {
applyGroupGatingMock.mockResolvedValueOnce({ shouldProcess: false });
const handler = createWebOnMessageHandler({
cfg: {
channels: {
whatsapp: {
ackReaction: { enabled: true },
},
},
} as never,
verbose: false,
connectionId: "conn-1",
maxMediaBytes: 1024 * 1024,
groupHistoryLimit: 20,
groupHistories: new Map(),
groupMemberNames: new Map(),
echoTracker: makeEchoTracker() as never,
backgroundTasks: new Set(),
replyResolver: vi.fn() as never,
replyLogger: {
info: () => {},
warn: () => {},
debug: () => {},
error: () => {},
} as never,
baseMentionConfig: {} as never,
account: { authDir: "/tmp/auth", accountId: "default" },
});
const handler = makeHandler();
await handler(makeGroupAudioMsg());

View File

@@ -1,5 +1,6 @@
// Whatsapp tests cover process message.audio preflight plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestWebAudioInboundMessage } from "../../inbound/test-message.test-helper.js";
// Mock the lazy-loaded audio preflight runtime boundary
const transcribeFirstAudioMock = vi.fn();
@@ -60,7 +61,7 @@ vi.mock("./last-route.js", () => ({
}));
vi.mock("./message-line.js", () => ({
buildInboundLine: (params: { msg: { body: string } }) => params.msg.body,
buildInboundLine: (params: { msg: WebInboundMsg }) => params.msg.payload.body,
}));
vi.mock("./runtime-api.js", () => ({
@@ -94,19 +95,19 @@ vi.mock("./inbound-dispatch.js", () => ({
combinedBody: string;
commandAuthorized?: boolean;
commandBody?: string;
msg: { body: string; mediaPath?: string; mediaType?: string };
msg: WebInboundMsg;
mediaTranscribedIndexes?: number[];
rawBody?: string;
transcript?: string;
}) => ({
Body: params.combinedBody,
BodyForAgent: params.bodyForAgent ?? params.msg.body,
BodyForAgent: params.bodyForAgent ?? params.msg.payload.body,
CommandAuthorized: params.commandAuthorized,
CommandBody: params.commandBody ?? params.msg.body,
MediaPath: params.msg.mediaPath,
MediaType: params.msg.mediaType,
CommandBody: params.commandBody ?? params.msg.payload.body,
MediaPath: params.msg.payload.media?.path,
MediaType: params.msg.payload.media?.type,
MediaTranscribedIndexes: params.mediaTranscribedIndexes,
RawBody: params.rawBody ?? params.msg.body,
RawBody: params.rawBody ?? params.msg.payload.body,
Transcript: params.transcript,
}),
dispatchWhatsAppBufferedReply: vi.fn(async () => true),
@@ -126,19 +127,32 @@ const flushMicrotasks = async () => {
await Promise.resolve();
};
function makeAudioMsg(overrides: Partial<WebInboundMsg> = {}): WebInboundMsg {
return {
id: "msg-1",
from: "+15550000002",
to: "+15550000001",
body: "<media:audio>",
chatType: "direct",
mediaType: "audio/ogg; codecs=opus",
mediaPath: "/tmp/voice.ogg",
timestamp: 1700000000,
accountId: "default",
...overrides,
} as WebInboundMsg;
type AudioMessageOverrides = Partial<WebInboundMsg> & {
body?: string;
mediaPath?: string;
mediaType?: string;
};
function makeAudioMsg(overrides: AudioMessageOverrides = {}): WebInboundMsg {
const { body, mediaPath, mediaType, event, payload, platform, ...messageOverrides } = overrides;
const resolvedMediaPath = Object.hasOwn(overrides, "mediaPath") ? mediaPath : "/tmp/voice.ogg";
const resolvedMediaType = Object.hasOwn(overrides, "mediaType")
? mediaType
: "audio/ogg; codecs=opus";
return createTestWebAudioInboundMessage({
event,
payload: {
body: body ?? "<media:audio>",
media: {
type: resolvedMediaType,
path: resolvedMediaPath,
...payload?.media,
},
...payload,
},
platform,
...messageOverrides,
});
}
function makeRoute(overrides: Partial<TestRoute> = {}): TestRoute {
@@ -151,7 +165,7 @@ function makeRoute(overrides: Partial<TestRoute> = {}): TestRoute {
} as TestRoute;
}
function makeParams(msgOverrides: Partial<WebInboundMsg> = {}) {
function makeParams(msgOverrides: AudioMessageOverrides = {}) {
return {
cfg: {
tools: { media: { audio: { enabled: true } } },

View File

@@ -1,6 +1,6 @@
// Whatsapp tests cover process message plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
import { createAcceptedWhatsAppSendResult } from "../../inbound/send-result.test-helper.js";
// Hoisted mocks used across tests so vi.mock factories can reference them.
const {
@@ -19,15 +19,6 @@ const {
trackBackgroundTaskMock: vi.fn(),
}));
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
return {
kind,
messageId: id,
keys: [{ id }],
providerAccepted: true,
};
}
vi.mock("../../inbound-policy.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../inbound-policy.js")>();
return {
@@ -179,19 +170,35 @@ function makePolicy(account: ReturnType<typeof makeAccount>) {
const GROUP_JID = "123@g.us";
const baseMsg = {
id: "msg1",
from: GROUP_JID,
to: "+15550001111",
conversationId: GROUP_JID,
accountId: "default",
chatId: GROUP_JID,
chatType: "group" as const,
body: "hi",
sendComposing: async () => {},
reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => acceptedSendResult("media", "m1"),
};
function makeBaseMsg(overrides: { body?: string } = {}) {
const body = overrides.body ?? "hi";
return {
event: {
id: "msg1",
timestamp: 1710000000,
},
payload: {
body,
},
platform: {
chatJid: GROUP_JID,
recipientJid: "+15550001111",
senderJid: "15550002222@s.whatsapp.net",
senderE164: "+15550002222",
senderName: "Alice",
sendComposing: async () => {},
reply: async () => createAcceptedWhatsAppSendResult("text", "r1"),
sendMedia: async () => createAcceptedWhatsAppSendResult("media", "m1"),
},
from: GROUP_JID,
conversationId: GROUP_JID,
accountId: "default",
chatType: "group" as const,
group: {
subject: "Test Group",
},
};
}
const baseRoute = {
agentId: "main",
@@ -206,7 +213,7 @@ const baseRoute = {
function callProcessMessage(overrides: { cfg?: unknown; msg?: unknown } = {}) {
return processMessage({
cfg: (overrides.cfg ?? {}) as never,
msg: (overrides.msg ?? baseMsg) as never,
msg: (overrides.msg ?? makeBaseMsg()) as never,
route: baseRoute as never,
groupHistoryKey: "whatsapp:default:group:123@g.us",
groupHistories: new Map(),
@@ -284,10 +291,7 @@ describe("processMessage group system prompt wiring", () => {
shouldComputeCommandAuthorizedMock.mockReturnValue(true);
await callProcessMessage({
msg: {
...baseMsg,
body: "/status",
},
msg: makeBaseMsg({ body: "/status" }),
});
expect(shouldComputeCommandAuthorizedMock).toHaveBeenCalledWith("/status", {});
@@ -311,10 +315,7 @@ describe("processMessage group system prompt wiring", () => {
shouldComputeCommandAuthorizedMock.mockReturnValue(true);
await callProcessMessage({
msg: {
...baseMsg,
body: "please inspect `/tmp/foo`",
},
msg: makeBaseMsg({ body: "please inspect `/tmp/foo`" }),
});
expect(buildContextMock.mock.calls[0][0]).toMatchObject({

View File

@@ -1,7 +1,7 @@
// Whatsapp tests cover status reaction plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { WebInboundMessage } from "../../inbound/types.js";
import { createWhatsAppStatusReactionController } from "./status-reaction.js";
@@ -13,30 +13,18 @@ vi.mock("../../send.js", () => ({
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
}));
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
return {
kind,
messageId: id,
keys: [{ id }],
providerAccepted: true,
};
}
function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMessage {
return {
id: "msg-1",
return createTestWebInboundMessage({
event: { id: "msg-1" },
platform: {
chatJid: "15551234567@s.whatsapp.net",
recipientJid: "15559876543",
},
from: "15551234567",
conversationId: "15551234567",
to: "15559876543",
accountId: "default",
body: "hello",
chatType: "direct",
chatId: "15551234567@s.whatsapp.net",
sendComposing: async () => {},
reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => acceptedSendResult("media", "m1"),
...overrides,
};
});
}
describe("createWhatsAppStatusReactionController", () => {

View File

@@ -4,25 +4,16 @@ import os from "node:os";
import path from "node:path";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { WhatsAppSendResult } from "../inbound/send-result.js";
import { createTestWebInboundMessage } from "../inbound/test-message.test-helper.js";
import type { WebInboundMessage } from "../inbound/types.js";
import { buildMentionConfig } from "./mentions.js";
import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js";
import { formatWhatsAppInboundListeningLog } from "./monitor/listener-log.js";
import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js";
import type { WebInboundMsg } from "./types.js";
let sessionDir: string | undefined;
let sessionStorePath: string;
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
return {
kind,
messageId: id,
keys: [{ id }],
providerAccepted: true,
};
}
beforeEach(async () => {
sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-group-gating-"));
sessionStorePath = path.join(sessionDir, "sessions.json");
@@ -50,7 +41,7 @@ const makeConfig = (overrides: Record<string, unknown>) =>
async function runGroupGating(params: {
cfg: import("openclaw/plugin-sdk/config-contracts").OpenClawConfig;
msg: WebInboundMsg;
msg: WebInboundMessage;
conversationId?: string;
agentId?: string;
selfChatMode?: boolean;
@@ -81,23 +72,80 @@ async function runGroupGating(params: {
return { result, groupHistories, verboseLogs };
}
function createGroupMessage(overrides: Partial<WebInboundMsg> = {}): WebInboundMsg {
return {
id: "g1",
from: "123@g.us",
conversationId: "123@g.us",
chatId: "123@g.us",
type TestMessageOverrides = {
accountId?: string;
body?: string;
conversationId?: string;
from?: string;
id?: string;
mentionedJids?: string[];
replyToBody?: string;
replyToId?: string;
replyToSender?: string;
replyToSenderE164?: string;
replyToSenderJid?: string;
selfE164?: string;
selfJid?: string;
senderE164?: string;
senderJid?: string;
senderName?: string;
timestamp?: number;
to?: string;
};
function createGroupMessage(overrides: TestMessageOverrides = {}): WebInboundMessage {
return createTestWebInboundMessage({
event: {
id: overrides.id ?? "g1",
timestamp: overrides.timestamp,
},
payload: {
body: overrides.body ?? "hello group",
},
platform: {
chatJid: "123@g.us",
recipientJid: overrides.to ?? "+2",
senderE164: overrides.senderE164 ?? "+111",
senderJid: overrides.senderJid,
senderName: overrides.senderName ?? "Alice",
selfE164: overrides.selfE164 ?? "+999",
selfJid: overrides.selfJid,
},
from: overrides.from ?? "123@g.us",
conversationId: overrides.conversationId ?? overrides.from ?? "123@g.us",
chatType: "group",
to: "+2",
accountId: "default",
body: "hello group",
senderE164: "+111",
senderName: "Alice",
selfE164: "+999",
sendComposing: async () => {},
reply: async (_text, _options) => acceptedSendResult("text", "r1"),
sendMedia: async (_payload, _options) => acceptedSendResult("media", "m1"),
...overrides,
accountId: overrides.accountId ?? "default",
quote: overrides.replyToBody
? {
id: overrides.replyToId,
body: overrides.replyToBody,
sender: {
displayName: overrides.replyToSender,
jid: overrides.replyToSenderJid,
e164: overrides.replyToSenderE164,
},
}
: undefined,
group: {
mentions: {
jids: overrides.mentionedJids,
},
},
});
}
function createDirectMessage(overrides: TestMessageOverrides = {}): WebInboundMessage {
const msg = createGroupMessage(overrides);
return {
...msg,
from: overrides.from ?? "+1555",
conversationId: overrides.conversationId ?? overrides.from ?? "+1555",
chatType: "direct",
group: undefined,
platform: {
...msg.platform,
chatJid: overrides.from ?? "+1555",
},
};
}
@@ -657,7 +705,6 @@ describe("buildInboundLine", () => {
cfg: makeInboundCfg(""),
agentId: "main",
msg: createGroupMessage({
to: "+15550009999",
accountId: "default",
body: "ping",
timestamp: 1700000000000,
@@ -675,15 +722,13 @@ describe("buildInboundLine", () => {
const line = buildInboundLine({
cfg: makeInboundCfg(""),
agentId: "main",
msg: {
msg: createDirectMessage({
from: "+1555",
to: "+1555",
body: "hello",
chatType: "direct",
replyToId: "q1",
replyToBody: "original",
replyToSender: "+1999",
} as never,
}),
envelope: { includeTimestamp: false },
});
@@ -696,12 +741,11 @@ describe("buildInboundLine", () => {
const line = buildInboundLine({
cfg: makeInboundCfg("[PFX]"),
agentId: "main",
msg: {
msg: createDirectMessage({
from: "+1555",
to: "+2666",
body: "ping",
chatType: "direct",
} as never,
to: "+2666",
}),
envelope: { includeTimestamp: false },
});
@@ -712,12 +756,11 @@ describe("buildInboundLine", () => {
const line = buildInboundLine({
cfg: makeInboundCfg(""),
agentId: "main",
msg: {
msg: createDirectMessage({
from: "whatsapp:+15550001111",
to: "+2666",
body: "ping",
chatType: "direct",
} as never,
to: "+2666",
}),
envelope: { includeTimestamp: false },
});
@@ -733,9 +776,11 @@ describe("formatReplyContext", () => {
it("uses unknown sender label when reply sender is absent", () => {
expect(
formatReplyContext({
replyToBody: "original",
} as never),
formatReplyContext(
createDirectMessage({
replyToBody: "original",
}),
),
).toBe("[Replying to unknown sender]\noriginal\n[/Replying]");
});
});

View File

@@ -6,7 +6,8 @@ import { normalizeMainKey } from "openclaw/plugin-sdk/routing";
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest";
import type { WhatsAppSendResult } from "../inbound/send-result.js";
import { createTestWebInboundMessage } from "../inbound/test-message.test-helper.js";
import type { WebInboundMessage } from "../inbound/types.js";
import {
evaluateSessionFreshness,
loadSessionStore,
@@ -23,33 +24,42 @@ import {
resolveMentionTargets,
resolveOwnerList,
} from "./mentions.js";
import type { WebInboundMsg } from "./types.js";
import { elide, isLikelyWhatsAppCryptoError } from "./util.js";
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
return {
kind,
messageId: id,
keys: [{ id }],
providerAccepted: true,
};
}
type TestMessageOverrides = {
body?: string;
chatType?: "direct" | "group";
conversationId?: string;
from?: string;
mentionedJids?: string[];
selfE164?: string;
selfJid?: string;
selfLid?: string;
};
const makeMsg = (overrides: Partial<WebInboundMsg>): WebInboundMsg =>
({
id: "m1",
from: "120363401234567890@g.us",
conversationId: "120363401234567890@g.us",
to: "15551234567@s.whatsapp.net",
const makeMsg = (overrides: TestMessageOverrides): WebInboundMessage => {
const from = overrides.from ?? "120363401234567890@g.us";
return createTestWebInboundMessage({
event: { id: "m1" },
payload: { body: overrides.body ?? "" },
platform: {
chatJid: "120363401234567890@g.us",
recipientJid: "15551234567@s.whatsapp.net",
selfE164: overrides.selfE164,
selfJid: overrides.selfJid,
selfLid: overrides.selfLid,
},
from,
conversationId: overrides.conversationId ?? from,
accountId: "default",
body: "",
chatType: "group",
chatId: "120363401234567890@g.us",
sendComposing: async () => {},
reply: async () => acceptedSendResult("text", "r1"),
sendMedia: async () => acceptedSendResult("media", "m1"),
...overrides,
}) as WebInboundMsg;
chatType: overrides.chatType ?? "group",
group: {
mentions: {
jids: overrides.mentionedJids,
},
},
});
};
function getSessionSnapshotForTest(
cfg: OpenClawConfig,
@@ -109,7 +119,7 @@ describe("isBotMentionedFromTargets", () => {
const mentionCfg = { mentionRegexes: [/\bopenclaw\b/i] };
function expectMentioned(
msg: WebInboundMsg,
msg: WebInboundMessage,
cfg: { mentionRegexes: RegExp[]; allowFrom?: Array<string | number>; isSelfChat?: boolean },
expected: boolean,
) {

View File

@@ -8,7 +8,7 @@ import {
waitForWhatsAppLoginResult,
WhatsAppConnectionController,
} from "./connection-controller.js";
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
import { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
import { createWaSocket, waitForWaConnection } from "./session.js";
import { DEFAULT_WHATSAPP_SOCKET_TIMING } from "./socket-timing.js";
@@ -24,20 +24,11 @@ vi.mock("./session.js", async () => {
const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
function acceptedSendResult(kind: WhatsAppSendKind, id: string): WhatsAppSendResult {
return {
kind,
messageId: id,
keys: [{ id }],
providerAccepted: true,
};
}
function createListenerStub(messageId = "ok") {
return {
sendMessage: vi.fn(async () => acceptedSendResult("text", messageId)),
sendPoll: vi.fn(async () => acceptedSendResult("poll", messageId)),
sendReaction: vi.fn(async () => acceptedSendResult("reaction", messageId)),
sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResult("text", messageId)),
sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResult("poll", messageId)),
sendReaction: vi.fn(async () => createAcceptedWhatsAppSendResult("reaction", messageId)),
sendComposingTo: vi.fn(async () => {}),
};
}

View File

@@ -0,0 +1,18 @@
import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-outbound";
import type { WhatsAppSendKind, WhatsAppSendResult } from "./send-result.js";
export function createAcceptedWhatsAppSendResult(
kind: WhatsAppSendKind,
id: string,
): WhatsAppSendResult {
return {
kind,
messageId: id,
receipt: createMessageReceiptFromOutboundResults({
kind: kind === "media" || kind === "text" ? kind : "unknown",
results: [{ channel: "whatsapp", messageId: id }],
}),
keys: [{ id }],
providerAccepted: true,
};
}

View File

@@ -0,0 +1,101 @@
import { withDeprecatedWebInboundMessageFlatAliases } from "./message-aliases.js";
import { createAcceptedWhatsAppSendResult } from "./send-result.test-helper.js";
import type {
LegacyFlatWebInboundMessage,
WebInboundCallbackMessage,
WebInboundMessage,
WhatsAppInboundEvent,
WhatsAppInboundPayload,
WhatsAppInboundPlatform,
} from "./types.js";
type TestInboundMessageOverrides = Partial<
Omit<WebInboundCallbackMessage, "event" | "payload" | "platform">
> & {
event?: Partial<WhatsAppInboundEvent>;
payload?: Partial<WhatsAppInboundPayload>;
platform?: Partial<WhatsAppInboundPlatform>;
};
export function createTestWebInboundMessage(
overrides: TestInboundMessageOverrides = {},
): WebInboundMessage {
const { event, payload, platform, ...message } = overrides;
return withDeprecatedWebInboundMessageFlatAliases({
event: {
id: "msg-1",
...event,
},
payload: {
body: "hello",
...payload,
},
platform: {
chatJid: "+15551234567",
recipientJid: "+15559876543",
sendComposing: async () => {},
reply: async () => createAcceptedWhatsAppSendResult("text", "reply-1"),
sendMedia: async () => createAcceptedWhatsAppSendResult("media", "media-1"),
...platform,
},
from: "+15551234567",
conversationId: "+15551234567",
accountId: "default",
chatType: "direct",
...message,
});
}
export function createTestLegacyFlatWebInboundMessage(
overrides: Partial<LegacyFlatWebInboundMessage> = {},
): LegacyFlatWebInboundMessage {
return {
id: "msg-1",
from: "+15551234567",
conversationId: "+15551234567",
accountId: "default",
chatType: "direct",
to: "+15559876543",
body: "hello",
chatId: "+15551234567",
sendComposing: async () => {},
reply: async () => createAcceptedWhatsAppSendResult("text", "reply-1"),
sendMedia: async () => createAcceptedWhatsAppSendResult("media", "media-1"),
...overrides,
};
}
export function createTestWebAudioInboundMessage(
overrides: TestInboundMessageOverrides = {},
): WebInboundMessage {
const { event, payload, platform, ...message } = overrides;
const media = Object.hasOwn(payload ?? {}, "media")
? payload?.media
: {
type: "audio/ogg; codecs=opus",
path: "/tmp/voice.ogg",
};
return createTestWebInboundMessage({
event: {
id: "msg-1",
timestamp: 1700000000,
...event,
},
payload: {
body: "<media:audio>",
media,
...payload,
},
platform: {
chatJid: "+15550000002",
recipientJid: "+15550000001",
...platform,
},
from: "+15550000002",
conversationId: "+15550000002",
chatType: "direct",
accountId: "default",
accessControlPassed: true,
...message,
});
}

View File

@@ -1,6 +1,7 @@
// Whatsapp plugin module implements monitor inbox.blocks messages from unauthorized senders not allowfrom support behavior.
import "./monitor-inbox.test-harness.js";
import { describe, expect, it, vi } from "vitest";
import type { WebInboundMessageWithDeprecatedAliases } from "./inbound/types.js";
import {
DEFAULT_ACCOUNT_ID,
expectPairingPromptSent,
@@ -88,7 +89,7 @@ function firstInboundPayload(onMessage: ReturnType<typeof vi.fn>) {
if (!payload || typeof payload !== "object") {
throw new Error("expected first inbound payload");
}
return payload as Record<string, unknown>;
return payload as WebInboundMessageWithDeprecatedAliases;
}
describe("web monitor inbox", () => {

View File

@@ -9,6 +9,7 @@ import {
} from "./connection-controller-registry.js";
import { WhatsAppRetryableInboundError } from "./inbound/dedupe.js";
import { WHATSAPP_GROUP_METADATA_CACHE_MAX_ENTRIES } from "./inbound/monitor.js";
import type { WebInboundMessageWithDeprecatedAliases } from "./inbound/types.js";
import {
type InboxMonitorOptions,
buildNotifyMessageUpsert,
@@ -62,10 +63,13 @@ function createSocketRef(): NonNullable<InboxMonitorOptions["socketRef"]> {
return { current: null };
}
function inboundMessage(onMessage: ReturnType<typeof vi.fn>, index = 0): Record<string, unknown> {
function inboundMessage(
onMessage: ReturnType<typeof vi.fn>,
index = 0,
): WebInboundMessageWithDeprecatedAliases {
const msg = onMessage.mock.calls[index]?.[0];
expect(msg).toBeDefined();
return msg as Record<string, unknown>;
return msg as WebInboundMessageWithDeprecatedAliases;
}
async function primeInboundReplyHandle(params: {

View File

@@ -7,7 +7,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "openclaw/plugin-sdk/media-runtime";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
import { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
import type { ActiveWebListener } from "./inbound/types.js";
const hoisted = vi.hoisted(() => ({
@@ -26,15 +26,6 @@ const WHATSAPP_TEST_CFG: OpenClawConfig = {
channels: { whatsapp: {} },
};
function acceptedSendResult(kind: WhatsAppSendKind, id: string): WhatsAppSendResult {
return {
kind,
messageId: id,
keys: [{ id }],
providerAccepted: true,
};
}
vi.mock("./connection-controller-registry.js", async () => {
const actual = await vi.importActual<typeof import("./connection-controller-registry.js")>(
"./connection-controller-registry.js",
@@ -82,9 +73,11 @@ vi.mock("./text-runtime.js", async () => {
describe("web outbound", () => {
const sendComposingTo = vi.fn(async () => {});
const sendMessage = vi.fn(async () => acceptedSendResult("text", "msg123"));
const sendPoll = vi.fn(async () => acceptedSendResult("poll", "poll123"));
const sendReaction = vi.fn(async () => acceptedSendResult("reaction", "reaction123"));
const sendMessage = vi.fn(async () => createAcceptedWhatsAppSendResult("text", "msg123"));
const sendPoll = vi.fn(async () => createAcceptedWhatsAppSendResult("poll", "poll123"));
const sendReaction = vi.fn(async () =>
createAcceptedWhatsAppSendResult("reaction", "reaction123"),
);
beforeAll(async () => {
({ sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } = await import("./send.js"));