mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 04:57:09 +02:00
* 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:
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user