From e9d691d472d05d6284a164d3e2bbf6e8c9cf0fc1 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Mon, 19 Jan 2026 18:23:00 -0800 Subject: [PATCH] (Step 2) Phase 2 & 3 Complete + Reviewed --- extensions/bluebubbles/src/actions.test.ts | 359 +++++++++++ extensions/bluebubbles/src/actions.ts | 269 ++++++-- .../bluebubbles/src/attachments.test.ts | 240 +++++++ extensions/bluebubbles/src/attachments.ts | 155 +++++ extensions/bluebubbles/src/channel.ts | 38 +- extensions/bluebubbles/src/chat.test.ts | 318 ++++++++++ extensions/bluebubbles/src/chat.ts | 216 +++++++ extensions/bluebubbles/src/config-schema.ts | 9 + extensions/bluebubbles/src/reactions.test.ts | 393 ++++++++++++ extensions/bluebubbles/src/send.test.ts | 587 ++++++++++++++++++ extensions/bluebubbles/src/send.ts | 47 +- extensions/bluebubbles/src/types.ts | 9 + 12 files changed, 2591 insertions(+), 49 deletions(-) create mode 100644 extensions/bluebubbles/src/actions.test.ts create mode 100644 extensions/bluebubbles/src/attachments.test.ts create mode 100644 extensions/bluebubbles/src/chat.test.ts create mode 100644 extensions/bluebubbles/src/reactions.test.ts create mode 100644 extensions/bluebubbles/src/send.test.ts diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts new file mode 100644 index 000000000..a07cbdf8c --- /dev/null +++ b/extensions/bluebubbles/src/actions.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { bluebubblesMessageActions } from "./actions.js"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; + +vi.mock("./accounts.js", () => ({ + resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { + const config = cfg?.channels?.bluebubbles ?? {}; + return { + accountId: accountId ?? "default", + enabled: config.enabled !== false, + configured: Boolean(config.serverUrl && config.password), + config, + }; + }), +})); + +vi.mock("./reactions.js", () => ({ + sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./send.js", () => ({ + resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"), + sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }), +})); + +vi.mock("./chat.js", () => ({ + editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined), + unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined), + renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined), + addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined), + removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined), + leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./attachments.js", () => ({ + sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }), +})); + +describe("bluebubblesMessageActions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("listActions", () => { + it("returns empty array when account is not enabled", () => { + const cfg: ClawdbotConfig = { + channels: { bluebubbles: { enabled: false } }, + }; + const actions = bluebubblesMessageActions.listActions({ cfg }); + expect(actions).toEqual([]); + }); + + it("returns empty array when account is not configured", () => { + const cfg: ClawdbotConfig = { + channels: { bluebubbles: { enabled: true } }, + }; + const actions = bluebubblesMessageActions.listActions({ cfg }); + expect(actions).toEqual([]); + }); + + it("returns react action when enabled and configured", () => { + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + const actions = bluebubblesMessageActions.listActions({ cfg }); + expect(actions).toContain("react"); + }); + + it("excludes react action when reactions are gated off", () => { + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://localhost:1234", + password: "test-password", + actions: { reactions: false }, + }, + }, + }; + const actions = bluebubblesMessageActions.listActions({ cfg }); + expect(actions).not.toContain("react"); + // Other actions should still be present + expect(actions).toContain("edit"); + expect(actions).toContain("unsend"); + }); + }); + + describe("supportsAction", () => { + it("returns true for react action", () => { + expect(bluebubblesMessageActions.supportsAction({ action: "react" })).toBe(true); + }); + + it("returns true for all supported actions", () => { + expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "unsend" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "sendAttachment" })).toBe(true); + }); + + it("returns false for unsupported actions", () => { + expect(bluebubblesMessageActions.supportsAction({ action: "delete" })).toBe(false); + expect(bluebubblesMessageActions.supportsAction({ action: "unknown" })).toBe(false); + }); + }); + + describe("extractToolSend", () => { + it("extracts send params from sendMessage action", () => { + const result = bluebubblesMessageActions.extractToolSend({ + args: { + action: "sendMessage", + to: "+15551234567", + accountId: "test-account", + }, + }); + expect(result).toEqual({ + to: "+15551234567", + accountId: "test-account", + }); + }); + + it("returns null for non-sendMessage action", () => { + const result = bluebubblesMessageActions.extractToolSend({ + args: { action: "react", to: "+15551234567" }, + }); + expect(result).toBeNull(); + }); + + it("returns null when to is missing", () => { + const result = bluebubblesMessageActions.extractToolSend({ + args: { action: "sendMessage" }, + }); + expect(result).toBeNull(); + }); + }); + + describe("handleAction", () => { + it("throws for unsupported actions", async () => { + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await expect( + bluebubblesMessageActions.handleAction({ + action: "unknownAction", + params: {}, + cfg, + accountId: null, + }), + ).rejects.toThrow("is not supported"); + }); + + it("throws when emoji is missing for react action", async () => { + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await expect( + bluebubblesMessageActions.handleAction({ + action: "react", + params: { messageId: "msg-123" }, + cfg, + accountId: null, + }), + ).rejects.toThrow(/emoji/i); + }); + + it("throws when messageId is missing", async () => { + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await expect( + bluebubblesMessageActions.handleAction({ + action: "react", + params: { emoji: "❤️" }, + cfg, + accountId: null, + }), + ).rejects.toThrow("messageId"); + }); + + it("throws when chatGuid cannot be resolved", async () => { + const { resolveChatGuidForTarget } = await import("./send.js"); + vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await expect( + bluebubblesMessageActions.handleAction({ + action: "react", + params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" }, + cfg, + accountId: null, + }), + ).rejects.toThrow("chatGuid not found"); + }); + + it("sends reaction successfully with chatGuid", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + const result = await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", + }, + cfg, + accountId: null, + }); + + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;-;+15551234567", + messageGuid: "msg-123", + emoji: "❤️", + }), + ); + // jsonResult returns { content: [...], details: payload } + expect(result).toMatchObject({ + details: { ok: true, added: "❤️" }, + }); + }); + + it("sends reaction removal successfully", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + const result = await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", + remove: true, + }, + cfg, + accountId: null, + }); + + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + remove: true, + }), + ); + // jsonResult returns { content: [...], details: payload } + expect(result).toMatchObject({ + details: { ok: true, removed: true }, + }); + }); + + it("resolves chatGuid from to parameter", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + const { resolveChatGuidForTarget } = await import("./send.js"); + vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543"); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "👍", + messageId: "msg-456", + to: "+15559876543", + }, + cfg, + accountId: null, + }); + + expect(resolveChatGuidForTarget).toHaveBeenCalled(); + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;-;+15559876543", + }), + ); + }); + + it("passes partIndex when provided", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "😂", + messageId: "msg-789", + chatGuid: "iMessage;-;chat-guid", + partIndex: 2, + }, + cfg, + accountId: null, + }); + + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + partIndex: 2, + }), + ); + }); + }); +}); diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 952895e7e..f67022c4f 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -1,6 +1,7 @@ import { createActionGate, jsonResult, + readBooleanParam, readNumberParam, readReactionParams, readStringParam, @@ -12,7 +13,16 @@ import { import { resolveBlueBubblesAccount } from "./accounts.js"; import { sendBlueBubblesReaction } from "./reactions.js"; -import { resolveChatGuidForTarget } from "./send.js"; +import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; +import { + editBlueBubblesMessage, + unsendBlueBubblesMessage, + renameBlueBubblesChat, + addBlueBubblesParticipant, + removeBlueBubblesParticipant, + leaveBlueBubblesChat, +} from "./chat.js"; +import { sendBlueBubblesAttachment } from "./attachments.js"; import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; import type { BlueBubblesSendTarget } from "./types.js"; @@ -32,6 +42,20 @@ function mapTarget(raw: string): BlueBubblesSendTarget { }; } +/** Supported action names for BlueBubbles */ +const SUPPORTED_ACTIONS = new Set([ + "react", + "edit", + "unsend", + "reply", + "sendWithEffect", + "renameGroup", + "addParticipant", + "removeParticipant", + "leaveGroup", + "sendAttachment", +]); + export const bluebubblesMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig }); @@ -39,9 +63,18 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions); const actions = new Set(); if (gate("reactions")) actions.add("react"); + if (gate("edit")) actions.add("edit"); + if (gate("unsend")) actions.add("unsend"); + if (gate("reply")) actions.add("reply"); + if (gate("sendWithEffect")) actions.add("sendWithEffect"); + if (gate("renameGroup")) actions.add("renameGroup"); + if (gate("addParticipant")) actions.add("addParticipant"); + if (gate("removeParticipant")) actions.add("removeParticipant"); + if (gate("leaveGroup")) actions.add("leaveGroup"); + if (gate("sendAttachment")) actions.add("sendAttachment"); return Array.from(actions); }, - supportsAction: ({ action }) => action === "react", + supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), extractToolSend: ({ args }): ChannelToolSend | null => { const action = typeof args.action === "string" ? args.action.trim() : ""; if (action !== "sendMessage") return null; @@ -51,31 +84,23 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { return { to, accountId }; }, handleAction: async ({ action, params, cfg, accountId }) => { - if (action !== "react") { - throw new Error(`Action ${action} is not supported for provider ${providerId}.`); - } - const { emoji, remove, isEmpty } = readReactionParams(params, { - removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.", - }); - if (isEmpty && !remove) { - throw new Error("Emoji is required to send a BlueBubbles reaction."); - } - const messageId = readStringParam(params, "messageId", { required: true }); - const chatGuid = readStringParam(params, "chatGuid"); - const chatIdentifier = readStringParam(params, "chatIdentifier"); - const chatId = readNumberParam(params, "chatId", { integer: true }); - const to = readStringParam(params, "to"); - const partIndex = readNumberParam(params, "partIndex", { integer: true }); - const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined, }); const baseUrl = account.config.serverUrl?.trim(); const password = account.config.password?.trim(); + const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined }; + + // Helper to resolve chatGuid from various params + const resolveChatGuid = async (): Promise => { + const chatGuid = readStringParam(params, "chatGuid"); + if (chatGuid?.trim()) return chatGuid.trim(); + + const chatIdentifier = readStringParam(params, "chatIdentifier"); + const chatId = readNumberParam(params, "chatId", { integer: true }); + const to = readStringParam(params, "to"); - let resolvedChatGuid = chatGuid?.trim() || ""; - if (!resolvedChatGuid) { const target = chatIdentifier?.trim() ? ({ kind: "chat_identifier", chatIdentifier: chatIdentifier.trim() } as BlueBubblesSendTarget) @@ -84,38 +109,192 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { : to ? mapTarget(to) : null; + if (!target) { - throw new Error("BlueBubbles reaction requires chatGuid, chatIdentifier, chatId, or to."); + throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`); } if (!baseUrl || !password) { - throw new Error("BlueBubbles reaction requires serverUrl and password."); + throw new Error(`BlueBubbles ${action} requires serverUrl and password.`); } - resolvedChatGuid = - (await resolveChatGuidForTarget({ - baseUrl, - password, - target, - })) ?? ""; - } - if (!resolvedChatGuid) { - throw new Error("BlueBubbles reaction failed: chatGuid not found for target."); + + const resolved = await resolveChatGuidForTarget({ baseUrl, password, target }); + if (!resolved) { + throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`); + } + return resolved; + }; + + // Handle react action + if (action === "react") { + const { emoji, remove, isEmpty } = readReactionParams(params, { + removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.", + }); + if (isEmpty && !remove) { + throw new Error("Emoji is required to send a BlueBubbles reaction."); + } + const messageId = readStringParam(params, "messageId", { required: true }); + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + const resolvedChatGuid = await resolveChatGuid(); + + await sendBlueBubblesReaction({ + chatGuid: resolvedChatGuid, + messageGuid: messageId, + emoji, + remove: remove || undefined, + partIndex: typeof partIndex === "number" ? partIndex : undefined, + opts, + }); + + return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: emoji }) }); } - await sendBlueBubblesReaction({ - chatGuid: resolvedChatGuid, - messageGuid: messageId, - emoji, - remove: remove || undefined, - partIndex: typeof partIndex === "number" ? partIndex : undefined, - opts: { - cfg: cfg as ClawdbotConfig, - accountId: accountId ?? undefined, - }, - }); + // Handle edit action + if (action === "edit") { + const messageId = readStringParam(params, "messageId", { required: true }); + const newText = readStringParam(params, "text") ?? readStringParam(params, "newText"); + if (!newText) { + throw new Error("BlueBubbles edit requires text or newText parameter."); + } + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); - if (!remove) { - return jsonResult({ ok: true, added: emoji }); + await editBlueBubblesMessage(messageId, newText, { + ...opts, + partIndex: typeof partIndex === "number" ? partIndex : undefined, + backwardsCompatMessage: backwardsCompatMessage ?? undefined, + }); + + return jsonResult({ ok: true, edited: messageId }); } - return jsonResult({ ok: true, removed: true }); + + // Handle unsend action + if (action === "unsend") { + const messageId = readStringParam(params, "messageId", { required: true }); + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + + await unsendBlueBubblesMessage(messageId, { + ...opts, + partIndex: typeof partIndex === "number" ? partIndex : undefined, + }); + + return jsonResult({ ok: true, unsent: messageId }); + } + + // Handle reply action + if (action === "reply") { + const messageId = readStringParam(params, "messageId", { required: true }); + const text = readStringParam(params, "text", { required: true }); + const to = readStringParam(params, "to", { required: true }); + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + + const result = await sendMessageBlueBubbles(to, text, { + ...opts, + replyToMessageGuid: messageId, + replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined, + }); + + return jsonResult({ ok: true, messageId: result.messageId, repliedTo: messageId }); + } + + // Handle sendWithEffect action + if (action === "sendWithEffect") { + const text = readStringParam(params, "text", { required: true }); + const to = readStringParam(params, "to", { required: true }); + const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect"); + if (!effectId) { + throw new Error("BlueBubbles sendWithEffect requires effectId or effect parameter."); + } + + const result = await sendMessageBlueBubbles(to, text, { + ...opts, + effectId, + }); + + return jsonResult({ ok: true, messageId: result.messageId, effect: effectId }); + } + + // Handle renameGroup action + if (action === "renameGroup") { + const resolvedChatGuid = await resolveChatGuid(); + const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name"); + if (!displayName) { + throw new Error("BlueBubbles renameGroup requires displayName or name parameter."); + } + + await renameBlueBubblesChat(resolvedChatGuid, displayName, opts); + + return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName }); + } + + // Handle addParticipant action + if (action === "addParticipant") { + const resolvedChatGuid = await resolveChatGuid(); + const address = readStringParam(params, "address") ?? readStringParam(params, "participant"); + if (!address) { + throw new Error("BlueBubbles addParticipant requires address or participant parameter."); + } + + await addBlueBubblesParticipant(resolvedChatGuid, address, opts); + + return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid }); + } + + // Handle removeParticipant action + if (action === "removeParticipant") { + const resolvedChatGuid = await resolveChatGuid(); + const address = readStringParam(params, "address") ?? readStringParam(params, "participant"); + if (!address) { + throw new Error("BlueBubbles removeParticipant requires address or participant parameter."); + } + + await removeBlueBubblesParticipant(resolvedChatGuid, address, opts); + + return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid }); + } + + // Handle leaveGroup action + if (action === "leaveGroup") { + const resolvedChatGuid = await resolveChatGuid(); + + await leaveBlueBubblesChat(resolvedChatGuid, opts); + + return jsonResult({ ok: true, left: resolvedChatGuid }); + } + + // Handle sendAttachment action + if (action === "sendAttachment") { + const to = readStringParam(params, "to", { required: true }); + const filename = readStringParam(params, "filename", { required: true }); + const caption = readStringParam(params, "caption"); + const contentType = readStringParam(params, "contentType") ?? readStringParam(params, "mimeType"); + + // Buffer can come from params.buffer (base64) or params.path (file path) + const base64Buffer = readStringParam(params, "buffer"); + const filePath = readStringParam(params, "path") ?? readStringParam(params, "filePath"); + + let buffer: Uint8Array; + if (base64Buffer) { + // Decode base64 to buffer + buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0)); + } else if (filePath) { + // Read file from path (will be handled by caller providing buffer) + throw new Error("BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64."); + } else { + throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter."); + } + + const result = await sendBlueBubblesAttachment({ + to, + buffer, + filename, + contentType: contentType ?? undefined, + caption: caption ?? undefined, + opts, + }); + + return jsonResult({ ok: true, messageId: result.messageId }); + } + + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); }, }; diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts new file mode 100644 index 000000000..c1b45af52 --- /dev/null +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { downloadBlueBubblesAttachment } from "./attachments.js"; +import type { BlueBubblesAttachment } from "./types.js"; + +vi.mock("./accounts.js", () => ({ + resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { + const config = cfg?.channels?.bluebubbles ?? {}; + return { + accountId: accountId ?? "default", + enabled: config.enabled !== false, + configured: Boolean(config.serverUrl && config.password), + config, + }; + }), +})); + +const mockFetch = vi.fn(); + +describe("downloadBlueBubblesAttachment", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("throws when guid is missing", async () => { + const attachment: BlueBubblesAttachment = {}; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test-password", + }), + ).rejects.toThrow("guid is required"); + }); + + it("throws when guid is empty string", async () => { + const attachment: BlueBubblesAttachment = { guid: " " }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test-password", + }), + ).rejects.toThrow("guid is required"); + }); + + it("throws when serverUrl is missing", async () => { + const attachment: BlueBubblesAttachment = { guid: "att-123" }; + await expect(downloadBlueBubblesAttachment(attachment, {})).rejects.toThrow( + "serverUrl is required", + ); + }); + + it("throws when password is missing", async () => { + const attachment: BlueBubblesAttachment = { guid: "att-123" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + }), + ).rejects.toThrow("password is required"); + }); + + it("downloads attachment successfully", async () => { + const mockBuffer = new Uint8Array([1, 2, 3, 4]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "image/png" }), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-123" }; + const result = await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(result.buffer).toEqual(mockBuffer); + expect(result.contentType).toBe("image/png"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/attachment/att-123/download"), + expect.objectContaining({ method: "GET" }), + ); + }); + + it("includes password in URL query", async () => { + const mockBuffer = new Uint8Array([1, 2, 3, 4]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "image/jpeg" }), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-456" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "my-secret-password", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("password=my-secret-password"); + }); + + it("encodes guid in URL", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("att%2Fwith%2Fspecial%20chars"); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: () => Promise.resolve("Attachment not found"), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-missing" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("download failed (404): Attachment not found"); + }); + + it("throws when attachment exceeds max bytes", async () => { + const largeBuffer = new Uint8Array(10 * 1024 * 1024); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-large" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + maxBytes: 5 * 1024 * 1024, + }), + ).rejects.toThrow("too large"); + }); + + it("uses default max bytes when not specified", async () => { + const largeBuffer = new Uint8Array(9 * 1024 * 1024); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-large" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("too large"); + }); + + it("uses attachment mimeType as fallback when response has no content-type", async () => { + const mockBuffer = new Uint8Array([1, 2, 3]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { + guid: "att-789", + mimeType: "video/mp4", + }; + const result = await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.contentType).toBe("video/mp4"); + }); + + it("prefers response content-type over attachment mimeType", async () => { + const mockBuffer = new Uint8Array([1, 2, 3]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "image/webp" }), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { + guid: "att-xyz", + mimeType: "image/png", + }; + const result = await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.contentType).toBe("image/webp"); + }); + + it("resolves credentials from config when opts not provided", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-config" }; + const result = await downloadBlueBubblesAttachment(attachment, { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://config-server:5678", + password: "config-password", + }, + }, + }, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("config-server:5678"); + expect(calledUrl).toContain("password=config-password"); + expect(result.buffer).toEqual(new Uint8Array([1])); + }); +}); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index fe2f904e2..106a53111 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -1,9 +1,13 @@ +import crypto from "node:crypto"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import { resolveChatGuidForTarget } from "./send.js"; +import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl, type BlueBubblesAttachment, + type BlueBubblesSendTarget, } from "./types.js"; export type BlueBubblesAttachmentOpts = { @@ -55,3 +59,154 @@ export async function downloadBlueBubblesAttachment( } return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined }; } + +export type SendBlueBubblesAttachmentResult = { + messageId: string; +}; + +function resolveSendTarget(raw: string): BlueBubblesSendTarget { + const parsed = parseBlueBubblesTarget(raw); + if (parsed.kind === "handle") { + return { + kind: "handle", + address: normalizeBlueBubblesHandle(parsed.to), + service: parsed.service, + }; + } + if (parsed.kind === "chat_id") { + return { kind: "chat_id", chatId: parsed.chatId }; + } + if (parsed.kind === "chat_guid") { + return { kind: "chat_guid", chatGuid: parsed.chatGuid }; + } + return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier }; +} + +function extractMessageId(payload: unknown): string { + if (!payload || typeof payload !== "object") return "unknown"; + const record = payload as Record; + const data = record.data && typeof record.data === "object" ? (record.data as Record) : null; + const candidates = [ + record.messageId, + record.guid, + record.id, + data?.messageId, + data?.guid, + data?.id, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate); + } + return "unknown"; +} + +/** + * Send an attachment via BlueBubbles API. + * Supports sending media files (images, videos, audio, documents) to a chat. + */ +export async function sendBlueBubblesAttachment(params: { + to: string; + buffer: Uint8Array; + filename: string; + contentType?: string; + caption?: string; + opts?: BlueBubblesAttachmentOpts; +}): Promise { + const { to, buffer, filename, contentType, caption, opts = {} } = params; + const { baseUrl, password } = resolveAccount(opts); + + const target = resolveSendTarget(to); + const chatGuid = await resolveChatGuidForTarget({ + baseUrl, + password, + timeoutMs: opts.timeoutMs, + target, + }); + if (!chatGuid) { + throw new Error( + "BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", + ); + } + + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: "/api/v1/message/attachment", + password, + }); + + // Build FormData with the attachment + const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`; + const parts: Uint8Array[] = []; + const encoder = new TextEncoder(); + + // Helper to add a form field + const addField = (name: string, value: string) => { + parts.push(encoder.encode(`--${boundary}\r\n`)); + parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`)); + parts.push(encoder.encode(`${value}\r\n`)); + }; + + // Helper to add a file field + const addFile = (name: string, fileBuffer: Uint8Array, fileName: string, mimeType?: string) => { + parts.push(encoder.encode(`--${boundary}\r\n`)); + parts.push( + encoder.encode( + `Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`, + ), + ); + parts.push(encoder.encode(`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`)); + parts.push(fileBuffer); + parts.push(encoder.encode("\r\n")); + }; + + // Add required fields + addFile("attachment", buffer, filename, contentType); + addField("chatGuid", chatGuid); + addField("name", filename); + addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`); + addField("method", "private-api"); + + // Add optional caption + if (caption) { + addField("message", caption); + } + + // Close the multipart body + parts.push(encoder.encode(`--${boundary}--\r\n`)); + + // Combine all parts into a single buffer + const totalLength = parts.reduce((acc, part) => acc + part.length, 0); + const body = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + body.set(part, offset); + offset += part.length; + } + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { + "Content-Type": `multipart/form-data; boundary=${boundary}`, + }, + body, + }, + opts.timeoutMs ?? 60_000, // longer timeout for file uploads + ); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`); + } + + const responseBody = await res.text(); + if (!responseBody) return { messageId: "ok" }; + try { + const parsed = JSON.parse(responseBody) as unknown; + return { messageId: extractMessageId(parsed) }; + } catch { + return { messageId: "ok" }; + } +} diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index f9735d208..b53a1b56a 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -20,6 +20,7 @@ import { import { BlueBubblesConfigSchema } from "./config-schema.js"; import { probeBlueBubbles } from "./probe.js"; import { sendMessageBlueBubbles } from "./send.js"; +import { sendBlueBubblesAttachment } from "./attachments.js"; import { normalizeBlueBubblesHandle } from "./targets.js"; import { bluebubblesMessageActions } from "./actions.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; @@ -40,8 +41,13 @@ export const bluebubblesPlugin: ChannelPlugin = { meta, capabilities: { chatTypes: ["direct", "group"], - media: false, + media: true, reactions: true, + edit: true, + unsend: true, + reply: true, + effects: true, + groupManagement: true, }, reload: { configPrefixes: ["channels.bluebubbles"] }, configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), @@ -210,8 +216,34 @@ export const bluebubblesPlugin: ChannelPlugin = { }); return { channel: "bluebubbles", ...result }; }, - sendMedia: async () => { - throw new Error("BlueBubbles media delivery is not supported yet."); + sendMedia: async ({ cfg, to, mediaPath, mediaBuffer, contentType, filename, caption, accountId }) => { + // Prefer buffer if provided, otherwise read from path + let buffer: Uint8Array; + if (mediaBuffer) { + buffer = mediaBuffer; + } else if (mediaPath) { + const fs = await import("node:fs/promises"); + buffer = new Uint8Array(await fs.readFile(mediaPath)); + } else { + throw new Error("BlueBubbles media delivery requires mediaPath or mediaBuffer."); + } + + // 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, + opts: { + cfg: cfg as ClawdbotConfig, + accountId: accountId ?? undefined, + }, + }); + + return { channel: "bluebubbles", ...result }; }, }, status: { diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts new file mode 100644 index 000000000..0f6260a95 --- /dev/null +++ b/extensions/bluebubbles/src/chat.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; + +vi.mock("./accounts.js", () => ({ + resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { + const config = cfg?.channels?.bluebubbles ?? {}; + return { + accountId: accountId ?? "default", + enabled: config.enabled !== false, + configured: Boolean(config.serverUrl && config.password), + config, + }; + }), +})); + +const mockFetch = vi.fn(); + +describe("chat", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("markBlueBubblesChatRead", () => { + it("does nothing when chatGuid is empty", async () => { + await markBlueBubblesChatRead("", { + serverUrl: "http://localhost:1234", + password: "test", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("does nothing when chatGuid is whitespace", async () => { + await markBlueBubblesChatRead(" ", { + serverUrl: "http://localhost:1234", + password: "test", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("throws when serverUrl is missing", async () => { + await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow( + "serverUrl is required", + ); + }); + + it("throws when password is missing", async () => { + await expect( + markBlueBubblesChatRead("chat-guid", { + serverUrl: "http://localhost:1234", + }), + ).rejects.toThrow("password is required"); + }); + + it("marks chat as read successfully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await markBlueBubblesChatRead("iMessage;-;+15551234567", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/read"), + expect.objectContaining({ method: "POST" }), + ); + }); + + it("includes password in URL query", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await markBlueBubblesChatRead("chat-123", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("password=my-secret"); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: () => Promise.resolve("Chat not found"), + }); + + await expect( + markBlueBubblesChatRead("missing-chat", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("read failed (404): Chat not found"); + }); + + it("trims chatGuid before using", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await markBlueBubblesChatRead(" chat-with-spaces ", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/read"); + expect(calledUrl).not.toContain("%20chat"); + }); + + it("resolves credentials from config", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await markBlueBubblesChatRead("chat-123", { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://config-server:9999", + password: "config-pass", + }, + }, + }, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("config-server:9999"); + expect(calledUrl).toContain("password=config-pass"); + }); + }); + + describe("sendBlueBubblesTyping", () => { + it("does nothing when chatGuid is empty", async () => { + await sendBlueBubblesTyping("", true, { + serverUrl: "http://localhost:1234", + password: "test", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("does nothing when chatGuid is whitespace", async () => { + await sendBlueBubblesTyping(" ", false, { + serverUrl: "http://localhost:1234", + password: "test", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("throws when serverUrl is missing", async () => { + await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow( + "serverUrl is required", + ); + }); + + it("throws when password is missing", async () => { + await expect( + sendBlueBubblesTyping("chat-guid", true, { + serverUrl: "http://localhost:1234", + }), + ).rejects.toThrow("password is required"); + }); + + it("sends typing start with POST method", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("iMessage;-;+15551234567", true, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"), + expect.objectContaining({ method: "POST" }), + ); + }); + + it("sends typing stop with DELETE method", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("iMessage;-;+15551234567", false, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"), + expect.objectContaining({ method: "DELETE" }), + ); + }); + + it("includes password in URL query", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("chat-123", true, { + serverUrl: "http://localhost:1234", + password: "typing-secret", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("password=typing-secret"); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal error"), + }); + + await expect( + sendBlueBubblesTyping("chat-123", true, { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("typing failed (500): Internal error"); + }); + + it("trims chatGuid before using", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping(" trimmed-chat ", true, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/chat/trimmed-chat/typing"); + }); + + it("encodes special characters in chatGuid", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("iMessage;+;group@chat.com", true, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("iMessage%3B%2B%3Bgroup%40chat.com"); + }); + + it("resolves credentials from config", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("chat-123", true, { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://typing-server:8888", + password: "typing-pass", + }, + }, + }, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("typing-server:8888"); + expect(calledUrl).toContain("password=typing-pass"); + }); + + it("can start and stop typing in sequence", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("chat-123", true, { + serverUrl: "http://localhost:1234", + password: "test", + }); + await sendBlueBubblesTyping("chat-123", false, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][1].method).toBe("POST"); + expect(mockFetch.mock.calls[1][1].method).toBe("DELETE"); + }); + }); +}); diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 8896d1bba..27eba154e 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -64,3 +64,219 @@ export async function sendBlueBubblesTyping( throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`); } } + +/** + * Edit a message via BlueBubbles API. + * Requires macOS 13 (Ventura) or higher with Private API enabled. + */ +export async function editBlueBubblesMessage( + messageGuid: string, + newText: string, + opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {}, +): Promise { + const trimmedGuid = messageGuid.trim(); + if (!trimmedGuid) throw new Error("BlueBubbles edit requires messageGuid"); + const trimmedText = newText.trim(); + if (!trimmedText) throw new Error("BlueBubbles edit requires newText"); + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`, + password, + }); + + const payload = { + editedMessage: trimmedText, + backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`, + partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, + }; + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + opts.timeoutMs, + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`); + } +} + +/** + * Unsend (retract) a message via BlueBubbles API. + * Requires macOS 13 (Ventura) or higher with Private API enabled. + */ +export async function unsendBlueBubblesMessage( + messageGuid: string, + opts: BlueBubblesChatOpts & { partIndex?: number } = {}, +): Promise { + const trimmedGuid = messageGuid.trim(); + if (!trimmedGuid) throw new Error("BlueBubbles unsend requires messageGuid"); + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`, + password, + }); + + const payload = { + partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, + }; + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + opts.timeoutMs, + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`); + } +} + +/** + * Rename a group chat via BlueBubbles API. + */ +export async function renameBlueBubblesChat( + chatGuid: string, + displayName: string, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmedGuid = chatGuid.trim(); + if (!trimmedGuid) throw new Error("BlueBubbles rename requires chatGuid"); + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`, + password, + }); + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName }), + }, + opts.timeoutMs, + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`); + } +} + +/** + * Add a participant to a group chat via BlueBubbles API. + */ +export async function addBlueBubblesParticipant( + chatGuid: string, + address: string, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmedGuid = chatGuid.trim(); + if (!trimmedGuid) throw new Error("BlueBubbles addParticipant requires chatGuid"); + const trimmedAddress = address.trim(); + if (!trimmedAddress) throw new Error("BlueBubbles addParticipant requires address"); + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, + password, + }); + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address: trimmedAddress }), + }, + opts.timeoutMs, + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`); + } +} + +/** + * Remove a participant from a group chat via BlueBubbles API. + */ +export async function removeBlueBubblesParticipant( + chatGuid: string, + address: string, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmedGuid = chatGuid.trim(); + if (!trimmedGuid) throw new Error("BlueBubbles removeParticipant requires chatGuid"); + const trimmedAddress = address.trim(); + if (!trimmedAddress) throw new Error("BlueBubbles removeParticipant requires address"); + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, + password, + }); + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address: trimmedAddress }), + }, + opts.timeoutMs, + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`); + } +} + +/** + * Leave a group chat via BlueBubbles API. + */ +export async function leaveBlueBubblesChat( + chatGuid: string, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmedGuid = chatGuid.trim(); + if (!trimmedGuid) throw new Error("BlueBubbles leaveChat requires chatGuid"); + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`, + password, + }); + + const res = await blueBubblesFetchWithTimeout( + url, + { method: "POST" }, + opts.timeoutMs, + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`); + } +} diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 34a8def34..20d139f20 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -5,6 +5,15 @@ const allowFromEntry = z.union([z.string(), z.number()]); const bluebubblesActionSchema = z .object({ reactions: z.boolean().optional(), + edit: z.boolean().optional(), + unsend: z.boolean().optional(), + reply: z.boolean().optional(), + sendWithEffect: z.boolean().optional(), + renameGroup: z.boolean().optional(), + addParticipant: z.boolean().optional(), + removeParticipant: z.boolean().optional(), + leaveGroup: z.boolean().optional(), + sendAttachment: z.boolean().optional(), }) .optional(); diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts new file mode 100644 index 000000000..c9d3a18d0 --- /dev/null +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -0,0 +1,393 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { sendBlueBubblesReaction } from "./reactions.js"; + +vi.mock("./accounts.js", () => ({ + resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { + const config = cfg?.channels?.bluebubbles ?? {}; + return { + accountId: accountId ?? "default", + enabled: config.enabled !== false, + configured: Boolean(config.serverUrl && config.password), + config, + }; + }), +})); + +const mockFetch = vi.fn(); + +describe("reactions", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("sendBlueBubblesReaction", () => { + it("throws when chatGuid is empty", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "", + messageGuid: "msg-123", + emoji: "love", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }), + ).rejects.toThrow("chatGuid"); + }); + + it("throws when messageGuid is empty", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "", + emoji: "love", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }), + ).rejects.toThrow("messageGuid"); + }); + + it("throws when emoji is empty", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }), + ).rejects.toThrow("emoji or name"); + }); + + it("throws when serverUrl is missing", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "love", + opts: {}, + }), + ).rejects.toThrow("serverUrl is required"); + }); + + it("throws when password is missing", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "love", + opts: { + serverUrl: "http://localhost:1234", + }, + }), + ).rejects.toThrow("password is required"); + }); + + it("throws for unsupported reaction type", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "unsupported", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }), + ).rejects.toThrow("Unsupported BlueBubbles reaction"); + }); + + describe("reaction type normalization", () => { + const testCases = [ + { input: "love", expected: "love" }, + { input: "like", expected: "like" }, + { input: "dislike", expected: "dislike" }, + { input: "laugh", expected: "laugh" }, + { input: "emphasize", expected: "emphasize" }, + { input: "question", expected: "question" }, + { input: "heart", expected: "love" }, + { input: "thumbs_up", expected: "like" }, + { input: "thumbs-down", expected: "dislike" }, + { input: "thumbs_down", expected: "dislike" }, + { input: "haha", expected: "laugh" }, + { input: "lol", expected: "laugh" }, + { input: "emphasis", expected: "emphasize" }, + { input: "exclaim", expected: "emphasize" }, + { input: "❤️", expected: "love" }, + { input: "❤", expected: "love" }, + { input: "♥️", expected: "love" }, + { input: "😍", expected: "love" }, + { input: "👍", expected: "like" }, + { input: "👎", expected: "dislike" }, + { input: "😂", expected: "laugh" }, + { input: "🤣", expected: "laugh" }, + { input: "😆", expected: "laugh" }, + { input: "‼️", expected: "emphasize" }, + { input: "‼", expected: "emphasize" }, + { input: "❗", expected: "emphasize" }, + { input: "❓", expected: "question" }, + { input: "❔", expected: "question" }, + { input: "LOVE", expected: "love" }, + { input: "Like", expected: "like" }, + ]; + + for (const { input, expected } of testCases) { + it(`normalizes "${input}" to "${expected}"`, async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: input, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe(expected); + }); + } + }); + + it("sends reaction successfully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "iMessage;-;+15551234567", + messageGuid: "msg-uuid-123", + emoji: "love", + opts: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/message/react"), + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.chatGuid).toBe("iMessage;-;+15551234567"); + expect(body.selectedMessageGuid).toBe("msg-uuid-123"); + expect(body.reaction).toBe("love"); + expect(body.partIndex).toBe(0); + }); + + it("includes password in URL query", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "like", + opts: { + serverUrl: "http://localhost:1234", + password: "my-react-password", + }, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("password=my-react-password"); + }); + + it("sends reaction removal with dash prefix", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "love", + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-love"); + }); + + it("strips leading dash from emoji when remove flag is set", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "-love", + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-love"); + }); + + it("uses custom partIndex when provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "laugh", + partIndex: 3, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(3); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve("Invalid reaction type"), + }); + + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "like", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }), + ).rejects.toThrow("reaction failed (400): Invalid reaction type"); + }); + + it("resolves credentials from config", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "emphasize", + opts: { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://react-server:7777", + password: "react-pass", + }, + }, + }, + }, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("react-server:7777"); + expect(calledUrl).toContain("password=react-pass"); + }); + + it("trims chatGuid and messageGuid", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: " chat-with-spaces ", + messageGuid: " msg-with-spaces ", + emoji: "question", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.chatGuid).toBe("chat-with-spaces"); + expect(body.selectedMessageGuid).toBe("msg-with-spaces"); + }); + + describe("reaction removal aliases", () => { + it("handles emoji-based removal", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "👍", + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-like"); + }); + + it("handles text alias removal", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "haha", + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-laugh"); + }); + }); + }); +}); diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts new file mode 100644 index 000000000..4a97c4646 --- /dev/null +++ b/extensions/bluebubbles/src/send.test.ts @@ -0,0 +1,587 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js"; +import type { BlueBubblesSendTarget } from "./types.js"; + +vi.mock("./accounts.js", () => ({ + resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { + const config = cfg?.channels?.bluebubbles ?? {}; + return { + accountId: accountId ?? "default", + enabled: config.enabled !== false, + configured: Boolean(config.serverUrl && config.password), + config, + }; + }), +})); + +const mockFetch = vi.fn(); + +describe("send", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("resolveChatGuidForTarget", () => { + it("returns chatGuid directly for chat_guid target", async () => { + const target: BlueBubblesSendTarget = { + kind: "chat_guid", + chatGuid: "iMessage;-;+15551234567", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + expect(result).toBe("iMessage;-;+15551234567"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("queries chats to resolve chat_id target", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { id: 123, guid: "iMessage;-;chat123", participants: [] }, + { id: 456, guid: "iMessage;-;chat456", participants: [] }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 456 }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("iMessage;-;chat456"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/query"), + expect.objectContaining({ method: "POST" }), + ); + }); + + it("queries chats to resolve chat_identifier target", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + identifier: "chat123@group.imessage", + guid: "iMessage;-;chat123", + participants: [], + }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { + kind: "chat_identifier", + chatIdentifier: "chat123@group.imessage", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("iMessage;-;chat123"); + }); + + it("resolves handle target by matching participant", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15559999999", + participants: [{ address: "+15559999999" }], + }, + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { + kind: "handle", + address: "+15551234567", + service: "imessage", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("iMessage;-;+15551234567"); + }); + + it("returns null when chat not found", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }); + + const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 999 }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBeNull(); + }); + + it("handles API error gracefully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 123 }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBeNull(); + }); + + it("paginates through chats to find match", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: Array(500) + .fill(null) + .map((_, i) => ({ + id: i, + guid: `chat-${i}`, + participants: [], + })), + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [{ id: 555, guid: "found-chat", participants: [] }], + }), + }); + + const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 555 }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("found-chat"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("normalizes handle addresses for matching", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;test@example.com", + participants: [{ address: "Test@Example.COM" }], + }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { + kind: "handle", + address: "test@example.com", + service: "auto", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("iMessage;-;test@example.com"); + }); + + it("extracts guid from various response formats", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + chatGuid: "format1-guid", + id: 100, + participants: [], + }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 100 }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("format1-guid"); + }); + }); + + describe("sendMessageBlueBubbles", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it("throws when text is empty", async () => { + await expect( + sendMessageBlueBubbles("+15551234567", "", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("requires text"); + }); + + it("throws when text is whitespace only", async () => { + await expect( + sendMessageBlueBubbles("+15551234567", " ", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("requires text"); + }); + + it("throws when serverUrl is missing", async () => { + await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow( + "serverUrl is required", + ); + }); + + it("throws when password is missing", async () => { + await expect( + sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + }), + ).rejects.toThrow("password is required"); + }); + + it("throws when chatGuid cannot be resolved", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }); + + await expect( + sendMessageBlueBubbles("+15559999999", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("chatGuid not found"); + }); + + it("sends message successfully", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid: "msg-uuid-123" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("msg-uuid-123"); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const sendCall = mockFetch.mock.calls[1]; + expect(sendCall[0]).toContain("/api/v1/message/text"); + const body = JSON.parse(sendCall[1].body); + expect(body.chatGuid).toBe("iMessage;-;+15551234567"); + expect(body.message).toBe("Hello world!"); + expect(body.method).toBeUndefined(); + }); + + it("uses private-api when reply metadata is present", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid: "msg-uuid-124" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Replying", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-123", + replyToPartIndex: 1, + }); + + expect(result.messageId).toBe("msg-uuid-124"); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBe("private-api"); + expect(body.selectedMessageGuid).toBe("reply-guid-123"); + expect(body.partIndex).toBe(1); + }); + + it("sends message with chat_guid target directly", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { messageId: "direct-msg-123" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles( + "chat_guid:iMessage;-;direct-chat", + "Direct message", + { + serverUrl: "http://localhost:1234", + password: "test", + }, + ); + + expect(result.messageId).toBe("direct-msg-123"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("handles send failure", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal server error"), + }); + + await expect( + sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("send failed (500)"); + }); + + it("handles empty response body", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("ok"); + }); + + it("handles invalid JSON response body", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("not valid json"), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("ok"); + }); + + it("extracts messageId from various response formats", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + id: "numeric-id-456", + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("numeric-id-456"); + }); + + it("resolves credentials from config", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://config-server:5678", + password: "config-pass", + }, + }, + }, + }); + + expect(result.messageId).toBe("msg-123"); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("config-server:5678"); + }); + + it("includes tempGuid in request payload", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })), + }); + + await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.tempGuid).toBeDefined(); + expect(typeof body.tempGuid).toBe("string"); + expect(body.tempGuid.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 0dc672d72..deb00ad4e 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -15,12 +15,42 @@ export type BlueBubblesSendOpts = { accountId?: string; timeoutMs?: number; cfg?: ClawdbotConfig; + /** Message GUID to reply to (reply threading) */ + replyToMessageGuid?: string; + /** Part index for reply (default: 0) */ + replyToPartIndex?: number; + /** Effect ID or short name for message effects (e.g., "slam", "balloons") */ + effectId?: string; }; export type BlueBubblesSendResult = { messageId: string; }; +/** Maps short effect names to full Apple effect IDs */ +const EFFECT_MAP: Record = { + // Bubble effects + slam: "com.apple.MobileSMS.expressivesend.impact", + loud: "com.apple.MobileSMS.expressivesend.loud", + gentle: "com.apple.MobileSMS.expressivesend.gentle", + invisible: "com.apple.MobileSMS.expressivesend.invisibleink", + // Screen effects + echo: "com.apple.messages.effect.CKEchoEffect", + spotlight: "com.apple.messages.effect.CKSpotlightEffect", + balloons: "com.apple.messages.effect.CKHappyBirthdayEffect", + confetti: "com.apple.messages.effect.CKConfettiEffect", + love: "com.apple.messages.effect.CKHeartEffect", + lasers: "com.apple.messages.effect.CKLasersEffect", + fireworks: "com.apple.messages.effect.CKFireworksEffect", + celebration: "com.apple.messages.effect.CKSparklesEffect", +}; + +function resolveEffectId(raw?: string): string | undefined { + if (!raw) return undefined; + const trimmed = raw.trim().toLowerCase(); + return EFFECT_MAP[trimmed] ?? raw; +} + function resolveSendTarget(raw: string): BlueBubblesSendTarget { const parsed = parseBlueBubblesTarget(raw); if (parsed.kind === "handle") { @@ -227,12 +257,27 @@ export async function sendMessageBlueBubbles( "BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", ); } + const effectId = resolveEffectId(opts.effectId); + const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId); const payload: Record = { chatGuid, tempGuid: crypto.randomUUID(), message: trimmedText, - method: "apple-script", }; + if (needsPrivateApi) { + payload.method = "private-api"; + } + + // Add reply threading support + if (opts.replyToMessageGuid) { + payload.selectedMessageGuid = opts.replyToMessageGuid; + payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0; + } + + // Add message effects support + if (effectId) { + payload.effectId = effectId; + } const url = buildBlueBubblesApiUrl({ baseUrl, diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 4425fa69d..a4c975359 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -49,6 +49,15 @@ export type BlueBubblesAccountConfig = { export type BlueBubblesActionConfig = { reactions?: boolean; + edit?: boolean; + unsend?: boolean; + reply?: boolean; + sendWithEffect?: boolean; + renameGroup?: boolean; + addParticipant?: boolean; + removeParticipant?: boolean; + leaveGroup?: boolean; + sendAttachment?: boolean; }; export type BlueBubblesConfig = {