@openclaw/bluebubbles 2026.2.21 → 2026.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/send.test.ts CHANGED
@@ -1,15 +1,22 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
1
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
3
  import "./test-mocks.js";
3
4
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
5
+ import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
4
6
  import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
5
- import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
7
+ import {
8
+ BLUE_BUBBLES_PRIVATE_API_STATUS,
9
+ installBlueBubblesFetchTestHooks,
10
+ mockBlueBubblesPrivateApiStatusOnce,
11
+ } from "./test-harness.js";
6
12
  import type { BlueBubblesSendTarget } from "./types.js";
7
13
 
8
14
  const mockFetch = vi.fn();
15
+ const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);
9
16
 
10
17
  installBlueBubblesFetchTestHooks({
11
18
  mockFetch,
12
- privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
19
+ privateApiStatusMock,
13
20
  });
14
21
 
15
22
  function mockResolvedHandleTarget(
@@ -37,6 +44,23 @@ function mockSendResponse(body: unknown) {
37
44
  });
38
45
  }
39
46
 
47
+ function mockNewChatSendResponse(guid: string) {
48
+ mockFetch
49
+ .mockResolvedValueOnce({
50
+ ok: true,
51
+ json: () => Promise.resolve({ data: [] }),
52
+ })
53
+ .mockResolvedValueOnce({
54
+ ok: true,
55
+ text: () =>
56
+ Promise.resolve(
57
+ JSON.stringify({
58
+ data: { guid },
59
+ }),
60
+ ),
61
+ });
62
+ }
63
+
40
64
  describe("send", () => {
41
65
  describe("resolveChatGuidForTarget", () => {
42
66
  const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
@@ -446,20 +470,7 @@ describe("send", () => {
446
470
  });
447
471
 
448
472
  it("strips markdown when creating a new chat", async () => {
449
- mockFetch
450
- .mockResolvedValueOnce({
451
- ok: true,
452
- json: () => Promise.resolve({ data: [] }),
453
- })
454
- .mockResolvedValueOnce({
455
- ok: true,
456
- text: () =>
457
- Promise.resolve(
458
- JSON.stringify({
459
- data: { guid: "new-msg-stripped" },
460
- }),
461
- ),
462
- });
473
+ mockNewChatSendResponse("new-msg-stripped");
463
474
 
464
475
  const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", {
465
476
  serverUrl: "http://localhost:1234",
@@ -476,20 +487,7 @@ describe("send", () => {
476
487
  });
477
488
 
478
489
  it("creates a new chat when handle target is missing", async () => {
479
- mockFetch
480
- .mockResolvedValueOnce({
481
- ok: true,
482
- json: () => Promise.resolve({ data: [] }),
483
- })
484
- .mockResolvedValueOnce({
485
- ok: true,
486
- text: () =>
487
- Promise.resolve(
488
- JSON.stringify({
489
- data: { guid: "new-msg-guid" },
490
- }),
491
- ),
492
- });
490
+ mockNewChatSendResponse("new-msg-guid");
493
491
 
494
492
  const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
495
493
  serverUrl: "http://localhost:1234",
@@ -527,6 +525,10 @@ describe("send", () => {
527
525
  });
528
526
 
529
527
  it("uses private-api when reply metadata is present", async () => {
528
+ mockBlueBubblesPrivateApiStatusOnce(
529
+ privateApiStatusMock,
530
+ BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
531
+ );
530
532
  mockResolvedHandleTarget();
531
533
  mockSendResponse({ data: { guid: "msg-uuid-124" } });
532
534
 
@@ -548,7 +550,10 @@ describe("send", () => {
548
550
  });
549
551
 
550
552
  it("downgrades threaded reply to plain send when private API is disabled", async () => {
551
- vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
553
+ mockBlueBubblesPrivateApiStatusOnce(
554
+ privateApiStatusMock,
555
+ BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
556
+ );
552
557
  mockResolvedHandleTarget();
553
558
  mockSendResponse({ data: { guid: "msg-uuid-plain" } });
554
559
 
@@ -568,6 +573,10 @@ describe("send", () => {
568
573
  });
569
574
 
570
575
  it("normalizes effect names and uses private-api for effects", async () => {
576
+ mockBlueBubblesPrivateApiStatusOnce(
577
+ privateApiStatusMock,
578
+ BLUE_BUBBLES_PRIVATE_API_STATUS.enabled,
579
+ );
571
580
  mockResolvedHandleTarget();
572
581
  mockSendResponse({ data: { guid: "msg-uuid-125" } });
573
582
 
@@ -586,6 +595,38 @@ describe("send", () => {
586
595
  expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink");
587
596
  });
588
597
 
598
+ it("warns and downgrades private-api features when status is unknown", async () => {
599
+ const runtimeLog = vi.fn();
600
+ setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime);
601
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
602
+ mockResolvedHandleTarget();
603
+ mockSendResponse({ data: { guid: "msg-uuid-unknown" } });
604
+
605
+ try {
606
+ const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
607
+ serverUrl: "http://localhost:1234",
608
+ password: "test",
609
+ replyToMessageGuid: "reply-guid-123",
610
+ effectId: "invisible ink",
611
+ });
612
+
613
+ expect(result.messageId).toBe("msg-uuid-unknown");
614
+ expect(runtimeLog).toHaveBeenCalledTimes(1);
615
+ expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
616
+ expect(warnSpy).not.toHaveBeenCalled();
617
+
618
+ const sendCall = mockFetch.mock.calls[1];
619
+ const body = JSON.parse(sendCall[1].body);
620
+ expect(body.method).toBeUndefined();
621
+ expect(body.selectedMessageGuid).toBeUndefined();
622
+ expect(body.partIndex).toBeUndefined();
623
+ expect(body.effectId).toBeUndefined();
624
+ } finally {
625
+ clearBlueBubblesRuntime();
626
+ warnSpy.mockRestore();
627
+ }
628
+ });
629
+
589
630
  it("sends message with chat_guid target directly", async () => {
590
631
  mockFetch.mockResolvedValueOnce({
591
632
  ok: true,
package/src/send.ts CHANGED
@@ -2,7 +2,11 @@ import crypto from "node:crypto";
2
2
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
3
  import { stripMarkdown } from "openclaw/plugin-sdk";
4
4
  import { resolveBlueBubblesAccount } from "./accounts.js";
5
- import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
5
+ import {
6
+ getCachedBlueBubblesPrivateApiStatus,
7
+ isBlueBubblesPrivateApiStatusEnabled,
8
+ } from "./probe.js";
9
+ import { warnBlueBubbles } from "./runtime.js";
6
10
  import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
7
11
  import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
8
12
  import {
@@ -71,6 +75,38 @@ function resolveEffectId(raw?: string): string | undefined {
71
75
  return raw;
72
76
  }
73
77
 
78
+ type PrivateApiDecision = {
79
+ canUsePrivateApi: boolean;
80
+ throwEffectDisabledError: boolean;
81
+ warningMessage?: string;
82
+ };
83
+
84
+ function resolvePrivateApiDecision(params: {
85
+ privateApiStatus: boolean | null;
86
+ wantsReplyThread: boolean;
87
+ wantsEffect: boolean;
88
+ }): PrivateApiDecision {
89
+ const { privateApiStatus, wantsReplyThread, wantsEffect } = params;
90
+ const needsPrivateApi = wantsReplyThread || wantsEffect;
91
+ const canUsePrivateApi =
92
+ needsPrivateApi && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
93
+ const throwEffectDisabledError = wantsEffect && privateApiStatus === false;
94
+ if (!needsPrivateApi || privateApiStatus !== null) {
95
+ return { canUsePrivateApi, throwEffectDisabledError };
96
+ }
97
+ const requested = [
98
+ wantsReplyThread ? "reply threading" : null,
99
+ wantsEffect ? "message effects" : null,
100
+ ]
101
+ .filter(Boolean)
102
+ .join(" + ");
103
+ return {
104
+ canUsePrivateApi,
105
+ throwEffectDisabledError,
106
+ warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`,
107
+ };
108
+ }
109
+
74
110
  type BlueBubblesChatRecord = Record<string, unknown>;
75
111
 
76
112
  function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
@@ -372,30 +408,36 @@ export async function sendMessageBlueBubbles(
372
408
  const effectId = resolveEffectId(opts.effectId);
373
409
  const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
374
410
  const wantsEffect = Boolean(effectId);
375
- const needsPrivateApi = wantsReplyThread || wantsEffect;
376
- const canUsePrivateApi = needsPrivateApi && privateApiStatus !== false;
377
- if (wantsEffect && privateApiStatus === false) {
411
+ const privateApiDecision = resolvePrivateApiDecision({
412
+ privateApiStatus,
413
+ wantsReplyThread,
414
+ wantsEffect,
415
+ });
416
+ if (privateApiDecision.throwEffectDisabledError) {
378
417
  throw new Error(
379
418
  "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
380
419
  );
381
420
  }
421
+ if (privateApiDecision.warningMessage) {
422
+ warnBlueBubbles(privateApiDecision.warningMessage);
423
+ }
382
424
  const payload: Record<string, unknown> = {
383
425
  chatGuid,
384
426
  tempGuid: crypto.randomUUID(),
385
427
  message: strippedText,
386
428
  };
387
- if (canUsePrivateApi) {
429
+ if (privateApiDecision.canUsePrivateApi) {
388
430
  payload.method = "private-api";
389
431
  }
390
432
 
391
433
  // Add reply threading support
392
- if (wantsReplyThread && canUsePrivateApi) {
434
+ if (wantsReplyThread && privateApiDecision.canUsePrivateApi) {
393
435
  payload.selectedMessageGuid = opts.replyToMessageGuid;
394
436
  payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
395
437
  }
396
438
 
397
439
  // Add message effects support
398
- if (effectId) {
440
+ if (effectId && privateApiDecision.canUsePrivateApi) {
399
441
  payload.effectId = effectId;
400
442
  }
401
443
 
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ isAllowedBlueBubblesSender,
3
4
  looksLikeBlueBubblesTargetId,
4
5
  normalizeBlueBubblesMessagingTarget,
5
6
  parseBlueBubblesTarget,
@@ -181,3 +182,21 @@ describe("parseBlueBubblesAllowTarget", () => {
181
182
  });
182
183
  });
183
184
  });
185
+
186
+ describe("isAllowedBlueBubblesSender", () => {
187
+ it("denies when allowFrom is empty", () => {
188
+ const allowed = isAllowedBlueBubblesSender({
189
+ allowFrom: [],
190
+ sender: "+15551234567",
191
+ });
192
+ expect(allowed).toBe(false);
193
+ });
194
+
195
+ it("allows wildcard entries", () => {
196
+ const allowed = isAllowedBlueBubblesSender({
197
+ allowFrom: ["*"],
198
+ sender: "+15551234567",
199
+ });
200
+ expect(allowed).toBe(true);
201
+ });
202
+ });
package/src/targets.ts CHANGED
@@ -78,6 +78,40 @@ function looksLikeRawChatIdentifier(value: string): boolean {
78
78
  return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
79
79
  }
80
80
 
81
+ function parseGroupTarget(params: {
82
+ trimmed: string;
83
+ lower: string;
84
+ requireValue: boolean;
85
+ }): { kind: "chat_id"; chatId: number } | { kind: "chat_guid"; chatGuid: string } | null {
86
+ if (!params.lower.startsWith("group:")) {
87
+ return null;
88
+ }
89
+ const value = stripPrefix(params.trimmed, "group:");
90
+ const chatId = Number.parseInt(value, 10);
91
+ if (Number.isFinite(chatId)) {
92
+ return { kind: "chat_id", chatId };
93
+ }
94
+ if (value) {
95
+ return { kind: "chat_guid", chatGuid: value };
96
+ }
97
+ if (params.requireValue) {
98
+ throw new Error("group target is required");
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function parseRawChatIdentifierTarget(
104
+ trimmed: string,
105
+ ): { kind: "chat_identifier"; chatIdentifier: string } | null {
106
+ if (/^chat\d+$/i.test(trimmed)) {
107
+ return { kind: "chat_identifier", chatIdentifier: trimmed };
108
+ }
109
+ if (looksLikeRawChatIdentifier(trimmed)) {
110
+ return { kind: "chat_identifier", chatIdentifier: trimmed };
111
+ }
112
+ return null;
113
+ }
114
+
81
115
  export function normalizeBlueBubblesHandle(raw: string): string {
82
116
  const trimmed = raw.trim();
83
117
  if (!trimmed) {
@@ -239,16 +273,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
239
273
  return chatTarget;
240
274
  }
241
275
 
242
- if (lower.startsWith("group:")) {
243
- const value = stripPrefix(trimmed, "group:");
244
- const chatId = Number.parseInt(value, 10);
245
- if (Number.isFinite(chatId)) {
246
- return { kind: "chat_id", chatId };
247
- }
248
- if (!value) {
249
- throw new Error("group target is required");
250
- }
251
- return { kind: "chat_guid", chatGuid: value };
276
+ const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: true });
277
+ if (groupTarget) {
278
+ return groupTarget;
252
279
  }
253
280
 
254
281
  const rawChatGuid = parseRawChatGuid(trimmed);
@@ -256,15 +283,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
256
283
  return { kind: "chat_guid", chatGuid: rawChatGuid };
257
284
  }
258
285
 
259
- // Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
260
- // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
261
- if (/^chat\d+$/i.test(trimmed)) {
262
- return { kind: "chat_identifier", chatIdentifier: trimmed };
263
- }
264
-
265
- // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc")
266
- if (looksLikeRawChatIdentifier(trimmed)) {
267
- return { kind: "chat_identifier", chatIdentifier: trimmed };
286
+ const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed);
287
+ if (rawChatIdentifierTarget) {
288
+ return rawChatIdentifierTarget;
268
289
  }
269
290
 
270
291
  return { kind: "handle", to: trimmed, service: "auto" };
@@ -298,26 +319,14 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
298
319
  return chatTarget;
299
320
  }
300
321
 
301
- if (lower.startsWith("group:")) {
302
- const value = stripPrefix(trimmed, "group:");
303
- const chatId = Number.parseInt(value, 10);
304
- if (Number.isFinite(chatId)) {
305
- return { kind: "chat_id", chatId };
306
- }
307
- if (value) {
308
- return { kind: "chat_guid", chatGuid: value };
309
- }
310
- }
311
-
312
- // Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
313
- // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
314
- if (/^chat\d+$/i.test(trimmed)) {
315
- return { kind: "chat_identifier", chatIdentifier: trimmed };
322
+ const groupTarget = parseGroupTarget({ trimmed, lower, requireValue: false });
323
+ if (groupTarget) {
324
+ return groupTarget;
316
325
  }
317
326
 
318
- // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc")
319
- if (looksLikeRawChatIdentifier(trimmed)) {
320
- return { kind: "chat_identifier", chatIdentifier: trimmed };
327
+ const rawChatIdentifierTarget = parseRawChatIdentifierTarget(trimmed);
328
+ if (rawChatIdentifierTarget) {
329
+ return rawChatIdentifierTarget;
321
330
  }
322
331
 
323
332
  return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
@@ -1,6 +1,31 @@
1
1
  import type { Mock } from "vitest";
2
2
  import { afterEach, beforeEach, vi } from "vitest";
3
3
 
4
+ export const BLUE_BUBBLES_PRIVATE_API_STATUS = {
5
+ enabled: true,
6
+ disabled: false,
7
+ unknown: null,
8
+ } as const;
9
+
10
+ type BlueBubblesPrivateApiStatusMock = {
11
+ mockReturnValue: (value: boolean | null) => unknown;
12
+ mockReturnValueOnce: (value: boolean | null) => unknown;
13
+ };
14
+
15
+ export function mockBlueBubblesPrivateApiStatus(
16
+ mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValue">,
17
+ value: boolean | null,
18
+ ) {
19
+ mock.mockReturnValue(value);
20
+ }
21
+
22
+ export function mockBlueBubblesPrivateApiStatusOnce(
23
+ mock: Pick<BlueBubblesPrivateApiStatusMock, "mockReturnValueOnce">,
24
+ value: boolean | null,
25
+ ) {
26
+ mock.mockReturnValueOnce(value);
27
+ }
28
+
4
29
  export function resolveBlueBubblesAccountFromConfig(params: {
5
30
  cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
6
31
  accountId?: string;
@@ -22,11 +47,15 @@ export function createBlueBubblesAccountsMockModule() {
22
47
 
23
48
  type BlueBubblesProbeMockModule = {
24
49
  getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>;
50
+ isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>;
25
51
  };
26
52
 
27
53
  export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
28
54
  return {
29
- getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
55
+ getCachedBlueBubblesPrivateApiStatus: vi
56
+ .fn()
57
+ .mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown),
58
+ isBlueBubblesPrivateApiStatusEnabled: vi.fn((status: boolean | null) => status === true),
30
59
  };
31
60
  }
32
61
 
@@ -41,7 +70,7 @@ export function installBlueBubblesFetchTestHooks(params: {
41
70
  vi.stubGlobal("fetch", params.mockFetch);
42
71
  params.mockFetch.mockReset();
43
72
  params.privateApiStatusMock.mockReset();
44
- params.privateApiStatusMock.mockReturnValue(null);
73
+ params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown);
45
74
  });
46
75
 
47
76
  afterEach(() => {
package/src/types.ts CHANGED
@@ -53,6 +53,8 @@ export type BlueBubblesAccountConfig = {
53
53
  mediaLocalRoots?: string[];
54
54
  /** Send read receipts for incoming messages (default: true). */
55
55
  sendReadReceipts?: boolean;
56
+ /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */
57
+ allowPrivateNetwork?: boolean;
56
58
  /** Per-group configuration keyed by chat GUID or identifier. */
57
59
  groups?: Record<string, BlueBubblesGroupConfig>;
58
60
  };