From 45e12d23881aa579a85d06a7672c73458e313e9a Mon Sep 17 00:00:00 2001 From: Tyler Yust <64381258+tyler6204@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:15:56 -0800 Subject: [PATCH] bluebubbles: gracefully handle disabled private API with action/tool filtering and fallbacks (#16002) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 243cc0cc9a9ac68b5a7069da50f9f496ac4722d2 Co-authored-by: tyler6204 <243?+tyler6204@users.noreply.github.com> Co-authored-by: tyler6204 <64381258+tyler6204@users.noreply.github.com> Reviewed-by: @tyler6204 --- CHANGELOG.md | 1 + extensions/bluebubbles/src/actions.test.ts | 52 +++++++++++++++++++ extensions/bluebubbles/src/actions.ts | 35 ++++++++++++- .../bluebubbles/src/attachments.test.ts | 32 ++++++++++++ extensions/bluebubbles/src/attachments.ts | 12 +++-- extensions/bluebubbles/src/chat.test.ts | 40 ++++++++++++++ extensions/bluebubbles/src/chat.ts | 42 +++++++++++---- .../bluebubbles/src/monitor-processing.ts | 24 +++++++-- extensions/bluebubbles/src/monitor.ts | 5 ++ extensions/bluebubbles/src/probe.ts | 12 +++++ extensions/bluebubbles/src/reactions.ts | 10 +++- extensions/bluebubbles/src/send.test.ts | 47 +++++++++++++++++ extensions/bluebubbles/src/send.ts | 16 ++++-- 13 files changed, 305 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2627a09acff..59fb5bbed60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y. - Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow. - Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u. - Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr. diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index 8dc55b1eff3..8736bab6d18 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { bluebubblesMessageActions } from "./actions.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; vi.mock("./accounts.js", () => ({ resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { @@ -41,9 +42,15 @@ vi.mock("./monitor.js", () => ({ resolveBlueBubblesMessageId: vi.fn((id: string) => id), })); +vi.mock("./probe.js", () => ({ + isMacOS26OrHigher: vi.fn().mockReturnValue(false), + getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), +})); + describe("bluebubblesMessageActions", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); }); describe("listActions", () => { @@ -94,6 +101,31 @@ describe("bluebubblesMessageActions", () => { expect(actions).toContain("edit"); expect(actions).toContain("unsend"); }); + + it("hides private-api actions when private API is disabled", () => { + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + const actions = bluebubblesMessageActions.listActions({ cfg }); + expect(actions).toContain("sendAttachment"); + expect(actions).not.toContain("react"); + expect(actions).not.toContain("reply"); + expect(actions).not.toContain("sendWithEffect"); + expect(actions).not.toContain("edit"); + expect(actions).not.toContain("unsend"); + expect(actions).not.toContain("renameGroup"); + expect(actions).not.toContain("setGroupIcon"); + expect(actions).not.toContain("addParticipant"); + expect(actions).not.toContain("removeParticipant"); + expect(actions).not.toContain("leaveGroup"); + }); }); describe("supportsAction", () => { @@ -189,6 +221,26 @@ describe("bluebubblesMessageActions", () => { ).rejects.toThrow(/emoji/i); }); + it("throws a private-api error for private-only actions when disabled", async () => { + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await expect( + bluebubblesMessageActions.handleAction({ + action: "react", + params: { emoji: "❤️", messageId: "msg-123", chatGuid: "iMessage;-;+15551234567" }, + cfg, + accountId: null, + }), + ).rejects.toThrow("requires Private API"); + }); + it("throws when messageId is missing", async () => { const cfg: OpenClawConfig = { channels: { diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index a3074d4e545..0f9d708b586 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -23,7 +23,7 @@ import { leaveBlueBubblesChat, } from "./chat.js"; import { resolveBlueBubblesMessageId } from "./monitor.js"; -import { isMacOS26OrHigher } from "./probe.js"; +import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js"; import { sendBlueBubblesReaction } from "./reactions.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; @@ -71,6 +71,18 @@ function readBooleanParam(params: Record, key: string): boolean /** Supported action names for BlueBubbles */ const SUPPORTED_ACTIONS = new Set(BLUEBUBBLES_ACTION_NAMES); +const PRIVATE_API_ACTIONS = new Set([ + "react", + "edit", + "unsend", + "reply", + "sendWithEffect", + "renameGroup", + "setGroupIcon", + "addParticipant", + "removeParticipant", + "leaveGroup", +]); export const bluebubblesMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { @@ -81,11 +93,15 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { const gate = createActionGate(cfg.channels?.bluebubbles?.actions); const actions = new Set(); const macOS26 = isMacOS26OrHigher(account.accountId); + const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId); for (const action of BLUEBUBBLES_ACTION_NAMES) { const spec = BLUEBUBBLES_ACTIONS[action]; if (!spec?.gate) { continue; } + if (privateApiStatus === false && PRIVATE_API_ACTIONS.has(action)) { + continue; + } if ("unsupportedOnMacOS26" in spec && spec.unsupportedOnMacOS26 && macOS26) { continue; } @@ -116,6 +132,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { const baseUrl = account.config.serverUrl?.trim(); const password = account.config.password?.trim(); const opts = { cfg: cfg, accountId: accountId ?? undefined }; + const assertPrivateApiEnabled = () => { + if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false) { + throw new Error( + `BlueBubbles ${action} requires Private API, but it is disabled on the BlueBubbles server.`, + ); + } + }; // Helper to resolve chatGuid from various params or session context const resolveChatGuid = async (): Promise => { @@ -159,6 +182,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle react action if (action === "react") { + assertPrivateApiEnabled(); const { emoji, remove, isEmpty } = readReactionParams(params, { removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.", }); @@ -193,6 +217,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle edit action if (action === "edit") { + assertPrivateApiEnabled(); // Edit is not supported on macOS 26+ if (isMacOS26OrHigher(accountId ?? undefined)) { throw new Error( @@ -234,6 +259,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle unsend action if (action === "unsend") { + assertPrivateApiEnabled(); const rawMessageId = readStringParam(params, "messageId"); if (!rawMessageId) { throw new Error( @@ -255,6 +281,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle reply action if (action === "reply") { + assertPrivateApiEnabled(); const rawMessageId = readStringParam(params, "messageId"); const text = readMessageText(params); const to = readStringParam(params, "to") ?? readStringParam(params, "target"); @@ -289,6 +316,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle sendWithEffect action if (action === "sendWithEffect") { + assertPrivateApiEnabled(); const text = readMessageText(params); const to = readStringParam(params, "to") ?? readStringParam(params, "target"); const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect"); @@ -321,6 +349,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle renameGroup action if (action === "renameGroup") { + assertPrivateApiEnabled(); const resolvedChatGuid = await resolveChatGuid(); const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name"); if (!displayName) { @@ -334,6 +363,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle setGroupIcon action if (action === "setGroupIcon") { + assertPrivateApiEnabled(); const resolvedChatGuid = await resolveChatGuid(); const base64Buffer = readStringParam(params, "buffer"); const filename = @@ -361,6 +391,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle addParticipant action if (action === "addParticipant") { + assertPrivateApiEnabled(); const resolvedChatGuid = await resolveChatGuid(); const address = readStringParam(params, "address") ?? readStringParam(params, "participant"); if (!address) { @@ -374,6 +405,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle removeParticipant action if (action === "removeParticipant") { + assertPrivateApiEnabled(); const resolvedChatGuid = await resolveChatGuid(); const address = readStringParam(params, "address") ?? readStringParam(params, "participant"); if (!address) { @@ -387,6 +419,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle leaveGroup action if (action === "leaveGroup") { + assertPrivateApiEnabled(); const resolvedChatGuid = await resolveChatGuid(); await leaveBlueBubblesChat(resolvedChatGuid, opts); diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 9bc0e4d217b..ca6f8b92aef 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import type { BlueBubblesAttachment } from "./types.js"; import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; vi.mock("./accounts.js", () => ({ resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { @@ -14,12 +15,18 @@ vi.mock("./accounts.js", () => ({ }), })); +vi.mock("./probe.js", () => ({ + getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), +})); + const mockFetch = vi.fn(); describe("downloadBlueBubblesAttachment", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockFetch.mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); }); afterEach(() => { @@ -242,6 +249,8 @@ describe("sendBlueBubblesAttachment", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockFetch.mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); }); afterEach(() => { @@ -342,4 +351,27 @@ describe("sendBlueBubblesAttachment", () => { expect(bodyText).toContain('filename="evil.mp3"'); expect(bodyText).toContain('name="evil.mp3"'); }); + + it("downgrades attachment reply threading when private API is disabled", async () => { + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "photo.jpg", + contentType: "image/jpeg", + replyToMessageGuid: "reply-guid-123", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).not.toContain('name="method"'); + expect(bodyText).not.toContain('name="selectedMessageGuid"'); + expect(bodyText).not.toContain('name="partIndex"'); + }); }); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 1d18126e9ad..24fe357b7c5 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import crypto from "node:crypto"; import path from "node:path"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { resolveChatGuidForTarget } from "./send.js"; import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js"; import { @@ -64,7 +65,7 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) { if (!password) { throw new Error("BlueBubbles password is required"); } - return { baseUrl, password }; + return { baseUrl, password, accountId: account.accountId }; } export async function downloadBlueBubblesAttachment( @@ -169,7 +170,8 @@ export async function sendBlueBubblesAttachment(params: { const fallbackName = wantsVoice ? "Audio Message" : "attachment"; filename = sanitizeFilename(filename, fallbackName); contentType = contentType?.trim() || undefined; - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, accountId } = resolveAccount(opts); + const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage). const isAudioMessage = wantsVoice; @@ -238,7 +240,9 @@ export async function sendBlueBubblesAttachment(params: { addField("chatGuid", chatGuid); addField("name", filename); addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`); - addField("method", "private-api"); + if (privateApiStatus !== false) { + addField("method", "private-api"); + } // Add isAudioMessage flag for voice memos if (isAudioMessage) { @@ -246,7 +250,7 @@ export async function sendBlueBubblesAttachment(params: { } const trimmedReplyTo = replyToMessageGuid?.trim(); - if (trimmedReplyTo) { + if (trimmedReplyTo && privateApiStatus !== false) { addField("selectedMessageGuid", trimmedReplyTo); addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0"); } diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index 39ac3ba325a..3f0a8da7e49 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; vi.mock("./accounts.js", () => ({ resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { @@ -13,12 +14,18 @@ vi.mock("./accounts.js", () => ({ }), })); +vi.mock("./probe.js", () => ({ + getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), +})); + const mockFetch = vi.fn(); describe("chat", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockFetch.mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); }); afterEach(() => { @@ -73,6 +80,17 @@ describe("chat", () => { ); }); + it("does not send read receipt when private API is disabled", async () => { + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + + await markBlueBubblesChatRead("iMessage;-;+15551234567", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + it("includes password in URL query", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -190,6 +208,17 @@ describe("chat", () => { ); }); + it("does not send typing when private API is disabled", async () => { + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + + await sendBlueBubblesTyping("iMessage;-;+15551234567", true, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + it("sends typing stop with DELETE method", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -348,6 +377,17 @@ describe("chat", () => { ).rejects.toThrow("password is required"); }); + it("throws when private API is disabled", async () => { + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + await expect( + setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("requires Private API"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it("sets group icon successfully", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 115dc06aae7..bfb37a4ddf8 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import crypto from "node:crypto"; import path from "node:path"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; export type BlueBubblesChatOpts = { @@ -25,7 +26,15 @@ function resolveAccount(params: BlueBubblesChatOpts) { if (!password) { throw new Error("BlueBubbles password is required"); } - return { baseUrl, password }; + return { baseUrl, password, accountId: account.accountId }; +} + +function assertPrivateApiEnabled(accountId: string, feature: string): void { + if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { + throw new Error( + `BlueBubbles ${feature} requires Private API, but it is disabled on the BlueBubbles server.`, + ); + } } export async function markBlueBubblesChatRead( @@ -36,7 +45,10 @@ export async function markBlueBubblesChatRead( if (!trimmed) { return; } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, accountId } = resolveAccount(opts); + if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { + return; + } const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`, @@ -58,7 +70,10 @@ export async function sendBlueBubblesTyping( if (!trimmed) { return; } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, accountId } = resolveAccount(opts); + if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { + return; + } const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`, @@ -93,7 +108,8 @@ export async function editBlueBubblesMessage( throw new Error("BlueBubbles edit requires newText"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, accountId } = resolveAccount(opts); + assertPrivateApiEnabled(accountId, "edit"); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`, @@ -135,7 +151,8 @@ export async function unsendBlueBubblesMessage( throw new Error("BlueBubbles unsend requires messageGuid"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, accountId } = resolveAccount(opts); + assertPrivateApiEnabled(accountId, "unsend"); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`, @@ -175,7 +192,8 @@ export async function renameBlueBubblesChat( throw new Error("BlueBubbles rename requires chatGuid"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, accountId } = resolveAccount(opts); + assertPrivateApiEnabled(accountId, "renameGroup"); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`, @@ -215,7 +233,8 @@ export async function addBlueBubblesParticipant( throw new Error("BlueBubbles addParticipant requires address"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, accountId } = resolveAccount(opts); + assertPrivateApiEnabled(accountId, "addParticipant"); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, @@ -255,7 +274,8 @@ export async function removeBlueBubblesParticipant( throw new Error("BlueBubbles removeParticipant requires address"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, accountId } = resolveAccount(opts); + assertPrivateApiEnabled(accountId, "removeParticipant"); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, @@ -292,7 +312,8 @@ export async function leaveBlueBubblesChat( throw new Error("BlueBubbles leaveChat requires chatGuid"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, accountId } = resolveAccount(opts); + assertPrivateApiEnabled(accountId, "leaveGroup"); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`, @@ -325,7 +346,8 @@ export async function setGroupIconBlueBubbles( throw new Error("BlueBubbles setGroupIcon requires image buffer"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, accountId } = resolveAccount(opts); + assertPrivateApiEnabled(accountId, "setGroupIcon"); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`, diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 34ae8b420cb..9d1514fa8e1 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -32,12 +32,14 @@ import { resolveBlueBubblesMessageId, resolveReplyContextFromCache, } from "./monitor-reply-cache.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js"; const DEFAULT_TEXT_LIMIT = 4000; const invalidAckReactions = new Set(); +const REPLY_DIRECTIVE_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\]/gi; export function logVerbose( core: BlueBubblesCoreRuntime, @@ -110,6 +112,7 @@ export async function processMessage( target: WebhookTarget, ): Promise { const { account, config, runtime, core, statusSink } = target; + const privateApiEnabled = getCachedBlueBubblesPrivateApiStatus(account.accountId) !== false; const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid); const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup; @@ -639,6 +642,15 @@ export async function processMessage( contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`, }); }; + const sanitizeReplyDirectiveText = (value: string): string => { + if (privateApiEnabled) { + return value; + } + return value + .replace(REPLY_DIRECTIVE_TAG_RE, " ") + .replace(/[ \t]+/g, " ") + .trim(); + }; const ctxPayload = { Body: body, @@ -721,7 +733,9 @@ export async function processMessage( ...prefixOptions, deliver: async (payload, info) => { const rawReplyToId = - typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; + privateApiEnabled && typeof payload.replyToId === "string" + ? payload.replyToId.trim() + : ""; // Resolve short ID (e.g., "5") to full UUID const replyToMessageGuid = rawReplyToId ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) @@ -737,7 +751,9 @@ export async function processMessage( channel: "bluebubbles", accountId: account.accountId, }); - const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const text = sanitizeReplyDirectiveText( + core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), + ); let first = true; for (const mediaUrl of mediaList) { const caption = first ? text : undefined; @@ -771,7 +787,9 @@ export async function processMessage( channel: "bluebubbles", accountId: account.accountId, }); - const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const text = sanitizeReplyDirectiveText( + core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), + ); const chunks = chunkMode === "newline" ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode) diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index ce0ca8d42f4..40bbad1419f 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -484,6 +484,11 @@ export async function monitorBlueBubblesProvider( if (serverInfo?.os_version) { runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`); } + if (typeof serverInfo?.private_api === "boolean") { + runtime.log?.( + `[${account.accountId}] BlueBubbles Private API ${serverInfo.private_api ? "enabled" : "disabled"}`, + ); + } const unregister = registerBlueBubblesWebhookTarget({ account, diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index d87a6d44714..7b49ae698ed 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -85,6 +85,18 @@ export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesS return null; } +/** + * Read cached private API capability for a BlueBubbles account. + * Returns null when capability is unknown (for example, before first probe). + */ +export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolean | null { + const info = getCachedBlueBubblesServerInfo(accountId); + if (!info || typeof info.private_api !== "boolean") { + return null; + } + return info.private_api; +} + /** * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number. */ diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts index 5b59eda0d88..9fab852089e 100644 --- a/extensions/bluebubbles/src/reactions.ts +++ b/extensions/bluebubbles/src/reactions.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; export type BlueBubblesReactionOpts = { @@ -123,7 +124,7 @@ function resolveAccount(params: BlueBubblesReactionOpts) { if (!password) { throw new Error("BlueBubbles password is required"); } - return { baseUrl, password }; + return { baseUrl, password, accountId: account.accountId }; } export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string { @@ -160,7 +161,12 @@ export async function sendBlueBubblesReaction(params: { throw new Error("BlueBubbles reaction requires messageGuid."); } const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove); - const { baseUrl, password } = resolveAccount(params.opts ?? {}); + const { baseUrl, password, accountId } = resolveAccount(params.opts ?? {}); + if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) { + throw new Error( + "BlueBubbles reaction requires Private API, but it is disabled on the BlueBubbles server.", + ); + } const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/message/react", diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index c10266068fc..88b1631ce93 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import type { BlueBubblesSendTarget } from "./types.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js"; vi.mock("./accounts.js", () => ({ @@ -14,12 +15,18 @@ vi.mock("./accounts.js", () => ({ }), })); +vi.mock("./probe.js", () => ({ + getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null), +})); + const mockFetch = vi.fn(); describe("send", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); mockFetch.mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null); }); afterEach(() => { @@ -611,6 +618,46 @@ describe("send", () => { expect(body.partIndex).toBe(1); }); + it("downgrades threaded reply to plain send when private API is disabled", async () => { + vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false); + 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-plain" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-123", + replyToPartIndex: 1, + }); + + expect(result.messageId).toBe("msg-uuid-plain"); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBeUndefined(); + expect(body.selectedMessageGuid).toBeUndefined(); + expect(body.partIndex).toBeUndefined(); + }); + it("normalizes effect names and uses private-api for effects", async () => { mockFetch .mockResolvedValueOnce({ diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 4a6a369dd56..eaa85a67898 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import crypto from "node:crypto"; import { stripMarkdown } from "openclaw/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { extractHandleFromChatGuid, normalizeBlueBubblesHandle, @@ -397,6 +398,7 @@ export async function sendMessageBlueBubbles( if (!password) { throw new Error("BlueBubbles password is required"); } + const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId); const target = resolveSendTarget(to); const chatGuid = await resolveChatGuidForTarget({ @@ -422,18 +424,26 @@ export async function sendMessageBlueBubbles( ); } const effectId = resolveEffectId(opts.effectId); - const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId); + const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim()); + const wantsEffect = Boolean(effectId); + const needsPrivateApi = wantsReplyThread || wantsEffect; + const canUsePrivateApi = needsPrivateApi && privateApiStatus !== false; + if (wantsEffect && privateApiStatus === false) { + throw new Error( + "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.", + ); + } const payload: Record = { chatGuid, tempGuid: crypto.randomUUID(), message: strippedText, }; - if (needsPrivateApi) { + if (canUsePrivateApi) { payload.method = "private-api"; } // Add reply threading support - if (opts.replyToMessageGuid) { + if (wantsReplyThread && canUsePrivateApi) { payload.selectedMessageGuid = opts.replyToMessageGuid; payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0; }