Download extensions/bluebubbles/src/monitor.test.ts from Darochin/openskynet: direct link, hf CLI and curl.
- Browser
- Download file 99.2 kB
-
https://huggingface.co/Darochin/openskynet/resolve/main/extensions/bluebubbles/src/monitor.test.ts
- Command line
-
hf download hf://Darochin/openskynet/extensions/bluebubbles/src/monitor.test.ts
-
curl -L -o monitor.test.ts https://huggingface.co/Darochin/openskynet/resolve/main/extensions/bluebubbles/src/monitor.test.ts
99.2 kB
| 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"; | |
| // 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<typeof import("./reactions.js")>("./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["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" }; | |
| // 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<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(); | |
| // Reset short ID state between tests for predictable behavior | |
| _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); | |
| // 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<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(), | |
| ); | |
| // 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" }), // pragma: allowlist secret | |
| accountId: "acc-a", | |
| }; | |
| const accountB: ResolvedBlueBubblesAccount = { | |
| ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), // pragma: allowlist secret | |
| 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(); | |
| }); | |
| }); | |
| }); | |