import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { fetchBlueBubblesHistory } from "./history.js"; import { handleBlueBubblesWebhookRequest, registerBlueBubblesWebhookTarget, resolveBlueBubblesMessageId, _resetBlueBubblesShortIdState, } from "./monitor.js"; import { setBlueBubblesRuntime } from "./runtime.js"; // Mock dependencies vi.mock("./send.js", () => ({ resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"), sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }), })); vi.mock("./chat.js", () => ({ markBlueBubblesChatRead: vi.fn().mockResolvedValue(undefined), sendBlueBubblesTyping: vi.fn().mockResolvedValue(undefined), })); vi.mock("./attachments.js", () => ({ downloadBlueBubblesAttachment: vi.fn().mockResolvedValue({ buffer: Buffer.from("test"), contentType: "image/jpeg", }), })); vi.mock("./reactions.js", async () => { const actual = await vi.importActual("./reactions.js"); return { ...actual, sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), }; }); vi.mock("./history.js", () => ({ fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }), })); // Mock runtime const mockEnqueueSystemEvent = vi.fn(); const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE"); const mockReadAllowFromStore = vi.fn().mockResolvedValue([]); const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true }); const mockResolveAgentRoute = vi.fn(() => ({ agentId: "main", channel: "bluebubbles", accountId: "default", sessionKey: "agent:main:bluebubbles:dm:+15551234567", mainSessionKey: "agent:main:main", matchedBy: "default", })); const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]); const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) => regexes.some((r) => r.test(text)), ); const mockMatchesMentionWithExplicit = vi.fn( (params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => { if (params.explicitWasMentioned) { return true; } return params.mentionRegexes.some((regex) => regex.test(params.text)); }, ); const mockResolveRequireMention = vi.fn(() => false); const mockResolveGroupPolicy = vi.fn(() => "open" as const); type DispatchReplyParams = Parameters< PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"] >[0]; const EMPTY_DISPATCH_RESULT = { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, } as const; const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn( async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT, ); const mockHasControlCommand = vi.fn(() => false); const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false); const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ id: "test-media.jpg", path: "/tmp/test-media.jpg", size: Buffer.byteLength("test"), contentType: "image/jpeg", }); const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json"); const mockReadSessionUpdatedAt = vi.fn(() => undefined); const mockResolveEnvelopeFormatOptions = vi.fn(() => ({})); const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body); const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body); const mockChunkMarkdownText = vi.fn((text: string) => [text]); const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : [])); const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : [])); const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : [])); const mockResolveChunkMode = vi.fn(() => "length" as const); const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory); function createMockRuntime(): PluginRuntime { return createPluginRuntimeMock({ system: { enqueueSystemEvent: mockEnqueueSystemEvent, }, channel: { text: { chunkMarkdownText: mockChunkMarkdownText, chunkByNewline: mockChunkByNewline, chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode, chunkTextWithMode: mockChunkTextWithMode, resolveChunkMode: mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"], hasControlCommand: mockHasControlCommand, }, reply: { dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], formatAgentEnvelope: mockFormatAgentEnvelope, formatInboundEnvelope: mockFormatInboundEnvelope, resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"], }, routing: { resolveAgentRoute: mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], }, pairing: { buildPairingReply: mockBuildPairingReply, readAllowFromStore: mockReadAllowFromStore, upsertPairingRequest: mockUpsertPairingRequest, }, media: { saveMediaBuffer: mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"], }, session: { resolveStorePath: mockResolveStorePath, readSessionUpdatedAt: mockReadSessionUpdatedAt, }, mentions: { buildMentionRegexes: mockBuildMentionRegexes, matchesMentionPatterns: mockMatchesMentionPatterns, matchesMentionWithExplicit: mockMatchesMentionWithExplicit, }, groups: { resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"], resolveRequireMention: mockResolveRequireMention, }, commands: { resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers, }, }, }); } function createMockAccount( overrides: Partial = {}, ): ResolvedBlueBubblesAccount { return { accountId: "default", enabled: true, configured: true, config: { serverUrl: "http://localhost:1234", password: "test-password", dmPolicy: "open", groupPolicy: "open", allowFrom: [], groupAllowFrom: [], ...overrides, }, }; } function createMockRequest( method: string, url: string, body: unknown, headers: Record = {}, ): IncomingMessage { if (headers.host === undefined) { headers.host = "localhost"; } const parsedUrl = new URL(url, "http://localhost"); const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password"); const hasAuthHeader = headers["x-guid"] !== undefined || headers["x-password"] !== undefined || headers["x-bluebubbles-guid"] !== undefined || headers.authorization !== undefined; if (!hasAuthQuery && !hasAuthHeader) { parsedUrl.searchParams.set("password", "test-password"); } const req = new EventEmitter() as IncomingMessage; req.method = method; req.url = `${parsedUrl.pathname}${parsedUrl.search}`; req.headers = headers; (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" }; // Emit body data after a microtask // oxlint-disable-next-line no-floating-promises Promise.resolve().then(() => { const bodyStr = typeof body === "string" ? body : JSON.stringify(body); req.emit("data", Buffer.from(bodyStr)); req.emit("end"); }); return req; } function createMockResponse(): ServerResponse & { body: string; statusCode: number } { const res = { statusCode: 200, body: "", setHeader: vi.fn(), end: vi.fn((data?: string) => { res.body = data ?? ""; }), } as unknown as ServerResponse & { body: string; statusCode: number }; return res; } const flushAsync = async () => { for (let i = 0; i < 2; i += 1) { await new Promise((resolve) => setImmediate(resolve)); } }; function getFirstDispatchCall(): DispatchReplyParams { const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; if (!callArgs) { throw new Error("expected dispatch call arguments"); } return callArgs; } describe("BlueBubbles webhook monitor", () => { let unregister: () => void; beforeEach(() => { vi.clearAllMocks(); // Reset short ID state between tests for predictable behavior _resetBlueBubblesShortIdState(); mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true }); mockReadAllowFromStore.mockResolvedValue([]); mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true }); mockResolveRequireMention.mockReturnValue(false); mockHasControlCommand.mockReturnValue(false); mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false); mockBuildMentionRegexes.mockReturnValue([/\bbert\b/i]); setBlueBubblesRuntime(createMockRuntime()); }); afterEach(() => { unregister?.(); }); describe("DM pairing behavior vs allowFrom", () => { it("allows DM from sender in allowFrom list", async () => { const account = createMockAccount({ dmPolicy: "allowlist", allowFrom: ["+15551234567"], }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello from allowed sender", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); // Wait for async processing await flushAsync(); expect(res.statusCode).toBe(200); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); it("blocks DM from sender not in allowFrom when dmPolicy=allowlist", async () => { const account = createMockAccount({ dmPolicy: "allowlist", allowFrom: ["+15559999999"], // Different number }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello from blocked sender", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(res.statusCode).toBe(200); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); it("blocks DM when dmPolicy=allowlist and allowFrom is empty", async () => { const account = createMockAccount({ dmPolicy: "allowlist", allowFrom: [], }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello from blocked sender", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(res.statusCode).toBe(200); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockUpsertPairingRequest).not.toHaveBeenCalled(); }); it("triggers pairing flow for unknown sender when dmPolicy=pairing and allowFrom is empty", async () => { const account = createMockAccount({ dmPolicy: "pairing", allowFrom: [], }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockUpsertPairingRequest).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => { const account = createMockAccount({ dmPolicy: "pairing", allowFrom: ["+15559999999"], // Different number than sender }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockUpsertPairingRequest).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); it("does not resend pairing reply when request already exists", async () => { mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false }); const account = createMockAccount({ dmPolicy: "pairing", allowFrom: ["+15559999999"], // Different number than sender }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello again", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-2", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockUpsertPairingRequest).toHaveBeenCalled(); // Should not send pairing reply since created=false const { sendMessageBlueBubbles } = await import("./send.js"); expect(sendMessageBlueBubbles).not.toHaveBeenCalled(); }); it("allows all DMs when dmPolicy=open", async () => { const account = createMockAccount({ dmPolicy: "open", allowFrom: [], }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello from anyone", handle: { address: "+15559999999" }, isGroup: false, isFromMe: false, guid: "msg-1", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); it("blocks all DMs when dmPolicy=disabled", async () => { const account = createMockAccount({ dmPolicy: "disabled", }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); }); describe("group message gating", () => { it("allows group messages when groupPolicy=open and no allowlist", async () => { const account = createMockAccount({ groupPolicy: "open", }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello from group", handle: { address: "+15551234567" }, isGroup: true, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); it("blocks group messages when groupPolicy=disabled", async () => { const account = createMockAccount({ groupPolicy: "disabled", }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello from group", handle: { address: "+15551234567" }, isGroup: true, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); it("treats chat_guid groups as group even when isGroup=false", async () => { const account = createMockAccount({ groupPolicy: "allowlist", dmPolicy: "open", }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello from group", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); it("allows group messages from allowed chat_guid in groupAllowFrom", async () => { const account = createMockAccount({ groupPolicy: "allowlist", groupAllowFrom: ["chat_guid:iMessage;+;chat123456"], }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello from allowed group", handle: { address: "+15551234567" }, isGroup: true, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); }); describe("mention gating (group messages)", () => { it("processes group message when mentioned and requireMention=true", async () => { mockResolveRequireMention.mockReturnValue(true); mockMatchesMentionPatterns.mockReturnValue(true); const account = createMockAccount({ groupPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "bert, can you help me?", handle: { address: "+15551234567" }, isGroup: true, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = getFirstDispatchCall(); expect(callArgs.ctx.WasMentioned).toBe(true); }); it("skips group message when not mentioned and requireMention=true", async () => { mockResolveRequireMention.mockReturnValue(true); mockMatchesMentionPatterns.mockReturnValue(false); const account = createMockAccount({ groupPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello everyone", handle: { address: "+15551234567" }, isGroup: true, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); it("processes group message without mention when requireMention=false", async () => { mockResolveRequireMention.mockReturnValue(false); const account = createMockAccount({ groupPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello everyone", handle: { address: "+15551234567" }, isGroup: true, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); }); describe("group metadata", () => { it("includes group subject + members in ctx", async () => { const account = createMockAccount({ groupPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello group", handle: { address: "+15551234567" }, isGroup: true, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", chatName: "Family", participants: [ { address: "+15551234567", displayName: "Alice" }, { address: "+15557654321", displayName: "Bob" }, ], date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = getFirstDispatchCall(); expect(callArgs.ctx.GroupSubject).toBe("Family"); expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)"); }); }); describe("group sender identity in envelope", () => { it("includes sender in envelope body and group label as from for group messages", async () => { const account = createMockAccount({ groupPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello everyone", handle: { address: "+15551234567" }, senderName: "Alice", isGroup: true, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", chatName: "Family Chat", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); // formatInboundEnvelope should be called with group label + id as from, and sender info expect(mockFormatInboundEnvelope).toHaveBeenCalledWith( expect.objectContaining({ from: "Family Chat id:iMessage;+;chat123456", chatType: "group", sender: { name: "Alice", id: "+15551234567" }, }), ); // ConversationLabel should be the group label + id, not the sender const callArgs = getFirstDispatchCall(); expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456"); expect(callArgs.ctx.SenderName).toBe("Alice"); // BodyForAgent should be raw text, not the envelope-formatted body expect(callArgs.ctx.BodyForAgent).toBe("hello everyone"); }); it("falls back to group:peerId when chatName is missing", async () => { const account = createMockAccount({ groupPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: true, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockFormatInboundEnvelope).toHaveBeenCalledWith( expect.objectContaining({ from: expect.stringMatching(/^Group id:/), chatType: "group", sender: { name: undefined, id: "+15551234567" }, }), ); }); it("uses sender as from label for DM messages", async () => { const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, senderName: "Alice", isGroup: false, isFromMe: false, guid: "msg-1", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockFormatInboundEnvelope).toHaveBeenCalledWith( expect.objectContaining({ from: "Alice id:+15551234567", chatType: "direct", sender: { name: "Alice", id: "+15551234567" }, }), ); const callArgs = getFirstDispatchCall(); expect(callArgs.ctx.ConversationLabel).toBe("Alice id:+15551234567"); }); }); describe("inbound debouncing", () => { it("coalesces text-only then attachment webhook events by messageId", async () => { vi.useFakeTimers(); try { const account = createMockAccount({ dmPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); // Use a timing-aware debouncer test double that respects debounceMs/buildKey/shouldDebounce. // oxlint-disable-next-line typescript/no-explicit-any core.channel.debounce.createInboundDebouncer = vi.fn((params: any) => { // oxlint-disable-next-line typescript/no-explicit-any type Item = any; const buckets = new Map< string, { items: Item[]; timer: ReturnType | null } >(); const flush = async (key: string) => { const bucket = buckets.get(key); if (!bucket) { return; } if (bucket.timer) { clearTimeout(bucket.timer); bucket.timer = null; } const items = bucket.items; bucket.items = []; if (items.length > 0) { try { await params.onFlush(items); } catch (err) { params.onError?.(err); throw err; } } }; return { enqueue: async (item: Item) => { if (params.shouldDebounce && !params.shouldDebounce(item)) { await params.onFlush([item]); return; } const key = params.buildKey(item); const existing = buckets.get(key); const bucket = existing ?? { items: [], timer: null }; bucket.items.push(item); if (bucket.timer) { clearTimeout(bucket.timer); } bucket.timer = setTimeout(async () => { await flush(key); }, params.debounceMs); buckets.set(key, bucket); }, flushKey: vi.fn(async (key: string) => { await flush(key); }), }; }) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"]; setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const messageId = "race-msg-1"; const chatGuid = "iMessage;-;+15551234567"; const payloadA = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: messageId, chatGuid, date: Date.now(), }, }; const payloadB = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: messageId, chatGuid, attachments: [ { guid: "att-1", mimeType: "image/jpeg", totalBytes: 1024, }, ], date: Date.now(), }, }; await handleBlueBubblesWebhookRequest( createMockRequest("POST", "/bluebubbles-webhook", payloadA), createMockResponse(), ); // Simulate the real-world delay where the attachment-bearing webhook arrives shortly after. await vi.advanceTimersByTimeAsync(300); await handleBlueBubblesWebhookRequest( createMockRequest("POST", "/bluebubbles-webhook", payloadB), createMockResponse(), ); // Not flushed yet; still within the debounce window. expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); // After the debounce window, the combined message should be processed exactly once. await vi.advanceTimersByTimeAsync(600); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); const callArgs = getFirstDispatchCall(); expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]); expect(callArgs.ctx.Body).toContain("hello"); } finally { vi.useRealTimers(); } }); }); describe("reply metadata", () => { it("surfaces reply fields in ctx when provided", async () => { const account = createMockAccount({ dmPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "replying now", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", replyTo: { guid: "msg-0", text: "original message", handle: { address: "+15550000000", displayName: "Alice" }, }, date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = getFirstDispatchCall(); // ReplyToId is the full UUID since it wasn't previously cached expect(callArgs.ctx.ReplyToId).toBe("msg-0"); expect(callArgs.ctx.ReplyToBody).toBe("original message"); expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); // Body uses inline [[reply_to:N]] tag format expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]"); }); it("preserves part index prefixes in reply tags when short IDs are unavailable", async () => { const account = createMockAccount({ dmPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "replying now", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", replyTo: { guid: "p:1/msg-0", text: "original message", handle: { address: "+15550000000", displayName: "Alice" }, }, date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = getFirstDispatchCall(); expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0"); expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0"); expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]"); }); it("hydrates missing reply sender/body from the recent-message cache", async () => { const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const chatGuid = "iMessage;+;chat-reply-cache"; const originalPayload = { type: "new-message", data: { text: "original message (cached)", handle: { address: "+15550000000" }, isGroup: true, isFromMe: false, guid: "cache-msg-0", chatGuid, date: Date.now(), }, }; const originalReq = createMockRequest("POST", "/bluebubbles-webhook", originalPayload); const originalRes = createMockResponse(); await handleBlueBubblesWebhookRequest(originalReq, originalRes); await flushAsync(); // Only assert the reply message behavior below. mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); const replyPayload = { type: "new-message", data: { text: "replying now", handle: { address: "+15551234567" }, isGroup: true, isFromMe: false, guid: "cache-msg-1", chatGuid, // Only the GUID is provided; sender/body must be hydrated. replyToMessageGuid: "cache-msg-0", date: Date.now(), }, }; const replyReq = createMockRequest("POST", "/bluebubbles-webhook", replyPayload); const replyRes = createMockResponse(); await handleBlueBubblesWebhookRequest(replyReq, replyRes); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = getFirstDispatchCall(); // ReplyToId uses short ID "1" (first cached message) for token savings expect(callArgs.ctx.ReplyToId).toBe("1"); expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0"); expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)"); expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); // Body uses inline [[reply_to:N]] tag format with short ID expect(callArgs.ctx.Body).toContain("[[reply_to:1]]"); }); it("falls back to threadOriginatorGuid when reply metadata is absent", async () => { const account = createMockAccount({ dmPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "replying now", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", threadOriginatorGuid: "msg-0", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = getFirstDispatchCall(); expect(callArgs.ctx.ReplyToId).toBe("msg-0"); }); }); describe("tapback text parsing", () => { it("does not rewrite tapback-like text without metadata", async () => { const account = createMockAccount({ dmPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "Loved this idea", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = getFirstDispatchCall(); expect(callArgs.ctx.RawBody).toBe("Loved this idea"); expect(callArgs.ctx.Body).toContain("Loved this idea"); expect(callArgs.ctx.Body).not.toContain("reacted with"); }); it("parses tapback text with custom emoji when metadata is present", async () => { const account = createMockAccount({ dmPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: 'Reacted 😅 to "nice one"', handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-2", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = getFirstDispatchCall(); expect(callArgs.ctx.RawBody).toBe("reacted with 😅"); expect(callArgs.ctx.Body).toContain("reacted with 😅"); expect(callArgs.ctx.Body).not.toContain("[[reply_to:"); }); }); describe("ack reactions", () => { it("sends ack reaction when configured", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); vi.mocked(sendBlueBubblesReaction).mockClear(); const account = createMockAccount({ dmPolicy: "open" }); const config: OpenClawConfig = { messages: { ackReaction: "❤️", ackReactionScope: "direct", }, }; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( expect.objectContaining({ chatGuid: "iMessage;-;+15551234567", messageGuid: "msg-1", emoji: "❤️", opts: expect.objectContaining({ accountId: "default" }), }), ); }); }); describe("command gating", () => { it("allows control command to bypass mention gating when authorized", async () => { mockResolveRequireMention.mockReturnValue(true); mockMatchesMentionPatterns.mockReturnValue(false); // Not mentioned mockHasControlCommand.mockReturnValue(true); // Has control command mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true); // Authorized const account = createMockAccount({ groupPolicy: "open", allowFrom: ["+15551234567"], }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "/status", handle: { address: "+15551234567" }, isGroup: true, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); // Should process even without mention because it's an authorized control command expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); it("blocks control command from unauthorized sender in group", async () => { mockHasControlCommand.mockReturnValue(true); mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false); const account = createMockAccount({ groupPolicy: "open", allowFrom: [], // No one authorized }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "/status", handle: { address: "+15559999999" }, isGroup: true, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;+;chat123456", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); it("does not auto-authorize DM control commands in open mode without allowlists", async () => { mockHasControlCommand.mockReturnValue(true); const account = createMockAccount({ dmPolicy: "open", allowFrom: [], }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "/status", handle: { address: "+15559999999" }, isGroup: false, isFromMe: false, guid: "msg-dm-open-unauthorized", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const latestDispatch = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[ mockDispatchReplyWithBufferedBlockDispatcher.mock.calls.length - 1 ]?.[0]; expect(latestDispatch?.ctx?.CommandAuthorized).toBe(false); }); }); describe("typing/read receipt toggles", () => { it("marks chat as read when sendReadReceipts=true (default)", async () => { const { markBlueBubblesChatRead } = await import("./chat.js"); vi.mocked(markBlueBubblesChatRead).mockClear(); const account = createMockAccount({ sendReadReceipts: true, }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(markBlueBubblesChatRead).toHaveBeenCalled(); }); it("does not mark chat as read when sendReadReceipts=false", async () => { const { markBlueBubblesChatRead } = await import("./chat.js"); vi.mocked(markBlueBubblesChatRead).mockClear(); const account = createMockAccount({ sendReadReceipts: false, }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(markBlueBubblesChatRead).not.toHaveBeenCalled(); }); it("sends typing indicator when processing message", async () => { const { sendBlueBubblesTyping } = await import("./chat.js"); vi.mocked(sendBlueBubblesTyping).mockClear(); const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.onReplyStart?.(); return EMPTY_DISPATCH_RESULT; }); const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); // Should call typing start when reply flow triggers it. expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect.any(String), true, expect.any(Object), ); }); it("stops typing on idle", async () => { const { sendBlueBubblesTyping } = await import("./chat.js"); vi.mocked(sendBlueBubblesTyping).mockClear(); const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.onReplyStart?.(); await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); await params.dispatcherOptions.onIdle?.(); return EMPTY_DISPATCH_RESULT; }); const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect.any(String), false, expect.any(Object), ); }); it("stops typing when no reply is sent", async () => { const { sendBlueBubblesTyping } = await import("./chat.js"); vi.mocked(sendBlueBubblesTyping).mockClear(); const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( async () => EMPTY_DISPATCH_RESULT, ); const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect.any(String), false, expect.any(Object), ); }); }); describe("outbound message ids", () => { it("enqueues system event for outbound message id", async () => { mockEnqueueSystemEvent.mockClear(); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); return EMPTY_DISPATCH_RESULT; }); const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); // Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2") expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( 'Assistant sent "replying now" [message_id:2]', expect.objectContaining({ sessionKey: "agent:main:bluebubbles:dm:+15551234567", }), ); }); it("falls back to from-me webhook when send response has no message id", async () => { mockEnqueueSystemEvent.mockClear(); const { sendMessageBlueBubbles } = await import("./send.js"); vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" }); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); return EMPTY_DISPATCH_RESULT; }); const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const inboundPayload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const inboundReq = createMockRequest("POST", "/bluebubbles-webhook", inboundPayload); const inboundRes = createMockResponse(); await handleBlueBubblesWebhookRequest(inboundReq, inboundRes); await flushAsync(); // Send response did not include a message id, so nothing should be enqueued yet. expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); const fromMePayload = { type: "new-message", data: { text: "replying now", handle: { address: "+15557654321" }, isGroup: false, isFromMe: true, guid: "msg-out-456", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const fromMeReq = createMockRequest("POST", "/bluebubbles-webhook", fromMePayload); const fromMeRes = createMockResponse(); await handleBlueBubblesWebhookRequest(fromMeReq, fromMeRes); await flushAsync(); expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( 'Assistant sent "replying now" [message_id:2]', expect.objectContaining({ sessionKey: "agent:main:bluebubbles:dm:+15551234567", }), ); }); it("matches from-me fallback by chatIdentifier when chatGuid is missing", async () => { mockEnqueueSystemEvent.mockClear(); const { sendMessageBlueBubbles } = await import("./send.js"); vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" }); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); return EMPTY_DISPATCH_RESULT; }); const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const inboundPayload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const inboundReq = createMockRequest("POST", "/bluebubbles-webhook", inboundPayload); const inboundRes = createMockResponse(); await handleBlueBubblesWebhookRequest(inboundReq, inboundRes); await flushAsync(); expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); const fromMePayload = { type: "new-message", data: { text: "replying now", handle: { address: "+15557654321" }, isGroup: false, isFromMe: true, guid: "msg-out-789", chatIdentifier: "+15551234567", date: Date.now(), }, }; const fromMeReq = createMockRequest("POST", "/bluebubbles-webhook", fromMePayload); const fromMeRes = createMockResponse(); await handleBlueBubblesWebhookRequest(fromMeReq, fromMeRes); await flushAsync(); expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( 'Assistant sent "replying now" [message_id:2]', expect.objectContaining({ sessionKey: "agent:main:bluebubbles:dm:+15551234567", }), ); }); }); describe("reaction events", () => { it("drops DM reactions when dmPolicy=pairing and allowFrom is empty", async () => { mockEnqueueSystemEvent.mockClear(); const account = createMockAccount({ dmPolicy: "pairing", allowFrom: [] }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "message-reaction", data: { handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, associatedMessageGuid: "msg-original-123", associatedMessageType: 2000, date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); }); it("enqueues system event for reaction added", async () => { mockEnqueueSystemEvent.mockClear(); const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "message-reaction", data: { handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, associatedMessageGuid: "msg-original-123", associatedMessageType: 2000, // Heart reaction added date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect.stringContaining("reacted with ❤️ [[reply_to:"), expect.any(Object), ); }); it("enqueues system event for reaction removed", async () => { mockEnqueueSystemEvent.mockClear(); const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "message-reaction", data: { handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, associatedMessageGuid: "msg-original-123", associatedMessageType: 3000, // Heart reaction removed date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect.stringContaining("removed ❤️ reaction [[reply_to:"), expect.any(Object), ); }); it("ignores reaction from self (fromMe=true)", async () => { mockEnqueueSystemEvent.mockClear(); const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "message-reaction", data: { handle: { address: "+15551234567" }, isGroup: false, isFromMe: true, // From self associatedMessageGuid: "msg-original-123", associatedMessageType: 2000, date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); }); it("maps reaction types to correct emojis", async () => { mockEnqueueSystemEvent.mockClear(); const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); // Test thumbs up reaction (2001) const payload = { type: "message-reaction", data: { handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, associatedMessageGuid: "msg-123", associatedMessageType: 2001, // Thumbs up date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect.stringContaining("👍"), expect.any(Object), ); }); }); describe("short message ID mapping", () => { it("assigns sequential short IDs to messages", async () => { const account = createMockAccount({ dmPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "p:1/msg-uuid-12345", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = getFirstDispatchCall(); // MessageSid should be short ID "1" instead of full UUID expect(callArgs.ctx.MessageSid).toBe("1"); expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345"); }); it("resolves short ID back to UUID", async () => { const account = createMockAccount({ dmPolicy: "open" }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "hello", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "p:1/msg-uuid-12345", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); // The short ID "1" should resolve back to the full UUID expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345"); }); it("returns UUID unchanged when not in cache", () => { expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe("msg-not-cached"); }); it("returns short ID unchanged when numeric but not in cache", () => { expect(resolveBlueBubblesMessageId("999")).toBe("999"); }); it("throws when numeric short ID is missing and requireKnownShortId is set", () => { expect(() => resolveBlueBubblesMessageId("999", { requireKnownShortId: true })).toThrow( /short message id/i, ); }); }); describe("history backfill", () => { it("scopes in-memory history by account to avoid cross-account leakage", async () => { mockFetchBlueBubblesHistory.mockImplementation(async (_chatIdentifier, _limit, opts) => { if (opts?.accountId === "acc-a") { return { resolved: true, entries: [ { sender: "A", body: "a-history", messageId: "a-history-1", timestamp: 1000 }, ], }; } if (opts?.accountId === "acc-b") { return { resolved: true, entries: [ { sender: "B", body: "b-history", messageId: "b-history-1", timestamp: 1000 }, ], }; } return { resolved: true, entries: [] }; }); const accountA: ResolvedBlueBubblesAccount = { ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }), accountId: "acc-a", }; const accountB: ResolvedBlueBubblesAccount = { ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), accountId: "acc-b", }; const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); const unregisterA = registerBlueBubblesWebhookTarget({ account: accountA, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const unregisterB = registerBlueBubblesWebhookTarget({ account: accountB, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); unregister = () => { unregisterA(); unregisterB(); }; await handleBlueBubblesWebhookRequest( createMockRequest("POST", "/bluebubbles-webhook?password=password-a", { type: "new-message", data: { text: "message for account a", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "a-msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }), createMockResponse(), ); await flushAsync(); await handleBlueBubblesWebhookRequest( createMockRequest("POST", "/bluebubbles-webhook?password=password-b", { type: "new-message", data: { text: "message for account b", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "b-msg-1", chatGuid: "iMessage;-;+15551234567", date: Date.now(), }, }), createMockResponse(), ); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2); const firstCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; const secondCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[1]?.[0]; const firstHistory = (firstCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>; const secondHistory = (secondCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>; expect(firstHistory.map((entry) => entry.body)).toContain("a-history"); expect(secondHistory.map((entry) => entry.body)).toContain("b-history"); expect(secondHistory.map((entry) => entry.body)).not.toContain("a-history"); }); it("dedupes and caps merged history to dmHistoryLimit", async () => { mockFetchBlueBubblesHistory.mockResolvedValueOnce({ resolved: true, entries: [ { sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 }, { sender: "Friend", body: "current text", messageId: "msg-1", timestamp: 2000 }, ], }); const account = createMockAccount({ dmHistoryLimit: 2 }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const req = createMockRequest("POST", "/bluebubbles-webhook", { type: "new-message", data: { text: "current text", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-1", chatGuid: "iMessage;-;+15550002002", date: Date.now(), }, }); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); const callArgs = getFirstDispatchCall(); const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>; expect(inboundHistory).toHaveLength(2); expect(inboundHistory.map((entry) => entry.body)).toEqual(["older context", "current text"]); expect(inboundHistory.filter((entry) => entry.body === "current text")).toHaveLength(1); }); it("uses exponential backoff for unresolved backfill and stops after resolve", async () => { mockFetchBlueBubblesHistory .mockResolvedValueOnce({ resolved: false, entries: [] }) .mockResolvedValueOnce({ resolved: true, entries: [ { sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 }, ], }); const account = createMockAccount({ dmHistoryLimit: 4 }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const mkPayload = (guid: string, text: string, now: number) => ({ type: "new-message", data: { text, handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid, chatGuid: "iMessage;-;+15550003003", date: now, }, }); let now = 1_700_000_000_000; const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); try { await handleBlueBubblesWebhookRequest( createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-1", "first text", now)), createMockResponse(), ); await flushAsync(); expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1); now += 1_000; await handleBlueBubblesWebhookRequest( createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-2", "second text", now)), createMockResponse(), ); await flushAsync(); expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1); now += 6_000; await handleBlueBubblesWebhookRequest( createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-3", "third text", now)), createMockResponse(), ); await flushAsync(); expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2); const thirdCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[2]?.[0]; const thirdHistory = (thirdCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>; expect(thirdHistory.map((entry) => entry.body)).toContain("older context"); expect(thirdHistory.map((entry) => entry.body)).toContain("third text"); now += 10_000; await handleBlueBubblesWebhookRequest( createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-4", "fourth text", now)), createMockResponse(), ); await flushAsync(); expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2); } finally { nowSpy.mockRestore(); } }); it("caps inbound history payload size to reduce prompt-bomb risk", async () => { const huge = "x".repeat(8_000); mockFetchBlueBubblesHistory.mockResolvedValueOnce({ resolved: true, entries: Array.from({ length: 20 }, (_, idx) => ({ sender: `Friend ${idx}`, body: `${huge} ${idx}`, messageId: `hist-${idx}`, timestamp: idx + 1, })), }); const account = createMockAccount({ dmHistoryLimit: 20 }); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); await handleBlueBubblesWebhookRequest( createMockRequest("POST", "/bluebubbles-webhook", { type: "new-message", data: { text: "latest text", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, guid: "msg-bomb-1", chatGuid: "iMessage;-;+15550004004", date: Date.now(), }, }), createMockResponse(), ); await flushAsync(); const callArgs = getFirstDispatchCall(); const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>; const totalChars = inboundHistory.reduce((sum, entry) => sum + entry.body.length, 0); expect(inboundHistory.length).toBeLessThan(20); expect(totalChars).toBeLessThanOrEqual(12_000); expect(inboundHistory.every((entry) => entry.body.length <= 1_203)).toBe(true); }); }); describe("fromMe messages", () => { it("ignores messages from self (fromMe=true)", async () => { const account = createMockAccount(); const config: OpenClawConfig = {}; const core = createMockRuntime(); setBlueBubblesRuntime(core); unregister = registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", }); const payload = { type: "new-message", data: { text: "my own message", handle: { address: "+15551234567" }, isGroup: false, isFromMe: true, guid: "msg-1", date: Date.now(), }, }; const req = createMockRequest("POST", "/bluebubbles-webhook", payload); const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); }); });