diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index f26f3c91c..f7527e9fe 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -11,6 +11,7 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R ## Overview - Runs on macOS via the BlueBubbles helper app ([bluebubbles.app](https://bluebubbles.app)). +- Recommended/tested: macOS Sequoia (15). macOS Tahoe (26) works; edit is currently broken on Tahoe, but other actions (reactions, effects, unsend, etc.) still work. - Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`). - Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls. - Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible). @@ -119,11 +120,12 @@ BlueBubbles supports advanced message actions when enabled in config: bluebubbles: { actions: { reactions: true, // tapbacks (default: true) - edit: true, // edit sent messages (macOS 13+) + edit: true, // edit sent messages (macOS 13+, broken on macOS 26 Tahoe) unsend: true, // unsend messages (macOS 13+) reply: true, // reply threading by message GUID sendWithEffect: true, // message effects (slam, loud, etc.) renameGroup: true, // rename group chats + setGroupIcon: true, // set group chat icon/photo addParticipant: true, // add participants to groups removeParticipant: true, // remove participants from groups leaveGroup: true, // leave group chats @@ -141,6 +143,7 @@ Available actions: - **reply**: Reply to a specific message (`messageId`, `text`, `to`) - **sendWithEffect**: Send with iMessage effect (`text`, `to`, `effectId`) - **renameGroup**: Rename a group chat (`chatGuid`, `displayName`) +- **setGroupIcon**: Set a group chat's icon/photo (`chatGuid`, `media`) - **addParticipant**: Add someone to a group (`chatGuid`, `address`) - **removeParticipant**: Remove someone from a group (`chatGuid`, `address`) - **leaveGroup**: Leave a group chat (`chatGuid`) @@ -205,7 +208,7 @@ Prefer `chat_guid` for stable routing: - If typing/read events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`. - Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles `. - Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it. -- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. +- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. On macOS 26 (Tahoe), edit is currently broken due to private API changes; other actions still work. - For status/health info: `clawdbot status --all` or `clawdbot status --deep`. For general channel workflow reference, see [Channels](/channels) and the [Plugins](/plugins) guide. diff --git a/docs/channels/index.md b/docs/channels/index.md index 4b4a2af10..f2677cdfa 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -16,7 +16,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs. - [Slack](/channels/slack) — Bolt SDK; workspace apps. - [Signal](/channels/signal) — signal-cli; privacy-focused. -- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management). +- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe). - [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). - [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index b328c3de3..21f1c9c9d 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -28,6 +28,7 @@ vi.mock("./chat.js", () => ({ editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined), unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined), renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined), + setGroupIconBlueBubbles: vi.fn().mockResolvedValue(undefined), addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined), removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined), leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined), @@ -103,6 +104,7 @@ describe("bluebubblesMessageActions", () => { 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: "setGroupIcon" })).toBe(true); expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true); expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true); expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true); @@ -414,5 +416,96 @@ describe("bluebubblesMessageActions", () => { details: { ok: true, messageId: "msg-123", effect: "invisible ink" }, }); }); + + it("throws when buffer is missing for setGroupIcon", async () => { + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + await expect( + bluebubblesMessageActions.handleAction({ + action: "setGroupIcon", + params: { chatGuid: "iMessage;-;chat-guid" }, + cfg, + accountId: null, + }), + ).rejects.toThrow(/requires an image/i); + }); + + it("sets group icon successfully with chatGuid and buffer", async () => { + const { setGroupIconBlueBubbles } = await import("./chat.js"); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + // Base64 encode a simple test buffer + const testBuffer = Buffer.from("fake-image-data"); + const base64Buffer = testBuffer.toString("base64"); + + const result = await bluebubblesMessageActions.handleAction({ + action: "setGroupIcon", + params: { + chatGuid: "iMessage;-;chat-guid", + buffer: base64Buffer, + filename: "group-icon.png", + contentType: "image/png", + }, + cfg, + accountId: null, + }); + + expect(setGroupIconBlueBubbles).toHaveBeenCalledWith( + "iMessage;-;chat-guid", + expect.any(Uint8Array), + "group-icon.png", + expect.objectContaining({ contentType: "image/png" }), + ); + expect(result).toMatchObject({ + details: { ok: true, chatGuid: "iMessage;-;chat-guid", iconSet: true }, + }); + }); + + it("uses default filename when not provided for setGroupIcon", async () => { + const { setGroupIconBlueBubbles } = await import("./chat.js"); + + const cfg: ClawdbotConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + const base64Buffer = Buffer.from("test").toString("base64"); + + await bluebubblesMessageActions.handleAction({ + action: "setGroupIcon", + params: { + chatGuid: "iMessage;-;chat-guid", + buffer: base64Buffer, + }, + cfg, + accountId: null, + }); + + expect(setGroupIconBlueBubbles).toHaveBeenCalledWith( + "iMessage;-;chat-guid", + expect.any(Uint8Array), + "icon.png", + expect.anything(), + ); + }); }); }); diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index cc3b3ce39..21721f277 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -18,6 +18,7 @@ import { editBlueBubblesMessage, unsendBlueBubblesMessage, renameBlueBubblesChat, + setGroupIconBlueBubbles, addBlueBubblesParticipant, removeBlueBubblesParticipant, leaveBlueBubblesChat, @@ -54,6 +55,7 @@ const SUPPORTED_ACTIONS = new Set([ "reply", "sendWithEffect", "renameGroup", + "setGroupIcon", "addParticipant", "removeParticipant", "leaveGroup", @@ -72,6 +74,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { if (gate("reply")) actions.add("reply"); if (gate("sendWithEffect")) actions.add("sendWithEffect"); if (gate("renameGroup")) actions.add("renameGroup"); + if (gate("setGroupIcon")) actions.add("setGroupIcon"); if (gate("addParticipant")) actions.add("addParticipant"); if (gate("removeParticipant")) actions.add("removeParticipant"); if (gate("leaveGroup")) actions.add("leaveGroup"); @@ -275,6 +278,35 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName }); } + // Handle setGroupIcon action + if (action === "setGroupIcon") { + const resolvedChatGuid = await resolveChatGuid(); + const base64Buffer = readStringParam(params, "buffer"); + const filename = + readStringParam(params, "filename") ?? + readStringParam(params, "name") ?? + "icon.png"; + const contentType = + readStringParam(params, "contentType") ?? readStringParam(params, "mimeType"); + + if (!base64Buffer) { + throw new Error( + "BlueBubbles setGroupIcon requires an image. " + + "Use action=setGroupIcon with media= or path= to set the group icon.", + ); + } + + // Decode base64 to buffer + const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0)); + + await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, { + ...opts, + contentType: contentType ?? undefined, + }); + + return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true }); + } + // Handle addParticipant action if (action === "addParticipant") { const resolvedChatGuid = await resolveChatGuid(); diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index 0f6260a95..6e89c05d8 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; +import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js"; vi.mock("./accounts.js", () => ({ resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { @@ -315,4 +315,148 @@ describe("chat", () => { expect(mockFetch.mock.calls[1][1].method).toBe("DELETE"); }); }); + + describe("setGroupIconBlueBubbles", () => { + it("throws when chatGuid is empty", async () => { + await expect( + setGroupIconBlueBubbles("", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("chatGuid"); + }); + + it("throws when buffer is empty", async () => { + await expect( + setGroupIconBlueBubbles("chat-guid", new Uint8Array(0), "icon.png", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("image buffer"); + }); + + it("throws when serverUrl is missing", async () => { + await expect( + setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}), + ).rejects.toThrow("serverUrl is required"); + }); + + it("throws when password is missing", async () => { + await expect( + setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + }), + ).rejects.toThrow("password is required"); + }); + + it("sets group icon successfully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes + await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", { + serverUrl: "http://localhost:1234", + password: "test-password", + contentType: "image/png", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": expect.stringContaining("multipart/form-data"), + }), + }), + ); + }); + + it("includes password in URL query", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { + 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: 500, + text: () => Promise.resolve("Internal error"), + }); + + await expect( + setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("setGroupIcon failed (500): Internal error"); + }); + + it("trims chatGuid before using", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await setGroupIconBlueBubbles(" chat-with-spaces ", new Uint8Array([1]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon"); + expect(calledUrl).not.toContain("%20chat"); + }); + + it("resolves credentials from config", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { + 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"); + }); + + it("includes filename in multipart body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", { + serverUrl: "http://localhost:1234", + password: "test", + contentType: "image/jpeg", + }); + + const body = mockFetch.mock.calls[0][1].body as Uint8Array; + const bodyString = new TextDecoder().decode(body); + expect(bodyString).toContain('filename="custom-icon.jpg"'); + expect(bodyString).toContain("image/jpeg"); + }); + }); }); diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 27eba154e..b894d30ce 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import { resolveBlueBubblesAccount } from "./accounts.js"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; @@ -280,3 +281,74 @@ export async function leaveBlueBubblesChat( throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`); } } + +/** + * Set a group chat's icon/photo via BlueBubbles API. + * Requires Private API to be enabled. + */ +export async function setGroupIconBlueBubbles( + chatGuid: string, + buffer: Uint8Array, + filename: string, + opts: BlueBubblesChatOpts & { contentType?: string } = {}, +): Promise { + const trimmedGuid = chatGuid.trim(); + if (!trimmedGuid) throw new Error("BlueBubbles setGroupIcon requires chatGuid"); + if (!buffer || buffer.length === 0) { + throw new Error("BlueBubbles setGroupIcon requires image buffer"); + } + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`, + password, + }); + + // Build multipart form-data + const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`; + const parts: Uint8Array[] = []; + const encoder = new TextEncoder(); + + // Add file field named "icon" as per API spec + parts.push(encoder.encode(`--${boundary}\r\n`)); + parts.push( + encoder.encode( + `Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`, + ), + ); + parts.push( + encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`), + ); + parts.push(buffer); + parts.push(encoder.encode("\r\n")); + + // Close multipart body + parts.push(encoder.encode(`--${boundary}--\r\n`)); + + // Combine into 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().catch(() => ""); + throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`); + } +} diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 20d139f20..84b389142 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -4,16 +4,17 @@ 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(), + reactions: z.boolean().default(true), + edit: z.boolean().default(true), + unsend: z.boolean().default(true), + reply: z.boolean().default(true), + sendWithEffect: z.boolean().default(true), + renameGroup: z.boolean().default(true), + setGroupIcon: z.boolean().default(true), + addParticipant: z.boolean().default(true), + removeParticipant: z.boolean().default(true), + leaveGroup: z.boolean().default(true), + sendAttachment: z.boolean().default(true), }) .optional(); diff --git a/extensions/bluebubbles/src/targets.test.ts b/extensions/bluebubbles/src/targets.test.ts index 63cb06a4c..01d2e9950 100644 --- a/extensions/bluebubbles/src/targets.test.ts +++ b/extensions/bluebubbles/src/targets.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { looksLikeBlueBubblesTargetId, normalizeBlueBubblesMessagingTarget } from "./targets.js"; +import { + looksLikeBlueBubblesTargetId, + normalizeBlueBubblesMessagingTarget, + parseBlueBubblesTarget, + parseBlueBubblesAllowTarget, +} from "./targets.js"; describe("normalizeBlueBubblesMessagingTarget", () => { it("normalizes chat_guid targets", () => { @@ -37,6 +42,21 @@ describe("normalizeBlueBubblesMessagingTarget", () => { "chat_guid:iMessage;+;chat123456789", ); }); + + it("normalizes raw chat_guid values", () => { + expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe( + "chat_guid:iMessage;+;chat660250192681427962", + ); + expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429"); + }); + + it("normalizes chat pattern to chat_id format", () => { + expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe( + "chat_id:660250192681427962", + ); + expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_id:123"); + expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_id:456789"); + }); }); describe("looksLikeBlueBubblesTargetId", () => { @@ -52,7 +72,68 @@ describe("looksLikeBlueBubblesTargetId", () => { expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true); }); + it("accepts raw chat_guid values", () => { + expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true); + }); + + it("accepts chat pattern as chat_id", () => { + expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true); + expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true); + expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true); + }); + it("rejects display names", () => { expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false); }); }); + +describe("parseBlueBubblesTarget", () => { + it("parses chat pattern as chat_id", () => { + expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({ + kind: "chat_id", + chatId: 660250192681427962, + }); + expect(parseBlueBubblesTarget("chat123")).toEqual({ kind: "chat_id", chatId: 123 }); + expect(parseBlueBubblesTarget("Chat456789")).toEqual({ kind: "chat_id", chatId: 456789 }); + }); + + it("parses explicit chat_id: prefix", () => { + expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 }); + }); + + it("parses phone numbers as handles", () => { + expect(parseBlueBubblesTarget("+19257864429")).toEqual({ + kind: "handle", + to: "+19257864429", + service: "auto", + }); + }); + + it("parses raw chat_guid format", () => { + expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({ + kind: "chat_guid", + chatGuid: "iMessage;+;chat660250192681427962", + }); + }); +}); + +describe("parseBlueBubblesAllowTarget", () => { + it("parses chat pattern as chat_id", () => { + expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({ + kind: "chat_id", + chatId: 660250192681427962, + }); + expect(parseBlueBubblesAllowTarget("chat123")).toEqual({ kind: "chat_id", chatId: 123 }); + }); + + it("parses explicit chat_id: prefix", () => { + expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 }); + }); + + it("parses phone numbers as handles", () => { + expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({ + kind: "handle", + handle: "+19257864429", + }); + }); +}); diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index ec11d9e84..14a8fb2e0 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -21,6 +21,19 @@ const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = { prefix: "auto:", service: "auto" }, ]; +function parseRawChatGuid(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const parts = trimmed.split(";"); + if (parts.length !== 3) return null; + const service = parts[0]?.trim(); + const separator = parts[1]?.trim(); + const identifier = parts[2]?.trim(); + if (!service || !identifier) return null; + if (separator !== "+" && separator !== "-") return null; + return `${service};${separator};${identifier}`; +} + function stripPrefix(value: string, prefix: string): string { return value.slice(prefix.length).trim(); } @@ -88,6 +101,7 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): if (!trimmed) return false; const candidate = stripBlueBubblesPrefix(trimmed); if (!candidate) return false; + if (parseRawChatGuid(candidate)) return true; const lowered = candidate.toLowerCase(); if (/^(imessage|sms|auto):/.test(lowered)) return true; if ( @@ -97,6 +111,8 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): ) { return true; } + // Recognize chat patterns (e.g., "chat660250192681427962") as chat IDs + if (/^chat\d+$/i.test(candidate)) return true; if (candidate.includes("@")) return true; const digitsOnly = candidate.replace(/[\s().-]/g, ""); if (/^\+?\d{3,}$/.test(digitsOnly)) return true; @@ -115,7 +131,7 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): } export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { - const trimmed = raw.trim(); + const trimmed = stripBlueBubblesPrefix(raw); if (!trimmed) throw new Error("BlueBubbles target is required"); const lower = trimmed.toLowerCase(); @@ -173,6 +189,17 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { return { kind: "chat_guid", chatGuid: value }; } + const rawChatGuid = parseRawChatGuid(trimmed); + if (rawChatGuid) { + return { kind: "chat_guid", chatGuid: rawChatGuid }; + } + + // Handle chat pattern (e.g., "chat660250192681427962") as chat_identifier + // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs + if (/^chat\d+$/i.test(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + return { kind: "handle", to: trimmed, service: "auto" }; } @@ -218,6 +245,13 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget if (value) return { kind: "chat_guid", chatGuid: value }; } + // Handle chat pattern (e.g., "chat660250192681427962") as chat_id + const chatDigitsMatch = /^chat(\d+)$/i.exec(trimmed); + if (chatDigitsMatch) { + const chatId = Number.parseInt(chatDigitsMatch[1], 10); + if (Number.isFinite(chatId)) return { kind: "chat_id", chatId }; + } + return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) }; } diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 7a293fd4c..eb4952fe7 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -27,6 +27,7 @@ import { jsonResult, readNumberParam, readStringParam } from "./common.js"; const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; const BLUEBUBBLES_GROUP_ACTIONS = new Set([ "renameGroup", + "setGroupIcon", "addParticipant", "removeParticipant", "leaveGroup", diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index 874731537..c884f6da3 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -10,6 +10,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "reply", "sendWithEffect", "renameGroup", + "setGroupIcon", "addParticipant", "removeParticipant", "leaveGroup", diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 2326dc080..0fd10eb3b 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -1,7 +1,3 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../../config/config.js"; @@ -15,9 +11,13 @@ import { runMessageAction } from "./message-action-runner.js"; import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; -vi.mock("../../web/media.js", () => ({ - loadWebMedia: vi.fn(), -})); +vi.mock("../../web/media.js", async () => { + const actual = await vi.importActual("../../web/media.js"); + return { + ...actual, + loadWebMedia: vi.fn(actual.loadWebMedia), + }; +}); const slackConfig = { channels: { @@ -76,66 +76,6 @@ describe("runMessageAction context isolation", () => { setActivePluginRegistry(createTestRegistry([])); }); - it("maps sendAttachment media to buffer + filename", async () => { - const filePath = path.join(os.tmpdir(), `clawdbot-attachment-${Date.now()}.txt`); - await fs.writeFile(filePath, "hello"); - - const handleAction = vi.fn(async (ctx) => { - return jsonResult({ ok: true, params: ctx.params }); - }); - - const testPlugin: ChannelPlugin = { - id: "bluebubbles", - meta: { - id: "bluebubbles", - label: "BlueBubbles", - selectionLabel: "BlueBubbles", - docsPath: "/channels/bluebubbles", - blurb: "BlueBubbles test plugin.", - }, - capabilities: { chatTypes: ["direct", "group"], media: true }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, - messaging: { - targetResolver: { - looksLikeId: () => true, - hint: "", - }, - normalizeTarget: (raw) => raw.trim(), - }, - actions: { - listActions: () => ["sendAttachment"], - handleAction: handleAction as NonNullable["handleAction"], - }, - }; - - setActivePluginRegistry( - createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: testPlugin }]), - ); - - try { - const result = await runMessageAction({ - cfg: { channels: { bluebubbles: {} } } as ClawdbotConfig, - action: "sendAttachment", - params: { - channel: "bluebubbles", - target: "chat_guid:TEST", - media: filePath, - }, - dryRun: false, - }); - - expect(result.kind).toBe("action"); - expect(handleAction).toHaveBeenCalledTimes(1); - const params = handleAction.mock.calls[0]?.[0]?.params as Record; - expect(params.filename).toBe(path.basename(filePath)); - expect(params.buffer).toBe(Buffer.from("hello").toString("base64")); - } finally { - await fs.unlink(filePath).catch(() => {}); - } - }); it("allows send when target matches current channel", async () => { const result = await runMessageAction({ cfg: slackConfig, @@ -152,6 +92,21 @@ describe("runMessageAction context isolation", () => { expect(result.kind).toBe("send"); }); + it("accepts legacy to parameter for send", async () => { + const result = await runMessageAction({ + cfg: slackConfig, + action: "send", + params: { + channel: "slack", + to: "#C12345678", + message: "hi", + }, + dryRun: true, + }); + + expect(result.kind).toBe("send"); + }); + it("defaults to current channel when target is omitted", async () => { const result = await runMessageAction({ cfg: slackConfig, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 0f79559aa..e307703be 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -215,9 +215,25 @@ function resolveAttachmentMaxBytes(params: { } const accountId = typeof params.accountId === "string" ? params.accountId.trim() : ""; const channelCfg = params.cfg.channels?.bluebubbles; - const accountCfg = accountId ? channelCfg?.accounts?.[accountId] : undefined; + const channelObj = + channelCfg && typeof channelCfg === "object" + ? (channelCfg as Record) + : undefined; + const channelMediaMax = + typeof channelObj?.mediaMaxMb === "number" ? channelObj.mediaMaxMb : undefined; + const accountsObj = + channelObj?.accounts && typeof channelObj.accounts === "object" + ? (channelObj.accounts as Record) + : undefined; + const accountCfg = accountId && accountsObj ? accountsObj[accountId] : undefined; + const accountMediaMax = + accountCfg && typeof accountCfg === "object" + ? (accountCfg as Record).mediaMaxMb + : undefined; const limitMb = - accountCfg?.mediaMaxMb ?? channelCfg?.mediaMaxMb ?? params.cfg.agents?.defaults?.mediaMaxMb; + (typeof accountMediaMax === "number" ? accountMediaMax : undefined) ?? + channelMediaMax ?? + params.cfg.agents?.defaults?.mediaMaxMb; return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined; } @@ -262,6 +278,63 @@ function normalizeBase64Payload(params: { base64?: string; contentType?: string }; } +async function hydrateSetGroupIconParams(params: { + cfg: ClawdbotConfig; + channel: ChannelId; + accountId?: string | null; + args: Record; + action: ChannelMessageActionName; + dryRun?: boolean; +}): Promise { + if (params.action !== "setGroupIcon") return; + + const mediaHint = readStringParam(params.args, "media", { trim: false }); + const fileHint = + readStringParam(params.args, "path", { trim: false }) ?? + readStringParam(params.args, "filePath", { trim: false }); + const contentTypeParam = + readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType"); + + const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); + const normalized = normalizeBase64Payload({ + base64: rawBuffer, + contentType: contentTypeParam ?? undefined, + }); + if (normalized.base64 !== rawBuffer && normalized.base64) { + params.args.buffer = normalized.base64; + if (normalized.contentType && !contentTypeParam) { + params.args.contentType = normalized.contentType; + } + } + + const filename = readStringParam(params.args, "filename"); + const mediaSource = mediaHint ?? fileHint; + + if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) { + const maxBytes = resolveAttachmentMaxBytes({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + }); + const media = await loadWebMedia(mediaSource, maxBytes); + params.args.buffer = media.buffer.toString("base64"); + if (!contentTypeParam && media.contentType) { + params.args.contentType = media.contentType; + } + if (!filename) { + params.args.filename = inferAttachmentFilename({ + mediaHint: media.fileName ?? mediaSource, + contentType: media.contentType ?? contentTypeParam ?? undefined, + }); + } + } else if (!filename) { + params.args.filename = inferAttachmentFilename({ + mediaHint: mediaSource, + contentType: contentTypeParam ?? undefined, + }); + } +} + async function hydrateSendAttachmentParams(params: { cfg: ClawdbotConfig; channel: ChannelId; @@ -666,6 +739,10 @@ export async function runMessageAction( const hasLegacyTarget = (typeof params.to === "string" && params.to.trim().length > 0) || (typeof params.channelId === "string" && params.channelId.trim().length > 0); + if (explicitTarget && hasLegacyTarget) { + delete params.to; + delete params.channelId; + } if ( !explicitTarget && !hasLegacyTarget && @@ -677,6 +754,16 @@ export async function runMessageAction( params.target = inferredTarget; } } + if (!explicitTarget && actionRequiresTarget(action) && hasLegacyTarget) { + const legacyTo = typeof params.to === "string" ? params.to.trim() : ""; + const legacyChannelId = typeof params.channelId === "string" ? params.channelId.trim() : ""; + const legacyTarget = legacyTo || legacyChannelId; + if (legacyTarget) { + params.target = legacyTarget; + delete params.to; + delete params.channelId; + } + } const explicitChannel = typeof params.channel === "string" ? params.channel.trim() : ""; if (!explicitChannel) { const inferredChannel = normalizeMessageChannel(input.toolContext?.currentChannelProvider); @@ -705,6 +792,15 @@ export async function runMessageAction( dryRun, }); + await hydrateSetGroupIconParams({ + cfg, + channel, + accountId, + args: params, + action, + dryRun, + }); + await resolveActionTarget({ cfg, channel, @@ -721,14 +817,6 @@ export async function runMessageAction( cfg, }); - await hydrateSendAttachmentParams({ - cfg, - channel, - accountId, - args: params, - dryRun, - }); - const gateway = resolveGateway(input); if (action === "send") { diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index 6cf1afb40..782f8dc8b 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -15,6 +15,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record> unsend: ["messageId"], edit: ["messageId"], renameGroup: ["chatGuid", "chatIdentifier", "chatId"], + setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"], addParticipant: ["chatGuid", "chatIdentifier", "chatId"], removeParticipant: ["chatGuid", "chatIdentifier", "chatId"], leaveGroup: ["chatGuid", "chatIdentifier", "chatId"], diff --git a/src/media/image-ops.ts b/src/media/image-ops.ts index 757d67484..3aa2070f2 100644 --- a/src/media/image-ops.ts +++ b/src/media/image-ops.ts @@ -181,6 +181,19 @@ async function sipsResizeToJpeg(params: { }); } +async function sipsConvertToJpeg(buffer: Buffer): Promise { + return await withTempDir(async (dir) => { + const input = path.join(dir, "in.heic"); + const output = path.join(dir, "out.jpg"); + await fs.writeFile(input, buffer); + await runExec("/usr/bin/sips", ["-s", "format", "jpeg", input, "--out", output], { + timeoutMs: 20_000, + maxBuffer: 1024 * 1024, + }); + return await fs.readFile(output); + }); +} + export async function getImageMetadata(buffer: Buffer): Promise { if (prefersSips()) { return await sipsMetadataFromBuffer(buffer).catch(() => null); @@ -318,6 +331,14 @@ export async function resizeToJpeg(params: { .toBuffer(); } +export async function convertHeicToJpeg(buffer: Buffer): Promise { + if (prefersSips()) { + return await sipsConvertToJpeg(buffer); + } + const sharp = await loadSharp(); + return await sharp(buffer).jpeg({ quality: 90, mozjpeg: true }).toBuffer(); +} + /** * Internal sips-only EXIF normalization (no sharp fallback). * Used by resizeToJpeg to normalize before sips resize. diff --git a/src/media/mime.ts b/src/media/mime.ts index 6d6672183..8ef8912e6 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -5,6 +5,8 @@ import { type MediaKind, mediaKindFromMime } from "./constants.js"; // Map common mimes to preferred file extensions. const EXT_BY_MIME: Record = { + "image/heic": ".heic", + "image/heif": ".heif", "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", @@ -137,6 +139,10 @@ export function imageMimeFromFormat(format?: string | null): string | undefined case "jpg": case "jpeg": return "image/jpeg"; + case "heic": + return "image/heic"; + case "heif": + return "image/heif"; case "png": return "image/png"; case "webp": diff --git a/src/web/media.ts b/src/web/media.ts index 84ad02c91..adc24879b 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js"; import { fetchRemoteMedia } from "../media/fetch.js"; -import { resizeToJpeg } from "../media/image-ops.js"; +import { convertHeicToJpeg, resizeToJpeg } from "../media/image-ops.js"; import { detectMime, extensionForMime } from "../media/mime.js"; type WebMediaResult = { @@ -20,6 +20,15 @@ type WebMediaOptions = { optimizeImages?: boolean; }; +const HEIC_MIME_RE = /^image\/hei[cf]$/i; +const HEIC_EXT_RE = /\.(heic|heif)$/i; + +function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean { + if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) return true; + if (opts.fileName && HEIC_EXT_RE.test(opts.fileName.trim())) return true; + return false; +} + async function loadWebMediaInternal( mediaUrl: string, options: WebMediaOptions = {}, @@ -34,9 +43,13 @@ async function loadWebMediaInternal( } } - const optimizeAndClampImage = async (buffer: Buffer, cap: number) => { + const optimizeAndClampImage = async ( + buffer: Buffer, + cap: number, + meta?: { contentType?: string; fileName?: string }, + ) => { const originalSize = buffer.length; - const optimized = await optimizeImageToJpeg(buffer, cap); + const optimized = await optimizeImageToJpeg(buffer, cap, meta); if (optimized.optimizedSize < originalSize && shouldLogVerbose()) { logVerbose( `Optimized media from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px, q=${optimized.quality})`, @@ -86,7 +99,10 @@ async function loadWebMediaInternal( }; } return { - ...(await optimizeAndClampImage(params.buffer, cap)), + ...(await optimizeAndClampImage(params.buffer, cap, { + contentType: params.contentType, + fileName: params.fileName, + })), fileName: params.fileName, }; } @@ -150,6 +166,7 @@ export async function loadWebMediaRaw( export async function optimizeImageToJpeg( buffer: Buffer, maxBytes: number, + opts: { contentType?: string; fileName?: string } = {}, ): Promise<{ buffer: Buffer; optimizedSize: number; @@ -157,6 +174,14 @@ export async function optimizeImageToJpeg( quality: number; }> { // Try a grid of sizes/qualities until under the limit. + let source = buffer; + if (isHeicSource(opts)) { + try { + source = await convertHeicToJpeg(buffer); + } catch (err) { + throw new Error(`HEIC image conversion failed: ${String(err)}`); + } + } const sides = [2048, 1536, 1280, 1024, 800]; const qualities = [80, 70, 60, 50, 40]; let smallest: { @@ -170,7 +195,7 @@ export async function optimizeImageToJpeg( for (const quality of qualities) { try { const out = await resizeToJpeg({ - buffer, + buffer: source, maxSide: side, quality, withoutEnlargement: true, diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index 3eab5b0df..e3aba97d7 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -197,7 +197,32 @@ function renderGenericChannelCard( `; } +const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes + +function hasRecentActivity(account: ChannelAccountSnapshot): boolean { + if (!account.lastInboundAt) return false; + return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS; +} + +function deriveRunningStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" { + if (account.running) return "Yes"; + // If we have recent inbound activity, the channel is effectively running + if (hasRecentActivity(account)) return "Active"; + return "No"; +} + +function deriveConnectedStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" | "n/a" { + if (account.connected === true) return "Yes"; + if (account.connected === false) return "No"; + // If connected is null/undefined but we have recent activity, show as active + if (hasRecentActivity(account)) return "Active"; + return "n/a"; +} + function renderGenericAccount(account: ChannelAccountSnapshot) { + const runningStatus = deriveRunningStatus(account); + const connectedStatus = deriveConnectedStatus(account); + return html`