import { vi } from "vitest"; vi.mock("../media/store.js", () => ({ saveMediaBuffer: vi.fn().mockResolvedValue({ id: "mid", path: "/tmp/mid", size: 1, contentType: "image/jpeg", }), })); const mockLoadConfig = vi.fn().mockReturnValue({ inbound: { allowFrom: ["*"], // Allow all in tests by default messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, }, }); vi.mock("../config/config.js", () => ({ loadConfig: () => mockLoadConfig(), })); vi.mock("./session.js", () => { const { EventEmitter } = require("node:events"); const ev = new EventEmitter(); const sock = { ev, ws: { close: vi.fn() }, sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), sendMessage: vi.fn().mockResolvedValue(undefined), readMessages: vi.fn().mockResolvedValue(undefined), updateMediaMessage: vi.fn(), logger: {}, user: { id: "123@s.whatsapp.net" }, }; return { createWaSocket: vi.fn().mockResolvedValue(sock), waitForWaConnection: vi.fn().mockResolvedValue(undefined), getStatusCode: vi.fn(() => 500), }; }); const { createWaSocket } = await import("./session.js"); const _getSock = () => (createWaSocket as unknown as () => Promise>)(); import crypto from "node:crypto"; import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { monitorWebInbox } from "./inbound.js"; describe("web monitor inbox", () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { resetLogger(); setLoggerOverride(null); vi.useRealTimers(); }); it("streams inbound messages", async () => { const onMessage = vi.fn(async (msg) => { await msg.sendComposing(); await msg.reply("pong"); }); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); const upsert = { type: "notify", messages: [ { key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" }, message: { conversation: "ping" }, messageTimestamp: 1_700_000_000, pushName: "Tester", }, ], }; sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({ body: "ping", from: "+999", to: "+123" }), ); expect(sock.readMessages).toHaveBeenCalledWith([ { remoteJid: "999@s.whatsapp.net", id: "abc", participant: undefined, fromMe: false, }, ]); expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); expect(sock.sendPresenceUpdate).toHaveBeenCalledWith( "composing", "999@s.whatsapp.net", ); expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { text: "pong", }); await listener.close(); }); it("captures media path for image messages", async () => { const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); const upsert = { type: "notify", messages: [ { key: { id: "med1", fromMe: false, remoteJid: "888@s.whatsapp.net" }, message: { imageMessage: { mimetype: "image/jpeg" } }, messageTimestamp: 1_700_000_100, }, ], }; sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({ body: "", }), ); expect(sock.readMessages).toHaveBeenCalledWith([ { remoteJid: "888@s.whatsapp.net", id: "med1", participant: undefined, fromMe: false, }, ]); expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); await listener.close(); }); it("resolves onClose when the socket closes", async () => { const listener = await monitorWebInbox({ verbose: false, onMessage: vi.fn(), }); const sock = await createWaSocket(); const reasonPromise = listener.onClose; sock.ev.emit("connection.update", { connection: "close", lastDisconnect: { error: { output: { statusCode: 500 } } }, }); await expect(reasonPromise).resolves.toEqual( expect.objectContaining({ status: 500, isLoggedOut: false }), ); await listener.close(); }); it("logs inbound bodies to file", async () => { const logPath = path.join( os.tmpdir(), `clawdis-log-test-${crypto.randomUUID()}.log`, ); setLoggerOverride({ level: "trace", file: logPath }); const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); const upsert = { type: "notify", messages: [ { key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" }, message: { conversation: "ping" }, messageTimestamp: 1_700_000_000, pushName: "Tester", }, ], }; sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); const content = fsSync.readFileSync(logPath, "utf-8"); expect(content).toMatch(/web-inbound/); expect(content).toMatch(/ping/); await listener.close(); }); it("includes participant when marking group messages read", async () => { const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); const upsert = { type: "notify", messages: [ { key: { id: "grp1", fromMe: false, remoteJid: "12345-67890@g.us", participant: "111@s.whatsapp.net", }, message: { conversation: "group ping" }, }, ], }; sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); expect(sock.readMessages).toHaveBeenCalledWith([ { remoteJid: "12345-67890@g.us", id: "grp1", participant: "111@s.whatsapp.net", fromMe: false, }, ]); await listener.close(); }); it("passes through group messages with participant metadata", async () => { const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); const upsert = { type: "notify", messages: [ { key: { id: "grp2", fromMe: false, remoteJid: "99999@g.us", participant: "777@s.whatsapp.net", }, pushName: "Alice", message: { extendedTextMessage: { text: "@bot ping", contextInfo: { mentionedJid: ["123@s.whatsapp.net"] }, }, }, messageTimestamp: 1_700_000_000, }, ], }; sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({ chatType: "group", conversationId: "99999@g.us", senderE164: "+777", mentionedJids: ["123@s.whatsapp.net"], }), ); await listener.close(); }); it("unwraps ephemeral messages, preserves mentions, and still delivers group pings", async () => { const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); const upsert = { type: "notify", messages: [ { key: { id: "grp-ephem", fromMe: false, remoteJid: "424242@g.us", participant: "888@s.whatsapp.net", }, message: { ephemeralMessage: { message: { extendedTextMessage: { text: "oh hey @Clawd UK !", contextInfo: { mentionedJid: ["123@s.whatsapp.net"] }, }, }, }, }, }, ], }; sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); expect(onMessage).toHaveBeenCalledTimes(1); expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({ chatType: "group", conversationId: "424242@g.us", body: "oh hey @Clawd UK !", mentionedJids: ["123@s.whatsapp.net"], senderE164: "+888", }), ); await listener.close(); }); it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => { mockLoadConfig.mockReturnValue({ inbound: { allowFrom: ["+111"], // does not include +777 messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, }, }); const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); const upsert = { type: "notify", messages: [ { key: { id: "grp-allow", fromMe: false, remoteJid: "55555@g.us", participant: "777@s.whatsapp.net", }, message: { extendedTextMessage: { text: "@bot hi", contextInfo: { mentionedJid: ["123@s.whatsapp.net"] }, }, }, }, ], }; sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); expect(onMessage).toHaveBeenCalledTimes(1); expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({ chatType: "group", from: "55555@g.us", senderE164: "+777", senderJid: "777@s.whatsapp.net", mentionedJids: ["123@s.whatsapp.net"], selfE164: "+123", selfJid: "123@s.whatsapp.net", }), ); await listener.close(); }); it("blocks messages from unauthorized senders not in allowFrom", async () => { // Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors // from unauthorized senders corrupting sessions mockLoadConfig.mockReturnValue({ inbound: { allowFrom: ["+111"], // Only allow +111 messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, }, }); const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); // Message from unauthorized sender +999 (not in allowFrom) const upsert = { type: "notify", messages: [ { key: { id: "unauth1", fromMe: false, remoteJid: "999@s.whatsapp.net", }, message: { conversation: "unauthorized message" }, messageTimestamp: 1_700_000_000, }, ], }; sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); // Should NOT call onMessage for unauthorized senders expect(onMessage).not.toHaveBeenCalled(); // Reset mock for other tests mockLoadConfig.mockReturnValue({ inbound: { allowFrom: ["*"], messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, }, }); await listener.close(); }); it("lets group messages through even when sender not in allowFrom", async () => { mockLoadConfig.mockReturnValue({ inbound: { allowFrom: ["+1234"], messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, }, }); const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); const upsert = { type: "notify", messages: [ { key: { id: "grp3", fromMe: false, remoteJid: "11111@g.us", participant: "999@s.whatsapp.net", }, message: { conversation: "unauthorized group message" }, }, ], }; sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); expect(onMessage).toHaveBeenCalledTimes(1); const payload = onMessage.mock.calls[0][0]; expect(payload.chatType).toBe("group"); expect(payload.senderE164).toBe("+999"); await listener.close(); }); it("allows messages from senders in allowFrom list", async () => { mockLoadConfig.mockReturnValue({ inbound: { allowFrom: ["+111", "+999"], // Allow +999 messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, }, }); const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); const upsert = { type: "notify", messages: [ { key: { id: "auth1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, message: { conversation: "authorized message" }, messageTimestamp: 1_700_000_000, }, ], }; sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); // Should call onMessage for authorized senders expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({ body: "authorized message", from: "+999" }), ); // Reset mock for other tests mockLoadConfig.mockReturnValue({ inbound: { allowFrom: ["*"], messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, }, }); await listener.close(); }); it("allows same-phone messages even if not in allowFrom", async () => { // Same-phone mode: when from === selfJid, should always be allowed // This allows users to message themselves even with restrictive allowFrom mockLoadConfig.mockReturnValue({ inbound: { allowFrom: ["+111"], // Only allow +111, but self is +123 messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, }, }); const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); // Message from self (sock.user.id is "123@s.whatsapp.net" in mock) const upsert = { type: "notify", messages: [ { key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" }, message: { conversation: "self message" }, messageTimestamp: 1_700_000_000, }, ], }; sock.ev.emit("messages.upsert", upsert); await new Promise((resolve) => setImmediate(resolve)); // Should allow self-messages even if not in allowFrom expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({ body: "self message", from: "+123" }), ); // Reset mock for other tests mockLoadConfig.mockReturnValue({ inbound: { allowFrom: ["*"], messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, }, }); await listener.close(); }); }); it("defaults to self-only when no config is present", async () => { // No config file => allowFrom should be derived from selfE164 mockLoadConfig.mockReturnValue({}); const onMessage = vi.fn(); const listener = await monitorWebInbox({ verbose: false, onMessage }); const sock = await createWaSocket(); // Message from someone else should be blocked const upsertBlocked = { type: "notify", messages: [ { key: { id: "no-config-1", fromMe: false, remoteJid: "999@s.whatsapp.net", }, message: { conversation: "ping" }, messageTimestamp: 1_700_000_000, }, ], }; sock.ev.emit("messages.upsert", upsertBlocked); await new Promise((resolve) => setImmediate(resolve)); expect(onMessage).not.toHaveBeenCalled(); // Message from self should be allowed const upsertSelf = { type: "notify", messages: [ { key: { id: "no-config-2", fromMe: false, remoteJid: "123@s.whatsapp.net", }, message: { conversation: "self ping" }, messageTimestamp: 1_700_000_001, }, ], }; sock.ev.emit("messages.upsert", upsertSelf); await new Promise((resolve) => setImmediate(resolve)); expect(onMessage).toHaveBeenCalledTimes(1); expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({ body: "self ping", from: "+123", to: "+123" }), ); // Reset mock for other tests mockLoadConfig.mockReturnValue({ inbound: { allowFrom: ["*"], messagePrefix: undefined, responsePrefix: undefined, timestampPrefix: false, }, }); await listener.close(); });