mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
refactor(bluebubbles): centralize private-api status handling
This commit is contained in:
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user