refactor(bluebubbles): centralize private-api status handling

This commit is contained in:
Peter Steinberger
2026-02-22 12:08:08 +01:00
parent 6f7e5f92c3
commit 296b3f49ef
8 changed files with 186 additions and 35 deletions

View File

@@ -4,7 +4,12 @@ import "./test-mocks.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { setBlueBubblesRuntime } from "./runtime.js";
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
import {
BLUE_BUBBLES_PRIVATE_API_STATUS,
installBlueBubblesFetchTestHooks,
mockBlueBubblesPrivateApiStatus,
mockBlueBubblesPrivateApiStatusOnce,
} from "./test-harness.js";
import type { BlueBubblesAttachment } from "./types.js";
const mockFetch = vi.fn();
@@ -278,7 +283,10 @@ describe("sendBlueBubblesAttachment", () => {
fetchRemoteMediaMock.mockClear();
setBlueBubblesRuntime(runtimeStub);
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
mockBlueBubblesPrivateApiStatus(
vi.mocked(getCachedBlueBubblesPrivateApiStatus),
BLUE_BUBBLES_PRIVATE_API_STATUS.unknown,
);
});
afterEach(() => {
@@ -381,7 +389,10 @@ describe("sendBlueBubblesAttachment", () => {
});
it("downgrades attachment reply threading when private API is disabled", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
mockBlueBubblesPrivateApiStatusOnce(
vi.mocked(getCachedBlueBubblesPrivateApiStatus),
BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
);
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
@@ -402,4 +413,32 @@ describe("sendBlueBubblesAttachment", () => {
expect(bodyText).not.toContain('name="selectedMessageGuid"');
expect(bodyText).not.toContain('name="partIndex"');
});
it("warns and downgrades attachment reply threading when private API status is unknown", async () => {
const runtimeLog = vi.fn();
setBlueBubblesRuntime({
...runtimeStub,
log: runtimeLog,
} as unknown as PluginRuntime);
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })),
});
await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "photo.jpg",
contentType: "image/jpeg",
replyToMessageGuid: "reply-guid-unknown",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
expect(runtimeLog).toHaveBeenCalledTimes(1);
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
const bodyText = decodeBody(body);
expect(bodyText).not.toContain('name="selectedMessageGuid"');
expect(bodyText).not.toContain('name="partIndex"');
});
});

View File

