fix(bluebubbles): lazy-refresh Private API status on send (#43764) (#65447)

* fix(bluebubbles): lazy refresh Private API cache on send to prevent silent reply threading degradation (#43764)

When the 10-minute server info cache expires, sends requesting reply
threading or effects silently degrade to plain messages. Add a lazy
async refresh of the cache in the send path when Private API features
are needed but status is unknown, preserving graceful degradation if
the refresh fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(bluebubbles): apply lazy Private API refresh to attachment sends and add missing test coverage (#43764)

Attachment sends had the same cache-expiry bug as text sends: when the
10-minute Private API status cache TTL expired, reply threading metadata
was silently dropped. Apply the same lazy-refresh pattern from send.ts.

Also add the missing "refresh succeeds with private_api: false" test case
for both send.ts and attachments.ts — proves effects throw and reply
threading degrades without the "unknown" warning when the API is explicitly
disabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update no-raw-channel-fetch allowlist for test-harness line shift

Adding fetchBlueBubblesServerInfo to the probe mock module shifted
globalThis.fetch in test-harness.ts from line 128 to 130.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Lobster <lobster@shahine.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Omar Shahine
2026-04-13 11:03:47 -07:00
committed by GitHub
parent ab4efa47b5
commit 85cfba675a
6 changed files with 378 additions and 5 deletions

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import type { PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
import {
@@ -13,6 +13,7 @@ import {
import type { BlueBubblesAttachment } from "./types.js";
const mockFetch = vi.fn();
const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo);
const fetchRemoteMediaMock = vi.fn(
async (params: {
url: string;
@@ -381,6 +382,8 @@ describe("sendBlueBubblesAttachment", () => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
fetchRemoteMediaMock.mockClear();
fetchServerInfoMock.mockReset();
fetchServerInfoMock.mockResolvedValue(null);
setBlueBubblesRuntime(runtimeStub);
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
mockBlueBubblesPrivateApiStatus(
@@ -620,6 +623,136 @@ describe("sendBlueBubblesAttachment", () => {
expect(attachText).toContain("iMessage;-;+15557654321");
});
describe("lazy private API refresh (#43764)", () => {
const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);
it("refreshes cache when expired and reply threading is requested", async () => {
privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true);
fetchServerInfoMock.mockResolvedValueOnce({ private_api: true });
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-refreshed" } })),
});
const result = await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "photo.jpg",
contentType: "image/jpeg",
replyToMessageGuid: "reply-guid-456",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
expect(result.messageId).toBe("msg-refreshed");
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
const bodyText = decodeBody(body);
expect(bodyText).toContain('name="method"');
expect(bodyText).toContain("private-api");
expect(bodyText).toContain('name="selectedMessageGuid"');
});
it("does not refresh when cache is populated (cache hit)", async () => {
mockBlueBubblesPrivateApiStatusOnce(
privateApiStatusMock,
BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
);
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-cached" } })),
});
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" },
});
expect(fetchServerInfoMock).not.toHaveBeenCalled();
});
it("degrades gracefully when refresh fails", async () => {
fetchServerInfoMock.mockRejectedValueOnce(new Error("network error"));
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-degraded" } })),
});
const runtimeLog = vi.fn();
setBlueBubblesRuntime({
...runtimeStub,
log: runtimeLog,
} as unknown as PluginRuntime);
const result = await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "photo.jpg",
contentType: "image/jpeg",
replyToMessageGuid: "reply-guid-789",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
expect(result.messageId).toBe("msg-degraded");
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
expect(runtimeLog).toHaveBeenCalledTimes(1);
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
});
it("degrades reply threading when refresh succeeds with private_api: false", async () => {
privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false);
fetchServerInfoMock.mockResolvedValueOnce({ private_api: false });
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-disabled" } })),
});
const runtimeLog = vi.fn();
setBlueBubblesRuntime({
...runtimeStub,
log: runtimeLog,
} as unknown as PluginRuntime);
const result = await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "photo.jpg",
contentType: "image/jpeg",
replyToMessageGuid: "reply-guid-disabled",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
expect(result.messageId).toBe("msg-disabled");
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
// No warning — status is known (disabled), not unknown
expect(runtimeLog).not.toHaveBeenCalled();
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="method"');
});
it("does not refresh when no reply threading is requested", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-plain" } })),
});
await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "photo.jpg",
contentType: "image/jpeg",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
expect(fetchServerInfoMock).not.toHaveBeenCalled();
});
});
it("still throws for non-handle targets when chatGuid is not found", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,

View File

@@ -10,6 +10,7 @@ import {
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
import {
fetchBlueBubblesServerInfo,
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
@@ -171,7 +172,27 @@ export async function sendBlueBubblesAttachment(params: {
filename = sanitizeFilename(filename, fallbackName);
contentType = normalizeOptionalString(contentType);
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts);
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
// Lazy refresh: when the cache has expired and Private API features are needed,
// fetch server info before making the decision. This prevents silent degradation
// of reply threading after the 10-minute cache TTL expires. (#43764)
const wantsReplyThread = Boolean(replyToMessageGuid?.trim());
if (privateApiStatus === null && wantsReplyThread) {
try {
await fetchBlueBubblesServerInfo({
baseUrl,
password,
accountId,
timeoutMs: opts.timeoutMs ?? 5000,
allowPrivateNetwork,
});
privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
} catch {
// Refresh failed — proceed with null status (existing graceful degradation)
}
}
const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import type { PluginRuntime } from "./runtime-api.js";
import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js";
@@ -14,6 +14,7 @@ import { _setFetchGuardForTesting, type BlueBubblesSendTarget } from "./types.js
const mockFetch = vi.fn();
const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);
const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo);
const setFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
installBlueBubblesFetchTestHooks({
@@ -378,6 +379,8 @@ describe("send", () => {
describe("sendMessageBlueBubbles", () => {
beforeEach(() => {
mockFetch.mockReset();
fetchServerInfoMock.mockReset();
fetchServerInfoMock.mockResolvedValue(null);
});
it("throws when text is empty", async () => {
@@ -826,6 +829,200 @@ describe("send", () => {
expect(typeof body.tempGuid).toBe("string");
expect(body.tempGuid.length).toBeGreaterThan(0);
});
describe("lazy private API refresh (#43764)", () => {
it("does not refresh when cache is populated (cache hit)", async () => {
mockBlueBubblesPrivateApiStatusOnce(
privateApiStatusMock,
BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-cached" } });
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
serverUrl: "http://localhost:1234",
password: "test",
replyToMessageGuid: "reply-guid-123",
});
expect(result.messageId).toBe("msg-cached");
expect(fetchServerInfoMock).not.toHaveBeenCalled();
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBe("private-api");
expect(body.selectedMessageGuid).toBe("reply-guid-123");
});
it("refreshes cache when expired and reply threading is requested", async () => {
// First call returns null (cache expired), after refresh returns enabled
privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true);
fetchServerInfoMock.mockResolvedValueOnce({ private_api: true });
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-refreshed" } });
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
serverUrl: "http://localhost:1234",
password: "test",
replyToMessageGuid: "reply-guid-456",
});
expect(result.messageId).toBe("msg-refreshed");
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
expect(fetchServerInfoMock).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: expect.stringContaining("localhost"),
password: "test",
accountId: expect.any(String),
allowPrivateNetwork: expect.any(Boolean),
}),
);
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBe("private-api");
expect(body.selectedMessageGuid).toBe("reply-guid-456");
});
it("refreshes cache when expired and effect is requested", async () => {
privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true);
fetchServerInfoMock.mockResolvedValueOnce({ private_api: true });
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-effect-refreshed" } });
const result = await sendMessageBlueBubbles("+15551234567", "Party!", {
serverUrl: "http://localhost:1234",
password: "test",
effectId: "confetti",
});
expect(result.messageId).toBe("msg-effect-refreshed");
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBe("private-api");
expect(body.effectId).toBe("com.apple.messages.effect.CKConfettiEffect");
});
it("degrades gracefully when refresh fails", async () => {
// Cache expired, refresh throws — should fall back to existing behavior
fetchServerInfoMock.mockRejectedValueOnce(new Error("network error"));
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-degraded" } });
const runtimeLog = vi.fn();
setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime);
try {
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
serverUrl: "http://localhost:1234",
password: "test",
replyToMessageGuid: "reply-guid-789",
});
expect(result.messageId).toBe("msg-degraded");
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
// Should warn about unknown status and send without threading
expect(runtimeLog).toHaveBeenCalledTimes(1);
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBeUndefined();
expect(body.selectedMessageGuid).toBeUndefined();
} finally {
clearBlueBubblesRuntime();
}
});
it("throws for effects when refresh succeeds with private_api: false", async () => {
// Cache expired, refresh succeeds but Private API is explicitly disabled
privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false);
fetchServerInfoMock.mockResolvedValueOnce({ private_api: false });
mockResolvedHandleTarget();
await expect(
sendMessageBlueBubbles("+15551234567", "Party!", {
serverUrl: "http://localhost:1234",
password: "test",
effectId: "confetti",
}),
).rejects.toThrow("Private API");
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
});
it("degrades reply threading when refresh succeeds with private_api: false", async () => {
// Cache expired, refresh succeeds but Private API is explicitly disabled
// Should degrade without the "unknown" warning (status is known: disabled)
privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false);
fetchServerInfoMock.mockResolvedValueOnce({ private_api: false });
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-disabled-after-refresh" } });
const runtimeLog = vi.fn();
setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime);
try {
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
serverUrl: "http://localhost:1234",
password: "test",
replyToMessageGuid: "reply-guid-disabled",
});
expect(result.messageId).toBe("msg-disabled-after-refresh");
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
// No warning — status is known (disabled), not unknown
expect(runtimeLog).not.toHaveBeenCalled();
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBeUndefined();
expect(body.selectedMessageGuid).toBeUndefined();
} finally {
clearBlueBubblesRuntime();
}
});
it("does not refresh when no reply or effect is requested", async () => {
// Cache expired but no Private API features needed — skip refresh
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-plain" } });
const result = await sendMessageBlueBubbles("+15551234567", "Plain message", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("msg-plain");
expect(fetchServerInfoMock).not.toHaveBeenCalled();
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBeUndefined();
});
it("degrades gracefully when refresh returns null (server unreachable)", async () => {
// Cache expired, refresh returns null (server info unavailable)
fetchServerInfoMock.mockResolvedValueOnce(null);
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-null-refresh" } });
const runtimeLog = vi.fn();
setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime);
try {
const result = await sendMessageBlueBubbles("+15551234567", "Reply attempt", {
serverUrl: "http://localhost:1234",
password: "test",
replyToMessageGuid: "reply-guid-000",
});
expect(result.messageId).toBe("msg-null-refresh");
expect(fetchServerInfoMock).toHaveBeenCalledTimes(1);
// privateApiStatus still null after failed refresh → warning + degradation
expect(runtimeLog).toHaveBeenCalledTimes(1);
expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
} finally {
clearBlueBubblesRuntime();
}
});
});
});
describe("createChatForHandle", () => {

View File

@@ -7,6 +7,7 @@ import {
} from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import {
fetchBlueBubblesServerInfo,
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
@@ -456,7 +457,7 @@ export async function sendMessageBlueBubbles(
serverUrl: opts.serverUrl,
password: opts.password,
});
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
const target = resolveBlueBubblesSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
@@ -486,6 +487,25 @@ export async function sendMessageBlueBubbles(
const effectId = resolveEffectId(opts.effectId);
const wantsReplyThread = normalizeOptionalString(opts.replyToMessageGuid) !== undefined;
const wantsEffect = Boolean(effectId);
// Lazy refresh: when the cache has expired and Private API features are needed,
// fetch server info before making the decision. This prevents silent degradation
// of reply threading and effects after the 10-minute cache TTL expires. (#43764)
if (privateApiStatus === null && (wantsReplyThread || wantsEffect)) {
try {
await fetchBlueBubblesServerInfo({
baseUrl,
password,
accountId,
timeoutMs: opts.timeoutMs ?? 5000,
allowPrivateNetwork,
});
privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
} catch {
// Refresh failed — proceed with null status (existing graceful degradation)
}
}
const privateApiDecision = resolvePrivateApiDecision({
privateApiStatus,
wantsReplyThread,

View File

@@ -82,12 +82,14 @@ export function createBlueBubblesAccountsMockModule() {
}
type BlueBubblesProbeMockModule = {
fetchBlueBubblesServerInfo: Mock<() => Promise<Record<string, unknown> | null>>;
getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>;
isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>;
};
export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
return {
fetchBlueBubblesServerInfo: vi.fn().mockResolvedValue(null),
getCachedBlueBubblesPrivateApiStatus: vi
.fn()
.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown),

View File

@@ -14,7 +14,7 @@ const sourceRoots = ["src/channels", "src/routing", "src/line", "extensions"];
// Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime
// code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers.
const allowedRawFetchCallsites = new Set([
bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 128),
bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 130),
bundledPluginCallsite("bluebubbles", "src/types.ts", 181),
bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 268),
bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 192),