diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 47f6e6d03cc..17060229930 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -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"'); + }); }); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 5d5841c8295..3b8850f2154 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -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 diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 8f58c7ab552..67fb50a78c6 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -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 { 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; diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index e60c47dc643..5ee95a26821 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -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. */ diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts index 2f183c74e4d..439e62d2503 100644 --- a/extensions/bluebubbles/src/runtime.ts +++ b/extensions/bluebubbles/src/runtime.ts @@ -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); +} diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 7a2edeaf850..9872372641e 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -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(); } }); diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 1530d1702c2..4719fb416f8 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -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; 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 = { 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; } diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts index 627b04197ba..7c6938a9681 100644 --- a/extensions/bluebubbles/src/test-harness.ts +++ b/extensions/bluebubbles/src/test-harness.ts @@ -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, + value: boolean | null, +) { + mock.mockReturnValue(value); +} + +export function mockBlueBubblesPrivateApiStatusOnce( + mock: Pick, + value: boolean | null, +) { + mock.mockReturnValueOnce(value); +} + export function resolveBlueBubblesAccountFromConfig(params: { cfg?: { channels?: { bluebubbles?: Record } }; 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(() => {