From 7870ce817731c60bc2d3db53a08e6762ef29e16d Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Mon, 19 Jan 2026 18:39:56 -0800 Subject: [PATCH] Step 3 + Review --- .../ChannelsSettings+ChannelState.swift | 4 +- .../Sources/Clawdbot/CronJobEditor.swift | 1 + .../Sources/Clawdbot/GatewayConnection.swift | 1 + .../GatewayAgentChannelTests.swift | 2 + extensions/bluebubbles/src/channel.ts | 101 +- extensions/bluebubbles/src/monitor.test.ts | 1357 +++++++++++++++++ ui/src/ui/controllers/cron.ts | 4 +- ui/src/ui/types.ts | 3 +- ui/src/ui/ui-types.ts | 3 +- ui/src/ui/views/channels.ts | 2 +- ui/src/ui/views/cron.ts | 1 + 11 files changed, 1462 insertions(+), 17 deletions(-) create mode 100644 extensions/bluebubbles/src/monitor.test.ts diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift index e4def6116..6d7be4e96 100644 --- a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift @@ -244,7 +244,7 @@ extension ChannelsSettings { } var orderedChannels: [ChannelItem] { - let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"] + let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "bluebubbles"] let order = self.store.snapshot?.channelOrder ?? fallback let channels = order.enumerated().map { index, id in ChannelItem( @@ -440,6 +440,7 @@ extension ChannelsSettings { case "slack": "Slack Bot" case "signal": "Signal REST" case "imessage": "iMessage" + case "bluebubbles": "BlueBubbles" default: self.resolveChannelTitle(id) } } @@ -452,6 +453,7 @@ extension ChannelsSettings { case "slack": "number" case "signal": "antenna.radiowaves.left.and.right" case "imessage": "message.fill" + case "bluebubbles": "bubble.left.and.text.bubble.right" default: "message" } } diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift index 05659d179..8d5b4a9b3 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift @@ -340,6 +340,7 @@ struct CronJobEditor: View { Text("slack").tag(GatewayAgentChannel.slack) Text("signal").tag(GatewayAgentChannel.signal) Text("imessage").tag(GatewayAgentChannel.imessage) + Text("bluebubbles").tag(GatewayAgentChannel.bluebubbles) } .labelsHidden() .pickerStyle(.segmented) diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index a5c055103..83c297fc8 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -15,6 +15,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { case signal case imessage case msteams + case bluebubbles case webchat init(raw: String?) { diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift index 248712262..bf72af7e5 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift @@ -11,6 +11,7 @@ import Testing #expect(GatewayAgentChannel.last.shouldDeliver(true) == true) #expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true) #expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true) + #expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true) #expect(GatewayAgentChannel.last.shouldDeliver(false) == false) } @@ -18,6 +19,7 @@ import Testing #expect(GatewayAgentChannel(raw: nil) == .last) #expect(GatewayAgentChannel(raw: " ") == .last) #expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat) + #expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles) #expect(GatewayAgentChannel(raw: "unknown") == .last) } } diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index b53a1b56a..2d0b4471b 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -1,3 +1,6 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk"; import { applyAccountNameToChannelSection, @@ -25,6 +28,7 @@ import { normalizeBlueBubblesHandle } from "./targets.js"; import { bluebubblesMessageActions } from "./actions.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; import { blueBubblesOnboardingAdapter } from "./onboarding.js"; +import { getBlueBubblesRuntime } from "./runtime.js"; const meta = { id: "bluebubbles", @@ -36,6 +40,37 @@ const meta = { order: 75, }; +const HTTP_URL_RE = /^https?:\/\//i; + +function resolveLocalMediaPath(source: string): string { + if (!source.startsWith("file://")) return source; + try { + return fileURLToPath(source); + } catch { + throw new Error(`Invalid file:// URL: ${source}`); + } +} + +function resolveFilenameFromSource(source?: string): string | undefined { + if (!source) return undefined; + if (source.startsWith("file://")) { + try { + return path.basename(fileURLToPath(source)) || undefined; + } catch { + return undefined; + } + } + if (HTTP_URL_RE.test(source)) { + try { + return path.basename(new URL(source).pathname) || undefined; + } catch { + return undefined; + } + } + const base = path.basename(source); + return base || undefined; +} + export const bluebubblesPlugin: ChannelPlugin = { id: "bluebubbles", meta, @@ -216,27 +251,69 @@ export const bluebubblesPlugin: ChannelPlugin = { }); return { channel: "bluebubbles", ...result }; }, - sendMedia: async ({ cfg, to, mediaPath, mediaBuffer, contentType, filename, caption, accountId }) => { - // Prefer buffer if provided, otherwise read from path + sendMedia: async (ctx) => { + const { cfg, to, text, mediaUrl, accountId } = ctx; + const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { + mediaPath?: string; + mediaBuffer?: Uint8Array; + contentType?: string; + filename?: string; + caption?: string; + }; + const core = getBlueBubblesRuntime(); + const resolvedCaption = caption ?? text; + let buffer: Uint8Array; + let resolvedContentType = contentType ?? undefined; + let resolvedFilename = filename ?? undefined; + if (mediaBuffer) { buffer = mediaBuffer; - } else if (mediaPath) { - const fs = await import("node:fs/promises"); - buffer = new Uint8Array(await fs.readFile(mediaPath)); + if (!resolvedContentType) { + const hint = mediaPath ?? mediaUrl; + const detected = await core.media.detectMime({ + buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer), + filePath: hint, + }); + resolvedContentType = detected ?? undefined; + } + if (!resolvedFilename) { + resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl); + } } else { - throw new Error("BlueBubbles media delivery requires mediaPath or mediaBuffer."); + const source = mediaPath ?? mediaUrl; + if (!source) { + throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer."); + } + if (HTTP_URL_RE.test(source)) { + const fetched = await core.channel.media.fetchRemoteMedia({ url: source }); + buffer = fetched.buffer; + resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined; + resolvedFilename = resolvedFilename ?? fetched.fileName; + } else { + const localPath = resolveLocalMediaPath(source); + const fs = await import("node:fs/promises"); + const data = await fs.readFile(localPath); + buffer = new Uint8Array(data); + if (!resolvedContentType) { + const detected = await core.media.detectMime({ + buffer: data, + filePath: localPath, + }); + resolvedContentType = detected ?? undefined; + } + if (!resolvedFilename) { + resolvedFilename = resolveFilenameFromSource(localPath); + } + } } - // Resolve filename from path if not provided - const resolvedFilename = filename ?? (mediaPath ? mediaPath.split("/").pop() ?? "attachment" : "attachment"); - const result = await sendBlueBubblesAttachment({ to, buffer, - filename: resolvedFilename, - contentType: contentType ?? undefined, - caption: caption ?? undefined, + filename: resolvedFilename ?? "attachment", + contentType: resolvedContentType ?? undefined, + caption: resolvedCaption ?? undefined, opts: { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined, diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts new file mode 100644 index 000000000..e6d9ea2d1 --- /dev/null +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -0,0 +1,1357 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { EventEmitter } from "node:events"; + +import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk"; +import { + handleBlueBubblesWebhookRequest, + registerBlueBubblesWebhookTarget, +} from "./monitor.js"; +import { setBlueBubblesRuntime } from "./runtime.js"; +import type { ResolvedBlueBubblesAccount } from "./accounts.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", + }), +})); + +// 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", + accountId: "default", + sessionKey: "agent:main:bluebubbles:dm:+15551234567", +})); +const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]); +const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) => + regexes.some((r) => r.test(text)), +); +const mockResolveRequireMention = vi.fn(() => false); +const mockResolveGroupPolicy = vi.fn(() => "open"); +const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => undefined); +const mockHasControlCommand = vi.fn(() => false); +const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false); +const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ + path: "/tmp/test-media.jpg", + contentType: "image/jpeg", +}); +const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json"); +const mockReadSessionUpdatedAt = vi.fn(() => undefined); +const mockResolveEnvelopeFormatOptions = vi.fn(() => ({ + template: "channel+name+time", +})); +const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body); +const mockChunkMarkdownText = vi.fn((text: string) => [text]); + +function createMockRuntime(): PluginRuntime { + return { + version: "1.0.0", + config: { + loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"], + writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"], + }, + system: { + enqueueSystemEvent: mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"], + runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"], + }, + media: { + loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"], + detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"], + mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"], + isVoiceCompatibleAudio: vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"], + getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"], + resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"], + }, + tools: { + createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"], + createMemorySearchTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"], + registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"], + }, + channel: { + text: { + chunkMarkdownText: mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"], + chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"], + resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"], + hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"], + }, + reply: { + dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], + createReplyDispatcherWithTyping: vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"], + resolveEffectiveMessagesConfig: vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"], + resolveHumanDelayConfig: vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"], + dispatchReplyFromConfig: vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"], + finalizeInboundContext: vi.fn() as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], + formatAgentEnvelope: mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"], + formatInboundEnvelope: vi.fn() as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"], + resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"], + }, + routing: { + resolveAgentRoute: mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], + }, + pairing: { + buildPairingReply: mockBuildPairingReply as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"], + readAllowFromStore: mockReadAllowFromStore as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"], + upsertPairingRequest: mockUpsertPairingRequest as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"], + }, + media: { + fetchRemoteMedia: vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"], + saveMediaBuffer: mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"], + }, + session: { + resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"], + readSessionUpdatedAt: mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"], + recordSessionMetaFromInbound: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"], + updateLastRoute: vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"], + }, + mentions: { + buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"], + matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"], + }, + groups: { + resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"], + resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"], + }, + debounce: { + createInboundDebouncer: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"], + resolveInboundDebounceMs: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"], + }, + commands: { + resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"], + isControlCommandMessage: vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"], + shouldComputeCommandAuthorized: vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"], + shouldHandleTextCommands: vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"], + }, + discord: {} as PluginRuntime["channel"]["discord"], + slack: {} as PluginRuntime["channel"]["slack"], + telegram: {} as PluginRuntime["channel"]["telegram"], + signal: {} as PluginRuntime["channel"]["signal"], + imessage: {} as PluginRuntime["channel"]["imessage"], + whatsapp: {} as PluginRuntime["channel"]["whatsapp"], + }, + logging: { + shouldLogVerbose: vi.fn(() => false) as unknown as PluginRuntime["logging"]["shouldLogVerbose"], + getChildLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })) as unknown as PluginRuntime["logging"]["getChildLogger"], + }, + state: { + resolveStateDir: vi.fn(() => "/tmp/clawdbot") as unknown as PluginRuntime["state"]["resolveStateDir"], + }, + }; +} + +function createMockAccount(overrides: Partial = {}): ResolvedBlueBubblesAccount { + return { + accountId: "default", + enabled: true, + configured: true, + config: { + serverUrl: "http://localhost:1234", + password: "test-password", + dmPolicy: "open", + groupPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + ...overrides, + }, + }; +} + +function createMockRequest( + method: string, + url: string, + body: unknown, + headers: Record = {}, +): IncomingMessage { + const req = new EventEmitter() as IncomingMessage; + req.method = method; + req.url = url; + req.headers = headers; + (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" }; + + // Emit body data after a microtask + 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; +} + +describe("BlueBubbles webhook monitor", () => { + let unregister: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + mockReadAllowFromStore.mockResolvedValue([]); + mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true }); + mockResolveRequireMention.mockReturnValue(false); + mockHasControlCommand.mockReturnValue(false); + mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false); + mockBuildMentionRegexes.mockReturnValue([/\bbert\b/i]); + + setBlueBubblesRuntime(createMockRuntime()); + }); + + afterEach(() => { + unregister?.(); + }); + + describe("webhook parsing + auth handling", () => { + it("rejects non-POST requests", async () => { + const account = createMockAccount(); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const req = createMockRequest("GET", "/bluebubbles-webhook", {}); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(405); + }); + + it("accepts POST requests with valid JSON payload", async () => { + const account = createMockAccount(); + const config: ClawdbotConfig = {}; + 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(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(res.body).toBe("ok"); + }); + + it("rejects requests with invalid JSON", async () => { + const account = createMockAccount(); + const config: ClawdbotConfig = {}; + 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", "invalid json {{"); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(400); + }); + + it("authenticates via password query parameter", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + // Mock non-localhost request + const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "192.168.1.100" }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + }); + + it("authenticates via x-password header", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const req = createMockRequest( + "POST", + "/bluebubbles-webhook", + { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }, + { "x-password": "secret-token" }, + ); + (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "192.168.1.100" }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + }); + + it("rejects unauthorized requests with wrong password", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "192.168.1.100" }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + }); + + it("allows localhost requests without authentication", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: ClawdbotConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const req = createMockRequest("POST", "/bluebubbles-webhook", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + // Localhost address + (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + }); + + it("ignores unregistered webhook paths", async () => { + const req = createMockRequest("POST", "/unregistered-path", {}); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(false); + }); + }); + + describe("DM pairing behavior vs allowFrom", () => { + it("allows DM from sender in allowFrom list", async () => { + const account = createMockAccount({ + dmPolicy: "allowlist", + allowFrom: ["+15551234567"], + }); + const config: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + 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: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(res.statusCode).toBe(200); + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => { + // Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty + // allowlist that doesn't include the sender + const account = createMockAccount({ + dmPolicy: "pairing", + allowFrom: ["+15559999999"], // Different number than sender + }); + const config: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockUpsertPairingRequest).toHaveBeenCalled(); + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("does not resend pairing reply when request already exists", async () => { + mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false }); + + // Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty + // allowlist that doesn't include the sender + const account = createMockAccount({ + dmPolicy: "pairing", + allowFrom: ["+15559999999"], // Different number than sender + }); + const config: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + 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: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + }); + + it("blocks all DMs when dmPolicy=disabled", async () => { + const account = createMockAccount({ + dmPolicy: "disabled", + }); + const config: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + 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: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + }); + + it("blocks group messages when groupPolicy=disabled", async () => { + const account = createMockAccount({ + groupPolicy: "disabled", + }); + const config: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + 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: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + 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: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + 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: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("processes group message without mention when requireMention=false", async () => { + mockResolveRequireMention.mockReturnValue(false); + + const account = createMockAccount({ groupPolicy: "open" }); + const config: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + }); + }); + + 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: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + // 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: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + }); + + 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: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + 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: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + 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: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + // Should call typing start + expect(sendBlueBubblesTyping).toHaveBeenCalledWith( + expect.any(String), + true, + expect.any(Object), + ); + }); + }); + + describe("reaction events", () => { + it("enqueues system event for reaction added", async () => { + mockEnqueueSystemEvent.mockClear(); + + const account = createMockAccount(); + const config: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( + expect.stringContaining("reaction added"), + expect.any(Object), + ); + }); + + it("enqueues system event for reaction removed", async () => { + mockEnqueueSystemEvent.mockClear(); + + const account = createMockAccount(); + const config: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( + expect.stringContaining("reaction removed"), + expect.any(Object), + ); + }); + + it("ignores reaction from self (fromMe=true)", async () => { + mockEnqueueSystemEvent.mockClear(); + + const account = createMockAccount(); + const config: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("maps reaction types to correct emojis", async () => { + mockEnqueueSystemEvent.mockClear(); + + const account = createMockAccount(); + const config: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( + expect.stringContaining("👍"), + expect.any(Object), + ); + }); + }); + + describe("fromMe messages", () => { + it("ignores messages from self (fromMe=true)", async () => { + const account = createMockAccount(); + const config: ClawdbotConfig = {}; + 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 new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index dd7a142f0..de7c96484 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -80,7 +80,9 @@ export function buildCronPayload(form: CronFormState) { | "discord" | "slack" | "signal" - | "imessage"; + | "imessage" + | "msteams" + | "bluebubbles"; to?: string; timeoutSeconds?: number; } = { kind: "agentTurn", message }; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 8055d77dd..3f74f26a5 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -332,7 +332,8 @@ export type CronPayload = | "slack" | "signal" | "imessage" - | "msteams"; + | "msteams" + | "bluebubbles"; to?: string; bestEffortDeliver?: boolean; }; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 3695fee6b..bc5babba1 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -28,7 +28,8 @@ export type CronFormState = { | "slack" | "signal" | "imessage" - | "msteams"; + | "msteams" + | "bluebubbles"; to: string; timeoutSeconds: string; postToMainPrefix: string; diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index 50ab1029e..10118ea8c 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -88,7 +88,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe if (snapshot?.channelOrder?.length) { return snapshot.channelOrder; } - return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]; + return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "bluebubbles"]; } function renderChannel( diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 106ec96b2..e34a436cf 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -199,6 +199,7 @@ export function renderCron(props: CronProps) { +