mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:40:43 +00: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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import "./test-mocks.js";
|
import "./test-mocks.js";
|
||||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.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 type { PluginRuntime } from "./runtime-api.js";
|
||||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import type { BlueBubblesAttachment } from "./types.js";
|
import type { BlueBubblesAttachment } from "./types.js";
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
|
const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo);
|
||||||
const fetchRemoteMediaMock = vi.fn(
|
const fetchRemoteMediaMock = vi.fn(
|
||||||
async (params: {
|
async (params: {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -381,6 +382,8 @@ describe("sendBlueBubblesAttachment", () => {
|
|||||||
vi.stubGlobal("fetch", mockFetch);
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
mockFetch.mockReset();
|
mockFetch.mockReset();
|
||||||
fetchRemoteMediaMock.mockClear();
|
fetchRemoteMediaMock.mockClear();
|
||||||
|
fetchServerInfoMock.mockReset();
|
||||||
|
fetchServerInfoMock.mockResolvedValue(null);
|
||||||
setBlueBubblesRuntime(runtimeStub);
|
setBlueBubblesRuntime(runtimeStub);
|
||||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||||
mockBlueBubblesPrivateApiStatus(
|
mockBlueBubblesPrivateApiStatus(
|
||||||
@@ -620,6 +623,136 @@ describe("sendBlueBubblesAttachment", () => {
|
|||||||
expect(attachText).toContain("iMessage;-;+15557654321");
|
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 () => {
|
it("still throws for non-handle targets when chatGuid is not found", async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||||
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
|
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
|
||||||
import {
|
import {
|
||||||
|
fetchBlueBubblesServerInfo,
|
||||||
getCachedBlueBubblesPrivateApiStatus,
|
getCachedBlueBubblesPrivateApiStatus,
|
||||||
isBlueBubblesPrivateApiStatusEnabled,
|
isBlueBubblesPrivateApiStatusEnabled,
|
||||||
} from "./probe.js";
|
} from "./probe.js";
|
||||||
@@ -171,7 +172,27 @@ export async function sendBlueBubblesAttachment(params: {
|
|||||||
filename = sanitizeFilename(filename, fallbackName);
|
filename = sanitizeFilename(filename, fallbackName);
|
||||||
contentType = normalizeOptionalString(contentType);
|
contentType = normalizeOptionalString(contentType);
|
||||||
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts);
|
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);
|
const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
|
||||||
|
|
||||||
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
// 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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import "./test-mocks.js";
|
import "./test-mocks.js";
|
||||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||||
import type { PluginRuntime } from "./runtime-api.js";
|
import type { PluginRuntime } from "./runtime-api.js";
|
||||||
import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
|
import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
|
||||||
import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js";
|
import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js";
|
||||||
@@ -14,6 +14,7 @@ import { _setFetchGuardForTesting, type BlueBubblesSendTarget } from "./types.js
|
|||||||
|
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);
|
const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);
|
||||||
|
const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo);
|
||||||
const setFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
|
const setFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
|
||||||
|
|
||||||
installBlueBubblesFetchTestHooks({
|
installBlueBubblesFetchTestHooks({
|
||||||
@@ -378,6 +379,8 @@ describe("send", () => {
|
|||||||
describe("sendMessageBlueBubbles", () => {
|
describe("sendMessageBlueBubbles", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetch.mockReset();
|
mockFetch.mockReset();
|
||||||
|
fetchServerInfoMock.mockReset();
|
||||||
|
fetchServerInfoMock.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when text is empty", async () => {
|
it("throws when text is empty", async () => {
|
||||||
@@ -826,6 +829,200 @@ describe("send", () => {
|
|||||||
expect(typeof body.tempGuid).toBe("string");
|
expect(typeof body.tempGuid).toBe("string");
|
||||||
expect(body.tempGuid.length).toBeGreaterThan(0);
|
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", () => {
|
describe("createChatForHandle", () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "openclaw/plugin-sdk/text-runtime";
|
} from "openclaw/plugin-sdk/text-runtime";
|
||||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||||
import {
|
import {
|
||||||
|
fetchBlueBubblesServerInfo,
|
||||||
getCachedBlueBubblesPrivateApiStatus,
|
getCachedBlueBubblesPrivateApiStatus,
|
||||||
isBlueBubblesPrivateApiStatusEnabled,
|
isBlueBubblesPrivateApiStatusEnabled,
|
||||||
} from "./probe.js";
|
} from "./probe.js";
|
||||||
@@ -456,7 +457,7 @@ export async function sendMessageBlueBubbles(
|
|||||||
serverUrl: opts.serverUrl,
|
serverUrl: opts.serverUrl,
|
||||||
password: opts.password,
|
password: opts.password,
|
||||||
});
|
});
|
||||||
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
|
let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
|
||||||
|
|
||||||
const target = resolveBlueBubblesSendTarget(to);
|
const target = resolveBlueBubblesSendTarget(to);
|
||||||
const chatGuid = await resolveChatGuidForTarget({
|
const chatGuid = await resolveChatGuidForTarget({
|
||||||
@@ -486,6 +487,25 @@ export async function sendMessageBlueBubbles(
|
|||||||
const effectId = resolveEffectId(opts.effectId);
|
const effectId = resolveEffectId(opts.effectId);
|
||||||
const wantsReplyThread = normalizeOptionalString(opts.replyToMessageGuid) !== undefined;
|
const wantsReplyThread = normalizeOptionalString(opts.replyToMessageGuid) !== undefined;
|
||||||
const wantsEffect = Boolean(effectId);
|
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({
|
const privateApiDecision = resolvePrivateApiDecision({
|
||||||
privateApiStatus,
|
privateApiStatus,
|
||||||
wantsReplyThread,
|
wantsReplyThread,
|
||||||
|
|||||||
@@ -82,12 +82,14 @@ export function createBlueBubblesAccountsMockModule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BlueBubblesProbeMockModule = {
|
type BlueBubblesProbeMockModule = {
|
||||||
|
fetchBlueBubblesServerInfo: Mock<() => Promise<Record<string, unknown> | null>>;
|
||||||
getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>;
|
getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>;
|
||||||
isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>;
|
isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
|
export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
|
||||||
return {
|
return {
|
||||||
|
fetchBlueBubblesServerInfo: vi.fn().mockResolvedValue(null),
|
||||||
getCachedBlueBubblesPrivateApiStatus: vi
|
getCachedBlueBubblesPrivateApiStatus: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown),
|
.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
|
// Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime
|
||||||
// code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers.
|
// code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers.
|
||||||
const allowedRawFetchCallsites = new Set([
|
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("bluebubbles", "src/types.ts", 181),
|
||||||
bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 268),
|
bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 268),
|
||||||
bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 192),
|
bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 192),
|
||||||
|
|||||||
Reference in New Issue
Block a user