import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import sharp from "sharp"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { WarelayConfig } from "../config/config.js"; import { resolveStorePath } from "../config/sessions.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, monitorWebProvider, resolveHeartbeatRecipients, resolveReplyHeartbeatMinutes, runWebHeartbeatOnce, stripHeartbeatToken, } from "./auto-reply.js"; import type { sendMessageWeb } from "./outbound.js"; import { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock, } from "./test-helpers.js"; describe("heartbeat helpers", () => { it("strips heartbeat token and skips when only token", () => { expect(stripHeartbeatToken(undefined)).toEqual({ shouldSkip: true, text: "", }); expect(stripHeartbeatToken(" ")).toEqual({ shouldSkip: true, text: "", }); expect(stripHeartbeatToken(HEARTBEAT_TOKEN)).toEqual({ shouldSkip: true, text: "", }); }); it("keeps content and removes token when mixed", () => { expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({ shouldSkip: false, text: "ALERT", }); expect(stripHeartbeatToken(`hello`)).toEqual({ shouldSkip: false, text: "hello", }); }); it("resolves heartbeat minutes with default and overrides", () => { const cfgBase: WarelayConfig = { inbound: { reply: { mode: "command" as const }, }, }; expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30); expect( resolveReplyHeartbeatMinutes({ inbound: { reply: { mode: "command", heartbeatMinutes: 5 } }, }), ).toBe(5); expect( resolveReplyHeartbeatMinutes({ inbound: { reply: { mode: "command", heartbeatMinutes: 0 } }, }), ).toBeNull(); expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7); expect( resolveReplyHeartbeatMinutes({ inbound: { reply: { mode: "text" } }, }), ).toBeNull(); }); }); describe("resolveHeartbeatRecipients", () => { const makeStore = async (entries: Record) => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "warelay-heartbeat-")); const storePath = path.join(dir, "sessions.json"); await fs.writeFile(storePath, JSON.stringify(entries)); return { storePath, cleanup: async () => fs.rm(dir, { recursive: true, force: true }), }; }; it("returns the sole session recipient", async () => { const now = Date.now(); const store = await makeStore({ "+1000": { updatedAt: now } }); const cfg: WarelayConfig = { inbound: { allowFrom: ["+1999"], reply: { mode: "command", session: { store: store.storePath } }, }, }; const result = resolveHeartbeatRecipients(cfg); expect(result.source).toBe("session-single"); expect(result.recipients).toEqual(["+1000"]); await store.cleanup(); }); it("surfaces ambiguity when multiple sessions exist", async () => { const now = Date.now(); const store = await makeStore({ "+1000": { updatedAt: now }, "+2000": { updatedAt: now - 10 }, }); const cfg: WarelayConfig = { inbound: { allowFrom: ["+1999"], reply: { mode: "command", session: { store: store.storePath } }, }, }; const result = resolveHeartbeatRecipients(cfg); expect(result.source).toBe("session-ambiguous"); expect(result.recipients).toEqual(["+1000", "+2000"]); await store.cleanup(); }); it("filters wildcard allowFrom when no sessions exist", async () => { const store = await makeStore({}); const cfg: WarelayConfig = { inbound: { allowFrom: ["*"], reply: { mode: "command", session: { store: store.storePath } }, }, }; const result = resolveHeartbeatRecipients(cfg); expect(result.recipients).toHaveLength(0); expect(result.source).toBe("allowFrom"); await store.cleanup(); }); it("merges sessions and allowFrom when --all is set", async () => { const now = Date.now(); const store = await makeStore({ "+1000": { updatedAt: now } }); const cfg: WarelayConfig = { inbound: { allowFrom: ["+1999"], reply: { mode: "command", session: { store: store.storePath } }, }, }; const result = resolveHeartbeatRecipients(cfg, { all: true }); expect(result.source).toBe("all"); expect(result.recipients.sort()).toEqual(["+1000", "+1999"].sort()); await store.cleanup(); }); }); describe("runWebHeartbeatOnce", () => { it("skips when heartbeat token returned", async () => { const sender: typeof sendMessageWeb = vi.fn(); const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN })); setLoadConfigMock({ inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, }); await runWebHeartbeatOnce({ to: "+1555", verbose: false, sender, replyResolver: resolver, }); expect(resolver).toHaveBeenCalled(); expect(sender).not.toHaveBeenCalled(); }); it("sends when alert text present", async () => { const sender: typeof sendMessageWeb = vi .fn() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); const resolver = vi.fn(async () => ({ text: "ALERT" })); setLoadConfigMock({ inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, }); await runWebHeartbeatOnce({ to: "+1555", verbose: false, sender, replyResolver: resolver, }); expect(sender).toHaveBeenCalledWith("+1555", "ALERT", { verbose: false }); }); it("falls back to most recent session when no to is provided", async () => { const sender: typeof sendMessageWeb = vi .fn() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); const resolver = vi.fn(async () => ({ text: "ALERT" })); // Seed session store const now = Date.now(); const store = { "+1222": { sessionId: "s1", updatedAt: now - 1000 }, "+1333": { sessionId: "s2", updatedAt: now }, }; const storePath = resolveStorePath(); await fs.mkdir(resolveStorePath().replace("sessions.json", ""), { recursive: true, }); await fs.writeFile(storePath, JSON.stringify(store)); setLoadConfigMock({ inbound: { allowFrom: ["+1999"], reply: { mode: "command", session: {} }, }, }); await runWebHeartbeatOnce({ to: "+1999", verbose: false, sender, replyResolver: resolver, }); expect(sender).toHaveBeenCalledWith("+1999", "ALERT", { verbose: false }); }); it("does not refresh updatedAt when heartbeat is skipped", async () => { const tmpDir = await fs.mkdtemp( path.join(os.tmpdir(), "warelay-heartbeat-"), ); const storePath = path.join(tmpDir, "sessions.json"); const now = Date.now(); const originalUpdated = now - 30 * 60 * 1000; const store = { "+1555": { sessionId: "sess1", updatedAt: originalUpdated }, }; await fs.writeFile(storePath, JSON.stringify(store)); const sender: typeof sendMessageWeb = vi.fn(); const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN })); setLoadConfigMock({ inbound: { allowFrom: ["+1555"], reply: { mode: "command", session: { store: storePath, idleMinutes: 60, heartbeatIdleMinutes: 10, }, }, }, }); await runWebHeartbeatOnce({ to: "+1555", verbose: false, sender, replyResolver: resolver, }); const after = JSON.parse(await fs.readFile(storePath, "utf-8")); expect(after["+1555"].updatedAt).toBe(originalUpdated); expect(sender).not.toHaveBeenCalled(); }); it("heartbeat reuses existing session id when last inbound is present", async () => { const tmpDir = await fs.mkdtemp( path.join(os.tmpdir(), "warelay-heartbeat-session-"), ); const storePath = path.join(tmpDir, "sessions.json"); const sessionId = "sess-keep"; await fs.writeFile( storePath, JSON.stringify({ "+4367": { sessionId, updatedAt: Date.now(), systemSent: false }, }), ); setLoadConfigMock(() => ({ inbound: { allowFrom: ["+4367"], reply: { mode: "command", heartbeatMinutes: 0.001, session: { store: storePath, idleMinutes: 60 }, }, }, })); const replyResolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN }); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never; const cfg: WarelayConfig = { inbound: { allowFrom: ["+4367"], reply: { mode: "command", session: { store: storePath, idleMinutes: 60 }, }, }, }; await runWebHeartbeatOnce({ cfg, to: "+4367", verbose: false, replyResolver, runtime, }); const heartbeatCall = replyResolver.mock.calls.find( (call) => call[0]?.Body === HEARTBEAT_PROMPT, ); expect(heartbeatCall?.[0]?.MessageSid).toBe(sessionId); }); it("heartbeat honors session-id override and seeds store", async () => { const tmpDir = await fs.mkdtemp( path.join(os.tmpdir(), "warelay-heartbeat-override-"), ); const storePath = path.join(tmpDir, "sessions.json"); await fs.writeFile(storePath, JSON.stringify({})); const sessionId = "override-123"; setLoadConfigMock(() => ({ inbound: { allowFrom: ["+1999"], reply: { mode: "command", session: { store: storePath, idleMinutes: 60 }, }, }, })); const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN })); const cfg: WarelayConfig = { inbound: { allowFrom: ["+1999"], reply: { mode: "command", session: { store: storePath, idleMinutes: 60 }, }, }, }; await runWebHeartbeatOnce({ cfg, to: "+1999", verbose: false, replyResolver: resolver, sessionId, }); const heartbeatCall = resolver.mock.calls.find( (call) => call[0]?.Body === HEARTBEAT_PROMPT, ); expect(heartbeatCall?.[0]?.MessageSid).toBe(sessionId); const raw = await fs.readFile(storePath, "utf-8"); const stored = raw ? JSON.parse(raw) : {}; expect(stored["+1999"]?.sessionId).toBe(sessionId); expect(stored["+1999"]?.updatedAt).toBeDefined(); }); it("sends overrideBody directly and skips resolver", async () => { const sender: typeof sendMessageWeb = vi .fn() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); const resolver = vi.fn(); setLoadConfigMock({ inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, }); await runWebHeartbeatOnce({ to: "+1555", verbose: false, sender, replyResolver: resolver, overrideBody: "manual ping", }); expect(sender).toHaveBeenCalledWith("+1555", "manual ping", { verbose: false, }); expect(resolver).not.toHaveBeenCalled(); }); it("dry-run overrideBody prints and skips send", async () => { const sender: typeof sendMessageWeb = vi.fn(); const resolver = vi.fn(); setLoadConfigMock({ inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, }); await runWebHeartbeatOnce({ to: "+1555", verbose: false, sender, replyResolver: resolver, overrideBody: "dry", dryRun: true, }); expect(sender).not.toHaveBeenCalled(); expect(resolver).not.toHaveBeenCalled(); }); }); describe("web auto-reply", () => { beforeEach(() => { vi.clearAllMocks(); resetBaileysMocks(); resetLoadConfigMock(); }); afterEach(() => { resetLogger(); setLoggerOverride(null); vi.useRealTimers(); }); it("reconnects after a connection close", async () => { const closeResolvers: Array<() => void> = []; const sleep = vi.fn(async () => {}); const listenerFactory = vi.fn(async () => { let _resolve!: () => void; const onClose = new Promise((res) => { _resolve = res; closeResolvers.push(res); }); return { close: vi.fn(), onClose }; }); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; const controller = new AbortController(); const run = monitorWebProvider( false, listenerFactory, true, async () => ({ text: "ok" }), runtime as never, controller.signal, { heartbeatSeconds: 1, reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, sleep, }, ); await Promise.resolve(); expect(listenerFactory).toHaveBeenCalledTimes(1); closeResolvers[0]?.(); const waitForSecondCall = async () => { const started = Date.now(); while ( listenerFactory.mock.calls.length < 2 && Date.now() - started < 200 ) { await new Promise((resolve) => setTimeout(resolve, 10)); } }; await waitForSecondCall(); expect(listenerFactory).toHaveBeenCalledTimes(2); expect(runtime.error).toHaveBeenCalledWith( expect.stringContaining("Retry 1"), ); controller.abort(); closeResolvers[1]?.(); await new Promise((resolve) => setTimeout(resolve, 5)); await run; }); it("stops after hitting max reconnect attempts", async () => { const closeResolvers: Array<() => void> = []; const sleep = vi.fn(async () => {}); const listenerFactory = vi.fn(async () => { const onClose = new Promise((res) => closeResolvers.push(res)); return { close: vi.fn(), onClose }; }); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; const run = monitorWebProvider( false, listenerFactory, true, async () => ({ text: "ok" }), runtime as never, undefined, { heartbeatSeconds: 1, reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 2, factor: 1.1 }, sleep, }, ); await Promise.resolve(); expect(listenerFactory).toHaveBeenCalledTimes(1); closeResolvers.shift()?.(); await new Promise((resolve) => setTimeout(resolve, 15)); expect(listenerFactory).toHaveBeenCalledTimes(2); closeResolvers.shift()?.(); await new Promise((resolve) => setTimeout(resolve, 15)); await run; expect(runtime.error).toHaveBeenCalledWith( expect.stringContaining("Reached max retries"), ); }); it("falls back to text when media send fails", async () => { const sendMedia = vi.fn().mockRejectedValue(new Error("boom")); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); const resolver = vi.fn().mockResolvedValue({ text: "hi", mediaUrl: "https://example.com/img.png", }); let capturedOnMessage: | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; return { close: vi.fn() }; }; const smallPng = await sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 }, }, }) .png() .toBuffer(); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, body: true, arrayBuffer: async () => smallPng.buffer.slice( smallPng.byteOffset, smallPng.byteOffset + smallPng.byteLength, ), headers: { get: () => "image/png" }, status: 200, } as Response); await monitorWebProvider(false, listenerFactory, false, resolver); expect(capturedOnMessage).toBeDefined(); await capturedOnMessage?.({ body: "hello", from: "+1", to: "+2", id: "msg1", sendComposing, reply, sendMedia, }); expect(sendMedia).toHaveBeenCalledTimes(1); expect(reply).toHaveBeenCalledWith("hi"); fetchMock.mockRestore(); }); it("compresses media over 5MB and still sends it", async () => { const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); const resolver = vi.fn().mockResolvedValue({ text: "hi", mediaUrl: "https://example.com/big.png", }); let capturedOnMessage: | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; return { close: vi.fn() }; }; const bigPng = await sharp({ create: { width: 3200, height: 3200, channels: 3, background: { r: 255, g: 0, b: 0 }, }, }) .png({ compressionLevel: 0 }) .toBuffer(); expect(bigPng.length).toBeGreaterThan(5 * 1024 * 1024); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, body: true, arrayBuffer: async () => bigPng.buffer.slice( bigPng.byteOffset, bigPng.byteOffset + bigPng.byteLength, ), headers: { get: () => "image/png" }, status: 200, } as Response); await monitorWebProvider(false, listenerFactory, false, resolver); expect(capturedOnMessage).toBeDefined(); await capturedOnMessage?.({ body: "hello", from: "+1", to: "+2", id: "msg1", sendComposing, reply, sendMedia, }); expect(sendMedia).toHaveBeenCalledTimes(1); const payload = sendMedia.mock.calls[0][0] as { image: Buffer; caption?: string; mimetype?: string; }; expect(payload.image.length).toBeLessThanOrEqual(5 * 1024 * 1024); expect(payload.mimetype).toBe("image/jpeg"); // Should not fall back to separate text reply because caption is used. expect(reply).not.toHaveBeenCalled(); fetchMock.mockRestore(); }); it( "compresses common formats to jpeg under the cap", { timeout: 15_000 }, async () => { const formats = [ { name: "png", mime: "image/png", make: (buf: Buffer, opts: { width: number; height: number }) => sharp(buf, { raw: { width: opts.width, height: opts.height, channels: 3 }, }) .png({ compressionLevel: 0 }) .toBuffer(), }, { name: "jpeg", mime: "image/jpeg", make: (buf: Buffer, opts: { width: number; height: number }) => sharp(buf, { raw: { width: opts.width, height: opts.height, channels: 3 }, }) .jpeg({ quality: 100, chromaSubsampling: "4:4:4" }) .toBuffer(), }, { name: "webp", mime: "image/webp", make: (buf: Buffer, opts: { width: number; height: number }) => sharp(buf, { raw: { width: opts.width, height: opts.height, channels: 3 }, }) .webp({ quality: 100 }) .toBuffer(), }, ] as const; for (const fmt of formats) { // Force a small cap to ensure compression is exercised for every format. setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } })); const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); const resolver = vi.fn().mockResolvedValue({ text: "hi", mediaUrl: `https://example.com/big.${fmt.name}`, }); let capturedOnMessage: | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; return { close: vi.fn() }; }; const width = 1200; const height = 1200; const raw = crypto.randomBytes(width * height * 3); const big = await fmt.make(raw, { width, height }); expect(big.length).toBeGreaterThan(1 * 1024 * 1024); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, body: true, arrayBuffer: async () => big.buffer.slice(big.byteOffset, big.byteOffset + big.byteLength), headers: { get: () => fmt.mime }, status: 200, } as Response); await monitorWebProvider(false, listenerFactory, false, resolver); expect(capturedOnMessage).toBeDefined(); await capturedOnMessage?.({ body: "hello", from: "+1", to: "+2", id: `msg-${fmt.name}`, sendComposing, reply, sendMedia, }); expect(sendMedia).toHaveBeenCalledTimes(1); const payload = sendMedia.mock.calls[0][0] as { image: Buffer; mimetype?: string; }; expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024); expect(payload.mimetype).toBe("image/jpeg"); expect(reply).not.toHaveBeenCalled(); fetchMock.mockRestore(); resetLoadConfigMock(); } }, ); it("honors mediaMaxMb from config", async () => { setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } })); const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); const resolver = vi.fn().mockResolvedValue({ text: "hi", mediaUrl: "https://example.com/big.png", }); let capturedOnMessage: | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; return { close: vi.fn() }; }; const bigPng = await sharp({ create: { width: 2600, height: 2600, channels: 3, background: { r: 0, g: 0, b: 255 }, }, }) .png({ compressionLevel: 0 }) .toBuffer(); expect(bigPng.length).toBeGreaterThan(1 * 1024 * 1024); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, body: true, arrayBuffer: async () => bigPng.buffer.slice( bigPng.byteOffset, bigPng.byteOffset + bigPng.byteLength, ), headers: { get: () => "image/png" }, status: 200, } as Response); await monitorWebProvider(false, listenerFactory, false, resolver); expect(capturedOnMessage).toBeDefined(); await capturedOnMessage?.({ body: "hello", from: "+1", to: "+2", id: "msg1", sendComposing, reply, sendMedia, }); expect(sendMedia).toHaveBeenCalledTimes(1); const payload = sendMedia.mock.calls[0][0] as { image: Buffer; caption?: string; mimetype?: string; }; expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024); expect(payload.mimetype).toBe("image/jpeg"); expect(reply).not.toHaveBeenCalled(); fetchMock.mockRestore(); }); it("falls back to text when media is unsupported", async () => { const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); const resolver = vi.fn().mockResolvedValue({ text: "hi", mediaUrl: "https://example.com/file.pdf", }); let capturedOnMessage: | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; return { close: vi.fn() }; }; const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, body: true, arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer, headers: { get: () => "application/pdf" }, status: 200, } as Response); await monitorWebProvider(false, listenerFactory, false, resolver); expect(capturedOnMessage).toBeDefined(); await capturedOnMessage?.({ body: "hello", from: "+1", to: "+2", id: "msg-pdf", sendComposing, reply, sendMedia, }); expect(sendMedia).toHaveBeenCalledTimes(1); const payload = sendMedia.mock.calls[0][0] as { document?: Buffer; caption?: string; fileName?: string; }; expect(payload.document).toBeInstanceOf(Buffer); expect(payload.fileName).toBe("file.pdf"); expect(payload.caption).toBe("hi"); expect(reply).not.toHaveBeenCalled(); fetchMock.mockRestore(); }); it("emits heartbeat logs with connection metadata", async () => { vi.useFakeTimers(); const logPath = `/tmp/warelay-heartbeat-${crypto.randomUUID()}.log`; setLoggerOverride({ level: "trace", file: logPath }); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; const controller = new AbortController(); const listenerFactory = vi.fn(async () => { const onClose = new Promise(() => { // never resolves; abort will short-circuit }); return { close: vi.fn(), onClose }; }); const run = monitorWebProvider( false, listenerFactory, true, async () => ({ text: "ok" }), runtime as never, controller.signal, { heartbeatSeconds: 1, reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 1, factor: 1.1 }, }, ); await vi.advanceTimersByTimeAsync(1_000); controller.abort(); await vi.runAllTimersAsync(); await run.catch(() => {}); const content = await fs.readFile(logPath, "utf-8"); expect(content).toContain('"module":"web-heartbeat"'); expect(content).toMatch(/connectionId/); expect(content).toMatch(/messagesHandled/); }); it("logs outbound replies to file", async () => { const logPath = `/tmp/warelay-log-test-${crypto.randomUUID()}.log`; setLoggerOverride({ level: "trace", file: logPath }); let capturedOnMessage: | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; return { close: vi.fn() }; }; const resolver = vi.fn().mockResolvedValue({ text: "auto" }); await monitorWebProvider(false, listenerFactory, false, resolver); expect(capturedOnMessage).toBeDefined(); await capturedOnMessage?.({ body: "hello", from: "+1", to: "+2", id: "msg1", sendComposing: vi.fn(), reply: vi.fn(), sendMedia: vi.fn(), }); const content = await fs.readFile(logPath, "utf-8"); expect(content).toContain('"module":"web-auto-reply"'); expect(content).toContain('"text":"auto"'); }); it("prefixes body with same-phone marker when from === to", async () => { let capturedOnMessage: | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; return { close: vi.fn() }; }; const resolver = vi.fn().mockResolvedValue({ text: "reply" }); await monitorWebProvider(false, listenerFactory, false, resolver); expect(capturedOnMessage).toBeDefined(); await capturedOnMessage?.({ body: "hello", from: "+1555", to: "+1555", // Same phone! id: "msg1", sendComposing: vi.fn(), reply: vi.fn(), sendMedia: vi.fn(), }); // The resolver should receive a prefixed body (the exact marker depends on config) // Key test: body should start with some marker and end with original message const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; expect(callArg?.Body).toBeDefined(); expect(callArg?.Body).toMatch(/^\[.*\] hello$/); expect(callArg?.Body).not.toBe("hello"); // Should be prefixed }); it("does not prefix body when from !== to", async () => { let capturedOnMessage: | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; return { close: vi.fn() }; }; const resolver = vi.fn().mockResolvedValue({ text: "reply" }); await monitorWebProvider(false, listenerFactory, false, resolver); expect(capturedOnMessage).toBeDefined(); await capturedOnMessage?.({ body: "hello", from: "+1555", to: "+2666", // Different phones id: "msg1", sendComposing: vi.fn(), reply: vi.fn(), sendMedia: vi.fn(), }); // Body should NOT be prefixed const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; expect(callArg?.Body).toBe("hello"); }); });