Last active
March 14, 2026 18:16
-
-
Save omarshahine/a0dd69e5126a62ad83dcd56275bacde7 to your computer and use it in GitHub Desktop.
fix(bluebubbles): lazy-refresh Private API status for reply threading — openclaw/openclaw#43764
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts | |
| index f820ebd9b..8e3013b73 100644 | |
| --- a/extensions/bluebubbles/src/send.test.ts | |
| +++ b/extensions/bluebubbles/src/send.test.ts | |
| @@ -1,7 +1,7 @@ | |
| import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; | |
| import { beforeEach, describe, expect, it, vi } from "vitest"; | |
| import "./test-mocks.js"; | |
| -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; | |
| +import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; | |
| import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js"; | |
| import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js"; | |
| import { | |
| @@ -13,6 +13,7 @@ import type { BlueBubblesSendTarget } from "./types.js"; | |
| const mockFetch = vi.fn(); | |
| const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus); | |
| +const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo); | |
| installBlueBubblesFetchTestHooks({ | |
| mockFetch, | |
| @@ -627,6 +628,76 @@ describe("send", () => { | |
| } | |
| }); | |
| + it("lazy-refreshes Private API status when cache expired and reply requested", async () => { | |
| + // Regression test for #43764: when the 10-minute server info cache expires, | |
| + // privateApiStatus becomes null and replies silently degrade to plain sends. | |
| + // The lazy-refresh should re-fetch server info and restore reply threading. | |
| + | |
| + // First call returns null (cache expired), second call returns true (after refresh) | |
| + privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true); | |
| + fetchServerInfoMock.mockResolvedValueOnce({ private_api: true, server_version: "1.0.0" }); | |
| + | |
| + mockResolvedHandleTarget(); | |
| + mockSendResponse({ data: { guid: "msg-uuid-refreshed" } }); | |
| + | |
| + const result = await sendMessageBlueBubbles("+15551234567", "Threaded reply", { | |
| + serverUrl: "http://localhost:1234", | |
| + password: "test", | |
| + replyToMessageGuid: "reply-guid-456", | |
| + replyToPartIndex: 0, | |
| + }); | |
| + | |
| + expect(result.messageId).toBe("msg-uuid-refreshed"); | |
| + | |
| + // fetchBlueBubblesServerInfo should have been called to refresh the cache | |
| + expect(fetchServerInfoMock).toHaveBeenCalledWith( | |
| + expect.objectContaining({ | |
| + baseUrl: "http://localhost:1234", | |
| + password: "test", | |
| + timeoutMs: 5000, | |
| + }), | |
| + ); | |
| + | |
| + // The send payload should include reply threading fields (not degraded) | |
| + 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"); | |
| + expect(body.partIndex).toBe(0); | |
| + }); | |
| + | |
| + it("degrades to plain send when lazy-refresh fails to restore Private API", async () => { | |
| + // If fetchBlueBubblesServerInfo returns null (server unreachable), | |
| + // privateApiStatus stays null and the reply should degrade gracefully. | |
| + const runtimeLog = vi.fn(); | |
| + setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); | |
| + | |
| + privateApiStatusMock.mockReturnValue(null); | |
| + fetchServerInfoMock.mockResolvedValueOnce(null); | |
| + | |
| + mockResolvedHandleTarget(); | |
| + mockSendResponse({ data: { guid: "msg-uuid-degraded" } }); | |
| + | |
| + try { | |
| + const result = await sendMessageBlueBubbles("+15551234567", "Fallback reply", { | |
| + serverUrl: "http://localhost:1234", | |
| + password: "test", | |
| + replyToMessageGuid: "reply-guid-789", | |
| + }); | |
| + | |
| + expect(result.messageId).toBe("msg-uuid-degraded"); | |
| + expect(fetchServerInfoMock).toHaveBeenCalled(); | |
| + | |
| + // Should degrade: no private-api method, no selectedMessageGuid | |
| + const sendCall = mockFetch.mock.calls[1]; | |
| + const body = JSON.parse(sendCall[1].body); | |
| + expect(body.method).toBeUndefined(); | |
| + expect(body.selectedMessageGuid).toBeUndefined(); | |
| + } finally { | |
| + clearBlueBubblesRuntime(); | |
| + } | |
| + }); | |
| + | |
| it("sends message with chat_guid target directly", async () => { | |
| mockFetch.mockResolvedValueOnce({ | |
| ok: true, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| From ddfcb3e5a3fe1091fc65e8f92e1a26b2ce95d5c5 Mon Sep 17 00:00:00 2001 | |
| From: Lobster <lobster@shahine.com> | |
| Date: Wed, 11 Mar 2026 23:58:41 -0700 | |
| Subject: [PATCH 1/3] fix(bluebubbles): lazy-refresh Private API status for | |
| reply threading | |
| MIME-Version: 1.0 | |
| Content-Type: text/plain; charset=UTF-8 | |
| Content-Transfer-Encoding: 8bit | |
| The server info cache (10-min TTL) expires after gateway startup, | |
| causing `getCachedBlueBubblesPrivateApiStatus()` to return `null`. | |
| Since #23393 changed the check to `=== true`, expired cache silently | |
| degrades reply threading to plain sends — no error, no agent-visible | |
| warning. | |
| Add a lazy `fetchBlueBubblesServerInfo()` call in `sendMessageBlueBubbles()` | |
| when Private API status is unknown and a reply or effect is requested. | |
| This re-populates the cache on-demand without affecting plain sends. | |
| Fixes #43764 | |
| Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> | |
| --- | |
| extensions/bluebubbles/src/send.ts | 18 +++++++++++++++++- | |
| 1 file changed, 17 insertions(+), 1 deletion(-) | |
| diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts | |
| index 8c12e88bd..df9cd7f14 100644 | |
| --- a/extensions/bluebubbles/src/send.ts | |
| +++ b/extensions/bluebubbles/src/send.ts | |
| @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; | |
| import { stripMarkdown } from "openclaw/plugin-sdk/bluebubbles"; | |
| import { resolveBlueBubblesAccount } from "./accounts.js"; | |
| import { | |
| + fetchBlueBubblesServerInfo, | |
| getCachedBlueBubblesPrivateApiStatus, | |
| isBlueBubblesPrivateApiStatusEnabled, | |
| } from "./probe.js"; | |
| @@ -389,7 +390,7 @@ export async function sendMessageBlueBubbles( | |
| if (!password) { | |
| throw new Error("BlueBubbles password is required"); | |
| } | |
| - const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId); | |
| + let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId); | |
| const target = resolveBlueBubblesSendTarget(to); | |
| const chatGuid = await resolveChatGuidForTarget({ | |
| @@ -417,6 +418,21 @@ export async function sendMessageBlueBubbles( | |
| const effectId = resolveEffectId(opts.effectId); | |
| const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim()); | |
| const wantsEffect = Boolean(effectId); | |
| + | |
| + // Lazy-refresh Private API status when it's unknown and we need it for reply/effect. | |
| + // The cache expires after 10 minutes; without this, replies silently degrade to plain sends. | |
| + if (privateApiStatus === null && (wantsReplyThread || wantsEffect)) { | |
| + const serverInfo = await fetchBlueBubblesServerInfo({ | |
| + baseUrl, | |
| + password, | |
| + accountId: account.accountId, | |
| + timeoutMs: 5000, | |
| + }).catch(() => null); | |
| + if (serverInfo) { | |
| + privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId); | |
| + } | |
| + } | |
| + | |
| const privateApiDecision = resolvePrivateApiDecision({ | |
| privateApiStatus, | |
| wantsReplyThread, | |
| -- | |
| 2.50.1 (Apple Git-155) | |
| From 464aa0dc8e0aa074e5f563c18cab52672e6872f9 Mon Sep 17 00:00:00 2001 | |
| From: Lobster <lobster@shahine.com> | |
| Date: Thu, 12 Mar 2026 00:05:38 -0700 | |
| Subject: [PATCH 2/3] test(bluebubbles): add fetchBlueBubblesServerInfo to | |
| probe mock module | |
| The lazy-refresh patch imports fetchBlueBubblesServerInfo in send.ts, | |
| which needs a corresponding mock in the test harness. Returns null by | |
| default (no-op in tests that don't configure Private API status). | |
| Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> | |
| --- | |
| extensions/bluebubbles/src/test-harness.ts | 2 ++ | |
| 1 file changed, 2 insertions(+) | |
| diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts | |
| index 5f7351b2e..d61009a39 100644 | |
| --- a/extensions/bluebubbles/src/test-harness.ts | |
| +++ b/extensions/bluebubbles/src/test-harness.ts | |
| @@ -46,12 +46,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), | |
| -- | |
| 2.50.1 (Apple Git-155) | |
| From 797f708297a914ccf95aa8b8441fa490c978e08e Mon Sep 17 00:00:00 2001 | |
| From: Lobster <lobster@shahine.com> | |
| Date: Fri, 13 Mar 2026 12:30:24 -0700 | |
| Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20review=20feedback=20?= | |
| =?UTF-8?q?=E2=80=94=20remove=20dead=20.catch(),=20tighten=20mock=20type?= | |
| MIME-Version: 1.0 | |
| Content-Type: text/plain; charset=UTF-8 | |
| Content-Transfer-Encoding: 8bit | |
| - Remove redundant `.catch(() => null)` on fetchBlueBubblesServerInfo | |
| (function already handles errors internally) | |
| - Fix mock type signature to match real function parameters for | |
| better TypeScript call-site checking in tests | |
| Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> | |
| --- | |
| extensions/bluebubbles/src/send.ts | 2 +- | |
| extensions/bluebubbles/src/test-harness.ts | 9 ++++++++- | |
| 2 files changed, 9 insertions(+), 2 deletions(-) | |
| diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts | |
| index df9cd7f14..297b83c12 100644 | |
| --- a/extensions/bluebubbles/src/send.ts | |
| +++ b/extensions/bluebubbles/src/send.ts | |
| @@ -427,7 +427,7 @@ export async function sendMessageBlueBubbles( | |
| password, | |
| accountId: account.accountId, | |
| timeoutMs: 5000, | |
| - }).catch(() => null); | |
| + }); | |
| if (serverInfo) { | |
| privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId); | |
| } | |
| diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts | |
| index d61009a39..fe0e31c19 100644 | |
| --- a/extensions/bluebubbles/src/test-harness.ts | |
| +++ b/extensions/bluebubbles/src/test-harness.ts | |
| @@ -46,7 +46,14 @@ export function createBlueBubblesAccountsMockModule() { | |
| } | |
| type BlueBubblesProbeMockModule = { | |
| - fetchBlueBubblesServerInfo: Mock<() => Promise<Record<string, unknown> | null>>; | |
| + fetchBlueBubblesServerInfo: Mock< | |
| + (params: { | |
| + baseUrl?: string | null; | |
| + password?: string | null; | |
| + accountId?: string; | |
| + timeoutMs?: number; | |
| + }) => Promise<Record<string, unknown> | null> | |
| + >; | |
| getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>; | |
| isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>; | |
| }; | |
| -- | |
| 2.50.1 (Apple Git-155) | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/sh | |
| # openclaw-patch-bb-reply | |
| # Patches the BlueBubbles send.ts to lazy-refresh Private API status before replies. | |
| # Without this, the 10-minute server info cache expires and replies silently degrade | |
| # to plain (non-threaded) sends — the tool returns success but selectedMessageGuid | |
| # is never added to the BB API payload. | |
| # | |
| # Workaround for https://github.com/openclaw/openclaw/issues/43764 | |
| # Remove this script once the upstream fix is released. | |
| # | |
| # Usage: openclaw-patch-bb-reply [--check] | |
| # --check Only report whether the patch is needed; don't modify files. | |
| set -eu | |
| # Resolve the install root from the openclaw binary's symlink target. | |
| ROOT_DIR="$(dirname "$(realpath "$(command -v openclaw)")")" | |
| SEND_TS="$ROOT_DIR/extensions/bluebubbles/src/send.ts" | |
| if [ ! -f "$SEND_TS" ]; then | |
| echo "OK: BlueBubbles send.ts not found (not applicable)." | |
| exit 0 | |
| fi | |
| # Check if already patched (look for our lazy-refresh comment) | |
| if grep -q 'Lazy-refresh Private API status' "$SEND_TS" 2>/dev/null; then | |
| echo "OK: Patch already applied to $SEND_TS" | |
| exit 0 | |
| fi | |
| # Check if the unpatched pattern exists | |
| if ! grep -q 'const privateApiStatus = getCachedBlueBubblesPrivateApiStatus' "$SEND_TS" 2>/dev/null; then | |
| echo "SKIP: send.ts does not match expected unpatched pattern (may be a different version)." | |
| exit 0 | |
| fi | |
| CHECK_ONLY=false | |
| if [ "${1:-}" = "--check" ]; then | |
| CHECK_ONLY=true | |
| fi | |
| if $CHECK_ONLY; then | |
| echo "NEEDS PATCH: $SEND_TS has 10-min Private API cache bug (replies silently degrade)." | |
| exit 1 | |
| fi | |
| # Use python3 for reliable multi-line TypeScript patching | |
| SEND_TS="$SEND_TS" python3 << 'PYEOF' | |
| import os, sys | |
| path = os.environ["SEND_TS"] | |
| with open(path, "r") as f: | |
| content = f.read() | |
| # Patch 1: Add fetchBlueBubblesServerInfo to the probe.js import | |
| old_import = """import { | |
| getCachedBlueBubblesPrivateApiStatus, | |
| isBlueBubblesPrivateApiStatusEnabled, | |
| } from "./probe.js";""" | |
| new_import = """import { | |
| fetchBlueBubblesServerInfo, | |
| getCachedBlueBubblesPrivateApiStatus, | |
| isBlueBubblesPrivateApiStatusEnabled, | |
| } from "./probe.js";""" | |
| if old_import not in content: | |
| print("ERROR: Could not find probe.js import block to patch", file=sys.stderr) | |
| sys.exit(1) | |
| content = content.replace(old_import, new_import, 1) | |
| # Patch 2: const -> let for privateApiStatus | |
| content = content.replace( | |
| "const privateApiStatus = getCachedBlueBubblesPrivateApiStatus", | |
| "let privateApiStatus = getCachedBlueBubblesPrivateApiStatus", | |
| 1, | |
| ) | |
| # Patch 3: Insert lazy-refresh block between wantsEffect and resolvePrivateApiDecision | |
| old_block = ( | |
| "const wantsEffect = Boolean(effectId);\n" | |
| " const privateApiDecision = resolvePrivateApiDecision({" | |
| ) | |
| new_block = """const wantsEffect = Boolean(effectId); | |
| // Lazy-refresh Private API status when it's unknown and we need it for reply/effect. | |
| // The cache expires after 10 minutes; without this, replies silently degrade to plain sends. | |
| if (privateApiStatus === null && (wantsReplyThread || wantsEffect)) { | |
| const serverInfo = await fetchBlueBubblesServerInfo({ | |
| baseUrl, | |
| password, | |
| accountId: account.accountId, | |
| timeoutMs: 5000, | |
| }); | |
| if (serverInfo) { | |
| privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId); | |
| } | |
| } | |
| const privateApiDecision = resolvePrivateApiDecision({""" | |
| if old_block not in content: | |
| print("ERROR: Could not find wantsEffect/resolvePrivateApiDecision block", file=sys.stderr) | |
| sys.exit(1) | |
| content = content.replace(old_block, new_block, 1) | |
| with open(path, "w") as f: | |
| f.write(content) | |
| PYEOF | |
| echo "Patched: $SEND_TS" | |
| echo "Restart the gateway for changes to take effect: openclaw gateway restart" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment