@openclaw/bluebubbles 2026.2.13 → 2026.2.14

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,5 +1,6 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
2
  import type { BlueBubblesSendTarget } from "./types.js";
3
+ import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
3
4
  import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
4
5
 
5
6
  vi.mock("./accounts.js", () => ({
@@ -14,12 +15,18 @@ vi.mock("./accounts.js", () => ({
14
15
  }),
15
16
  }));
16
17
 
18
+ vi.mock("./probe.js", () => ({
19
+ getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
20
+ }));
21
+
17
22
  const mockFetch = vi.fn();
18
23
 
19
24
  describe("send", () => {
20
25
  beforeEach(() => {
21
26
  vi.stubGlobal("fetch", mockFetch);
22
27
  mockFetch.mockReset();
28
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
29
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
23
30
  });
24
31
 
25
32
  afterEach(() => {
@@ -611,6 +618,46 @@ describe("send", () => {
611
618
  expect(body.partIndex).toBe(1);
612
619
  });
613
620
 
621
+ it("downgrades threaded reply to plain send when private API is disabled", async () => {
622
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
623
+ mockFetch
624
+ .mockResolvedValueOnce({
625
+ ok: true,
626
+ json: () =>
627
+ Promise.resolve({
628
+ data: [
629
+ {
630
+ guid: "iMessage;-;+15551234567",
631
+ participants: [{ address: "+15551234567" }],
632
+ },
633
+ ],
634
+ }),
635
+ })
636
+ .mockResolvedValueOnce({
637
+ ok: true,
638
+ text: () =>
639
+ Promise.resolve(
640
+ JSON.stringify({
641
+ data: { guid: "msg-uuid-plain" },
642
+ }),
643
+ ),
644
+ });
645
+
646
+ const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
647
+ serverUrl: "http://localhost:1234",
648
+ password: "test",
649
+ replyToMessageGuid: "reply-guid-123",
650
+ replyToPartIndex: 1,
651
+ });
652
+
653
+ expect(result.messageId).toBe("msg-uuid-plain");
654
+ const sendCall = mockFetch.mock.calls[1];
655
+ const body = JSON.parse(sendCall[1].body);
656
+ expect(body.method).toBeUndefined();
657
+ expect(body.selectedMessageGuid).toBeUndefined();
658
+ expect(body.partIndex).toBeUndefined();
659
+ });
660
+
614
661
  it("normalizes effect names and uses private-api for effects", async () => {
615
662
  mockFetch
616
663
  .mockResolvedValueOnce({
package/src/send.ts CHANGED
@@ -2,11 +2,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import crypto from "node:crypto";
3
3
  import { stripMarkdown } from "openclaw/plugin-sdk";
4
4
  import { resolveBlueBubblesAccount } from "./accounts.js";
5
- import {
6
- extractHandleFromChatGuid,
7
- normalizeBlueBubblesHandle,
8
- parseBlueBubblesTarget,
9
- } from "./targets.js";
5
+ import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
6
+ import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
7
+ import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
10
8
  import {
11
9
  blueBubblesFetchWithTimeout,
12
10
  buildBlueBubblesApiUrl,
@@ -73,57 +71,6 @@ function resolveEffectId(raw?: string): string | undefined {
73
71
  return raw;
74
72
  }
75
73
 
76
- function resolveSendTarget(raw: string): BlueBubblesSendTarget {
77
- const parsed = parseBlueBubblesTarget(raw);
78
- if (parsed.kind === "handle") {
79
- return {
80
- kind: "handle",
81
- address: normalizeBlueBubblesHandle(parsed.to),
82
- service: parsed.service,
83
- };
84
- }
85
- if (parsed.kind === "chat_id") {
86
- return { kind: "chat_id", chatId: parsed.chatId };
87
- }
88
- if (parsed.kind === "chat_guid") {
89
- return { kind: "chat_guid", chatGuid: parsed.chatGuid };
90
- }
91
- return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
92
- }
93
-
94
- function extractMessageId(payload: unknown): string {
95
- if (!payload || typeof payload !== "object") {
96
- return "unknown";
97
- }
98
- const record = payload as Record<string, unknown>;
99
- const data =
100
- record.data && typeof record.data === "object"
101
- ? (record.data as Record<string, unknown>)
102
- : null;
103
- const candidates = [
104
- record.messageId,
105
- record.messageGuid,
106
- record.message_guid,
107
- record.guid,
108
- record.id,
109
- data?.messageId,
110
- data?.messageGuid,
111
- data?.message_guid,
112
- data?.message_id,
113
- data?.guid,
114
- data?.id,
115
- ];
116
- for (const candidate of candidates) {
117
- if (typeof candidate === "string" && candidate.trim()) {
118
- return candidate.trim();
119
- }
120
- if (typeof candidate === "number" && Number.isFinite(candidate)) {
121
- return String(candidate);
122
- }
123
- }
124
- return "unknown";
125
- }
126
-
127
74
  type BlueBubblesChatRecord = Record<string, unknown>;
128
75
 
129
76
  function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
@@ -364,7 +311,7 @@ async function createNewChatWithMessage(params: {
364
311
  }
365
312
  try {
366
313
  const parsed = JSON.parse(body) as unknown;
367
- return { messageId: extractMessageId(parsed) };
314
+ return { messageId: extractBlueBubblesMessageId(parsed) };
368
315
  } catch {
369
316
  return { messageId: "ok" };
370
317
  }
@@ -397,8 +344,9 @@ export async function sendMessageBlueBubbles(
397
344
  if (!password) {
398
345
  throw new Error("BlueBubbles password is required");
399
346
  }
347
+ const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
400
348
 
401
- const target = resolveSendTarget(to);
349
+ const target = resolveBlueBubblesSendTarget(to);
402
350
  const chatGuid = await resolveChatGuidForTarget({
403
351
  baseUrl,
404
352
  password,
@@ -422,18 +370,26 @@ export async function sendMessageBlueBubbles(
422
370
  );
423
371
  }
424
372
  const effectId = resolveEffectId(opts.effectId);
425
- const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId);
373
+ const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
374
+ const wantsEffect = Boolean(effectId);
375
+ const needsPrivateApi = wantsReplyThread || wantsEffect;
376
+ const canUsePrivateApi = needsPrivateApi && privateApiStatus !== false;
377
+ if (wantsEffect && privateApiStatus === false) {
378
+ throw new Error(
379
+ "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
380
+ );
381
+ }
426
382
  const payload: Record<string, unknown> = {
427
383
  chatGuid,
428
384
  tempGuid: crypto.randomUUID(),
429
385
  message: strippedText,
430
386
  };
431
- if (needsPrivateApi) {
387
+ if (canUsePrivateApi) {
432
388
  payload.method = "private-api";
433
389
  }
434
390
 
435
391
  // Add reply threading support
436
- if (opts.replyToMessageGuid) {
392
+ if (wantsReplyThread && canUsePrivateApi) {
437
393
  payload.selectedMessageGuid = opts.replyToMessageGuid;
438
394
  payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
439
395
  }
@@ -467,7 +423,7 @@ export async function sendMessageBlueBubbles(
467
423
  }
468
424
  try {
469
425
  const parsed = JSON.parse(body) as unknown;
470
- return { messageId: extractMessageId(parsed) };
426
+ return { messageId: extractBlueBubblesMessageId(parsed) };
471
427
  } catch {
472
428
  return { messageId: "ok" };
473
429
  }
package/src/targets.ts CHANGED
@@ -1,3 +1,10 @@
1
+ import {
2
+ parseChatAllowTargetPrefixes,
3
+ parseChatTargetPrefixesOrThrow,
4
+ resolveServicePrefixedAllowTarget,
5
+ resolveServicePrefixedTarget,
6
+ } from "openclaw/plugin-sdk";
7
+
1
8
  export type BlueBubblesService = "imessage" | "sms" | "auto";
2
9
 
3
10
  export type BlueBubblesTarget =
@@ -205,54 +212,30 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
205
212
  }
206
213
  const lower = trimmed.toLowerCase();
207
214
 
208
- for (const { prefix, service } of SERVICE_PREFIXES) {
209
- if (lower.startsWith(prefix)) {
210
- const remainder = stripPrefix(trimmed, prefix);
211
- if (!remainder) {
212
- throw new Error(`${prefix} target is required`);
213
- }
214
- const remainderLower = remainder.toLowerCase();
215
- const isChatTarget =
216
- CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
217
- CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
218
- CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
219
- remainderLower.startsWith("group:");
220
- if (isChatTarget) {
221
- return parseBlueBubblesTarget(remainder);
222
- }
223
- return { kind: "handle", to: remainder, service };
224
- }
225
- }
226
-
227
- for (const prefix of CHAT_ID_PREFIXES) {
228
- if (lower.startsWith(prefix)) {
229
- const value = stripPrefix(trimmed, prefix);
230
- const chatId = Number.parseInt(value, 10);
231
- if (!Number.isFinite(chatId)) {
232
- throw new Error(`Invalid chat_id: ${value}`);
233
- }
234
- return { kind: "chat_id", chatId };
235
- }
236
- }
237
-
238
- for (const prefix of CHAT_GUID_PREFIXES) {
239
- if (lower.startsWith(prefix)) {
240
- const value = stripPrefix(trimmed, prefix);
241
- if (!value) {
242
- throw new Error("chat_guid is required");
243
- }
244
- return { kind: "chat_guid", chatGuid: value };
245
- }
246
- }
247
-
248
- for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
249
- if (lower.startsWith(prefix)) {
250
- const value = stripPrefix(trimmed, prefix);
251
- if (!value) {
252
- throw new Error("chat_identifier is required");
253
- }
254
- return { kind: "chat_identifier", chatIdentifier: value };
255
- }
215
+ const servicePrefixed = resolveServicePrefixedTarget({
216
+ trimmed,
217
+ lower,
218
+ servicePrefixes: SERVICE_PREFIXES,
219
+ isChatTarget: (remainderLower) =>
220
+ CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
221
+ CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
222
+ CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
223
+ remainderLower.startsWith("group:"),
224
+ parseTarget: parseBlueBubblesTarget,
225
+ });
226
+ if (servicePrefixed) {
227
+ return servicePrefixed;
228
+ }
229
+
230
+ const chatTarget = parseChatTargetPrefixesOrThrow({
231
+ trimmed,
232
+ lower,
233
+ chatIdPrefixes: CHAT_ID_PREFIXES,
234
+ chatGuidPrefixes: CHAT_GUID_PREFIXES,
235
+ chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
236
+ });
237
+ if (chatTarget) {
238
+ return chatTarget;
256
239
  }
257
240
 
258
241
  if (lower.startsWith("group:")) {
@@ -293,42 +276,25 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
293
276
  }
294
277
  const lower = trimmed.toLowerCase();
295
278
 
296
- for (const { prefix } of SERVICE_PREFIXES) {
297
- if (lower.startsWith(prefix)) {
298
- const remainder = stripPrefix(trimmed, prefix);
299
- if (!remainder) {
300
- return { kind: "handle", handle: "" };
301
- }
302
- return parseBlueBubblesAllowTarget(remainder);
303
- }
304
- }
305
-
306
- for (const prefix of CHAT_ID_PREFIXES) {
307
- if (lower.startsWith(prefix)) {
308
- const value = stripPrefix(trimmed, prefix);
309
- const chatId = Number.parseInt(value, 10);
310
- if (Number.isFinite(chatId)) {
311
- return { kind: "chat_id", chatId };
312
- }
313
- }
314
- }
315
-
316
- for (const prefix of CHAT_GUID_PREFIXES) {
317
- if (lower.startsWith(prefix)) {
318
- const value = stripPrefix(trimmed, prefix);
319
- if (value) {
320
- return { kind: "chat_guid", chatGuid: value };
321
- }
322
- }
323
- }
324
-
325
- for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
326
- if (lower.startsWith(prefix)) {
327
- const value = stripPrefix(trimmed, prefix);
328
- if (value) {
329
- return { kind: "chat_identifier", chatIdentifier: value };
330
- }
331
- }
279
+ const servicePrefixed = resolveServicePrefixedAllowTarget({
280
+ trimmed,
281
+ lower,
282
+ servicePrefixes: SERVICE_PREFIXES,
283
+ parseAllowTarget: parseBlueBubblesAllowTarget,
284
+ });
285
+ if (servicePrefixed) {
286
+ return servicePrefixed;
287
+ }
288
+
289
+ const chatTarget = parseChatAllowTargetPrefixes({
290
+ trimmed,
291
+ lower,
292
+ chatIdPrefixes: CHAT_ID_PREFIXES,
293
+ chatGuidPrefixes: CHAT_GUID_PREFIXES,
294
+ chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
295
+ });
296
+ if (chatTarget) {
297
+ return chatTarget;
332
298
  }
333
299
 
334
300
  if (lower.startsWith("group:")) {
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
2
- export type { DmPolicy, GroupPolicy };
2
+
3
+ export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
3
4
 
4
5
  export type BlueBubblesGroupConfig = {
5
6
  /** If true, only respond in this group when mentioned. */
@@ -45,6 +46,11 @@ export type BlueBubblesAccountConfig = {
45
46
  blockStreamingCoalesce?: Record<string, unknown>;
46
47
  /** Max outbound media size in MB. */
47
48
  mediaMaxMb?: number;
49
+ /**
50
+ * Explicit allowlist of local directory roots permitted for outbound media paths.
51
+ * Local paths are rejected unless they resolve under one of these roots.
52
+ */
53
+ mediaLocalRoots?: string[];
48
54
  /** Send read receipts for incoming messages (default: true). */
49
55
  sendReadReceipts?: boolean;
50
56
  /** Per-group configuration keyed by chat GUID or identifier. */