@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/bluebubbles",
3
- "version": "2026.2.21",
3
+ "version": "2026.2.22",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
6
  "devDependencies": {
@@ -3,17 +3,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
3
3
  import { bluebubblesMessageActions } from "./actions.js";
4
4
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
5
5
 
6
- vi.mock("./accounts.js", () => ({
7
- resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
8
- const config = cfg?.channels?.bluebubbles ?? {};
9
- return {
10
- accountId: accountId ?? "default",
11
- enabled: config.enabled !== false,
12
- configured: Boolean(config.serverUrl && config.password),
13
- config,
14
- };
15
- }),
16
- }));
6
+ vi.mock("./accounts.js", async () => {
7
+ const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
8
+ return createBlueBubblesAccountsMockModule();
9
+ });
17
10
 
18
11
  vi.mock("./reactions.js", () => ({
19
12
  sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
@@ -1,18 +1,69 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
1
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3
  import "./test-mocks.js";
3
4
  import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
4
5
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
5
- import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
6
+ import { setBlueBubblesRuntime } from "./runtime.js";
7
+ import {
8
+ BLUE_BUBBLES_PRIVATE_API_STATUS,
9
+ installBlueBubblesFetchTestHooks,
10
+ mockBlueBubblesPrivateApiStatus,
11
+ mockBlueBubblesPrivateApiStatusOnce,
12
+ } from "./test-harness.js";
6
13
  import type { BlueBubblesAttachment } from "./types.js";
7
14
 
8
15
  const mockFetch = vi.fn();
16
+ const fetchRemoteMediaMock = vi.fn(
17
+ async (params: {
18
+ url: string;
19
+ maxBytes?: number;
20
+ fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
21
+ }) => {
22
+ const fetchFn = params.fetchImpl ?? fetch;
23
+ const res = await fetchFn(params.url);
24
+ if (!res.ok) {
25
+ const text = await res.text().catch(() => "unknown");
26
+ throw new Error(
27
+ `Failed to fetch media from ${params.url}: HTTP ${res.status}; body: ${text}`,
28
+ );
29
+ }
30
+ const buffer = Buffer.from(await res.arrayBuffer());
31
+ if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
32
+ const error = new Error(`payload exceeds maxBytes ${params.maxBytes}`) as Error & {
33
+ code?: string;
34
+ };
35
+ error.code = "max_bytes";
36
+ throw error;
37
+ }
38
+ return {
39
+ buffer,
40
+ contentType: res.headers.get("content-type") ?? undefined,
41
+ fileName: undefined,
42
+ };
43
+ },
44
+ );
9
45
 
10
46
  installBlueBubblesFetchTestHooks({
11
47
  mockFetch,
12
48
  privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
13
49
  });
14
50
 
51
+ const runtimeStub = {
52
+ channel: {
53
+ media: {
54
+ fetchRemoteMedia:
55
+ fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
56
+ },
57
+ },
58
+ } as unknown as PluginRuntime;
59
+
15
60
  describe("downloadBlueBubblesAttachment", () => {
61
+ beforeEach(() => {
62
+ fetchRemoteMediaMock.mockClear();
63
+ mockFetch.mockReset();
64
+ setBlueBubblesRuntime(runtimeStub);
65
+ });
66
+
16
67
  it("throws when guid is missing", async () => {
17
68
  const attachment: BlueBubblesAttachment = {};
18
69
  await expect(
@@ -120,7 +171,7 @@ describe("downloadBlueBubblesAttachment", () => {
120
171
  serverUrl: "http://localhost:1234",
121
172
  password: "test",
122
173
  }),
123
- ).rejects.toThrow("download failed (404): Attachment not found");
174
+ ).rejects.toThrow("Attachment not found");
124
175
  });
125
176
 
126
177
  it("throws when attachment exceeds max bytes", async () => {
@@ -229,8 +280,13 @@ describe("sendBlueBubblesAttachment", () => {
229
280
  beforeEach(() => {
230
281
  vi.stubGlobal("fetch", mockFetch);
231
282
  mockFetch.mockReset();
283
+ fetchRemoteMediaMock.mockClear();
284
+ setBlueBubblesRuntime(runtimeStub);
232
285
  vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
233
- vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
286
+ mockBlueBubblesPrivateApiStatus(
287
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus),
288
+ BLUE_BUBBLES_PRIVATE_API_STATUS.unknown,
289
+ );
234
290
  });
235
291
 
236
292
  afterEach(() => {
@@ -333,7 +389,10 @@ describe("sendBlueBubblesAttachment", () => {
333
389
  });
334
390
 
335
391
  it("downgrades attachment reply threading when private API is disabled", async () => {
336
- vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
392
+ mockBlueBubblesPrivateApiStatusOnce(
393
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus),
394
+ BLUE_BUBBLES_PRIVATE_API_STATUS.disabled,
395
+ );
337
396
  mockFetch.mockResolvedValueOnce({
338
397
  ok: true,
339
398
  text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
@@ -354,4 +413,32 @@ describe("sendBlueBubblesAttachment", () => {
354
413
  expect(bodyText).not.toContain('name="selectedMessageGuid"');
355
414
  expect(bodyText).not.toContain('name="partIndex"');
356
415
  });
416
+
417
+ it("warns and downgrades attachment reply threading when private API status is unknown", async () => {
418
+ const runtimeLog = vi.fn();
419
+ setBlueBubblesRuntime({
420
+ ...runtimeStub,
421
+ log: runtimeLog,
422
+ } as unknown as PluginRuntime);
423
+ mockFetch.mockResolvedValueOnce({
424
+ ok: true,
425
+ text: () => Promise.resolve(JSON.stringify({ messageId: "msg-5" })),
426
+ });
427
+
428
+ await sendBlueBubblesAttachment({
429
+ to: "chat_guid:iMessage;-;+15551234567",
430
+ buffer: new Uint8Array([1, 2, 3]),
431
+ filename: "photo.jpg",
432
+ contentType: "image/jpeg",
433
+ replyToMessageGuid: "reply-guid-unknown",
434
+ opts: { serverUrl: "http://localhost:1234", password: "test" },
435
+ });
436
+
437
+ expect(runtimeLog).toHaveBeenCalledTimes(1);
438
+ expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown");
439
+ const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
440
+ const bodyText = decodeBody(body);
441
+ expect(bodyText).not.toContain('name="selectedMessageGuid"');
442
+ expect(bodyText).not.toContain('name="partIndex"');
443
+ });
357
444
  });
@@ -3,7 +3,12 @@ import path from "node:path";
3
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
4
  import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
5
5
  import { postMultipartFormData } from "./multipart.js";
6
- import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
6
+ import {
7
+ getCachedBlueBubblesPrivateApiStatus,
8
+ isBlueBubblesPrivateApiStatusEnabled,
9
+ } from "./probe.js";
10
+ import { resolveRequestUrl } from "./request-url.js";
11
+ import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
7
12
  import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
8
13
  import { resolveChatGuidForTarget } from "./send.js";
9
14
  import {
@@ -57,6 +62,18 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
57
62
  return resolveBlueBubblesServerAccount(params);
58
63
  }
59
64
 
65
+ type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
66
+
67
+ function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
68
+ if (!error || typeof error !== "object") {
69
+ return undefined;
70
+ }
71
+ const code = (error as { code?: unknown }).code;
72
+ return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
73
+ ? code
74
+ : undefined;
75
+ }
76
+
60
77
  export async function downloadBlueBubblesAttachment(
61
78
  attachment: BlueBubblesAttachment,
62
79
  opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
@@ -71,20 +88,30 @@ export async function downloadBlueBubblesAttachment(
71
88
  path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
72
89
  password,
73
90
  });
74
- const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
75
- if (!res.ok) {
76
- const errorText = await res.text().catch(() => "");
77
- throw new Error(
78
- `BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,
79
- );
80
- }
81
- const contentType = res.headers.get("content-type") ?? undefined;
82
- const buf = new Uint8Array(await res.arrayBuffer());
83
91
  const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
84
- if (buf.byteLength > maxBytes) {
85
- throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);
92
+ try {
93
+ const fetched = await getBlueBubblesRuntime().channel.media.fetchRemoteMedia({
94
+ url,
95
+ filePathHint: attachment.transferName ?? attachment.guid ?? "attachment",
96
+ maxBytes,
97
+ fetchImpl: async (input, init) =>
98
+ await blueBubblesFetchWithTimeout(
99
+ resolveRequestUrl(input),
100
+ { ...init, method: init?.method ?? "GET" },
101
+ opts.timeoutMs,
102
+ ),
103
+ });
104
+ return {
105
+ buffer: new Uint8Array(fetched.buffer),
106
+ contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
107
+ };
108
+ } catch (error) {
109
+ if (readMediaFetchErrorCode(error) === "max_bytes") {
110
+ throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`);
111
+ }
112
+ const text = error instanceof Error ? error.message : String(error);
113
+ throw new Error(`BlueBubbles attachment download failed: ${text}`);
86
114
  }
87
- return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
88
115
  }
89
116
 
90
117
  export type SendBlueBubblesAttachmentResult = {
@@ -115,6 +142,7 @@ export async function sendBlueBubblesAttachment(params: {
115
142
  contentType = contentType?.trim() || undefined;
116
143
  const { baseUrl, password, accountId } = resolveAccount(opts);
117
144
  const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
145
+ const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
118
146
 
119
147
  // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
120
148
  const isAudioMessage = wantsVoice;
@@ -183,7 +211,7 @@ export async function sendBlueBubblesAttachment(params: {
183
211
  addField("chatGuid", chatGuid);
184
212
  addField("name", filename);
185
213
  addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
186
- if (privateApiStatus !== false) {
214
+ if (privateApiEnabled) {
187
215
  addField("method", "private-api");
188
216
  }
189
217
 
@@ -193,9 +221,13 @@ export async function sendBlueBubblesAttachment(params: {
193
221
  }
194
222
 
195
223
  const trimmedReplyTo = replyToMessageGuid?.trim();
196
- if (trimmedReplyTo && privateApiStatus !== false) {
224
+ if (trimmedReplyTo && privateApiEnabled) {
197
225
  addField("selectedMessageGuid", trimmedReplyTo);
198
226
  addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
227
+ } else if (trimmedReplyTo && privateApiStatus === null) {
228
+ warnBlueBubbles(
229
+ "Private API status unknown; sending attachment without reply threading metadata. Run a status probe to restore private-api reply features.",
230
+ );
199
231
  }
200
232
 
201
233
  // Add optional caption
package/src/chat.test.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import "./test-mocks.js";
3
- import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
3
+ import {
4
+ addBlueBubblesParticipant,
5
+ editBlueBubblesMessage,
6
+ leaveBlueBubblesChat,
7
+ markBlueBubblesChatRead,
8
+ removeBlueBubblesParticipant,
9
+ renameBlueBubblesChat,
10
+ sendBlueBubblesTyping,
11
+ setGroupIconBlueBubbles,
12
+ unsendBlueBubblesMessage,
13
+ } from "./chat.js";
4
14
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
5
15
  import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
6
16
 
@@ -278,6 +288,188 @@ describe("chat", () => {
278
288
  });
279
289
  });
280
290
 
291
+ describe("editBlueBubblesMessage", () => {
292
+ it("throws when required args are missing", async () => {
293
+ await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid");
294
+ await expect(editBlueBubblesMessage("message-guid", " ", {})).rejects.toThrow("newText");
295
+ });
296
+
297
+ it("sends edit request with default payload values", async () => {
298
+ mockFetch.mockResolvedValueOnce({
299
+ ok: true,
300
+ text: () => Promise.resolve(""),
301
+ });
302
+
303
+ await editBlueBubblesMessage(" message-guid ", " updated text ", {
304
+ serverUrl: "http://localhost:1234",
305
+ password: "test-password",
306
+ });
307
+
308
+ expect(mockFetch).toHaveBeenCalledWith(
309
+ expect.stringContaining("/api/v1/message/message-guid/edit"),
310
+ expect.objectContaining({
311
+ method: "POST",
312
+ headers: { "Content-Type": "application/json" },
313
+ }),
314
+ );
315
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
316
+ expect(body).toEqual({
317
+ editedMessage: "updated text",
318
+ backwardsCompatibilityMessage: "Edited to: updated text",
319
+ partIndex: 0,
320
+ });
321
+ });
322
+
323
+ it("supports custom part index and backwards compatibility message", async () => {
324
+ mockFetch.mockResolvedValueOnce({
325
+ ok: true,
326
+ text: () => Promise.resolve(""),
327
+ });
328
+
329
+ await editBlueBubblesMessage("message-guid", "new text", {
330
+ serverUrl: "http://localhost:1234",
331
+ password: "test-password",
332
+ partIndex: 3,
333
+ backwardsCompatMessage: "custom-backwards-message",
334
+ });
335
+
336
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
337
+ expect(body.partIndex).toBe(3);
338
+ expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message");
339
+ });
340
+
341
+ it("throws on non-ok response", async () => {
342
+ mockFetch.mockResolvedValueOnce({
343
+ ok: false,
344
+ status: 422,
345
+ text: () => Promise.resolve("Unprocessable"),
346
+ });
347
+
348
+ await expect(
349
+ editBlueBubblesMessage("message-guid", "new text", {
350
+ serverUrl: "http://localhost:1234",
351
+ password: "test-password",
352
+ }),
353
+ ).rejects.toThrow("edit failed (422): Unprocessable");
354
+ });
355
+ });
356
+
357
+ describe("unsendBlueBubblesMessage", () => {
358
+ it("throws when messageGuid is missing", async () => {
359
+ await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid");
360
+ });
361
+
362
+ it("sends unsend request with default part index", async () => {
363
+ mockFetch.mockResolvedValueOnce({
364
+ ok: true,
365
+ text: () => Promise.resolve(""),
366
+ });
367
+
368
+ await unsendBlueBubblesMessage(" msg-123 ", {
369
+ serverUrl: "http://localhost:1234",
370
+ password: "test-password",
371
+ });
372
+
373
+ expect(mockFetch).toHaveBeenCalledWith(
374
+ expect.stringContaining("/api/v1/message/msg-123/unsend"),
375
+ expect.objectContaining({
376
+ method: "POST",
377
+ headers: { "Content-Type": "application/json" },
378
+ }),
379
+ );
380
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
381
+ expect(body.partIndex).toBe(0);
382
+ });
383
+
384
+ it("uses custom part index", async () => {
385
+ mockFetch.mockResolvedValueOnce({
386
+ ok: true,
387
+ text: () => Promise.resolve(""),
388
+ });
389
+
390
+ await unsendBlueBubblesMessage("msg-123", {
391
+ serverUrl: "http://localhost:1234",
392
+ password: "test-password",
393
+ partIndex: 2,
394
+ });
395
+
396
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
397
+ expect(body.partIndex).toBe(2);
398
+ });
399
+ });
400
+
401
+ describe("group chat mutation actions", () => {
402
+ it("renames chat", async () => {
403
+ mockFetch.mockResolvedValueOnce({
404
+ ok: true,
405
+ text: () => Promise.resolve(""),
406
+ });
407
+
408
+ await renameBlueBubblesChat(" chat-guid ", "New Group Name", {
409
+ serverUrl: "http://localhost:1234",
410
+ password: "test-password",
411
+ });
412
+
413
+ expect(mockFetch).toHaveBeenCalledWith(
414
+ expect.stringContaining("/api/v1/chat/chat-guid"),
415
+ expect.objectContaining({ method: "PUT" }),
416
+ );
417
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
418
+ expect(body.displayName).toBe("New Group Name");
419
+ });
420
+
421
+ it("adds and removes participant using matching endpoint", async () => {
422
+ mockFetch
423
+ .mockResolvedValueOnce({
424
+ ok: true,
425
+ text: () => Promise.resolve(""),
426
+ })
427
+ .mockResolvedValueOnce({
428
+ ok: true,
429
+ text: () => Promise.resolve(""),
430
+ });
431
+
432
+ await addBlueBubblesParticipant("chat-guid", "+15551234567", {
433
+ serverUrl: "http://localhost:1234",
434
+ password: "test-password",
435
+ });
436
+ await removeBlueBubblesParticipant("chat-guid", "+15551234567", {
437
+ serverUrl: "http://localhost:1234",
438
+ password: "test-password",
439
+ });
440
+
441
+ expect(mockFetch).toHaveBeenCalledTimes(2);
442
+ expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant");
443
+ expect(mockFetch.mock.calls[0][1].method).toBe("POST");
444
+ expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant");
445
+ expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
446
+
447
+ const addBody = JSON.parse(mockFetch.mock.calls[0][1].body);
448
+ const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body);
449
+ expect(addBody.address).toBe("+15551234567");
450
+ expect(removeBody.address).toBe("+15551234567");
451
+ });
452
+
453
+ it("leaves chat without JSON body", async () => {
454
+ mockFetch.mockResolvedValueOnce({
455
+ ok: true,
456
+ text: () => Promise.resolve(""),
457
+ });
458
+
459
+ await leaveBlueBubblesChat("chat-guid", {
460
+ serverUrl: "http://localhost:1234",
461
+ password: "test-password",
462
+ });
463
+
464
+ expect(mockFetch).toHaveBeenCalledWith(
465
+ expect.stringContaining("/api/v1/chat/chat-guid/leave"),
466
+ expect.objectContaining({ method: "POST" }),
467
+ );
468
+ expect(mockFetch.mock.calls[0][1].body).toBeUndefined();
469
+ expect(mockFetch.mock.calls[0][1].headers).toBeUndefined();
470
+ });
471
+ });
472
+
281
473
  describe("setGroupIconBlueBubbles", () => {
282
474
  it("throws when chatGuid is empty", async () => {
283
475
  await expect(