From dc38411ca4de27a063adcd937127b259d52fa758 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 00:20:17 -0700 Subject: [PATCH] test: consolidate WhatsApp monitor suites (#117854) * test: consolidate WhatsApp monitor suites * test: preserve WhatsApp max-lines ratchet path --- ...ply.web-auto-reply.media-delivery.test.ts} | 4 +- ...auto-reply.web-auto-reply.routing.test.ts} | 4 +- .../src/auto-reply/monitor/ack-emoji.test.ts | 110 ------ .../src/auto-reply/monitor/echo.test.ts | 53 --- .../group-gating.allowlist-warn.test.ts | 104 +++--- .../auto-reply/monitor/group-members.test.ts | 57 --- .../monitor/inbound-context.test.ts | 127 ------- .../monitor/inbound-dispatch.test.ts | 308 +++++++---------- .../monitor/inbound-helpers.test.ts | 326 ++++++++++++++++++ .../src/auto-reply/monitor/last-route.test.ts | 43 --- .../monitor/prepared-inbound.test.ts | 92 ----- .../process-message.audio-preflight.test.ts | 90 +++-- .../monitor/process-message.test.ts | 106 +++--- ...ack-reaction.test.ts => reactions.test.ts} | 243 +++++++++++++ .../monitor/status-reaction.test.ts | 275 --------------- ... => monitor-inbox.access-and-echo.test.ts} | 3 +- ...onitor-inbox.append-upsert.test-support.ts | 283 --------------- .../whatsapp/src/monitor-inbox.append.test.ts | 237 +++++++++++++ .../src/monitor-inbox.behavior.test.ts | 6 - ...nitor-inbox.delivery-and-lifecycle.test.ts | 2 + ...> monitor-inbox.media-and-session.test.ts} | 3 +- ...upport.ts => monitor-inbox.policy.test.ts} | 301 +++++++--------- ...x.streams-inbound-messages.test-support.ts | 3 +- .../src/monitor-inbox.test-harness.ts | 32 +- 24 files changed, 1215 insertions(+), 1597 deletions(-) rename extensions/whatsapp/src/{auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts => auto-reply.web-auto-reply.media-delivery.test.ts} (98%) rename extensions/whatsapp/src/{auto-reply.web-auto-reply.last-route.test.ts => auto-reply.web-auto-reply.routing.test.ts} (98%) delete mode 100644 extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts delete mode 100644 extensions/whatsapp/src/auto-reply/monitor/echo.test.ts delete mode 100644 extensions/whatsapp/src/auto-reply/monitor/group-members.test.ts delete mode 100644 extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts create mode 100644 extensions/whatsapp/src/auto-reply/monitor/inbound-helpers.test.ts delete mode 100644 extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts delete mode 100644 extensions/whatsapp/src/auto-reply/monitor/prepared-inbound.test.ts rename extensions/whatsapp/src/auto-reply/monitor/{ack-reaction.test.ts => reactions.test.ts} (53%) delete mode 100644 extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts rename extensions/whatsapp/src/{monitor-inbox.allows-messages-from-senders-allowfrom-list.test-support.ts => monitor-inbox.access-and-echo.test.ts} (99%) delete mode 100644 extensions/whatsapp/src/monitor-inbox.append-upsert.test-support.ts create mode 100644 extensions/whatsapp/src/monitor-inbox.append.test.ts delete mode 100644 extensions/whatsapp/src/monitor-inbox.behavior.test.ts create mode 100644 extensions/whatsapp/src/monitor-inbox.delivery-and-lifecycle.test.ts rename extensions/whatsapp/src/{monitor-inbox.captures-media-path-image-messages.test-support.ts => monitor-inbox.media-and-session.test.ts} (98%) rename extensions/whatsapp/src/{monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts => monitor-inbox.policy.test.ts} (56%) 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.media-delivery.test.ts similarity index 98% rename from extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts rename to extensions/whatsapp/src/auto-reply.web-auto-reply.media-delivery.test.ts index aaeaf3c04a38..06fd60244699 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.media-delivery.test.ts @@ -1,4 +1,4 @@ -// Whatsapp tests cover auto reply.web auto reply.compresses common formats jpeg cap plugin behavior. +// WhatsApp web auto-reply media delivery behavior. import fs from "node:fs/promises"; import { createNoisyPngBuffer, createSolidPngBuffer } from "openclaw/plugin-sdk/test-fixtures"; import { beforeAll, describe, expect, it, vi } from "vitest"; @@ -17,7 +17,7 @@ installWebAutoReplyTestHomeHooks(); let monitorWebChannel: typeof import("./auto-reply/monitor.js").monitorWebChannel; -describe("web auto-reply", () => { +describe("web auto-reply media delivery", () => { installWebAutoReplyUnitTestHooks({ pinDns: true }); type ListenerFactory = NonNullable[1]>; type WebInboundPlatform = WebInboundCallbackMessage["platform"]; diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.routing.test.ts similarity index 98% rename from extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts rename to extensions/whatsapp/src/auto-reply.web-auto-reply.routing.test.ts index 645dc42af266..f0bd795c3f7a 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.routing.test.ts @@ -1,4 +1,4 @@ -// Whatsapp tests cover auto reply.web auto reply.last route plugin behavior. +// WhatsApp web auto-reply routing behavior. import "./test-helpers.js"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -115,7 +115,7 @@ function buildInboundMessage(params: { }); } -describe("web auto-reply last-route", () => { +describe("web auto-reply routing", () => { installWebAutoReplyUnitTestHooks(); beforeEach(() => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts deleted file mode 100644 index 69bc3e7dd3cb..000000000000 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -// Whatsapp tests cover ack emoji plugin behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { describe, expect, it } from "vitest"; -import { resolveWhatsAppAckEmoji } from "./ack-emoji.js"; - -function createConfig( - ackReaction?: NonNullable< - NonNullable["whatsapp"]>["ackReaction"] - >, -): OpenClawConfig { - const cfg: OpenClawConfig = { - agents: { - list: [{ id: "agent", identity: { emoji: "πŸ”₯" } }], - }, - channels: { - whatsapp: {}, - }, - } as OpenClawConfig; - if (ackReaction !== undefined) { - cfg.channels!.whatsapp!.ackReaction = ackReaction; - } - return cfg; -} - -describe("resolveWhatsAppAckEmoji", () => { - it("keeps missing ackReaction config disabled", () => { - expect( - resolveWhatsAppAckEmoji({ - cfg: createConfig(), - agentId: "agent", - ackConfig: undefined, - }), - ).toBe(""); - }); - - it("uses the configured WhatsApp emoji when present", () => { - const cfg = createConfig({ emoji: " πŸ‘€ ", direct: true, group: "mentions" }); - - expect( - resolveWhatsAppAckEmoji({ - cfg, - agentId: "agent", - ackConfig: cfg.channels?.whatsapp?.ackReaction, - }), - ).toBe("πŸ‘€"); - }); - - it("falls back to the routed agent identity for an empty emoji", () => { - const cfg = createConfig({ emoji: " ", direct: true, group: "mentions" }); - - expect( - resolveWhatsAppAckEmoji({ - cfg, - agentId: "agent", - ackConfig: cfg.channels?.whatsapp?.ackReaction, - }), - ).toBe("πŸ”₯"); - }); - - it("falls back to the routed agent identity emoji when the ack object has no emoji", () => { - const cfg = createConfig({ direct: true, group: "mentions" }); - - expect( - resolveWhatsAppAckEmoji({ - cfg, - agentId: "agent", - ackConfig: cfg.channels?.whatsapp?.ackReaction, - }), - ).toBe("πŸ”₯"); - }); - - it("uses normalized agent ids for the identity fallback", () => { - const cfg: OpenClawConfig = { - agents: { - list: [{ id: "Agent", identity: { emoji: "πŸ”₯" } }], - }, - channels: { - whatsapp: { - ackReaction: { direct: true, group: "mentions" }, - }, - }, - } as OpenClawConfig; - - expect( - resolveWhatsAppAckEmoji({ - cfg, - agentId: "agent", - ackConfig: cfg.channels?.whatsapp?.ackReaction, - }), - ).toBe("πŸ”₯"); - }); - - it("uses the default ack emoji when configured without an emoji or agent identity", () => { - const cfg: OpenClawConfig = { - channels: { - whatsapp: { - ackReaction: { direct: true, group: "mentions" }, - }, - }, - } as OpenClawConfig; - - expect( - resolveWhatsAppAckEmoji({ - cfg, - agentId: "agent", - ackConfig: cfg.channels?.whatsapp?.ackReaction, - }), - ).toBe("πŸ‘€"); - }); -}); diff --git a/extensions/whatsapp/src/auto-reply/monitor/echo.test.ts b/extensions/whatsapp/src/auto-reply/monitor/echo.test.ts deleted file mode 100644 index 02cae55bf448..000000000000 --- a/extensions/whatsapp/src/auto-reply/monitor/echo.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createEchoTracker } from "./echo.js"; - -describe("createEchoTracker", () => { - it("keeps verbose previews UTF-16 safe without changing the tracked text", () => { - const logVerbose = vi.fn(); - const tracker = createEchoTracker({ logVerbose }); - const prefix = "x".repeat(49); - const text = `${prefix}πŸ˜€tail`; - - tracker.rememberText(text, { logVerboseMessage: true }); - - expect(logVerbose).toHaveBeenCalledExactlyOnceWith( - `Added to echo detection set (size now: 1): ${prefix}...`, - ); - expect(tracker.has(text)).toBe(true); - }); - - it("keeps identical text isolated to its originating conversation", () => { - const tracker = createEchoTracker({}); - - tracker.rememberText("Done.", { conversationId: "+1000" }); - - expect(tracker.has("Done.", "+1000")).toBe(true); - expect(tracker.has("Done.", "+3000")).toBe(false); - - tracker.forget("Done.", "+1000"); - - expect(tracker.has("Done.", "+1000")).toBe(false); - }); - - it("keeps combined-message deduplication independent of conversation-scoped text", () => { - const tracker = createEchoTracker({}); - const combinedKey = tracker.buildCombinedKey({ - sessionKey: "agent:main:whatsapp:+1000", - combinedBody: "first\nsecond", - }); - - tracker.rememberText("Done.", { - conversationId: "+1000", - combinedBody: "first\nsecond", - combinedBodySessionKey: "agent:main:whatsapp:+1000", - }); - - expect(tracker.has(combinedKey)).toBe(true); - expect(tracker.has("Done.", "+1000")).toBe(true); - - tracker.forget(combinedKey); - - expect(tracker.has(combinedKey)).toBe(false); - expect(tracker.has("Done.", "+1000")).toBe(true); - }); -}); 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 5905e82ec7e9..08fad4923756 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 @@ -93,78 +93,56 @@ function makeParams( } describe("applyGroupGating allowlist drop warning", () => { - it("emits a warn log naming the root groups path for the default account", async () => { - const warn = vi.fn(); - const msg = makeUnregisteredGroupMsg("root-unregistered@g.us"); - const params = makeParams(msg, warn); - - const result = await applyGroupGating(params); - - expect(result).toEqual({ shouldProcess: false }); - expect(warn).toHaveBeenCalledTimes(1); - expect(params.logVerbose).toHaveBeenCalledWith( - 'Dropping message from unregistered WhatsApp group root-unregistered@g.us. Add the group JID to channels.whatsapp.groups, or add "*" there to admit all groups. Sender authorization still applies.', - ); - const [context, message] = warn.mock.calls[0] ?? []; - expect(context).toMatchObject({ + it.each([ + { + name: "emits a warn log naming the root groups path for the default account", conversationId: "root-unregistered@g.us", accountId: "default", groupsPath: "channels.whatsapp.groups", - }); - expect(message).toContain("root-unregistered@g.us"); - expect(message).toContain("channels.whatsapp.groups"); - }); - - it("names the account-scoped groups path for non-default accounts", async () => { - const warn = vi.fn(); - const msg = makeUnregisteredGroupMsg("work-unregistered@g.us", "work"); - - await applyGroupGating(makeParams(msg, warn)); - - expect(warn).toHaveBeenCalledTimes(1); - const [context, message] = warn.mock.calls[0] ?? []; - expect(context).toMatchObject({ + cfg: undefined, + verboseMessage: + 'Dropping message from unregistered WhatsApp group root-unregistered@g.us. Add the group JID to channels.whatsapp.groups, or add "*" there to admit all groups. Sender authorization still applies.', + }, + { + name: "names the account-scoped groups path for non-default accounts", conversationId: "work-unregistered@g.us", accountId: "work", groupsPath: "channels.whatsapp.accounts.work.groups", - }); - expect(message).toContain("channels.whatsapp.accounts.work.groups"); - }); - - it("names the root groups path for non-default accounts inheriting root groups", async () => { - const warn = vi.fn(); - const msg = makeUnregisteredGroupMsg("inherited-unregistered@g.us", "work"); - const cfg = { - channels: { - whatsapp: { - groupPolicy: "allowlist", - groups: { - "registered@g.us": {}, - }, - accounts: { - work: { - groupPolicy: "allowlist", - }, - }, - }, - }, - messages: { - groupChat: { - mentionPatterns: ["\\bopenclaw\\b"], - }, - }, - } as ApplyGroupGatingParams["cfg"]; - - await applyGroupGating(makeParams(msg, warn, cfg)); - - expect(warn).toHaveBeenCalledTimes(1); - const [context, message] = warn.mock.calls[0] ?? []; - expect(context).toMatchObject({ + cfg: undefined, + verboseMessage: undefined, + }, + { + name: "names the root groups path for non-default accounts inheriting root groups", conversationId: "inherited-unregistered@g.us", accountId: "work", groupsPath: "channels.whatsapp.groups", - }); - expect(message).toContain("channels.whatsapp.groups"); + cfg: { + channels: { + whatsapp: { + groupPolicy: "allowlist", + groups: { "registered@g.us": {} }, + accounts: { work: { groupPolicy: "allowlist" } }, + }, + }, + messages: { groupChat: { mentionPatterns: ["\\bopenclaw\\b"] } }, + } as ApplyGroupGatingParams["cfg"], + verboseMessage: undefined, + }, + ])("$name", async ({ conversationId, accountId, groupsPath, cfg, verboseMessage }) => { + const warn = vi.fn(); + const msg = makeUnregisteredGroupMsg(conversationId, accountId); + const params = makeParams(msg, warn, cfg); + + await expect(applyGroupGating(params)).resolves.toEqual({ shouldProcess: false }); + + expect(warn).toHaveBeenCalledTimes(1); + if (verboseMessage) { + expect(params.logVerbose).toHaveBeenCalledWith(verboseMessage); + } + const [context, message] = warn.mock.calls[0] ?? []; + expect(context).toMatchObject({ conversationId, accountId, groupsPath }); + expect(message).toContain(conversationId); + expect(message).toContain(groupsPath); }); it("warns once but keeps verbose diagnostics per dropped message", async () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-members.test.ts b/extensions/whatsapp/src/auto-reply/monitor/group-members.test.ts deleted file mode 100644 index 6fb4bcf2773f..000000000000 --- a/extensions/whatsapp/src/auto-reply/monitor/group-members.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Whatsapp tests cover group members plugin behavior. -import { describe, expect, it } from "vitest"; -import { formatGroupMembers, noteGroupMember } from "./group-members.js"; - -describe("noteGroupMember", () => { - it("normalizes member phone numbers before storing", () => { - const groupMemberNames = new Map>(); - - noteGroupMember(groupMemberNames, "g1", "+1 (555) 123-4567", "Alice"); - - expect(groupMemberNames.get("g1")?.get("+15551234567")).toBe("Alice"); - }); - - it("ignores incomplete member values", () => { - const groupMemberNames = new Map>(); - - noteGroupMember(groupMemberNames, "g1", undefined, "Alice"); - noteGroupMember(groupMemberNames, "g1", "+15551234567", undefined); - - expect(groupMemberNames.get("g1")).toBeUndefined(); - }); -}); - -describe("formatGroupMembers", () => { - it("deduplicates participants and appends named roster members", () => { - const roster = new Map([ - ["+16660000000", "Bob"], - ["+17770000000", "Carol"], - ]); - - const formatted = formatGroupMembers({ - participants: ["+1 (555) 000-0000", "+15550000000", "+16660000000"], - roster, - }); - - expect(formatted).toBe("+15550000000, Bob (+16660000000), Carol (+17770000000)"); - }); - - it("falls back to sender when no participants or roster are available", () => { - const formatted = formatGroupMembers({ - participants: [], - roster: undefined, - fallbackE164: "+1 (555) 222-3333", - }); - - expect(formatted).toBe("+15552223333"); - }); - - it("returns undefined when no members can be resolved", () => { - expect( - formatGroupMembers({ - participants: [], - roster: undefined, - }), - ).toBeUndefined(); - }); -}); diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts deleted file mode 100644 index e0ead38f705b..000000000000 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// Whatsapp tests cover inbound context plugin behavior. -import { describe, expect, it } from "vitest"; -import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js"; -import { - resolveVisibleWhatsAppGroupHistory, - resolveVisibleWhatsAppReplyContext, -} from "./inbound-context.js"; - -type ReplyContextParams = Parameters[0]; - -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", - }, - admission: { - accountId: "default", - conversation: { - kind: "group", - id: "123@g.us", - }, - sender: { - id: "111@s.whatsapp.net", - }, - senderAccess: { - reasonCode: "group_policy_allowed", - }, - }, - 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", () => { - const history = resolveVisibleWhatsAppGroupHistory({ - history: [ - { - sender: "Alice (+111)", - body: "Allowed context", - senderJid: "111@s.whatsapp.net", - }, - { - sender: "Mallory (+999)", - body: "Blocked context", - senderJid: "999@s.whatsapp.net", - }, - ], - mode: "allowlist", - groupPolicy: "allowlist", - groupAllowFrom: ["+111"], - }); - - expect(history).toEqual([ - { - sender: "Alice (+111)", - body: "Allowed context", - senderJid: "111@s.whatsapp.net", - }, - ]); - }); - - it("redacts blocked quoted replies in allowlist mode", () => { - const reply = resolveVisibleWhatsAppReplyContext({ - msg: makeBlockedQuotedReplyMessage("msg-reply-1"), - mode: "allowlist", - groupPolicy: "allowlist", - groupAllowFrom: ["+111"], - }); - - expect(reply).toBeNull(); - }); - - it("keeps blocked quoted replies in allowlist_quote mode", () => { - const reply = resolveVisibleWhatsAppReplyContext({ - msg: makeBlockedQuotedReplyMessage("msg-reply-2"), - mode: "allowlist_quote", - groupPolicy: "allowlist", - groupAllowFrom: ["+111"], - }); - - expect(reply).toEqual({ - id: "blocked-reply", - body: "Blocked quoted text", - sender: { - jid: "999@s.whatsapp.net", - lid: null, - e164: "+999", - label: "Mallory (+999)", - }, - }); - }); - - it("renders structured quoted media only at the visible preview boundary", () => { - const msg = makeBlockedQuotedReplyMessage("msg-reply-media"); - msg.quote = { - context: { - id: "quoted-sticker", - body: "", - media: { contentType: "image/webp", kind: "sticker" }, - sender: { jid: "111@s.whatsapp.net", label: "Alice (+111)" }, - }, - }; - - const reply = resolveVisibleWhatsAppReplyContext({ - msg, - mode: "allowlist", - groupPolicy: "allowlist", - groupAllowFrom: ["+111"], - }); - - expect(reply?.body).toBe(""); - expect(reply?.media).toEqual({ contentType: "image/webp", kind: "sticker" }); - }); -}); 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 71117c7316e5..62ce0e91dd91 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -487,10 +487,10 @@ function finalizedContext( function makeReplyLogger(): BufferedReplyParams["replyLogger"] { return { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), } as never; } @@ -1762,26 +1762,26 @@ describe("whatsapp inbound dispatch", () => { expect(rememberSentText).not.toHaveBeenCalled(); }); - it("maps WhatsApp streaming.block.enabled=true to disableBlockStreaming=false", async () => { - await dispatchBufferedReply(); - - expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(false); - }); - - it("maps WhatsApp streaming.block.enabled=false to disableBlockStreaming=true", async () => { - await dispatchBufferedReply({ + it.each([ + { + name: "maps WhatsApp streaming.block.enabled=true to disableBlockStreaming=false", + cfg: undefined, + expected: false, + }, + { + name: "maps WhatsApp streaming.block.enabled=false to disableBlockStreaming=true", cfg: { channels: { whatsapp: { streaming: { block: { enabled: false } } } } } as never, - }); - - expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(true); - }); - - it("leaves disableBlockStreaming undefined when WhatsApp block streaming is unset", async () => { - await dispatchBufferedReply({ + expected: true, + }, + { + name: "leaves disableBlockStreaming undefined when WhatsApp block streaming is unset", cfg: { channels: { whatsapp: {} } } as never, - }); + expected: undefined, + }, + ])("$name", async ({ cfg, expected }) => { + await dispatchBufferedReply(cfg ? { cfg } : {}); - expect(getCapturedReplyOptions()?.disableBlockStreaming).toBeUndefined(); + expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(expected); }); it("leaves WhatsApp direct reply mode unset by default", async () => { @@ -1850,37 +1850,42 @@ describe("whatsapp inbound dispatch", () => { }); }); - it("suppresses typing for message-tool-only group chat without mention", async () => { + it.each([ + { + name: "suppresses typing for message-tool-only group chat without mention", + chatType: "group" as const, + wasMentioned: false, + expected: true, + }, + { + name: "does not suppress typing for group chat when mentioned", + chatType: "group" as const, + wasMentioned: true, + expected: false, + }, + { + name: "does not suppress typing for direct chat", + chatType: "direct" as const, + wasMentioned: undefined, + expected: false, + }, + ])("$name", async ({ chatType, wasMentioned, expected }) => { + const isGroup = chatType === "group"; await dispatchBufferedReply({ - context: { Body: "hi", ChatType: "group", WasMentioned: false }, + context: { + Body: wasMentioned ? "@bot hi" : "hi", + ChatType: chatType, + ...(wasMentioned === undefined ? {} : { WasMentioned: wasMentioned }), + }, msg: makeMsg({ - admission: groupAdmission("120363000000000000@g.us"), - wasMentioned: false, + admission: isGroup + ? groupAdmission("120363000000000000@g.us") + : directAdmission("+15550001000"), + ...(wasMentioned === undefined ? {} : { wasMentioned }), }), }); - expect(getCapturedReplyOptions()?.suppressTyping).toBe(true); - }); - - it("does not suppress typing for group chat when mentioned", async () => { - await dispatchBufferedReply({ - context: { Body: "@bot hi", ChatType: "group", WasMentioned: true }, - msg: makeMsg({ - admission: groupAdmission("120363000000000000@g.us"), - wasMentioned: true, - }), - }); - - expect(getCapturedReplyOptions()?.suppressTyping).toBe(false); - }); - - it("does not suppress typing for direct chat", async () => { - await dispatchBufferedReply({ - context: { Body: "hi", ChatType: "direct" }, - msg: makeMsg({ admission: directAdmission("+15550001000") }), - }); - - expect(getCapturedReplyOptions()?.suppressTyping).toBe(false); + expect(getCapturedReplyOptions()?.suppressTyping).toBe(expected); }); it("treats block-only turns as visible replies instead of silent turns", async () => { @@ -2032,12 +2037,7 @@ describe("whatsapp inbound dispatch", () => { }); it("logs delivery failures from the shared dispatcher with WhatsApp context", async () => { - const replyLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as unknown as BufferedReplyParams["replyLogger"]; + const replyLogger = makeReplyLogger(); const error = new Error("send failed"); await dispatchBufferedReply({ @@ -2071,12 +2071,7 @@ describe("whatsapp inbound dispatch", () => { }); it("preserves Error subclass own-enumerable fields (e.g. Boom output) in the logged err", async () => { - const replyLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as unknown as BufferedReplyParams["replyLogger"]; + const replyLogger = makeReplyLogger(); class BoomLikeError extends Error { output: { statusCode: number; payload: { error: string } }; @@ -2121,152 +2116,101 @@ describe("whatsapp inbound dispatch", () => { ); }); - it("logs delivery failures with non-Error rejection values via pass-through", async () => { - const replyLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as unknown as BufferedReplyParams["replyLogger"]; - - await dispatchBufferedReply({ + it.each([ + { + name: "logs delivery failures with non-Error rejection values via pass-through", + rejection: "plain string rejection", + replyKind: "block" as const, connectionId: "conn-2", - msg: makeMsg({ - admission: directAdmission("+15550003000"), - event: { id: "msg-2" }, - platform: { - recipientJid: "+15550004000", - chatJid: "15550003000@s.whatsapp.net", - }, - }), - replyLogger, - }); - - await getCapturedOnError()?.("plain string rejection", { kind: "block" }); - - expect(replyLogger["error"]).toHaveBeenCalledWith( - expect.objectContaining({ - err: "plain string rejection", - replyKind: "block", - correlationId: "msg-2", - }), - "auto-reply delivery failed", - ); - }); - - it("preserves structured object rejections so diagnostic fields stay queryable", async () => { - const replyLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as unknown as BufferedReplyParams["replyLogger"]; - - await dispatchBufferedReply({ + messageId: "msg-2", + conversationId: "+15550003000", + recipientJid: "+15550004000", + }, + { + name: "preserves structured object rejections so diagnostic fields stay queryable", + rejection: { + error: { message: "wrapped failure", code: "BAILEYS_NACK" }, + attempt: 2, + }, + replyKind: "tool" as const, connectionId: "conn-3", - msg: makeMsg({ - admission: directAdmission("+15550005000"), - event: { id: "msg-3" }, - platform: { - recipientJid: "+15550006000", - chatJid: "15550005000@s.whatsapp.net", - }, - }), - replyLogger, - }); + messageId: "msg-3", + conversationId: "+15550005000", + recipientJid: "+15550006000", + }, + ])( + "$name", + async ({ rejection, replyKind, connectionId, messageId, conversationId, recipientJid }) => { + const replyLogger = makeReplyLogger(); + await dispatchBufferedReply({ + connectionId, + msg: makeMsg({ + admission: directAdmission(conversationId), + event: { id: messageId }, + platform: { + recipientJid, + chatJid: `${conversationId.slice(1)}@s.whatsapp.net`, + }, + }), + replyLogger, + }); - const objectRejection = { - error: { message: "wrapped failure", code: "BAILEYS_NACK" }, - attempt: 2, - }; + await getCapturedOnError()?.(rejection, { kind: replyKind }); - await getCapturedOnError()?.(objectRejection, { kind: "tool" }); + expect(replyLogger["error"]).toHaveBeenCalledWith( + expect.objectContaining({ err: rejection, replyKind, correlationId: messageId }), + "auto-reply delivery failed", + ); + }, + ); - expect(replyLogger["error"]).toHaveBeenCalledWith( - expect.objectContaining({ - err: objectRejection, - replyKind: "tool", - correlationId: "msg-3", - }), - "auto-reply delivery failed", - ); - }); - - it("updates main last route for DM when session key matches main session key", async () => { - const updateLastRoute = vi.fn(); - - updateWhatsAppMainLastRoute({ - backgroundTasks: new Set(), - cfg: {} as never, - ctx: { Body: "hello" }, + it.each([ + { + name: "updates main last route for DM when session key matches main session key", dmRouteTarget: "+1000", pinnedMainDmRecipient: null, - route: makeRoute(), - updateLastRoute, - warn: () => {}, - }); - - expect(updateLastRoute).toHaveBeenCalledTimes(1); - }); - - it("does not update main last route for isolated DM scope sessions", async () => { - const updateLastRoute = vi.fn(); - - updateWhatsAppMainLastRoute({ - backgroundTasks: new Set(), - cfg: {} as never, - ctx: { Body: "hello" }, + route: {}, + expectedCalls: 1, + }, + { + name: "does not update main last route for isolated DM scope sessions", dmRouteTarget: "+3000", pinnedMainDmRecipient: null, - route: makeRoute({ + route: { sessionKey: "agent:main:whatsapp:dm:+1000:peer:+3000", mainSessionKey: "agent:main:whatsapp:direct:+1000", - }), - updateLastRoute, - warn: () => {}, - }); - - expect(updateLastRoute).not.toHaveBeenCalled(); - }); - - it("does not update main last route for non-owner sender when main DM scope is pinned", async () => { - const updateLastRoute = vi.fn(); - - updateWhatsAppMainLastRoute({ - backgroundTasks: new Set(), - cfg: {} as never, - ctx: { Body: "hello" }, + }, + expectedCalls: 0, + }, + { + name: "does not update main last route for non-owner sender when main DM scope is pinned", dmRouteTarget: "+3000", pinnedMainDmRecipient: "+1000", - route: makeRoute({ - sessionKey: "agent:main:main", - mainSessionKey: "agent:main:main", - }), - updateLastRoute, - warn: () => {}, - }); - - expect(updateLastRoute).not.toHaveBeenCalled(); - }); - - it("updates main last route for owner sender when main DM scope is pinned", async () => { + route: { sessionKey: "agent:main:main", mainSessionKey: "agent:main:main" }, + expectedCalls: 0, + }, + { + name: "updates main last route for owner sender when main DM scope is pinned", + dmRouteTarget: "+1000", + pinnedMainDmRecipient: "+1000", + route: { sessionKey: "agent:main:main", mainSessionKey: "agent:main:main" }, + expectedCalls: 1, + }, + ])("$name", ({ dmRouteTarget, pinnedMainDmRecipient, route, expectedCalls }) => { const updateLastRoute = vi.fn(); updateWhatsAppMainLastRoute({ backgroundTasks: new Set(), cfg: {} as never, ctx: { Body: "hello" }, - dmRouteTarget: "+1000", - pinnedMainDmRecipient: "+1000", - route: makeRoute({ - sessionKey: "agent:main:main", - mainSessionKey: "agent:main:main", - }), + dmRouteTarget, + pinnedMainDmRecipient, + route: makeRoute(route), updateLastRoute, warn: () => {}, }); - expect(updateLastRoute).toHaveBeenCalledTimes(1); + expect(updateLastRoute).toHaveBeenCalledTimes(expectedCalls); }); it("resolves DM route targets from the sender first and the chat JID second", async () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-helpers.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-helpers.test.ts new file mode 100644 index 000000000000..ba93a7ba06c6 --- /dev/null +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-helpers.test.ts @@ -0,0 +1,326 @@ +// Whatsapp tests cover inbound context plugin behavior. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js"; +import { createEchoTracker } from "./echo.js"; +import { formatGroupMembers, noteGroupMember } from "./group-members.js"; +import { + resolveVisibleWhatsAppGroupHistory, + resolveVisibleWhatsAppReplyContext, +} from "./inbound-context.js"; +import { trackBackgroundTask } from "./last-route.js"; +import { projectPreparedChannelInbound, type PreparedChannelInbound } from "./prepared-inbound.js"; + +type ReplyContextParams = Parameters[0]; + +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", + }, + admission: { + accountId: "default", + conversation: { + kind: "group", + id: "123@g.us", + }, + sender: { + id: "111@s.whatsapp.net", + }, + senderAccess: { + reasonCode: "group_policy_allowed", + }, + }, + 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", () => { + const history = resolveVisibleWhatsAppGroupHistory({ + history: [ + { + sender: "Alice (+111)", + body: "Allowed context", + senderJid: "111@s.whatsapp.net", + }, + { + sender: "Mallory (+999)", + body: "Blocked context", + senderJid: "999@s.whatsapp.net", + }, + ], + mode: "allowlist", + groupPolicy: "allowlist", + groupAllowFrom: ["+111"], + }); + + expect(history).toEqual([ + { + sender: "Alice (+111)", + body: "Allowed context", + senderJid: "111@s.whatsapp.net", + }, + ]); + }); + + it("redacts blocked quoted replies in allowlist mode", () => { + const reply = resolveVisibleWhatsAppReplyContext({ + msg: makeBlockedQuotedReplyMessage("msg-reply-1"), + mode: "allowlist", + groupPolicy: "allowlist", + groupAllowFrom: ["+111"], + }); + + expect(reply).toBeNull(); + }); + + it("keeps blocked quoted replies in allowlist_quote mode", () => { + const reply = resolveVisibleWhatsAppReplyContext({ + msg: makeBlockedQuotedReplyMessage("msg-reply-2"), + mode: "allowlist_quote", + groupPolicy: "allowlist", + groupAllowFrom: ["+111"], + }); + + expect(reply).toEqual({ + id: "blocked-reply", + body: "Blocked quoted text", + sender: { + jid: "999@s.whatsapp.net", + lid: null, + e164: "+999", + label: "Mallory (+999)", + }, + }); + }); + + it("renders structured quoted media only at the visible preview boundary", () => { + const msg = makeBlockedQuotedReplyMessage("msg-reply-media"); + msg.quote = { + context: { + id: "quoted-sticker", + body: "", + media: { contentType: "image/webp", kind: "sticker" }, + sender: { jid: "111@s.whatsapp.net", label: "Alice (+111)" }, + }, + }; + + const reply = resolveVisibleWhatsAppReplyContext({ + msg, + mode: "allowlist", + groupPolicy: "allowlist", + groupAllowFrom: ["+111"], + }); + + expect(reply?.body).toBe(""); + expect(reply?.media).toEqual({ contentType: "image/webp", kind: "sticker" }); + }); +}); + +describe("createEchoTracker", () => { + it("keeps verbose previews UTF-16 safe without changing the tracked text", () => { + const logVerbose = vi.fn(); + const tracker = createEchoTracker({ logVerbose }); + const prefix = "x".repeat(49); + const text = `${prefix}πŸ˜€tail`; + + tracker.rememberText(text, { logVerboseMessage: true }); + + expect(logVerbose).toHaveBeenCalledExactlyOnceWith( + `Added to echo detection set (size now: 1): ${prefix}...`, + ); + expect(tracker.has(text)).toBe(true); + }); + + it("keeps identical text isolated to its originating conversation", () => { + const tracker = createEchoTracker({}); + + tracker.rememberText("Done.", { conversationId: "+1000" }); + + expect(tracker.has("Done.", "+1000")).toBe(true); + expect(tracker.has("Done.", "+3000")).toBe(false); + + tracker.forget("Done.", "+1000"); + + expect(tracker.has("Done.", "+1000")).toBe(false); + }); + + it("keeps combined-message deduplication independent of conversation-scoped text", () => { + const tracker = createEchoTracker({}); + const combinedKey = tracker.buildCombinedKey({ + sessionKey: "agent:main:whatsapp:+1000", + combinedBody: "first\nsecond", + }); + + tracker.rememberText("Done.", { + conversationId: "+1000", + combinedBody: "first\nsecond", + combinedBodySessionKey: "agent:main:whatsapp:+1000", + }); + + expect(tracker.has(combinedKey)).toBe(true); + expect(tracker.has("Done.", "+1000")).toBe(true); + + tracker.forget(combinedKey); + + expect(tracker.has(combinedKey)).toBe(false); + expect(tracker.has("Done.", "+1000")).toBe(true); + }); +}); + +describe("group member display", () => { + it("normalizes member phone numbers before storing", () => { + const groupMemberNames = new Map>(); + + noteGroupMember(groupMemberNames, "g1", "+1 (555) 123-4567", "Alice"); + + expect(groupMemberNames.get("g1")?.get("+15551234567")).toBe("Alice"); + }); + + it("ignores incomplete member values", () => { + const groupMemberNames = new Map>(); + + noteGroupMember(groupMemberNames, "g1", undefined, "Alice"); + noteGroupMember(groupMemberNames, "g1", "+15551234567", undefined); + + expect(groupMemberNames.get("g1")).toBeUndefined(); + }); + + it.each([ + { + name: "deduplicates participants and appends named roster members", + params: { + participants: ["+1 (555) 000-0000", "+15550000000", "+16660000000"], + roster: new Map([ + ["+16660000000", "Bob"], + ["+17770000000", "Carol"], + ]), + }, + expected: "+15550000000, Bob (+16660000000), Carol (+17770000000)", + }, + { + name: "falls back to sender when no participants or roster are available", + params: { + participants: [], + roster: undefined, + fallbackE164: "+1 (555) 222-3333", + }, + expected: "+15552223333", + }, + { + name: "returns undefined when no members can be resolved", + params: { participants: [], roster: undefined }, + expected: undefined, + }, + ])("$name", ({ params, expected }) => { + expect(formatGroupMembers(params)).toBe(expected); + }); +}); + +describe("trackBackgroundTask", () => { + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + + afterEach(() => { + process.off("unhandledRejection", onUnhandledRejection); + unhandledRejections.length = 0; + }); + + it("does not leak unhandled rejections when a tracked task fails", async () => { + process.on("unhandledRejection", onUnhandledRejection); + const backgroundTasks = new Set>(); + let rejectTask: ((reason?: unknown) => void) | undefined; + const task = new Promise((_resolve, reject) => { + rejectTask = reject; + }); + + trackBackgroundTask(backgroundTasks, task); + expect(backgroundTasks.size).toBe(1); + + if (!rejectTask) { + throw new Error("Expected tracked task reject callback to be initialized"); + } + rejectTask(new Error("boom")); + await Promise.allSettled([task]); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(backgroundTasks.size).toBe(0); + expect(unhandledRejections).toStrictEqual([]); + }); +}); + +describe("WhatsApp prepared inbound", () => { + it("projects portable facts without WhatsApp transport state", () => { + const inbound = { + channel: "whatsapp", + accountId: "work", + event: { id: "event-1", fullId: "whatsapp:event-1", timestamp: 1_710_000_000 }, + from: "whatsapp:user:u1", + sender: { id: "u1", name: "Alice" }, + conversation: { kind: "group", id: "room-1", label: "Example Room" }, + route: { + agentId: "main", + accountId: "work", + routeSessionKey: "agent:main:whatsapp:group:room-1", + }, + reply: { to: "whatsapp:room:room-1", replyToId: "quoted-1" }, + message: { + body: "agent body", + bodyForAgent: "agent body", + rawBody: "raw body", + commandBody: "/status", + }, + command: { + kind: "text-slash", + body: "/status", + authorization: { kind: "denied", reason: "sender_not_allowed" }, + }, + media: [{ path: "/tmp/example.jpg", contentType: "image/jpeg", kind: "image" }], + context: { senderE164: "+15550001111" }, + } satisfies PreparedChannelInbound; + + const projected = projectPreparedChannelInbound({ + inbound, + control: { messageReceivedHooks: "core" }, + }); + + expect(projected.input).toEqual({ + id: "event-1", + timestamp: 1_710_000_000, + rawText: "raw body", + textForAgent: "agent body", + textForCommands: "/status", + raw: inbound, + }); + expect(projected.context).toMatchObject({ + MessageSid: "event-1", + MessageSidFull: "whatsapp:event-1", + BodyForAgent: "agent body", + RawBody: "raw body", + CommandBody: "/status", + ReplyToId: "quoted-1", + CommandAuthorized: false, + ConversationLabel: "Example Room", + GroupSubject: "Example Room", + SenderE164: "+15550001111", + media: [{ path: "/tmp/example.jpg", contentType: "image/jpeg", kind: "image" }], + }); + }); +}); diff --git a/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts b/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts deleted file mode 100644 index aae5757e4019..000000000000 --- a/extensions/whatsapp/src/auto-reply/monitor/last-route.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Whatsapp tests cover last route plugin behavior. -import { afterEach, describe, expect, it } from "vitest"; -import { trackBackgroundTask } from "./last-route.js"; - -const waitForTaskCleanup = async (task: Promise) => { - await Promise.allSettled([task]); - await new Promise((resolve) => { - setImmediate(resolve); - }); -}; - -describe("trackBackgroundTask", () => { - const unhandledRejections: unknown[] = []; - const onUnhandledRejection = (reason: unknown) => { - unhandledRejections.push(reason); - }; - - afterEach(() => { - process.off("unhandledRejection", onUnhandledRejection); - unhandledRejections.length = 0; - }); - - it("does not leak unhandled rejections when a tracked task fails", async () => { - process.on("unhandledRejection", onUnhandledRejection); - const backgroundTasks = new Set>(); - let rejectTask: ((reason?: unknown) => void) | undefined; - const task = new Promise((_resolve, reject) => { - rejectTask = reject; - }); - - trackBackgroundTask(backgroundTasks, task); - expect(backgroundTasks.size).toBe(1); - - if (!rejectTask) { - throw new Error("Expected tracked task reject callback to be initialized"); - } - rejectTask(new Error("boom")); - await waitForTaskCleanup(task); - - expect(backgroundTasks.size).toBe(0); - expect(unhandledRejections).toStrictEqual([]); - }); -}); diff --git a/extensions/whatsapp/src/auto-reply/monitor/prepared-inbound.test.ts b/extensions/whatsapp/src/auto-reply/monitor/prepared-inbound.test.ts deleted file mode 100644 index 6373d15c44d4..000000000000 --- a/extensions/whatsapp/src/auto-reply/monitor/prepared-inbound.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { projectPreparedChannelInbound, type PreparedChannelInbound } from "./prepared-inbound.js"; - -describe("WhatsApp prepared inbound", () => { - it("projects portable facts without WhatsApp transport state", () => { - const inbound = { - channel: "whatsapp", - accountId: "work", - event: { - id: "event-1", - fullId: "whatsapp:event-1", - timestamp: 1_710_000_000, - }, - from: "whatsapp:user:u1", - sender: { - id: "u1", - name: "Alice", - }, - conversation: { - kind: "group", - id: "room-1", - label: "Example Room", - }, - route: { - agentId: "main", - accountId: "work", - routeSessionKey: "agent:main:whatsapp:group:room-1", - }, - reply: { - to: "whatsapp:room:room-1", - replyToId: "quoted-1", - }, - message: { - body: "agent body", - bodyForAgent: "agent body", - rawBody: "raw body", - commandBody: "/status", - }, - command: { - kind: "text-slash", - body: "/status", - authorization: { - kind: "denied", - reason: "sender_not_allowed", - }, - }, - media: [ - { - path: "/tmp/example.jpg", - contentType: "image/jpeg", - kind: "image", - }, - ], - context: { - senderE164: "+15550001111", - }, - } satisfies PreparedChannelInbound; - - const projected = projectPreparedChannelInbound({ - inbound, - control: { messageReceivedHooks: "core" }, - }); - - expect(projected.input).toEqual({ - id: "event-1", - timestamp: 1_710_000_000, - rawText: "raw body", - textForAgent: "agent body", - textForCommands: "/status", - raw: inbound, - }); - expect(projected.context).toMatchObject({ - MessageSid: "event-1", - MessageSidFull: "whatsapp:event-1", - BodyForAgent: "agent body", - RawBody: "raw body", - CommandBody: "/status", - ReplyToId: "quoted-1", - CommandAuthorized: false, - ConversationLabel: "Example Room", - GroupSubject: "Example Room", - SenderE164: "+15550001111", - media: [ - { - path: "/tmp/example.jpg", - contentType: "image/jpeg", - kind: "image", - }, - ], - }); - }); -}); 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 d8c56cfaf0ec..9e293d3e132a 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 @@ -308,61 +308,57 @@ describe("processMessage audio preflight transcription", () => { }); }); - it("keeps the empty caption and audio fact when transcription fails", async () => { - transcribeFirstAudioMock.mockRejectedValueOnce(new Error("provider unavailable")); + it.each([ + { + name: "keeps the empty caption and audio fact when transcription fails", + arrange: () => + transcribeFirstAudioMock.mockRejectedValueOnce(new Error("provider unavailable")), + }, + { + name: "keeps the empty caption when transcription returns undefined", + arrange: () => transcribeFirstAudioMock.mockResolvedValueOnce(undefined), + }, + ])("$name", async ({ arrange }) => { + arrange(); await processMessage(makeParams()); expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1); - - expectContextFields(firstDispatchContext(), { - Body: "", - BodyForAgent: "", - }); + expectContextFields(firstDispatchContext(), { Body: "", BodyForAgent: "" }); }); - it("keeps the empty caption when transcription returns undefined", async () => { - transcribeFirstAudioMock.mockResolvedValueOnce(undefined); - - await processMessage(makeParams()); - - expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1); - - expectContextFields(firstDispatchContext(), { - Body: "", - BodyForAgent: "", - }); - }); - - it("does not call transcribeFirstAudio when mediaType is not audio", async () => { - await processMessage( - makeParams({ body: "", mediaType: "image/jpeg", mediaPath: "/tmp/img.jpg" }), - ); + it.each([ + { + name: "does not call transcribeFirstAudio when mediaType is not audio", + overrides: { + body: "", + mediaType: "image/jpeg", + mediaPath: "/tmp/img.jpg", + }, + assertEmptyBody: false, + }, + { + name: "does not call transcribeFirstAudio when audio has a caption", + overrides: { body: "hello there", mediaType: "audio/ogg; codecs=opus" }, + assertEmptyBody: false, + }, + { + name: "does not call transcribeFirstAudio when mediaPath is absent", + overrides: { mediaPath: undefined }, + assertEmptyBody: false, + }, + { + name: "does not call transcribeFirstAudio when msg.mediaType is absent", + overrides: { mediaType: undefined, mediaPath: "/tmp/voice.ogg" }, + assertEmptyBody: true, + }, + ])("$name", async ({ overrides, assertEmptyBody }) => { + await processMessage(makeParams(overrides)); expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); - }); - - it("does not call transcribeFirstAudio when audio has a caption", async () => { - await processMessage(makeParams({ body: "hello there", mediaType: "audio/ogg; codecs=opus" })); - - expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); - }); - - it("does not call transcribeFirstAudio when mediaPath is absent", async () => { - await processMessage(makeParams({ mediaPath: undefined })); - - expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); - }); - - it("does not call transcribeFirstAudio when msg.mediaType is absent", async () => { - await processMessage(makeParams({ mediaType: undefined, mediaPath: "/tmp/voice.ogg" })); - - expect(transcribeFirstAudioMock).not.toHaveBeenCalled(); - - // Empty body passes through without a classified audio fact. - expectContextFields(firstDispatchContext(), { - Body: "", - }); + if (assertEmptyBody) { + expectContextFields(firstDispatchContext(), { Body: "" }); + } }); it("does not use transcript body for command detection", async () => { 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 ea4c7d24162d..8cb92a1048c7 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts @@ -342,69 +342,65 @@ describe("processMessage group system prompt wiring", () => { ).toBe("from config"); }); - it("marks detected WhatsApp slash messages as text command turns", async () => { - resolvePolicyMock.mockReturnValue(makePolicy(makeAccount())); - isControlCommandMessageMock.mockReturnValue(true); - shouldComputeCommandAuthorizedMock.mockReturnValue(true); - - await callProcessMessage({ - msg: makeBaseMsg({ body: "/status" }), - }); - - expect(shouldComputeCommandAuthorizedMock).toHaveBeenCalledWith("/status", {}); - expect(isControlCommandMessageMock).toHaveBeenCalledWith("/status", {}); - expect(mockCallArg(buildContextMock, "buildWhatsAppInboundContext")).toMatchObject({ - command: { - kind: "text-slash", - authorization: { kind: "authorized" }, - body: "/status", + it.each([ + { + name: "marks detected WhatsApp slash messages as text command turns", + message: { body: "/status" }, + commandBody: "/status", + isControlCommand: true, + expectedContext: { + command: { + kind: "text-slash", + authorization: { kind: "authorized" }, + body: "/status", + }, + rawBody: "/status", }, - rawBody: "/status", - }); - }); - - it("keeps generated media notices out of command input", async () => { - resolvePolicyMock.mockReturnValue(makePolicy(makeAccount())); - isControlCommandMessageMock.mockReturnValue(true); - shouldComputeCommandAuthorizedMock.mockReturnValue(true); - - await callProcessMessage({ - msg: makeBaseMsg({ + }, + { + name: "keeps generated media notices out of command input", + message: { body: "/reset\n\n[whatsapp attachment unavailable]", commandBody: "/reset", - }), - }); - - expect(shouldComputeCommandAuthorizedMock).toHaveBeenCalledWith("/reset", {}); - expect(isControlCommandMessageMock).toHaveBeenCalledWith("/reset", {}); - expect(mockCallArg(buildContextMock, "buildWhatsAppInboundContext")).toMatchObject({ - bodyForAgent: "/reset\n\n[whatsapp attachment unavailable]", - command: { - kind: "text-slash", - authorization: { kind: "authorized" }, - body: "/reset", }, - rawBody: "/reset", - }); - }); - - it("checks auth for inline command tokens without marking them as command-source turns", async () => { + commandBody: "/reset", + isControlCommand: true, + expectedContext: { + bodyForAgent: "/reset\n\n[whatsapp attachment unavailable]", + command: { + kind: "text-slash", + authorization: { kind: "authorized" }, + body: "/reset", + }, + rawBody: "/reset", + }, + }, + { + name: "checks auth for inline command tokens without marking them as command-source turns", + message: { body: "please inspect `/tmp/foo`" }, + commandBody: "please inspect `/tmp/foo`", + isControlCommand: false, + expectedContext: { + command: { + kind: "normal", + authorization: { kind: "authorized" }, + body: "please inspect `/tmp/foo`", + }, + rawBody: "please inspect `/tmp/foo`", + }, + }, + ])("$name", async ({ message, commandBody, isControlCommand, expectedContext }) => { resolvePolicyMock.mockReturnValue(makePolicy(makeAccount())); - isControlCommandMessageMock.mockReturnValue(false); + isControlCommandMessageMock.mockReturnValue(isControlCommand); shouldComputeCommandAuthorizedMock.mockReturnValue(true); - await callProcessMessage({ - msg: makeBaseMsg({ body: "please inspect `/tmp/foo`" }), - }); + await callProcessMessage({ msg: makeBaseMsg(message) }); - expect(mockCallArg(buildContextMock, "buildWhatsAppInboundContext")).toMatchObject({ - command: { - kind: "normal", - authorization: { kind: "authorized" }, - body: "please inspect `/tmp/foo`", - }, - rawBody: "please inspect `/tmp/foo`", - }); + expect(shouldComputeCommandAuthorizedMock).toHaveBeenCalledWith(commandBody, {}); + expect(isControlCommandMessageMock).toHaveBeenCalledWith(commandBody, {}); + expect(mockCallArg(buildContextMock, "buildWhatsAppInboundContext")).toMatchObject( + expectedContext, + ); }); it("passes pending group history from the history window into inbound context", async () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/reactions.test.ts similarity index 53% rename from extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts rename to extensions/whatsapp/src/auto-reply/monitor/reactions.test.ts index 51d4caa5f307..a5ee1836b2a7 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/reactions.test.ts @@ -3,7 +3,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js"; import type { AdmittedWebInboundMessage } from "../../inbound/types.js"; +import { resolveWhatsAppAckEmoji } from "./ack-emoji.js"; import { maybeSendAckReaction } from "./ack-reaction.js"; +import { createWhatsAppStatusReactionController } from "./status-reaction.js"; const hoisted = vi.hoisted(() => ({ sendReactionWhatsApp: vi.fn(async () => undefined), @@ -19,6 +21,10 @@ vi.mock("./group-activation.js", () => ({ type TestMsgOverrides = NonNullable[0]>; +type AckReactionConfig = NonNullable< + NonNullable["whatsapp"]>["ackReaction"] +>; + function createMessage(overrides: TestMsgOverrides = {}): AdmittedWebInboundMessage { return createTestWebInboundMessage({ event: { id: "msg-1" }, @@ -56,6 +62,57 @@ function createConfig( } as OpenClawConfig; } +function createAckEmojiConfig(ackReaction?: AckReactionConfig): OpenClawConfig { + const cfg = { + agents: { + list: [{ id: "agent", identity: { emoji: "πŸ”₯" } }], + }, + channels: { + whatsapp: {}, + }, + } as OpenClawConfig; + if (ackReaction !== undefined) { + cfg.channels!.whatsapp!.ackReaction = ackReaction; + } + return cfg; +} + +function resolveAckEmoji(cfg: OpenClawConfig, agentId = "agent") { + return resolveWhatsAppAckEmoji({ + cfg, + agentId, + ackConfig: cfg.channels?.whatsapp?.ackReaction, + }); +} + +function createStatusConfig( + overrides: { + ackEmoji?: string; + agentEmoji?: string; + reactionLevel?: "off" | "ack"; + workReactionLevel?: "off" | "ack"; + } = {}, +): OpenClawConfig { + return { + ...(overrides.agentEmoji + ? { agents: { entries: { agent: { identity: { emoji: overrides.agentEmoji } } } } } + : {}), + messages: { + ackReaction: overrides.ackEmoji ?? "πŸ‘€", + ackReactionScope: "all", + statusReactions: { enabled: true }, + }, + channels: { + whatsapp: { + reactionLevel: overrides.reactionLevel ?? "ack", + ...(overrides.workReactionLevel + ? { accounts: { work: { reactionLevel: overrides.workReactionLevel } } } + : {}), + }, + }, + } as OpenClawConfig; +} + type AckReactionParams = Parameters[0]; const runAckReaction = (overrides: Partial = {}) => @@ -84,6 +141,48 @@ const expectAckReactionSent = (accountId: string, cfg: OpenClawConfig = createCo ); }; +describe("resolveWhatsAppAckEmoji", () => { + it.each([ + { + name: "keeps missing ackReaction config disabled", + cfg: createAckEmojiConfig(), + expected: "", + }, + { + name: "uses the configured WhatsApp emoji when present", + cfg: createAckEmojiConfig({ emoji: " πŸ‘€ ", direct: true, group: "mentions" }), + expected: "πŸ‘€", + }, + { + name: "falls back to the routed agent identity for an empty emoji", + cfg: createAckEmojiConfig({ emoji: " ", direct: true, group: "mentions" }), + expected: "πŸ”₯", + }, + { + name: "falls back to the routed agent identity emoji when the ack object has no emoji", + cfg: createAckEmojiConfig({ direct: true, group: "mentions" }), + expected: "πŸ”₯", + }, + { + name: "uses normalized agent ids for the identity fallback", + cfg: { + agents: { list: [{ id: "Agent", identity: { emoji: "πŸ”₯" } }] }, + channels: { whatsapp: { ackReaction: { direct: true, group: "mentions" } } }, + } as OpenClawConfig, + expected: "πŸ”₯", + }, + { + name: "uses the default ack emoji when configured without an emoji or agent identity", + cfg: { + channels: { whatsapp: { ackReaction: { direct: true, group: "mentions" } } }, + } as OpenClawConfig, + expected: "πŸ‘€", + }, + ])("$name", ({ cfg, expected }) => { + expect(resolveAckEmoji(cfg)).toBe(expected); + }); +}); + describe("maybeSendAckReaction", () => { beforeEach(() => { vi.clearAllMocks(); @@ -297,3 +396,147 @@ describe("maybeSendAckReaction", () => { ); }); }); + +describe("createWhatsAppStatusReactionController", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses the sender LID as the group reaction participant when no sender JID is available", async () => { + const cfg = createStatusConfig(); + const controller = await createWhatsAppStatusReactionController({ + cfg, + msg: createMessage({ + platform: { + chatJid: "120363000000000000@g.us", + sender: { jid: null, lid: "277038292303944@lid" }, + }, + admission: { + conversation: { kind: "group", id: "120363000000000000@g.us" }, + sender: { id: "277038292303944@lid" }, + }, + }), + agentId: "agent", + sessionKey: "whatsapp:default:120363000000000000@g.us", + verbose: false, + }); + + void controller?.setQueued(); + await vi.waitFor(() => { + expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith( + "120363000000000000@g.us", + "msg-1", + "πŸ‘€", + { + verbose: false, + fromMe: false, + participant: "277038292303944@lid", + accountId: "default", + cfg, + }, + ); + }); + await controller?.clear(); + expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith( + "120363000000000000@g.us", + "msg-1", + "", + { + verbose: false, + fromMe: false, + participant: "277038292303944@lid", + accountId: "default", + cfg, + }, + ); + }); + + it("preserves self-authored direction for status set and clear", async () => { + const cfg = createStatusConfig(); + const controller = await createWhatsAppStatusReactionController({ + cfg, + msg: createMessage({ + platform: { + chatJid: "15551234567@s.whatsapp.net", + recipientJid: "15559876543", + fromMe: true, + }, + }), + agentId: "agent", + sessionKey: "whatsapp:default:15551234567", + verbose: false, + }); + + void controller?.setQueued(); + await vi.waitFor(() => { + expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith( + "15551234567@s.whatsapp.net", + "msg-1", + "πŸ‘€", + { + verbose: false, + fromMe: true, + accountId: "default", + cfg, + }, + ); + }); + + await controller?.clear(); + + expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith( + "15551234567@s.whatsapp.net", + "msg-1", + "", + { + verbose: false, + fromMe: true, + accountId: "default", + cfg, + }, + ); + }); + + it.each([ + { + name: "uses the canonical emoji preserved from agent identity", + cfg: createStatusConfig({ ackEmoji: "πŸ”₯", agentEmoji: "πŸ”₯" }), + msg: createMessage(), + sessionKey: "whatsapp:default:15551234567", + expectedEmoji: "πŸ”₯", + expectedAccountId: "default", + }, + { + name: "uses the active account reactionLevel override from admission", + cfg: createStatusConfig({ reactionLevel: "off", workReactionLevel: "ack" }), + msg: createMessage({ admission: { accountId: "work" } }), + sessionKey: "whatsapp:work:15551234567", + expectedEmoji: "πŸ‘€", + expectedAccountId: "work", + }, + ])("$name", async ({ cfg, msg, sessionKey, expectedEmoji, expectedAccountId }) => { + const controller = await createWhatsAppStatusReactionController({ + cfg, + msg, + agentId: "agent", + sessionKey, + verbose: false, + }); + + void controller?.setQueued(); + await vi.waitFor(() => { + expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith( + "15551234567@s.whatsapp.net", + "msg-1", + expectedEmoji, + { + verbose: false, + fromMe: false, + accountId: expectedAccountId, + cfg, + }, + ); + }); + await controller?.clear(); + }); +}); diff --git a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts deleted file mode 100644 index a4d4f48cabdd..000000000000 --- a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -// 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 { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js"; -import type { AdmittedWebInboundMessage } from "../../inbound/types.js"; -import { createWhatsAppStatusReactionController } from "./status-reaction.js"; - -const hoisted = vi.hoisted(() => ({ - sendReactionWhatsApp: vi.fn(async () => undefined), -})); - -vi.mock("../../send.js", () => ({ - sendReactionWhatsApp: hoisted.sendReactionWhatsApp, -})); - -vi.mock("./group-activation.js", () => ({ - resolveGroupActivationFor: vi.fn(async () => "always"), -})); - -type TestMsgOverrides = NonNullable[0]>; - -function createMessage(overrides: TestMsgOverrides = {}): AdmittedWebInboundMessage { - return createTestWebInboundMessage({ - event: { id: "msg-1" }, - platform: { - chatJid: "15551234567@s.whatsapp.net", - recipientJid: "15559876543", - fromMe: false, - }, - admission: { - accountId: "default", - conversation: { - kind: "direct", - id: "15551234567", - }, - sender: { - id: "15551234567", - }, - }, - ...overrides, - }); -} - -describe("createWhatsAppStatusReactionController", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("uses the sender LID as the group reaction participant when no sender JID is available", async () => { - const cfg = { - messages: { - ackReaction: "πŸ‘€", - ackReactionScope: "all", - statusReactions: { - enabled: true, - }, - }, - channels: { - whatsapp: { - reactionLevel: "ack", - }, - }, - } as OpenClawConfig; - - const controller = await createWhatsAppStatusReactionController({ - cfg, - msg: createMessage({ - platform: { - chatJid: "120363000000000000@g.us", - sender: { - jid: null, - lid: "277038292303944@lid", - }, - }, - admission: { - conversation: { - kind: "group", - id: "120363000000000000@g.us", - }, - sender: { - id: "277038292303944@lid", - }, - }, - }), - agentId: "agent", - sessionKey: "whatsapp:default:120363000000000000@g.us", - verbose: false, - }); - - void controller?.setQueued(); - await vi.waitFor(() => { - expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith( - "120363000000000000@g.us", - "msg-1", - "πŸ‘€", - { - verbose: false, - fromMe: false, - participant: "277038292303944@lid", - accountId: "default", - cfg, - }, - ); - }); - - await controller?.clear(); - - expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith( - "120363000000000000@g.us", - "msg-1", - "", - { - verbose: false, - fromMe: false, - participant: "277038292303944@lid", - accountId: "default", - cfg, - }, - ); - }); - - it("uses the canonical emoji preserved from agent identity", async () => { - const cfg = { - agents: { - entries: { agent: { identity: { emoji: "πŸ”₯" } } }, - }, - messages: { - ackReaction: "πŸ”₯", - ackReactionScope: "all", - statusReactions: { - enabled: true, - }, - }, - channels: { - whatsapp: { - reactionLevel: "ack", - }, - }, - } as OpenClawConfig; - - const controller = await createWhatsAppStatusReactionController({ - cfg, - msg: createMessage(), - agentId: "agent", - sessionKey: "whatsapp:default:15551234567", - verbose: false, - }); - - void controller?.setQueued(); - await vi.waitFor(() => { - expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith( - "15551234567@s.whatsapp.net", - "msg-1", - "πŸ”₯", - { - verbose: false, - fromMe: false, - accountId: "default", - cfg, - }, - ); - }); - await controller?.clear(); - }); - - it("preserves self-authored direction for status set and clear", async () => { - const cfg = { - messages: { - ackReaction: "πŸ‘€", - ackReactionScope: "all", - statusReactions: { - enabled: true, - }, - }, - channels: { - whatsapp: { - reactionLevel: "ack", - }, - }, - } as OpenClawConfig; - - const controller = await createWhatsAppStatusReactionController({ - cfg, - msg: createMessage({ - platform: { - chatJid: "15551234567@s.whatsapp.net", - recipientJid: "15559876543", - fromMe: true, - }, - }), - agentId: "agent", - sessionKey: "whatsapp:default:15551234567", - verbose: false, - }); - - void controller?.setQueued(); - await vi.waitFor(() => { - expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith( - "15551234567@s.whatsapp.net", - "msg-1", - "πŸ‘€", - { - verbose: false, - fromMe: true, - accountId: "default", - cfg, - }, - ); - }); - - await controller?.clear(); - - expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith( - "15551234567@s.whatsapp.net", - "msg-1", - "", - { - verbose: false, - fromMe: true, - accountId: "default", - cfg, - }, - ); - }); - - it("uses the active account reactionLevel override from admission", async () => { - const cfg = { - messages: { - ackReaction: "πŸ‘€", - ackReactionScope: "all", - statusReactions: { - enabled: true, - }, - }, - channels: { - whatsapp: { - reactionLevel: "off", - accounts: { - work: { - reactionLevel: "ack", - }, - }, - }, - }, - } as OpenClawConfig; - - const controller = await createWhatsAppStatusReactionController({ - cfg, - msg: createMessage({ - admission: { - accountId: "work", - }, - }), - agentId: "agent", - sessionKey: "whatsapp:work:15551234567", - verbose: false, - }); - - void controller?.setQueued(); - await vi.waitFor(() => { - expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith( - "15551234567@s.whatsapp.net", - "msg-1", - "πŸ‘€", - { - verbose: false, - fromMe: false, - accountId: "work", - cfg, - }, - ); - }); - await controller?.clear(); - }); -}); diff --git a/extensions/whatsapp/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test-support.ts b/extensions/whatsapp/src/monitor-inbox.access-and-echo.test.ts similarity index 99% rename from extensions/whatsapp/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test-support.ts rename to extensions/whatsapp/src/monitor-inbox.access-and-echo.test.ts index bbe998f0e3d1..1629e1de7440 100644 --- a/extensions/whatsapp/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test-support.ts +++ b/extensions/whatsapp/src/monitor-inbox.access-and-echo.test.ts @@ -1,5 +1,4 @@ -// Whatsapp plugin module implements monitor inbox.allows messages from senders allowfrom list support behavior. -import "./monitor-inbox.test-harness.js"; +// WhatsApp monitor inbox access and echo behavior. import { cleanMessage } from "baileys"; import { describe, expect, it, vi } from "vitest"; import { isRecentOutboundMessage } from "./inbound/dedupe.js"; diff --git a/extensions/whatsapp/src/monitor-inbox.append-upsert.test-support.ts b/extensions/whatsapp/src/monitor-inbox.append-upsert.test-support.ts deleted file mode 100644 index ffb622d97f7f..000000000000 --- a/extensions/whatsapp/src/monitor-inbox.append-upsert.test-support.ts +++ /dev/null @@ -1,283 +0,0 @@ -// Whatsapp plugin module implements monitor inbox.append upsert support behavior. -import "./monitor-inbox.test-harness.js"; -import { describe, expect, it, vi } from "vitest"; -import { - installWebMonitorInboxUnitTestHooks, - settleInboundWork, - startInboxMonitor, - waitForMessageCalls, -} from "./monitor-inbox.test-harness.js"; - -describe("append upsert handling (#20952)", () => { - installWebMonitorInboxUnitTestHooks(); - - it("delivery coordinator processes recent append messages", async () => { - const onMessage = vi.fn(async () => {}); - const { listener, sock } = await startInboxMonitor(onMessage); - - // Timestamp ~5 seconds ago β€” recent, should be processed. - const recentTs = Math.floor(Date.now() / 1000) - 5; - sock.ev.emit("messages.upsert", { - type: "append", - messages: [ - { - key: { id: "recent-1", fromMe: false, remoteJid: "120363@g.us" }, - message: { conversation: "hello from group" }, - messageTimestamp: recentTs, - pushName: "Tester", - }, - ], - }); - await waitForMessageCalls(onMessage, 1); - - expect(onMessage).toHaveBeenCalledTimes(1); - - await listener.close(); - }); - - it("delivery coordinator skips stale append messages", async () => { - const onMessage = vi.fn(async () => {}); - const { listener, sock } = await startInboxMonitor(onMessage); - - // Timestamp 5 minutes ago β€” stale history sync, should be skipped. - const staleTs = Math.floor(Date.now() / 1000) - 300; - sock.ev.emit("messages.upsert", { - type: "append", - messages: [ - { - key: { id: "stale-1", fromMe: false, remoteJid: "120363@g.us" }, - message: { conversation: "old history sync" }, - messageTimestamp: staleTs, - pushName: "OldTester", - }, - ], - }); - await settleInboundWork(); - - expect(onMessage).not.toHaveBeenCalled(); - - await listener.close(); - }); - - it("delivery coordinator limits reconnect catch-up appends by dedupe age", async () => { - const onMessage = vi.fn(async () => {}); - const { listener, sock } = await startInboxMonitor(onMessage, { - appendReplyWindow: { - afterMs: Date.now() - 30 * 60_000, - untilMs: Date.now() + 30 * 60_000, - maxAgeMs: 20 * 60_000, - }, - }); - - sock.ev.emit("messages.upsert", { - type: "append", - messages: [ - { - key: { id: "catch-up-1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "missed while reconnecting" }, - messageTimestamp: Math.floor(Date.now() / 1000) - 15 * 60, - pushName: "Reconnect Tester", - }, - ], - }); - await waitForMessageCalls(onMessage, 1); - - sock.ev.emit("messages.upsert", { - type: "append", - messages: [ - { - key: { id: "catch-up-old", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "before the recovery window" }, - messageTimestamp: Math.floor(Date.now() / 1000) - 25 * 60, - pushName: "Reconnect Tester", - }, - ], - }); - await settleInboundWork(); - - expect(onMessage).toHaveBeenCalledTimes(1); - - await listener.close(); - }); - - it("delivery coordinator preserves fresh appends after catch-up expires", async () => { - const onMessage = vi.fn(async () => {}); - const { listener, sock } = await startInboxMonitor(onMessage, { - appendReplyWindow: { - afterMs: Date.now() - 30 * 60_000, - untilMs: Date.now() - 1, - maxAgeMs: 20 * 60_000, - }, - }); - - sock.ev.emit("messages.upsert", { - type: "append", - messages: [ - { - key: { id: "catch-up-late", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "arrived after recovery" }, - messageTimestamp: Math.floor(Date.now() / 1000) - 5 * 60, - pushName: "Reconnect Tester", - }, - ], - }); - await settleInboundWork(); - - expect(onMessage).not.toHaveBeenCalled(); - - sock.ev.emit("messages.upsert", { - type: "append", - messages: [ - { - key: { id: "fresh-after-catch-up", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "fresh after recovery" }, - messageTimestamp: Math.floor(Date.now() / 1000), - pushName: "Reconnect Tester", - }, - ], - }); - await waitForMessageCalls(onMessage, 1); - - expect(onMessage).toHaveBeenCalledTimes(1); - - await listener.close(); - }); - - it("delivery coordinator processes distinct catch-up messages at the boundary", async () => { - // Baileys timestamps use whole seconds. Freeze this inclusive boundary so - // async monitor startup cannot age the fixture beyond maxAgeMs. - const nowMs = 1_700_000_000_000; - const dateNow = vi.spyOn(Date, "now").mockReturnValue(nowMs); - try { - const onMessage = vi.fn(async () => {}); - const boundarySeconds = nowMs / 1000 - 20 * 60; - const { listener, sock } = await startInboxMonitor(onMessage, { - appendReplyWindow: { - afterMs: boundarySeconds * 1000, - untilMs: nowMs + 30 * 60_000, - maxAgeMs: 20 * 60_000, - }, - }); - try { - sock.ev.emit("messages.upsert", { - type: "append", - messages: [ - { - key: { - id: "catch-up-same-second", - fromMe: false, - remoteJid: "999@s.whatsapp.net", - }, - message: { conversation: "same second, different message" }, - messageTimestamp: boundarySeconds, - pushName: "Reconnect Tester", - }, - ], - }); - await waitForMessageCalls(onMessage, 1); - - expect(onMessage).toHaveBeenCalledTimes(1); - } finally { - await listener.close(); - } - } finally { - dateNow.mockRestore(); - } - }); - - it("delivery coordinator skips append messages with non-finite timestamps", async () => { - const onMessage = vi.fn(async () => {}); - const { listener, sock } = await startInboxMonitor(onMessage); - - // NaN timestamp should be treated as 0 (stale) and skipped. - sock.ev.emit("messages.upsert", { - type: "append", - messages: [ - { - key: { id: "nan-1", fromMe: false, remoteJid: "120363@g.us" }, - message: { conversation: "bad timestamp" }, - messageTimestamp: Number.NaN, - pushName: "BadTs", - }, - ], - }); - await settleInboundWork(); - - expect(onMessage).not.toHaveBeenCalled(); - - await listener.close(); - }); - - it("delivery coordinator skips append messages with non-decimal timestamps", async () => { - const onMessage = vi.fn(async () => {}); - const { listener, sock } = await startInboxMonitor(onMessage); - - const recentTs = Math.floor(Date.now() / 1000) - 5; - sock.ev.emit("messages.upsert", { - type: "append", - messages: [ - { - key: { id: "hex-1", fromMe: false, remoteJid: "120363@g.us" }, - message: { conversation: "hex timestamp" }, - messageTimestamp: `0x${recentTs.toString(16)}`, - pushName: "HexTs", - }, - ], - }); - await settleInboundWork(); - - expect(onMessage).not.toHaveBeenCalled(); - - await listener.close(); - }); - - it("delivery coordinator handles Long-like protobuf timestamps", async () => { - const onMessage = vi.fn(async () => {}); - const { listener, sock } = await startInboxMonitor(onMessage); - - // Baileys can deliver messageTimestamp as a Long object (from protobufjs). - // Number(longObj) calls valueOf() and returns the numeric value. - const recentTs = Math.floor(Date.now() / 1000) - 5; - const longLike = { low: recentTs, high: 0, unsigned: true, valueOf: () => recentTs }; - sock.ev.emit("messages.upsert", { - type: "append", - messages: [ - { - key: { id: "long-1", fromMe: false, remoteJid: "120363@g.us" }, - message: { conversation: "long timestamp" }, - messageTimestamp: longLike, - pushName: "LongTs", - }, - ], - }); - await waitForMessageCalls(onMessage, 1); - - expect(onMessage).toHaveBeenCalledTimes(1); - - await listener.close(); - }); - - it("delivery coordinator always processes notify messages", async () => { - const onMessage = vi.fn(async () => {}); - const { listener, sock } = await startInboxMonitor(onMessage); - - // Very old timestamp but type=notify β€” should always be processed. - const oldTs = Math.floor(Date.now() / 1000) - 86400; - sock.ev.emit("messages.upsert", { - type: "notify", - messages: [ - { - key: { id: "notify-1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "normal message" }, - messageTimestamp: oldTs, - pushName: "User", - }, - ], - }); - await waitForMessageCalls(onMessage, 1); - - expect(onMessage).toHaveBeenCalledTimes(1); - - await listener.close(); - }); -}); diff --git a/extensions/whatsapp/src/monitor-inbox.append.test.ts b/extensions/whatsapp/src/monitor-inbox.append.test.ts new file mode 100644 index 000000000000..60cb73244d9a --- /dev/null +++ b/extensions/whatsapp/src/monitor-inbox.append.test.ts @@ -0,0 +1,237 @@ +// WhatsApp monitor inbox append behavior. +import { describe, expect, it, vi } from "vitest"; +import { + installWebMonitorInboxUnitTestHooks, + settleInboundWork, + startInboxMonitor, + waitForMessageCalls, +} from "./monitor-inbox.test-harness.js"; + +function emitUpsert( + sock: { ev: { emit: (event: string, payload: unknown) => void } }, + params: { + id: string; + body: string; + remoteJid: string; + type: "append" | "notify"; + timestamp: unknown; + }, +) { + sock.ev.emit("messages.upsert", { + type: params.type, + messages: [ + { + key: { id: params.id, fromMe: false, remoteJid: params.remoteJid }, + message: { conversation: params.body }, + messageTimestamp: params.timestamp, + pushName: "Tester", + }, + ], + }); +} + +describe("append upsert handling (#20952)", () => { + installWebMonitorInboxUnitTestHooks(); + + it.each([ + { + name: "delivery coordinator processes recent append messages", + id: "recent-1", + body: "hello from group", + remoteJid: "120363@g.us", + type: "append" as const, + timestamp: () => Math.floor(Date.now() / 1000) - 5, + expectedCalls: 1, + }, + { + name: "delivery coordinator skips stale append messages", + id: "stale-1", + body: "old history sync", + remoteJid: "120363@g.us", + type: "append" as const, + timestamp: () => Math.floor(Date.now() / 1000) - 300, + expectedCalls: 0, + }, + { + name: "delivery coordinator skips append messages with non-finite timestamps", + id: "nan-1", + body: "bad timestamp", + remoteJid: "120363@g.us", + type: "append" as const, + timestamp: () => Number.NaN, + expectedCalls: 0, + }, + { + name: "delivery coordinator skips append messages with non-decimal timestamps", + id: "hex-1", + body: "hex timestamp", + remoteJid: "120363@g.us", + type: "append" as const, + timestamp: () => { + const recent = Math.floor(Date.now() / 1000) - 5; + return `0x${recent.toString(16)}`; + }, + expectedCalls: 0, + }, + { + name: "delivery coordinator handles Long-like protobuf timestamps", + id: "long-1", + body: "long timestamp", + remoteJid: "120363@g.us", + type: "append" as const, + timestamp: () => { + const recent = Math.floor(Date.now() / 1000) - 5; + return { low: recent, high: 0, unsigned: true, valueOf: () => recent }; + }, + expectedCalls: 1, + }, + { + name: "delivery coordinator always processes notify messages", + id: "notify-1", + body: "normal message", + remoteJid: "999@s.whatsapp.net", + type: "notify" as const, + timestamp: () => Math.floor(Date.now() / 1000) - 86_400, + expectedCalls: 1, + }, + ])("$name", async ({ id, body, remoteJid, type, timestamp, expectedCalls }) => { + const onMessage = vi.fn(async () => {}); + const { listener, sock } = await startInboxMonitor(onMessage); + + emitUpsert(sock, { id, body, remoteJid, type, timestamp: timestamp() }); + if (expectedCalls === 1) { + await waitForMessageCalls(onMessage, 1); + } else { + await settleInboundWork(); + } + + expect(onMessage).toHaveBeenCalledTimes(expectedCalls); + await listener.close(); + }); + + it("delivery coordinator limits reconnect catch-up appends by dedupe age", async () => { + const onMessage = vi.fn(async () => {}); + const { listener, sock } = await startInboxMonitor(onMessage, { + appendReplyWindow: { + afterMs: Date.now() - 30 * 60_000, + untilMs: Date.now() + 30 * 60_000, + maxAgeMs: 20 * 60_000, + }, + }); + + sock.ev.emit("messages.upsert", { + type: "append", + messages: [ + { + key: { id: "catch-up-1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + message: { conversation: "missed while reconnecting" }, + messageTimestamp: Math.floor(Date.now() / 1000) - 15 * 60, + pushName: "Reconnect Tester", + }, + ], + }); + await waitForMessageCalls(onMessage, 1); + + sock.ev.emit("messages.upsert", { + type: "append", + messages: [ + { + key: { id: "catch-up-old", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + message: { conversation: "before the recovery window" }, + messageTimestamp: Math.floor(Date.now() / 1000) - 25 * 60, + pushName: "Reconnect Tester", + }, + ], + }); + await settleInboundWork(); + + expect(onMessage).toHaveBeenCalledTimes(1); + + await listener.close(); + }); + + it("delivery coordinator preserves fresh appends after catch-up expires", async () => { + const onMessage = vi.fn(async () => {}); + const { listener, sock } = await startInboxMonitor(onMessage, { + appendReplyWindow: { + afterMs: Date.now() - 30 * 60_000, + untilMs: Date.now() - 1, + maxAgeMs: 20 * 60_000, + }, + }); + + sock.ev.emit("messages.upsert", { + type: "append", + messages: [ + { + key: { id: "catch-up-late", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + message: { conversation: "arrived after recovery" }, + messageTimestamp: Math.floor(Date.now() / 1000) - 5 * 60, + pushName: "Reconnect Tester", + }, + ], + }); + await settleInboundWork(); + + expect(onMessage).not.toHaveBeenCalled(); + + sock.ev.emit("messages.upsert", { + type: "append", + messages: [ + { + key: { id: "fresh-after-catch-up", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + message: { conversation: "fresh after recovery" }, + messageTimestamp: Math.floor(Date.now() / 1000), + pushName: "Reconnect Tester", + }, + ], + }); + await waitForMessageCalls(onMessage, 1); + + expect(onMessage).toHaveBeenCalledTimes(1); + + await listener.close(); + }); + + it("delivery coordinator processes distinct catch-up messages at the boundary", async () => { + // Baileys timestamps use whole seconds. Freeze this inclusive boundary so + // async monitor startup cannot age the fixture beyond maxAgeMs. + const nowMs = 1_700_000_000_000; + const dateNow = vi.spyOn(Date, "now").mockReturnValue(nowMs); + try { + const onMessage = vi.fn(async () => {}); + const boundarySeconds = nowMs / 1000 - 20 * 60; + const { listener, sock } = await startInboxMonitor(onMessage, { + appendReplyWindow: { + afterMs: boundarySeconds * 1000, + untilMs: nowMs + 30 * 60_000, + maxAgeMs: 20 * 60_000, + }, + }); + try { + sock.ev.emit("messages.upsert", { + type: "append", + messages: [ + { + key: { + id: "catch-up-same-second", + fromMe: false, + remoteJid: "999@s.whatsapp.net", + }, + message: { conversation: "same second, different message" }, + messageTimestamp: boundarySeconds, + pushName: "Reconnect Tester", + }, + ], + }); + await waitForMessageCalls(onMessage, 1); + + expect(onMessage).toHaveBeenCalledTimes(1); + } finally { + await listener.close(); + } + } finally { + dateNow.mockRestore(); + } + }); +}); diff --git a/extensions/whatsapp/src/monitor-inbox.behavior.test.ts b/extensions/whatsapp/src/monitor-inbox.behavior.test.ts deleted file mode 100644 index 1b2488da6bbf..000000000000 --- a/extensions/whatsapp/src/monitor-inbox.behavior.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Whatsapp tests cover monitor inbox.behavior plugin behavior. -import "./monitor-inbox.allows-messages-from-senders-allowfrom-list.test-support.js"; -import "./monitor-inbox.append-upsert.test-support.js"; -import "./monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.js"; -import "./monitor-inbox.captures-media-path-image-messages.test-support.js"; -import "./monitor-inbox.streams-inbound-messages.test-support.js"; diff --git a/extensions/whatsapp/src/monitor-inbox.delivery-and-lifecycle.test.ts b/extensions/whatsapp/src/monitor-inbox.delivery-and-lifecycle.test.ts new file mode 100644 index 000000000000..e557efdd8d9b --- /dev/null +++ b/extensions/whatsapp/src/monitor-inbox.delivery-and-lifecycle.test.ts @@ -0,0 +1,2 @@ +// WhatsApp monitor inbox delivery and lifecycle behavior. +import "./monitor-inbox.streams-inbound-messages.test-support.js"; diff --git a/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test-support.ts b/extensions/whatsapp/src/monitor-inbox.media-and-session.test.ts similarity index 98% rename from extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test-support.ts rename to extensions/whatsapp/src/monitor-inbox.media-and-session.test.ts index 510ff8da5141..942994eb7e60 100644 --- a/extensions/whatsapp/src/monitor-inbox.captures-media-path-image-messages.test-support.ts +++ b/extensions/whatsapp/src/monitor-inbox.media-and-session.test.ts @@ -1,5 +1,4 @@ -// Whatsapp plugin module implements monitor inbox.captures media path image messages support behavior. -import "./monitor-inbox.test-harness.js"; +// WhatsApp monitor inbox media and session behavior. import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_ACCOUNT_ID, diff --git a/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts b/extensions/whatsapp/src/monitor-inbox.policy.test.ts similarity index 56% rename from extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts rename to extensions/whatsapp/src/monitor-inbox.policy.test.ts index c4ba546977d3..e0d1c12fd004 100644 --- a/extensions/whatsapp/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts +++ b/extensions/whatsapp/src/monitor-inbox.policy.test.ts @@ -1,5 +1,4 @@ -// Whatsapp plugin module implements monitor inbox.blocks messages from unauthorized senders not allowfrom support behavior. -import "./monitor-inbox.test-harness.js"; +// WhatsApp monitor inbox policy behavior. import { describe, expect, it, vi } from "vitest"; import type { WebInboundMessage } from "./inbound/types.js"; import { @@ -252,179 +251,133 @@ describe("web monitor inbox", () => { await listener.close(); }); - it("lets group messages through even when sender not in allowFrom", async () => { - const { onMessage, listener, sock } = await startWebInboxMonitor({ - config: { - channels: { whatsapp: { allowFrom: ["+1234"], groupPolicy: "open" } }, - messages: DEFAULT_MESSAGES_CFG, - }, - }); - sock.ev.emit( - "messages.upsert", - createNotifyUpsert( - createGroupMessage({ - id: "grp3", - participant: "999@s.whatsapp.net", - conversation: "unauthorized group message", - }), - ), - ); - await settleInboundWork(); - - expect(onMessage).toHaveBeenCalledTimes(1); - const payload = firstInboundPayload(onMessage); - expect(payload.admission?.conversation.kind).toBe("group"); - expect(payload.platform.senderE164).toBe("+999"); - - await listener.close(); - }); - - it("blocks all group messages when groupPolicy is 'disabled'", async () => { - const { onMessage, listener, sock } = await startWebInboxMonitor({ - config: { - channels: { whatsapp: { allowFrom: ["+1234"], groupPolicy: "disabled" } }, - messages: TIMESTAMP_OFF_MESSAGES_CFG, - }, - }); - sock.ev.emit( - "messages.upsert", - createNotifyUpsert( - createGroupMessage({ - id: "grp-disabled", - participant: "999@s.whatsapp.net", - conversation: "group message should be blocked", - }), - ), - ); - await settleInboundWork(); - - // Should NOT call onMessage because groupPolicy is disabled - expect(onMessage).not.toHaveBeenCalled(); - - await listener.close(); - }); - - it("blocks group messages from senders not in groupAllowFrom when groupPolicy is 'allowlist'", async () => { - const { onMessage, listener, sock } = await startWebInboxMonitor({ - config: { - channels: { - whatsapp: { - groupAllowFrom: ["+1234"], // Does not include +999 - groupPolicy: "allowlist", + it.each([ + { + name: "lets group messages through even when sender not in allowFrom", + id: "grp3", + groupPolicy: "open", + messages: DEFAULT_MESSAGES_CFG, + allowFrom: ["+1234"], + groupAllowFrom: undefined, + participant: "999@s.whatsapp.net", + remoteJid: undefined, + conversation: "unauthorized group message", + expectedCalls: 1, + expectedSender: "+999", + }, + { + name: "blocks all group messages when groupPolicy is 'disabled'", + id: "grp-disabled", + groupPolicy: "disabled", + messages: TIMESTAMP_OFF_MESSAGES_CFG, + allowFrom: ["+1234"], + groupAllowFrom: undefined, + participant: "999@s.whatsapp.net", + remoteJid: undefined, + conversation: "group message should be blocked", + expectedCalls: 0, + expectedSender: undefined, + }, + { + name: "blocks group messages from senders not in groupAllowFrom when groupPolicy is 'allowlist'", + id: "grp-allowlist-blocked", + groupPolicy: "allowlist", + messages: TIMESTAMP_OFF_MESSAGES_CFG, + allowFrom: undefined, + groupAllowFrom: ["+1234"], + participant: "999@s.whatsapp.net", + remoteJid: undefined, + conversation: "unauthorized group sender", + expectedCalls: 0, + expectedSender: undefined, + }, + { + name: "allows group messages from senders in groupAllowFrom when groupPolicy is 'allowlist'", + id: "grp-allowlist-allowed", + groupPolicy: "allowlist", + messages: TIMESTAMP_OFF_MESSAGES_CFG, + allowFrom: undefined, + groupAllowFrom: ["+15551234567"], + participant: "15551234567@s.whatsapp.net", + remoteJid: undefined, + conversation: "authorized group sender", + expectedCalls: 1, + expectedSender: "+15551234567", + }, + { + name: "allows all group senders with wildcard in groupPolicy allowlist", + id: "grp-wildcard-test", + groupPolicy: "allowlist", + messages: TIMESTAMP_OFF_MESSAGES_CFG, + allowFrom: undefined, + groupAllowFrom: ["*"], + participant: "9999999999@s.whatsapp.net", + remoteJid: "22222@g.us", + conversation: "wildcard group sender", + expectedCalls: 1, + expectedSender: undefined, + }, + { + name: "blocks group messages when groupPolicy allowlist has no groupAllowFrom", + id: "grp-allowlist-empty", + groupPolicy: "allowlist", + messages: TIMESTAMP_OFF_MESSAGES_CFG, + allowFrom: undefined, + groupAllowFrom: undefined, + participant: "999@s.whatsapp.net", + remoteJid: undefined, + conversation: "blocked by empty allowlist", + expectedCalls: 0, + expectedSender: undefined, + }, + ] as const)( + "$name", + async ({ + id, + groupPolicy, + messages, + allowFrom, + groupAllowFrom, + participant, + remoteJid, + conversation, + expectedCalls, + expectedSender, + }) => { + const { onMessage, listener, sock } = await startWebInboxMonitor({ + config: { + channels: { + whatsapp: { + groupPolicy, + ...(allowFrom ? { allowFrom: [...allowFrom] } : {}), + ...(groupAllowFrom ? { groupAllowFrom: [...groupAllowFrom] } : {}), + }, }, + messages, }, - messages: TIMESTAMP_OFF_MESSAGES_CFG, - }, - }); - sock.ev.emit( - "messages.upsert", - createNotifyUpsert( - createGroupMessage({ - id: "grp-allowlist-blocked", - participant: "999@s.whatsapp.net", - conversation: "unauthorized group sender", - }), - ), - ); - await settleInboundWork(); + }); + sock.ev.emit( + "messages.upsert", + createNotifyUpsert( + createGroupMessage({ + id, + ...(remoteJid ? { remoteJid } : {}), + participant, + conversation, + }), + ), + ); + await settleInboundWork(); - // Should NOT call onMessage because sender +999 not in groupAllowFrom - expect(onMessage).not.toHaveBeenCalled(); - - await listener.close(); - }); - - it("allows group messages from senders in groupAllowFrom when groupPolicy is 'allowlist'", async () => { - const { onMessage, listener, sock } = await startWebInboxMonitor({ - config: { - channels: { - whatsapp: { - groupAllowFrom: ["+15551234567"], // Includes the sender - groupPolicy: "allowlist", - }, - }, - messages: TIMESTAMP_OFF_MESSAGES_CFG, - }, - }); - sock.ev.emit( - "messages.upsert", - createNotifyUpsert( - createGroupMessage({ - id: "grp-allowlist-allowed", - participant: "15551234567@s.whatsapp.net", - conversation: "authorized group sender", - }), - ), - ); - await settleInboundWork(); - - // Should call onMessage because sender is in groupAllowFrom - expect(onMessage).toHaveBeenCalledTimes(1); - const payload = firstInboundPayload(onMessage); - expect(payload.admission?.conversation.kind).toBe("group"); - expect(payload.platform.senderE164).toBe("+15551234567"); - - await listener.close(); - }); - - it("allows all group senders with wildcard in groupPolicy allowlist", async () => { - const { onMessage, listener, sock } = await startWebInboxMonitor({ - config: { - channels: { - whatsapp: { - groupAllowFrom: ["*"], // Wildcard allows everyone - groupPolicy: "allowlist", - }, - }, - messages: TIMESTAMP_OFF_MESSAGES_CFG, - }, - }); - sock.ev.emit( - "messages.upsert", - createNotifyUpsert( - createGroupMessage({ - id: "grp-wildcard-test", - remoteJid: "22222@g.us", - participant: "9999999999@s.whatsapp.net", - conversation: "wildcard group sender", - }), - ), - ); - await settleInboundWork(); - - // Should call onMessage because wildcard allows all senders - expect(onMessage).toHaveBeenCalledTimes(1); - const payload = firstInboundPayload(onMessage); - expect(payload.admission?.conversation.kind).toBe("group"); - - await listener.close(); - }); - - it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => { - const { onMessage, listener, sock } = await startWebInboxMonitor({ - config: { - channels: { - whatsapp: { - groupPolicy: "allowlist", - }, - }, - messages: TIMESTAMP_OFF_MESSAGES_CFG, - }, - }); - sock.ev.emit( - "messages.upsert", - createNotifyUpsert( - createGroupMessage({ - id: "grp-allowlist-empty", - participant: "999@s.whatsapp.net", - conversation: "blocked by empty allowlist", - }), - ), - ); - await settleInboundWork(); - - expect(onMessage).not.toHaveBeenCalled(); - - await listener.close(); - }); + expect(onMessage).toHaveBeenCalledTimes(expectedCalls); + if (expectedCalls === 1) { + const payload = firstInboundPayload(onMessage); + expect(payload.admission?.conversation.kind).toBe("group"); + if (expectedSender) { + expect(payload.platform.senderE164).toBe(expectedSender); + } + } + await listener.close(); + }, + ); }); 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 39bbbe6afe8b..76da53eefd6a 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 @@ -1,8 +1,7 @@ -// Whatsapp plugin module implements monitor inbox.streams inbound messages support behavior. +// WhatsApp monitor inbox delivery and lifecycle behavior. import fsSync from "node:fs"; import path from "node:path"; import type { GroupMetadata, WAMessageKey } from "baileys"; -import "./monitor-inbox.test-harness.js"; import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { diff --git a/extensions/whatsapp/src/monitor-inbox.test-harness.ts b/extensions/whatsapp/src/monitor-inbox.test-harness.ts index d0d4c1186baa..14cc91408297 100644 --- a/extensions/whatsapp/src/monitor-inbox.test-harness.ts +++ b/extensions/whatsapp/src/monitor-inbox.test-harness.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; +import { createChannelIngressQueueForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { afterEach, beforeEach, expect, vi } from "vitest"; import { @@ -10,6 +11,7 @@ import { resetPairingSecurityMocks, upsertPairingRequestMock as pairingUpsertPairingRequestMock, } from "./pairing-security.test-harness.js"; +import { setWhatsAppRuntime } from "./runtime.js"; // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). type AnyMockFn = any; @@ -131,26 +133,6 @@ vi.mock("openclaw/plugin-sdk/channel-activity-runtime", async () => { }; }); -vi.mock("./runtime.js", async () => { - const { createChannelIngressQueueForTests: createChannelIngressQueue } = await Promise.resolve( - vi.importActual( - "openclaw/plugin-sdk/plugin-state-test-runtime", - ), - ); - return { - getWhatsAppRuntime: () => ({ - state: { - resolveStateDir: pluginRuntimeMocks.stateDir, - openKeyedStore: pluginRuntimeMocks.openKeyedStore, - openChannelIngressQueue: ( - options?: Omit[0], "channelId">, - ) => createChannelIngressQueue({ ...options, channelId: "whatsapp" }), - }, - }), - setWhatsAppRuntime: vi.fn(), - }; -}); - const inboundRuntimeMocks = vi.hoisted(() => { const wrapperKeys = [ "ephemeralMessage", @@ -391,6 +373,16 @@ export function installWebMonitorInboxUnitTestHooks(opts?: { authDir?: boolean } vi.clearAllMocks(); channelActivityMocks.recordChannelActivity.mockClear(); pluginRuntimeMocks.reset(); + setWhatsAppRuntime({ + channel: {}, + state: { + resolveStateDir: pluginRuntimeMocks.stateDir, + openKeyedStore: pluginRuntimeMocks.openKeyedStore, + openChannelIngressQueue: ( + options?: Omit[0], "channelId">, + ) => createChannelIngressQueueForTests({ ...options, channelId: "whatsapp" }), + }, + } as never); sessionState.sock = createMockSock(); resetPairingSecurityMocks(DEFAULT_WEB_INBOX_CONFIG); if (!monitorWebInbox || !resetWebInboundDedupe) {