bluebubbles: gracefully handle disabled private API with action/tool filtering and fallbacks (#16002)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 243cc0cc9a
Co-authored-by: tyler6204 <243?+tyler6204@users.noreply.github.com>
Co-authored-by: tyler6204 <64381258+tyler6204@users.noreply.github.com>
Reviewed-by: @tyler6204
This commit is contained in:
Tyler Yust
2026-02-13 21:15:56 -08:00
committed by GitHub
parent d8beddc8b7
commit 45e12d2388
13 changed files with 305 additions and 23 deletions

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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<string, unknown>, key: string): boolean
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
"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<ChannelMessageActionName>();
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<string> => {
@@ -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);

View File

@@ -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"');
});
});

View File

@@ -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");
}

View File

@@ -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,

View File

@@ -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`,

View File

@@ -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<string>();
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<void> {
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)

View File

@@ -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,

View File

@@ -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.
*/

View File

@@ -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",

View File

@@ -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({

View File

@@ -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<string, unknown> = {
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;
}