Skip to content

Instantly share code, notes, and snippets.

@omarshahine
Last active March 14, 2026 18:16
Show Gist options
  • Select an option

  • Save omarshahine/a0dd69e5126a62ad83dcd56275bacde7 to your computer and use it in GitHub Desktop.

Select an option

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
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,
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)
#!/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