@openclaw/bluebubbles 2026.2.21 → 2026.2.22

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.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(() => {