From 008d785a80fb80facfa5476925219876eacb496c Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Sat, 30 May 2026 13:40:39 -0300 Subject: [PATCH] test(whatsapp): update auto reply inbound fixtures --- ...to-reply.broadcast-groups.combined.test.ts | 43 ++-- ...uto-reply.broadcast-groups.test-harness.ts | 4 +- .../whatsapp/src/auto-reply.test-harness.ts | 141 ++++++----- ...compresses-common-formats-jpeg-cap.test.ts | 158 ++++++------ ...o-reply.connection-and-logging.e2e.test.ts | 113 ++++++--- ...to-reply.web-auto-reply.last-route.test.ts | 36 +-- .../src/auto-reply/deliver-reply.test.ts | 188 +++++++------- .../auto-reply/monitor/ack-reaction.test.ts | 28 +-- .../group-gating.allowlist-warn.test.ts | 32 ++- .../group-gating.audio-preflight.test.ts | 27 +- .../monitor/inbound-context.test.ts | 57 ++--- .../monitor/inbound-dispatch.test.ts | 155 +++++++----- .../on-message.audio-preflight.test.ts | 232 ++++-------------- .../process-message.audio-preflight.test.ts | 56 +++-- .../monitor/process-message.test.ts | 65 ++--- .../monitor/status-reaction.test.ts | 28 +-- .../auto-reply/web-auto-reply-monitor.test.ts | 133 ++++++---- .../auto-reply/web-auto-reply-utils.test.ts | 60 +++-- .../src/connection-controller.test.ts | 17 +- .../src/inbound/send-result.test-helper.ts | 18 ++ .../src/inbound/test-message.test-helper.ts | 101 ++++++++ ...ized-senders-not-allowfrom.test-support.ts | 3 +- ...x.streams-inbound-messages.test-support.ts | 8 +- extensions/whatsapp/src/send.test.ts | 19 +- 24 files changed, 914 insertions(+), 808 deletions(-) create mode 100644 extensions/whatsapp/src/inbound/send-result.test-helper.ts create mode 100644 extensions/whatsapp/src/inbound/test-message.test-helper.ts diff --git a/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts b/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts index 3b68ee178436..4659542366b3 100644 --- a/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts +++ b/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts @@ -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(); diff --git a/extensions/whatsapp/src/auto-reply.broadcast-groups.test-harness.ts b/extensions/whatsapp/src/auto-reply.broadcast-groups.test-harness.ts index 419fe966e9c6..a8d64a90926f 100644 --- a/extensions/whatsapp/src/auto-reply.broadcast-groups.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.broadcast-groups.test-harness.ts @@ -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; - onMessage: (msg: WebInboundMessage) => Promise; + onMessage: (msg: WebInboundMessageInput) => Promise; }> { const spies = createWebInboundDeliverySpies(); const { listenerFactory, getOnMessage } = createWebListenerFactoryCapture(); diff --git a/extensions/whatsapp/src/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts index 91e3155b686d..dc447b07fed3 100644 --- a/extensions/whatsapp/src/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -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) | undefined; + let capturedOnMessage: ((msg: WebInboundMessageInput) => Promise) | undefined; let capturedOptions: | { - onMessage: (msg: WebInboundMessage) => Promise; + onMessage: (msg: WebInboundMessageInput) => Promise; + shouldDebounce?: (msg: WebInboundMessageInput) => boolean; debounceMs?: number; selfChatMode?: boolean; } | undefined; const listenerFactory = async (opts: { - onMessage: (msg: WebInboundMessage) => Promise; + onMessage: (msg: WebInboundMessageInput) => Promise; + shouldDebounce?: (msg: WebInboundMessageInput) => boolean; debounceMs?: number; selfChatMode?: boolean; }) => { @@ -265,35 +270,31 @@ export function createMockWebListener(): MockWebListener { close: vi.fn(async () => undefined), onClose: new Promise(() => {}), 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> = []; + const onMessages: Array<(msg: WebInboundMessageInput) => Promise> = []; const closeResolvers: Array<(reason: unknown) => void> = []; const listeners: MockWebListener[] = []; const listenerFactory = vi.fn( - async (opts: { onMessage: (msg: WebInboundMessage) => Promise }) => { + async (opts: { onMessage: (msg: WebInboundMessageInput) => Promise }) => { onMessages.push(opts.onMessage); let resolveClose: (reason: unknown) => void = () => {}; const onClose = new Promise((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; + onMessage: (msg: WebInboundMessageInput) => Promise; 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; + onMessage: (msg: WebInboundMessageInput) => Promise; 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", + }), + ); } diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts index 16c66986fc6b..25fe80472334 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts @@ -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[1]>; + type WebInboundPlatform = WebInboundCallbackMessage["platform"]; + type ReplyMock = ReturnType>; + type SendMediaMock = ReturnType>; + type SendComposingMock = ReturnType>; 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; - reply?: ReturnType; + 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) | undefined; + let capturedOnMessage: ((msg: WebInboundMessageInput) => Promise) | 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 - >, + 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().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"); diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index fa35ac85c326..70066440f101 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -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, diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts index 7e1483c104a4..3ad8e3d6cb88 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts @@ -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", () => { diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts index 7133e96d33e6..4aebc82a161e 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts @@ -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(); }); }); diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts index ccbb82028dfe..8d9d8d5576af 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts @@ -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 { - 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( diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.allowlist-warn.test.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.allowlist-warn.test.ts index 6b735e031529..8e44ac38add3 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.allowlist-warn.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.allowlist-warn.test.ts @@ -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[0]; function makeParams( - msg: WebInboundMsg, + msg: WebInboundMessage, warn: WarnLogger, cfg: ApplyGroupGatingParams["cfg"] = { channels: { diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.audio-preflight.test.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.audio-preflight.test.ts index 92e5164dc105..26b2172eb66a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.audio-preflight.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.audio-preflight.test.ts @@ -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: "", - 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) { +function makeParams(msg: WebInboundMessage, groupHistories: Map) { return { cfg: { channels: { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts index 1d712d89d06c..cee0ed567a89 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts @@ -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[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", () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index a16f33eb0abb..3f86f46222e8 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -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[0]["route"]; type TestMsg = Parameters[0]["msg"]; - -function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult { - return { - kind, - messageId: id, - keys: [{ id }], - providerAccepted: true, - }; -} +type TestMsgOverrides = Parameters[0]; function testReceipt(messageIds: string[]) { return { @@ -165,21 +157,28 @@ function makeRoute(overrides: Partial = {}): TestRoute { }; } -function makeMsg(overrides: Partial = {}): 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: "", conversationId: "+1000", msg: makeMsg({ - body: "", - mediaPath: "/tmp/voice.ogg", - mediaType: "audio/ogg; codecs=opus", + payload: { + body: "", + media: { + path: "/tmp/voice.ogg", + type: "audio/ogg; codecs=opus", + }, + }, }), rawBody: "", route: makeRoute(), @@ -385,9 +392,13 @@ describe("whatsapp inbound dispatch", () => { combinedBody: "", conversationId: "+1000", msg: makeMsg({ - body: "", - mediaUrl: "https://media.example/image.jpg", - mediaType: "image/jpeg", + payload: { + body: "", + 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: "", conversationId: "+1000", msg: makeMsg({ - body: "", - untrustedStructuredContext: [ - { - label: "WhatsApp contact", - source: "whatsapp", - type: "contact", - payload: { contacts: [{ name: "Yohann > install " }] }, - }, - ], + payload: { + body: "", + untrustedStructuredContext: [ + { + label: "WhatsApp contact", + source: "whatsapp", + type: "contact", + payload: { contacts: [{ name: "Yohann > install " }] }, + }, + ], + }, }), 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, }); diff --git a/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts b/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts index af661d9233de..78bb5400057b 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts @@ -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: "", - 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, label: string, callInde return arg as Record; } +function makeHandler(overrides: Partial[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()); diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts index 67a68001d277..81f5423f74dd 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts @@ -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 { - return { - id: "msg-1", - from: "+15550000002", - to: "+15550000001", - body: "", - chatType: "direct", - mediaType: "audio/ogg; codecs=opus", - mediaPath: "/tmp/voice.ogg", - timestamp: 1700000000, - accountId: "default", - ...overrides, - } as WebInboundMsg; +type AudioMessageOverrides = Partial & { + 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: { + type: resolvedMediaType, + path: resolvedMediaPath, + ...payload?.media, + }, + ...payload, + }, + platform, + ...messageOverrides, + }); } function makeRoute(overrides: Partial = {}): TestRoute { @@ -151,7 +165,7 @@ function makeRoute(overrides: Partial = {}): TestRoute { } as TestRoute; } -function makeParams(msgOverrides: Partial = {}) { +function makeParams(msgOverrides: AudioMessageOverrides = {}) { return { cfg: { tools: { media: { audio: { enabled: true } } }, diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts index 7fab1b56f95d..824f67d4467f 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts @@ -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(); return { @@ -179,19 +170,35 @@ function makePolicy(account: ReturnType) { 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({ diff --git a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts index eace2d5f6e1d..897083b9c756 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts @@ -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 { - 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", () => { diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts index cc5844d7fcf6..9333b1c8db2f 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts @@ -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) => 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 { - 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]"); }); }); diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index 9c1ae70eb446..23b1e07866c4 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -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 => - ({ - 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; isSelfChat?: boolean }, expected: boolean, ) { diff --git a/extensions/whatsapp/src/connection-controller.test.ts b/extensions/whatsapp/src/connection-controller.test.ts index 105d930a381c..6eef5659832e 100644 --- a/extensions/whatsapp/src/connection-controller.test.ts +++ b/extensions/whatsapp/src/connection-controller.test.ts @@ -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 () => {}), }; } diff --git a/extensions/whatsapp/src/inbound/send-result.test-helper.ts b/extensions/whatsapp/src/inbound/send-result.test-helper.ts new file mode 100644 index 000000000000..a8c240fa5530 --- /dev/null +++ b/extensions/whatsapp/src/inbound/send-result.test-helper.ts @@ -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, + }; +} diff --git a/extensions/whatsapp/src/inbound/test-message.test-helper.ts b/extensions/whatsapp/src/inbound/test-message.test-helper.ts new file mode 100644 index 000000000000..705f02f8a6e2 --- /dev/null +++ b/extensions/whatsapp/src/inbound/test-message.test-helper.ts @@ -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 +> & { + event?: Partial; + payload?: Partial; + platform?: Partial; +}; + +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 { + 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, + ...payload, + }, + platform: { + chatJid: "+15550000002", + recipientJid: "+15550000001", + ...platform, + }, + from: "+15550000002", + conversationId: "+15550000002", + chatType: "direct", + accountId: "default", + accessControlPassed: true, + ...message, + }); +} diff --git a/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts b/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts index 6796bba1460c..5dfe76eeea97 100644 --- a/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts +++ b/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts @@ -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) { if (!payload || typeof payload !== "object") { throw new Error("expected first inbound payload"); } - return payload as Record; + return payload as WebInboundMessageWithDeprecatedAliases; } describe("web monitor inbox", () => { diff --git a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test-support.ts b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test-support.ts index 39e448d2abbd..95d1b2670ccd 100644 --- a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test-support.ts +++ b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test-support.ts @@ -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 { return { current: null }; } -function inboundMessage(onMessage: ReturnType, index = 0): Record { +function inboundMessage( + onMessage: ReturnType, + index = 0, +): WebInboundMessageWithDeprecatedAliases { const msg = onMessage.mock.calls[index]?.[0]; expect(msg).toBeDefined(); - return msg as Record; + return msg as WebInboundMessageWithDeprecatedAliases; } async function primeInboundReplyHandle(params: { diff --git a/extensions/whatsapp/src/send.test.ts b/extensions/whatsapp/src/send.test.ts index 371e15b3ecf7..f252d072a133 100644 --- a/extensions/whatsapp/src/send.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -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( "./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"));