@@ -3,9 +3,12 @@ import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import {
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import { resolveRequestUrl } from "./request-url.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { resolveChatGuidForTarget } from "./send.js";
import {
@@ -139,6 +142,7 @@ export async function sendBlueBubblesAttachment(params: {
contentType = contentType?.trim() || undefined;
const { baseUrl, password, accountId } = resolveAccount(opts);
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
const isAudioMessage = wantsVoice;
@@ -207,7 +211,7 @@ export async function sendBlueBubblesAttachment(params: {
addField("chatGuid", chatGuid);
addField("name", filename);
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
if (privateApiStatus === true) {
if (privateApiEnabled) {
addField("method", "private-api");
}
@@ -217,9 +221,13 @@ export async function sendBlueBubblesAttachment(params: {
}
const trimmedReplyTo = replyToMessageGuid?.trim();
if (trimmedReplyTo && privateApiStatus === true) {
if (trimmedReplyTo && privateApiEnabled) {
addField("selectedMessageGuid", trimmedReplyTo);
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
} else if (trimmedReplyTo && privateApiStatus === null) {
warnBlueBubbles(
"Private API status unknown; sending attachment without reply threading metadata. Run a status probe to restore private-api reply features.",
);
}
// Add optional caption

View File

@@ -39,7 +39,7 @@ import type {
BlueBubblesRuntimeEnv,
WebhookTarget,
} from "./monitor-shared.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
@@ -420,7 +420,7 @@ export async function processMessage(
target: WebhookTarget,
): Promise<void> {
const { account, config, runtime, core, statusSink } = target;
const privateApiEnabled = getCachedBlueBubblesPrivateApiStatus(account.accountId) === true;
const privateApiEnabled = isBlueBubblesPrivateApiEnabled(account.accountId);
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;

View File

@@ -96,6 +96,14 @@ export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolea
return info.private_api;
}
export function isBlueBubblesPrivateApiStatusEnabled(status: boolean | null): boolean {
return status === true;
}
export function isBlueBubblesPrivateApiEnabled(accountId?: string): boolean {
return isBlueBubblesPrivateApiStatusEnabled(getCachedBlueBubblesPrivateApiStatus(accountId));
}
/**
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
*/

View File

@@ -6,9 +6,27 @@ export function setBlueBubblesRuntime(next: PluginRuntime): void {
runtime = next;
}
export function clearBlueBubblesRuntime(): void {
runtime = null;
}
export function tryGetBlueBubblesRuntime(): PluginRuntime | null {
return runtime;
}
export function getBlueBubblesRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("BlueBubbles runtime not initialized");
}
return runtime;
}
export function warnBlueBubbles(message: string): void {
const formatted = `[bluebubbles] ${message}`;
const log = runtime?.log;
if (typeof log === "function") {
log(formatted);
return;
}
console.warn(formatted);
}

View File

@@ -1,15 +1,22 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
import {
BLUE_BUBBLES_PRIVATE_API_STATUS,
installBlueBubblesFetchTestHooks,
mockBlueBubblesPrivateApiStatusOnce,
} from "./test-harness.js";
import type { BlueBubblesSendTarget } from "./types.js";
const mockFetch = vi.fn();
const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
privateApiStatusMock,
});
function mockResolvedHandleTarget(
@@ -527,7 +534,10 @@ describe("send", () => {
});
it("uses private-api when reply metadata is present", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(true);
mockBlueBubblesPrivateApiStatusOnce(
privateApiStatusMock,
BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-124" } });
@@ -549,7 +559,10 @@ describe("send", () => {
});
it("downgrades threaded reply to plain send when private API is disabled", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
mockBlueBubblesPrivateApiStatusOnce(
privateApiStatusMock,
BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-plain" } });
@@ -569,7 +582,10 @@ describe("send", () => {
});
it("normalizes effect names and uses private-api for effects", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(true);
mockBlueBubblesPrivateApiStatusOnce(
privateApiStatusMock,
BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-125" } });
@@ -589,6 +605,8 @@ describe("send", () => {
});
it("warns and downgrades private-api features when status is unknown", async () => {
const runtimeLog = vi.fn();
setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-unknown" } });
@@ -602,8 +620,9 @@ describe("send", () => {
});
expect(result.messageId).toBe("msg-uuid-unknown");
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0]?.[0]).toContain("Private API status unknown");
expect(runtimeLog).toHaveBeenCalledTimes(1);
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
expect(warnSpy).not.toHaveBeenCalled();
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
@@ -612,6 +631,7 @@ describe("send", () => {
expect(body.partIndex).toBeUndefined();
expect(body.effectId).toBeUndefined();
} finally {
clearBlueBubblesRuntime();
warnSpy.mockRestore();
}
});

View File

@@ -2,7 +2,11 @@ import crypto from "node:crypto";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { stripMarkdown } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import {
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import { warnBlueBubbles } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import {
@@ -71,6 +75,38 @@ function resolveEffectId(raw?: string): string | undefined {
return raw;
}
type PrivateApiDecision = {
canUsePrivateApi: boolean;
throwEffectDisabledError: boolean;
warningMessage?: string;
};
function resolvePrivateApiDecision(params: {
privateApiStatus: boolean | null;
wantsReplyThread: boolean;
wantsEffect: boolean;
}): PrivateApiDecision {
const { privateApiStatus, wantsReplyThread, wantsEffect } = params;
const needsPrivateApi = wantsReplyThread || wantsEffect;
const canUsePrivateApi =
needsPrivateApi && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
const throwEffectDisabledError = wantsEffect && privateApiStatus === false;
if (!needsPrivateApi || privateApiStatus !== null) {
return { canUsePrivateApi, throwEffectDisabledError };
}
const requested = [
wantsReplyThread ? "reply threading" : null,
wantsEffect ? "message effects" : null,
]
.filter(Boolean)
.join(" + ");
return {
canUsePrivateApi,
throwEffectDisabledError,
warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`,
};
}
type BlueBubblesChatRecord = Record<string, unknown>;
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
@@ -372,41 +408,36 @@ export async function sendMessageBlueBubbles(
const effectId = resolveEffectId(opts.effectId);
const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
const wantsEffect = Boolean(effectId);
const needsPrivateApi = wantsReplyThread || wantsEffect;
const canUsePrivateApi = needsPrivateApi && privateApiStatus === true;
if (wantsEffect && privateApiStatus === false) {
const privateApiDecision = resolvePrivateApiDecision({
privateApiStatus,
wantsReplyThread,
wantsEffect,
});
if (privateApiDecision.throwEffectDisabledError) {
throw new Error(
"BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
);
}
if (needsPrivateApi && privateApiStatus === null) {
const requested = [
wantsReplyThread ? "reply threading" : null,
wantsEffect ? "message effects" : null,
]
.filter(Boolean)
.join(" + ");
console.warn(
`[bluebubbles] Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`,
);
if (privateApiDecision.warningMessage) {
warnBlueBubbles(privateApiDecision.warningMessage);
}
const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: strippedText,
};
if (canUsePrivateApi) {
if (privateApiDecision.canUsePrivateApi) {
payload.method = "private-api";
}
// Add reply threading support
if (wantsReplyThread && canUsePrivateApi) {
if (wantsReplyThread && privateApiDecision.canUsePrivateApi) {
payload.selectedMessageGuid = opts.replyToMessageGuid;
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
}
// Add message effects support
if (effectId && canUsePrivateApi) {
if (effectId && privateApiDecision.canUsePrivateApi) {
payload.effectId = effectId;
}

View File

@@ -1,6 +1,31 @@
import type { Mock } from "vitest";
import { afterEach, beforeEach, vi } from "vitest";
export const BLUE_BUBBLES_PRIVATE_API_STATUS = {
enabled: true as const,
disabled: false as const,
unknown: null as const,
};
type BlueBubblesPrivateApiStatusMock = {
mockReturnValue: (value: boolean | null) => unknown;
mockReturnValueOnce: (value: boolean | null) => unknown;
};
export function mockBlueBubblesPrivateApiStatus(
mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValue">,
value: boolean | null,
) {
mock.mockReturnValue(value);
}
export function mockBlueBubblesPrivateApiStatusOnce(
mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValueOnce">,
value: boolean | null,
) {
mock.mockReturnValueOnce(value);
}
export function resolveBlueBubblesAccountFromConfig(params: {
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
accountId?: string;
@@ -26,7 +51,9 @@ type BlueBubblesProbeMockModule = {
export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
return {
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
getCachedBlueBubblesPrivateApiStatus: vi
.fn()
.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown),
};
}
@@ -41,7 +68,7 @@ export function installBlueBubblesFetchTestHooks(params: {
vi.stubGlobal("fetch", params.mockFetch);
params.mockFetch.mockReset();
params.privateApiStatusMock.mockReset();
params.privateApiStatusMock.mockReturnValue(null);
params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown);
});
afterEach(() => {