From a16934b2ab65d341e60a2c17ee44dad2bec30e77 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Tue, 20 Jan 2026 00:43:56 -0800 Subject: [PATCH] feat: update BlueBubbles documentation and code to clarify group icon handling and normalize chat identifiers --- docs/channels/bluebubbles.md | 9 ++-- extensions/bluebubbles/src/probe.ts | 62 ++++++++++++++++++++++ extensions/bluebubbles/src/targets.test.ts | 35 +++++++----- extensions/bluebubbles/src/targets.ts | 9 ++-- 4 files changed, 93 insertions(+), 22 deletions(-) diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index f7527e9fe..b83dd1970 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -11,7 +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. +- Recommended/tested: macOS Sequoia (15). macOS Tahoe (26) works; edit is currently broken on Tahoe, and group icon updates may report success but not sync. - 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). @@ -125,7 +125,7 @@ BlueBubbles supports advanced message actions when enabled in config: 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 + setGroupIcon: true, // set group chat icon/photo (flaky on macOS 26 Tahoe) addParticipant: true, // add participants to groups removeParticipant: true, // remove participants from groups leaveGroup: true, // leave group chats @@ -143,7 +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`) +- **setGroupIcon**: Set a group chat's icon/photo (`chatGuid`, `media`) — flaky on macOS 26 Tahoe (API may return success but the icon does not sync). - **addParticipant**: Add someone to a group (`chatGuid`, `address`) - **removeParticipant**: Remove someone from a group (`chatGuid`, `address`) - **leaveGroup**: Leave a group chat (`chatGuid`) @@ -208,7 +208,8 @@ 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. On macOS 26 (Tahoe), edit is currently broken due to private API changes; other actions still work. +- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. On macOS 26 (Tahoe), edit is currently broken due to private API changes. +- Group icon updates can be flaky on macOS 26 (Tahoe): the API may return success but the new icon does not sync. - 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/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index dbbf5027b..5afd9657b 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -6,6 +6,68 @@ export type BlueBubblesProbe = { error?: string | null; }; +export type BlueBubblesServerInfo = { + os_version?: string; + server_version?: string; + private_api?: boolean; + helper_connected?: boolean; + proxy_service?: string; + detected_icloud?: string; + computer_id?: string; +}; + +/** Cache server info to avoid repeated API calls */ +const serverInfoCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Fetch server info from BlueBubbles API. + * Returns cached result if available and not expired. + */ +export async function fetchBlueBubblesServerInfo(params: { + baseUrl?: string | null; + password?: string | null; + timeoutMs?: number; +}): Promise { + const baseUrl = params.baseUrl?.trim(); + const password = params.password?.trim(); + if (!baseUrl || !password) return null; + + const cacheKey = `${baseUrl}:${password}`; + const cached = serverInfoCache.get(cacheKey); + if (cached && cached.expires > Date.now()) { + return cached.info; + } + + const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password }); + try { + const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000); + if (!res.ok) return null; + const payload = (await res.json().catch(() => null)) as Record | null; + const data = payload?.data as BlueBubblesServerInfo | undefined; + if (data) { + serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS }); + } + return data ?? null; + } catch { + return null; + } +} + +/** + * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number. + */ +export function parseMacOSMajorVersion(version?: string | null): number | null { + if (!version) return null; + const match = /^(\d+)/.exec(version.trim()); + return match ? Number.parseInt(match[1], 10) : null; +} + +/** Clear the server info cache (for testing) */ +export function clearServerInfoCache(): void { + serverInfoCache.clear(); +} + export async function probeBlueBubbles(params: { baseUrl?: string | null; password?: string | null; diff --git a/extensions/bluebubbles/src/targets.test.ts b/extensions/bluebubbles/src/targets.test.ts index 01d2e9950..731d6dbf6 100644 --- a/extensions/bluebubbles/src/targets.test.ts +++ b/extensions/bluebubbles/src/targets.test.ts @@ -50,12 +50,12 @@ describe("normalizeBlueBubblesMessagingTarget", () => { expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429"); }); - it("normalizes chat pattern to chat_id format", () => { + it("normalizes chat pattern to chat_identifier format", () => { expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe( - "chat_id:660250192681427962", + "chat_identifier:chat660250192681427962", ); - expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_id:123"); - expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_id:456789"); + expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123"); + expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789"); }); }); @@ -88,13 +88,19 @@ describe("looksLikeBlueBubblesTargetId", () => { }); describe("parseBlueBubblesTarget", () => { - it("parses chat pattern as chat_id", () => { + it("parses chat pattern as chat_identifier", () => { expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({ - kind: "chat_id", - chatId: 660250192681427962, + kind: "chat_identifier", + chatIdentifier: "chat660250192681427962", + }); + expect(parseBlueBubblesTarget("chat123")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "chat123", + }); + expect(parseBlueBubblesTarget("Chat456789")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "Chat456789", }); - expect(parseBlueBubblesTarget("chat123")).toEqual({ kind: "chat_id", chatId: 123 }); - expect(parseBlueBubblesTarget("Chat456789")).toEqual({ kind: "chat_id", chatId: 456789 }); }); it("parses explicit chat_id: prefix", () => { @@ -118,12 +124,15 @@ describe("parseBlueBubblesTarget", () => { }); describe("parseBlueBubblesAllowTarget", () => { - it("parses chat pattern as chat_id", () => { + it("parses chat pattern as chat_identifier", () => { expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({ - kind: "chat_id", - chatId: 660250192681427962, + kind: "chat_identifier", + chatIdentifier: "chat660250192681427962", + }); + expect(parseBlueBubblesAllowTarget("chat123")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "chat123", }); - expect(parseBlueBubblesAllowTarget("chat123")).toEqual({ kind: "chat_id", chatId: 123 }); }); it("parses explicit chat_id: prefix", () => { diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index 14a8fb2e0..a51415416 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -245,11 +245,10 @@ 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 }; + // 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", handle: normalizeBlueBubblesHandle(trimmed) };