import { beforeEach, describe, expect, it, vi } from "vitest"; import { monitorIMessageProvider } from "./monitor.js"; const requestMock = vi.fn(); const stopMock = vi.fn(); const sendMock = vi.fn(); const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); const readAllowFromStoreMock = vi.fn(); const upsertPairingRequestMock = vi.fn(); let config: Record = {}; let notificationHandler: | ((msg: { method: string; params?: unknown }) => void) | undefined; let closeResolve: (() => void) | undefined; vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadConfig: () => config, }; }); vi.mock("../auto-reply/reply.js", () => ({ getReplyFromConfig: (...args: unknown[]) => replyMock(...args), })); vi.mock("./send.js", () => ({ sendMessageIMessage: (...args: unknown[]) => sendMock(...args), })); vi.mock("../pairing/pairing-store.js", () => ({ readProviderAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertProviderPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), })); vi.mock("../config/sessions.js", () => ({ resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), })); vi.mock("./client.js", () => ({ createIMessageRpcClient: vi.fn( async (opts: { onNotification?: typeof notificationHandler }) => { notificationHandler = opts.onNotification; return { request: (...args: unknown[]) => requestMock(...args), waitForClose: () => new Promise((resolve) => { closeResolve = resolve; }), stop: (...args: unknown[]) => stopMock(...args), }; }, ), })); const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); async function waitForSubscribe() { for (let i = 0; i < 5; i += 1) { if (requestMock.mock.calls.some((call) => call[0] === "watch.subscribe")) return; await flush(); } } beforeEach(() => { config = { imessage: { dmPolicy: "open", allowFrom: ["*"], groups: { "*": { requireMention: true } }, }, session: { mainKey: "main" }, messages: { groupChat: { mentionPatterns: ["@clawd"] }, }, }; requestMock.mockReset().mockImplementation((method: string) => { if (method === "watch.subscribe") return Promise.resolve({ subscription: 1 }); return Promise.resolve({}); }); stopMock.mockReset().mockResolvedValue(undefined); sendMock.mockReset().mockResolvedValue({ messageId: "ok" }); replyMock.mockReset().mockResolvedValue({ text: "ok" }); updateLastRouteMock.mockReset(); readAllowFromStoreMock.mockReset().mockResolvedValue([]); upsertPairingRequestMock .mockReset() .mockResolvedValue({ code: "PAIRCODE", created: true }); notificationHandler = undefined; closeResolve = undefined; }); describe("monitorIMessageProvider", () => { it("skips group messages without a mention by default", async () => { const run = monitorIMessageProvider(); await waitForSubscribe(); notificationHandler?.({ method: "message", params: { message: { id: 1, chat_id: 99, sender: "+15550001111", is_from_me: false, text: "hello group", is_group: true, }, }, }); await flush(); closeResolve?.(); await run; expect(replyMock).not.toHaveBeenCalled(); expect(sendMock).not.toHaveBeenCalled(); }); it("allows group messages when imessage groups default disables mention gating", async () => { config = { ...config, imessage: { groups: { "*": { requireMention: false } } }, }; const run = monitorIMessageProvider(); await waitForSubscribe(); notificationHandler?.({ method: "message", params: { message: { id: 11, chat_id: 123, sender: "+15550001111", is_from_me: false, text: "hello group", is_group: true, }, }, }); await flush(); closeResolve?.(); await run; expect(replyMock).toHaveBeenCalled(); }); it("allows group messages when requireMention is true but no mentionPatterns exist", async () => { config = { ...config, messages: { groupChat: { mentionPatterns: [] } }, imessage: { groups: { "*": { requireMention: true } } }, }; const run = monitorIMessageProvider(); await waitForSubscribe(); notificationHandler?.({ method: "message", params: { message: { id: 12, chat_id: 777, sender: "+15550001111", is_from_me: false, text: "hello group", is_group: true, }, }, }); await flush(); closeResolve?.(); await run; expect(replyMock).toHaveBeenCalled(); }); it("blocks group messages when imessage.groups is set without a wildcard", async () => { config = { ...config, imessage: { groups: { "99": { requireMention: false } } }, }; const run = monitorIMessageProvider(); await waitForSubscribe(); notificationHandler?.({ method: "message", params: { message: { id: 13, chat_id: 123, sender: "+15550001111", is_from_me: false, text: "@clawd hello", is_group: true, }, }, }); await flush(); closeResolve?.(); await run; expect(replyMock).not.toHaveBeenCalled(); expect(sendMock).not.toHaveBeenCalled(); }); it("prefixes tool and final replies with responsePrefix", async () => { config = { ...config, messages: { responsePrefix: "PFX" }, }; replyMock.mockImplementation(async (_ctx, opts) => { await opts?.onToolResult?.({ text: "tool update" }); return { text: "final reply" }; }); const run = monitorIMessageProvider(); await waitForSubscribe(); notificationHandler?.({ method: "message", params: { message: { id: 7, chat_id: 77, sender: "+15550001111", is_from_me: false, text: "hello", is_group: false, }, }, }); await flush(); closeResolve?.(); await run; expect(sendMock).toHaveBeenCalledTimes(2); expect(sendMock.mock.calls[0][1]).toBe("PFX tool update"); expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); it("defaults to dmPolicy=pairing behavior when allowFrom is empty", async () => { config = { ...config, imessage: { dmPolicy: "pairing", allowFrom: [], groups: { "*": { requireMention: true } }, }, }; const run = monitorIMessageProvider(); await waitForSubscribe(); notificationHandler?.({ method: "message", params: { message: { id: 99, chat_id: 77, sender: "+15550001111", is_from_me: false, text: "hello", is_group: false, }, }, }); await flush(); closeResolve?.(); await run; expect(replyMock).not.toHaveBeenCalled(); expect(upsertPairingRequestMock).toHaveBeenCalled(); expect(sendMock).toHaveBeenCalledTimes(1); expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( "Your iMessage sender id: +15550001111", ); expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain( "Pairing code: PAIRCODE", ); }); it("delivers group replies when mentioned", async () => { replyMock.mockResolvedValueOnce({ text: "yo" }); const run = monitorIMessageProvider(); await waitForSubscribe(); notificationHandler?.({ method: "message", params: { message: { id: 2, chat_id: 42, sender: "+15550002222", is_from_me: false, text: "@clawd ping", is_group: true, chat_name: "Lobster Squad", participants: ["+1555", "+1556"], }, }, }); await flush(); closeResolve?.(); await run; expect(sendMock).toHaveBeenCalledWith( "chat_id:42", "yo", expect.objectContaining({ client: expect.any(Object) }), ); }); it("honors group allowlist when groupPolicy is allowlist", async () => { config = { ...config, imessage: { groupPolicy: "allowlist", groupAllowFrom: ["chat_id:101"], }, }; const run = monitorIMessageProvider(); await waitForSubscribe(); notificationHandler?.({ method: "message", params: { message: { id: 3, chat_id: 202, sender: "+15550003333", is_from_me: false, text: "@clawd hi", is_group: true, }, }, }); await flush(); closeResolve?.(); await run; expect(replyMock).not.toHaveBeenCalled(); }); it("blocks group messages when groupPolicy is disabled", async () => { config = { ...config, imessage: { groupPolicy: "disabled" }, }; const run = monitorIMessageProvider(); await waitForSubscribe(); notificationHandler?.({ method: "message", params: { message: { id: 10, chat_id: 303, sender: "+15550003333", is_from_me: false, text: "@clawd hi", is_group: true, }, }, }); await flush(); closeResolve?.(); await run; expect(replyMock).not.toHaveBeenCalled(); }); it("updates last route with chat_id for direct messages", async () => { replyMock.mockResolvedValueOnce({ text: "ok" }); const run = monitorIMessageProvider(); await waitForSubscribe(); notificationHandler?.({ method: "message", params: { message: { id: 4, chat_id: 7, sender: "+15550004444", is_from_me: false, text: "hey", is_group: false, }, }, }); await flush(); closeResolve?.(); await run; expect(updateLastRouteMock).toHaveBeenCalledWith( expect.objectContaining({ provider: "imessage", to: "chat_id:7", }), ); }); it("does not trigger unhandledRejection when aborting during shutdown", async () => { requestMock.mockImplementation((method: string) => { if (method === "watch.subscribe") return Promise.resolve({ subscription: 1 }); if (method === "watch.unsubscribe") return Promise.reject(new Error("imsg rpc closed")); return Promise.resolve({}); }); const abortController = new AbortController(); const unhandled: unknown[] = []; const onUnhandled = (reason: unknown) => { unhandled.push(reason); }; process.on("unhandledRejection", onUnhandled); try { const run = monitorIMessageProvider({ abortSignal: abortController.signal, }); await waitForSubscribe(); await flush(); abortController.abort(); await flush(); closeResolve?.(); await run; } finally { process.off("unhandledRejection", onUnhandled); } expect(unhandled).toHaveLength(0); expect(stopMock).toHaveBeenCalled(); }); });