| import { EventEmitter } from "node:events"; |
| import type { IncomingMessage, ServerResponse } from "node:http"; |
| import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; |
| 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 { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js"; |
| import { |
| handleBlueBubblesWebhookRequest, |
| registerBlueBubblesWebhookTarget, |
| resolveBlueBubblesMessageId, |
| _resetBlueBubblesShortIdState, |
| } from "./monitor.js"; |
| import { setBlueBubblesRuntime } from "./runtime.js"; |
|
|
| |
| 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<typeof import("./reactions.js")>("./reactions.js"); |
| return { |
| ...actual, |
| sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), |
| }; |
| }); |
|
|
| vi.mock("./history.js", () => ({ |
| fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }), |
| })); |
|
|
| |
| 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["config"]> = {}, |
| ): 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<string, string> = {}, |
| ): 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" }; |
|
|
| |
| |
| 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<void>((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(); |
| |
| _resetBlueBubblesShortIdState(); |
| resetBlueBubblesSelfChatCache(); |
| 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?.(); |
| vi.useRealTimers(); |
| }); |
|
|
| 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); |
|
|
| |
| 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"], |
| }); |
| 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"], |
| }); |
| 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"], |
| }); |
| 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(); |
| |
| 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(); |
|
|
| |
| expect(mockFormatInboundEnvelope).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| from: "Family Chat id:iMessage;+;chat123456", |
| chatType: "group", |
| sender: { name: "Alice", id: "+15551234567" }, |
| }), |
| ); |
| |
| const callArgs = getFirstDispatchCall(); |
| expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456"); |
| expect(callArgs.ctx.SenderName).toBe("Alice"); |
| |
| 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(); |
|
|
| |
| |
| core.channel.debounce.createInboundDebouncer = vi.fn((params: any) => { |
| |
| type Item = any; |
| const buckets = new Map< |
| string, |
| { items: Item[]; timer: ReturnType<typeof setTimeout> | 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(), |
| ); |
|
|
| |
| await vi.advanceTimersByTimeAsync(300); |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", payloadB), |
| createMockResponse(), |
| ); |
|
|
| |
| expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); |
|
|
| |
| 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(); |
| |
| expect(callArgs.ctx.ReplyToId).toBe("msg-0"); |
| expect(callArgs.ctx.ReplyToBody).toBe("original message"); |
| expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); |
| |
| 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(); |
|
|
| |
| mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); |
|
|
| const replyPayload = { |
| type: "new-message", |
| data: { |
| text: "replying now", |
| handle: { address: "+15551234567" }, |
| isGroup: true, |
| isFromMe: false, |
| guid: "cache-msg-1", |
| chatGuid, |
| |
| 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(); |
| |
| 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"); |
| |
| 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); |
| mockHasControlCommand.mockReturnValue(true); |
| mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true); |
|
|
| 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(); |
|
|
| |
| 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: [], |
| }); |
| 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(); |
|
|
| |
| 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(); |
|
|
| |
| 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(); |
|
|
| |
| 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, |
| 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, |
| 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, |
| 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", |
| }); |
|
|
| |
| const payload = { |
| type: "message-reaction", |
| data: { |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: false, |
| associatedMessageGuid: "msg-123", |
| associatedMessageType: 2001, |
| 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(); |
| |
| 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(); |
|
|
| |
| 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(); |
| }); |
|
|
| it("drops reflected self-chat duplicates after a confirmed assistant outbound", async () => { |
| const account = createMockAccount({ dmPolicy: "open" }); |
| const config: OpenClawConfig = {}; |
| const core = createMockRuntime(); |
| setBlueBubblesRuntime(core); |
|
|
| const { sendMessageBlueBubbles } = await import("./send.js"); |
| vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "msg-self-1" }); |
|
|
| mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { |
| await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); |
| return EMPTY_DISPATCH_RESULT; |
| }); |
|
|
| unregister = registerBlueBubblesWebhookTarget({ |
| account, |
| config, |
| runtime: { log: vi.fn(), error: vi.fn() }, |
| core, |
| path: "/bluebubbles-webhook", |
| }); |
|
|
| const timestamp = Date.now(); |
| const inboundPayload = { |
| type: "new-message", |
| data: { |
| text: "hello", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: false, |
| guid: "msg-self-0", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", inboundPayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); |
| mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); |
|
|
| const fromMePayload = { |
| type: "new-message", |
| data: { |
| text: "replying now", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: true, |
| guid: "msg-self-1", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", fromMePayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| const reflectedPayload = { |
| type: "new-message", |
| data: { |
| text: "replying now", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: false, |
| guid: "msg-self-2", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); |
| }); |
|
|
| it("does not drop inbound messages when no fromMe self-chat copy was seen", 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 inboundPayload = { |
| type: "new-message", |
| data: { |
| text: "genuinely new message", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: false, |
| guid: "msg-inbound-1", |
| chatGuid: "iMessage;-;+15551234567", |
| date: Date.now(), |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", inboundPayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); |
| }); |
|
|
| it("does not drop reflected copies after the self-chat cache TTL expires", async () => { |
| vi.useFakeTimers(); |
| vi.setSystemTime(new Date("2026-03-07T00:00:00Z")); |
|
|
| 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 timestamp = Date.now(); |
| const fromMePayload = { |
| type: "new-message", |
| data: { |
| text: "ttl me", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: true, |
| guid: "msg-self-ttl-1", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", fromMePayload), |
| createMockResponse(), |
| ); |
| await vi.runAllTimersAsync(); |
|
|
| mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); |
| vi.advanceTimersByTime(10_001); |
|
|
| const reflectedPayload = { |
| type: "new-message", |
| data: { |
| text: "ttl me", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: false, |
| guid: "msg-self-ttl-2", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload), |
| createMockResponse(), |
| ); |
| await vi.runAllTimersAsync(); |
|
|
| expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); |
| }); |
|
|
| it("does not cache regular fromMe DMs as self-chat reflections", 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 timestamp = Date.now(); |
| const fromMePayload = { |
| type: "new-message", |
| data: { |
| text: "shared text", |
| handle: { address: "+15557654321" }, |
| isGroup: false, |
| isFromMe: true, |
| guid: "msg-normal-fromme", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", fromMePayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); |
|
|
| const inboundPayload = { |
| type: "new-message", |
| data: { |
| text: "shared text", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: false, |
| guid: "msg-normal-inbound", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", inboundPayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); |
| }); |
|
|
| it("does not drop user-authored self-chat prompts without a confirmed assistant outbound", 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 timestamp = Date.now(); |
| const fromMePayload = { |
| type: "new-message", |
| data: { |
| text: "user-authored self prompt", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: true, |
| guid: "msg-self-user-1", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", fromMePayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); |
|
|
| const reflectedPayload = { |
| type: "new-message", |
| data: { |
| text: "user-authored self prompt", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: false, |
| guid: "msg-self-user-2", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); |
| }); |
|
|
| it("does not treat a pending text-only match as confirmed assistant outbound", async () => { |
| const account = createMockAccount({ dmPolicy: "open" }); |
| const config: OpenClawConfig = {}; |
| const core = createMockRuntime(); |
| setBlueBubblesRuntime(core); |
|
|
| const { sendMessageBlueBubbles } = await import("./send.js"); |
| vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" }); |
|
|
| mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { |
| await params.dispatcherOptions.deliver({ text: "same text" }, { kind: "final" }); |
| return EMPTY_DISPATCH_RESULT; |
| }); |
|
|
| unregister = registerBlueBubblesWebhookTarget({ |
| account, |
| config, |
| runtime: { log: vi.fn(), error: vi.fn() }, |
| core, |
| path: "/bluebubbles-webhook", |
| }); |
|
|
| const timestamp = Date.now(); |
| const inboundPayload = { |
| type: "new-message", |
| data: { |
| text: "hello", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: false, |
| guid: "msg-self-race-0", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", inboundPayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); |
| mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); |
|
|
| const fromMePayload = { |
| type: "new-message", |
| data: { |
| text: "same text", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: true, |
| guid: "msg-self-race-1", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", fromMePayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| const reflectedPayload = { |
| type: "new-message", |
| data: { |
| text: "same text", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: false, |
| guid: "msg-self-race-2", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); |
| }); |
|
|
| it("does not treat chatGuid-inferred sender ids as self-chat evidence", 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 timestamp = Date.now(); |
| const fromMePayload = { |
| type: "new-message", |
| data: { |
| text: "shared inferred text", |
| handle: null, |
| isGroup: false, |
| isFromMe: true, |
| guid: "msg-inferred-fromme", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", fromMePayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); |
|
|
| const inboundPayload = { |
| type: "new-message", |
| data: { |
| text: "shared inferred text", |
| handle: { address: "+15551234567" }, |
| isGroup: false, |
| isFromMe: false, |
| guid: "msg-inferred-inbound", |
| chatGuid: "iMessage;-;+15551234567", |
| date: timestamp, |
| }, |
| }; |
|
|
| await handleBlueBubblesWebhookRequest( |
| createMockRequest("POST", "/bluebubbles-webhook", inboundPayload), |
| createMockResponse(), |
| ); |
| await flushAsync(); |
|
|
| expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); |
| }); |
| }); |
| }); |
|
|