@openclaw/bluebubbles 2026.2.13 → 2026.2.15

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.13",
3
+ "version": "2026.2.15",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
6
  "devDependencies": {
package/src/accounts.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
3
3
  import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
4
4
 
5
5
  export type ResolvedBlueBubblesAccount = {
@@ -1,6 +1,7 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import { describe, expect, it, vi, beforeEach } from "vitest";
3
3
  import { bluebubblesMessageActions } from "./actions.js";
4
+ import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
4
5
 
5
6
  vi.mock("./accounts.js", () => ({
6
7
  resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
@@ -41,9 +42,15 @@ vi.mock("./monitor.js", () => ({
41
42
  resolveBlueBubblesMessageId: vi.fn((id: string) => id),
42
43
  }));
43
44
 
45
+ vi.mock("./probe.js", () => ({
46
+ isMacOS26OrHigher: vi.fn().mockReturnValue(false),
47
+ getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
48
+ }));
49
+
44
50
  describe("bluebubblesMessageActions", () => {
45
51
  beforeEach(() => {
46
52
  vi.clearAllMocks();
53
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
47
54
  });
48
55
 
49
56
  describe("listActions", () => {
@@ -94,6 +101,31 @@ describe("bluebubblesMessageActions", () => {
94
101
  expect(actions).toContain("edit");
95
102
  expect(actions).toContain("unsend");
96
103
  });
104
+
105
+ it("hides private-api actions when private API is disabled", () => {
106
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
107
+ const cfg: OpenClawConfig = {
108
+ channels: {
109
+ bluebubbles: {
110
+ enabled: true,
111
+ serverUrl: "http://localhost:1234",
112
+ password: "test-password",
113
+ },
114
+ },
115
+ };
116
+ const actions = bluebubblesMessageActions.listActions({ cfg });
117
+ expect(actions).toContain("sendAttachment");
118
+ expect(actions).not.toContain("react");
119
+ expect(actions).not.toContain("reply");
120
+ expect(actions).not.toContain("sendWithEffect");
121
+ expect(actions).not.toContain("edit");
122
+ expect(actions).not.toContain("unsend");
123
+ expect(actions).not.toContain("renameGroup");
124
+ expect(actions).not.toContain("setGroupIcon");
125
+ expect(actions).not.toContain("addParticipant");
126
+ expect(actions).not.toContain("removeParticipant");
127
+ expect(actions).not.toContain("leaveGroup");
128
+ });
97
129
  });
98
130
 
99
131
  describe("supportsAction", () => {
@@ -189,6 +221,26 @@ describe("bluebubblesMessageActions", () => {
189
221
  ).rejects.toThrow(/emoji/i);
190
222
  });
191
223
 
224
+ it("throws a private-api error for private-only actions when disabled", async () => {
225
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
226
+ const cfg: OpenClawConfig = {
227
+ channels: {
228
+ bluebubbles: {
229
+ serverUrl: "http://localhost:1234",
230
+ password: "test-password",
231
+ },
232
+ },
233
+ };
234
+ await expect(
235
+ bluebubblesMessageActions.handleAction({
236
+ action: "react",
237
+ params: { emoji: "❤️", messageId: "msg-123", chatGuid: "iMessage;-;+15551234567" },
238
+ cfg,
239
+ accountId: null,
240
+ }),
241
+ ).rejects.toThrow("requires Private API");
242
+ });
243
+
192
244
  it("throws when messageId is missing", async () => {
193
245
  const cfg: OpenClawConfig = {
194
246
  channels: {
package/src/actions.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  leaveBlueBubblesChat,
24
24
  } from "./chat.js";
25
25
  import { resolveBlueBubblesMessageId } from "./monitor.js";
26
- import { isMacOS26OrHigher } from "./probe.js";
26
+ import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
27
27
  import { sendBlueBubblesReaction } from "./reactions.js";
28
28
  import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
29
29
  import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
@@ -71,6 +71,18 @@ function readBooleanParam(params: Record<string, unknown>, key: string): boolean
71
71
 
72
72
  /** Supported action names for BlueBubbles */
73
73
  const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
74
+ const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
75
+ "react",
76
+ "edit",
77
+ "unsend",
78
+ "reply",
79
+ "sendWithEffect",
80
+ "renameGroup",
81
+ "setGroupIcon",
82
+ "addParticipant",
83
+ "removeParticipant",
84
+ "leaveGroup",
85
+ ]);
74
86
 
75
87
  export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
76
88
  listActions: ({ cfg }) => {
@@ -81,11 +93,15 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
81
93
  const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
82
94
  const actions = new Set<ChannelMessageActionName>();
83
95
  const macOS26 = isMacOS26OrHigher(account.accountId);
96
+ const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
84
97
  for (const action of BLUEBUBBLES_ACTION_NAMES) {
85
98
  const spec = BLUEBUBBLES_ACTIONS[action];
86
99
  if (!spec?.gate) {
87
100
  continue;
88
101
  }
102
+ if (privateApiStatus === false && PRIVATE_API_ACTIONS.has(action)) {
103
+ continue;
104
+ }
89
105
  if ("unsupportedOnMacOS26" in spec && spec.unsupportedOnMacOS26 && macOS26) {
90
106
  continue;
91
107
  }
@@ -116,6 +132,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
116
132
  const baseUrl = account.config.serverUrl?.trim();
117
133
  const password = account.config.password?.trim();
118
134
  const opts = { cfg: cfg, accountId: accountId ?? undefined };
135
+ const assertPrivateApiEnabled = () => {
136
+ if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false) {
137
+ throw new Error(
138
+ `BlueBubbles ${action} requires Private API, but it is disabled on the BlueBubbles server.`,
139
+ );
140
+ }
141
+ };
119
142
 
120
143
  // Helper to resolve chatGuid from various params or session context
121
144
  const resolveChatGuid = async (): Promise<string> => {
@@ -159,6 +182,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
159
182
 
160
183
  // Handle react action
161
184
  if (action === "react") {
185
+ assertPrivateApiEnabled();
162
186
  const { emoji, remove, isEmpty } = readReactionParams(params, {
163
187
  removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
164
188
  });
@@ -193,6 +217,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
193
217
 
194
218
  // Handle edit action
195
219
  if (action === "edit") {
220
+ assertPrivateApiEnabled();
196
221
  // Edit is not supported on macOS 26+
197
222
  if (isMacOS26OrHigher(accountId ?? undefined)) {
198
223
  throw new Error(
@@ -234,6 +259,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
234
259
 
235
260
  // Handle unsend action
236
261
  if (action === "unsend") {
262
+ assertPrivateApiEnabled();
237
263
  const rawMessageId = readStringParam(params, "messageId");
238
264
  if (!rawMessageId) {
239
265
  throw new Error(
@@ -255,6 +281,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
255
281
 
256
282
  // Handle reply action
257
283
  if (action === "reply") {
284
+ assertPrivateApiEnabled();
258
285
  const rawMessageId = readStringParam(params, "messageId");
259
286
  const text = readMessageText(params);
260
287
  const to = readStringParam(params, "to") ?? readStringParam(params, "target");
@@ -289,6 +316,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
289
316
 
290
317
  // Handle sendWithEffect action
291
318
  if (action === "sendWithEffect") {
319
+ assertPrivateApiEnabled();
292
320
  const text = readMessageText(params);
293
321
  const to = readStringParam(params, "to") ?? readStringParam(params, "target");
294
322
  const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
@@ -321,6 +349,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
321
349
 
322
350
  // Handle renameGroup action
323
351
  if (action === "renameGroup") {
352
+ assertPrivateApiEnabled();
324
353
  const resolvedChatGuid = await resolveChatGuid();
325
354
  const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
326
355
  if (!displayName) {
@@ -334,6 +363,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
334
363
 
335
364
  // Handle setGroupIcon action
336
365
  if (action === "setGroupIcon") {
366
+ assertPrivateApiEnabled();
337
367
  const resolvedChatGuid = await resolveChatGuid();
338
368
  const base64Buffer = readStringParam(params, "buffer");
339
369
  const filename =
@@ -361,6 +391,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
361
391
 
362
392
  // Handle addParticipant action
363
393
  if (action === "addParticipant") {
394
+ assertPrivateApiEnabled();
364
395
  const resolvedChatGuid = await resolveChatGuid();
365
396
  const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
366
397
  if (!address) {
@@ -374,6 +405,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
374
405
 
375
406
  // Handle removeParticipant action
376
407
  if (action === "removeParticipant") {
408
+ assertPrivateApiEnabled();
377
409
  const resolvedChatGuid = await resolveChatGuid();
378
410
  const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
379
411
  if (!address) {
@@ -387,6 +419,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
387
419
 
388
420
  // Handle leaveGroup action
389
421
  if (action === "leaveGroup") {
422
+ assertPrivateApiEnabled();
390
423
  const resolvedChatGuid = await resolveChatGuid();
391
424
 
392
425
  await leaveBlueBubblesChat(resolvedChatGuid, opts);
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
2
  import type { BlueBubblesAttachment } from "./types.js";
3
3
  import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
4
+ import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
4
5
 
5
6
  vi.mock("./accounts.js", () => ({
6
7
  resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
@@ -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("downloadBlueBubblesAttachment", () => {
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(() => {
@@ -242,6 +249,8 @@ describe("sendBlueBubblesAttachment", () => {
242
249
  beforeEach(() => {
243
250
  vi.stubGlobal("fetch", mockFetch);
244
251
  mockFetch.mockReset();
252
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
253
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
245
254
  });
246
255
 
247
256
  afterEach(() => {
@@ -342,4 +351,27 @@ describe("sendBlueBubblesAttachment", () => {
342
351
  expect(bodyText).toContain('filename="evil.mp3"');
343
352
  expect(bodyText).toContain('name="evil.mp3"');
344
353
  });
354
+
355
+ it("downgrades attachment reply threading when private API is disabled", async () => {
356
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
357
+ mockFetch.mockResolvedValueOnce({
358
+ ok: true,
359
+ text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
360
+ });
361
+
362
+ await sendBlueBubblesAttachment({
363
+ to: "chat_guid:iMessage;-;+15551234567",
364
+ buffer: new Uint8Array([1, 2, 3]),
365
+ filename: "photo.jpg",
366
+ contentType: "image/jpeg",
367
+ replyToMessageGuid: "reply-guid-123",
368
+ opts: { serverUrl: "http://localhost:1234", password: "test" },
369
+ });
370
+
371
+ const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
372
+ const bodyText = decodeBody(body);
373
+ expect(bodyText).not.toContain('name="method"');
374
+ expect(bodyText).not.toContain('name="selectedMessageGuid"');
375
+ expect(bodyText).not.toContain('name="partIndex"');
376
+ });
345
377
  });
@@ -2,8 +2,10 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import crypto from "node:crypto";
3
3
  import path from "node:path";
4
4
  import { resolveBlueBubblesAccount } from "./accounts.js";
5
+ import { postMultipartFormData } from "./multipart.js";
6
+ import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
7
+ import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
5
8
  import { resolveChatGuidForTarget } from "./send.js";
6
- import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
7
9
  import {
8
10
  blueBubblesFetchWithTimeout,
9
11
  buildBlueBubblesApiUrl,
@@ -64,7 +66,7 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
64
66
  if (!password) {
65
67
  throw new Error("BlueBubbles password is required");
66
68
  }
67
- return { baseUrl, password };
69
+ return { baseUrl, password, accountId: account.accountId };
68
70
  }
69
71
 
70
72
  export async function downloadBlueBubblesAttachment(
@@ -101,52 +103,6 @@ export type SendBlueBubblesAttachmentResult = {
101
103
  messageId: string;
102
104
  };
103
105
 
104
- function resolveSendTarget(raw: string): BlueBubblesSendTarget {
105
- const parsed = parseBlueBubblesTarget(raw);
106
- if (parsed.kind === "handle") {
107
- return {
108
- kind: "handle",
109
- address: normalizeBlueBubblesHandle(parsed.to),
110
- service: parsed.service,
111
- };
112
- }
113
- if (parsed.kind === "chat_id") {
114
- return { kind: "chat_id", chatId: parsed.chatId };
115
- }
116
- if (parsed.kind === "chat_guid") {
117
- return { kind: "chat_guid", chatGuid: parsed.chatGuid };
118
- }
119
- return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
120
- }
121
-
122
- function extractMessageId(payload: unknown): string {
123
- if (!payload || typeof payload !== "object") {
124
- return "unknown";
125
- }
126
- const record = payload as Record<string, unknown>;
127
- const data =
128
- record.data && typeof record.data === "object"
129
- ? (record.data as Record<string, unknown>)
130
- : null;
131
- const candidates = [
132
- record.messageId,
133
- record.guid,
134
- record.id,
135
- data?.messageId,
136
- data?.guid,
137
- data?.id,
138
- ];
139
- for (const candidate of candidates) {
140
- if (typeof candidate === "string" && candidate.trim()) {
141
- return candidate.trim();
142
- }
143
- if (typeof candidate === "number" && Number.isFinite(candidate)) {
144
- return String(candidate);
145
- }
146
- }
147
- return "unknown";
148
- }
149
-
150
106
  /**
151
107
  * Send an attachment via BlueBubbles API.
152
108
  * Supports sending media files (images, videos, audio, documents) to a chat.
@@ -169,7 +125,8 @@ export async function sendBlueBubblesAttachment(params: {
169
125
  const fallbackName = wantsVoice ? "Audio Message" : "attachment";
170
126
  filename = sanitizeFilename(filename, fallbackName);
171
127
  contentType = contentType?.trim() || undefined;
172
- const { baseUrl, password } = resolveAccount(opts);
128
+ const { baseUrl, password, accountId } = resolveAccount(opts);
129
+ const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
173
130
 
174
131
  // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
175
132
  const isAudioMessage = wantsVoice;
@@ -191,7 +148,7 @@ export async function sendBlueBubblesAttachment(params: {
191
148
  }
192
149
  }
193
150
 
194
- const target = resolveSendTarget(to);
151
+ const target = resolveBlueBubblesSendTarget(to);
195
152
  const chatGuid = await resolveChatGuidForTarget({
196
153
  baseUrl,
197
154
  password,
@@ -238,7 +195,9 @@ export async function sendBlueBubblesAttachment(params: {
238
195
  addField("chatGuid", chatGuid);
239
196
  addField("name", filename);
240
197
  addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
241
- addField("method", "private-api");
198
+ if (privateApiStatus !== false) {
199
+ addField("method", "private-api");
200
+ }
242
201
 
243
202
  // Add isAudioMessage flag for voice memos
244
203
  if (isAudioMessage) {
@@ -246,7 +205,7 @@ export async function sendBlueBubblesAttachment(params: {
246
205
  }
247
206
 
248
207
  const trimmedReplyTo = replyToMessageGuid?.trim();
249
- if (trimmedReplyTo) {
208
+ if (trimmedReplyTo && privateApiStatus !== false) {
250
209
  addField("selectedMessageGuid", trimmedReplyTo);
251
210
  addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
252
211
  }
@@ -261,26 +220,12 @@ export async function sendBlueBubblesAttachment(params: {
261
220
  // Close the multipart body
262
221
  parts.push(encoder.encode(`--${boundary}--\r\n`));
263
222
 
264
- // Combine all parts into a single buffer
265
- const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
266
- const body = new Uint8Array(totalLength);
267
- let offset = 0;
268
- for (const part of parts) {
269
- body.set(part, offset);
270
- offset += part.length;
271
- }
272
-
273
- const res = await blueBubblesFetchWithTimeout(
223
+ const res = await postMultipartFormData({
274
224
  url,
275
- {
276
- method: "POST",
277
- headers: {
278
- "Content-Type": `multipart/form-data; boundary=${boundary}`,
279
- },
280
- body,
281
- },
282
- opts.timeoutMs ?? 60_000, // longer timeout for file uploads
283
- );
225
+ boundary,
226
+ parts,
227
+ timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
228
+ });
284
229
 
285
230
  if (!res.ok) {
286
231
  const errorText = await res.text();
@@ -295,7 +240,7 @@ export async function sendBlueBubblesAttachment(params: {
295
240
  }
296
241
  try {
297
242
  const parsed = JSON.parse(responseBody) as unknown;
298
- return { messageId: extractMessageId(parsed) };
243
+ return { messageId: extractBlueBubblesMessageId(parsed) };
299
244
  } catch {
300
245
  return { messageId: "ok" };
301
246
  }
package/src/chat.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
2
  import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
3
+ import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
3
4
 
4
5
  vi.mock("./accounts.js", () => ({
5
6
  resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
@@ -13,12 +14,18 @@ vi.mock("./accounts.js", () => ({
13
14
  }),
14
15
  }));
15
16
 
17
+ vi.mock("./probe.js", () => ({
18
+ getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
19
+ }));
20
+
16
21
  const mockFetch = vi.fn();
17
22
 
18
23
  describe("chat", () => {
19
24
  beforeEach(() => {
20
25
  vi.stubGlobal("fetch", mockFetch);
21
26
  mockFetch.mockReset();
27
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
28
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
22
29
  });
23
30
 
24
31
  afterEach(() => {
@@ -73,6 +80,17 @@ describe("chat", () => {
73
80
  );
74
81
  });
75
82
 
83
+ it("does not send read receipt when private API is disabled", async () => {
84
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
85
+
86
+ await markBlueBubblesChatRead("iMessage;-;+15551234567", {
87
+ serverUrl: "http://localhost:1234",
88
+ password: "test-password",
89
+ });
90
+
91
+ expect(mockFetch).not.toHaveBeenCalled();
92
+ });
93
+
76
94
  it("includes password in URL query", async () => {
77
95
  mockFetch.mockResolvedValueOnce({
78
96
  ok: true,
@@ -190,6 +208,17 @@ describe("chat", () => {
190
208
  );
191
209
  });
192
210
 
211
+ it("does not send typing when private API is disabled", async () => {
212
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
213
+
214
+ await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
215
+ serverUrl: "http://localhost:1234",
216
+ password: "test",
217
+ });
218
+
219
+ expect(mockFetch).not.toHaveBeenCalled();
220
+ });
221
+
193
222
  it("sends typing stop with DELETE method", async () => {
194
223
  mockFetch.mockResolvedValueOnce({
195
224
  ok: true,
@@ -348,6 +377,17 @@ describe("chat", () => {
348
377
  ).rejects.toThrow("password is required");
349
378
  });
350
379
 
380
+ it("throws when private API is disabled", async () => {
381
+ vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
382
+ await expect(
383
+ setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
384
+ serverUrl: "http://localhost:1234",
385
+ password: "test",
386
+ }),
387
+ ).rejects.toThrow("requires Private API");
388
+ expect(mockFetch).not.toHaveBeenCalled();
389
+ });
390
+
351
391
  it("sets group icon successfully", async () => {
352
392
  mockFetch.mockResolvedValueOnce({
353
393
  ok: true,
package/src/chat.ts CHANGED
@@ -2,6 +2,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
2
  import crypto from "node:crypto";
3
3
  import path from "node:path";
4
4
  import { resolveBlueBubblesAccount } from "./accounts.js";
5
+ import { postMultipartFormData } from "./multipart.js";
6
+ import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
5
7
  import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
6
8
 
7
9
  export type BlueBubblesChatOpts = {
@@ -25,7 +27,15 @@ function resolveAccount(params: BlueBubblesChatOpts) {
25
27
  if (!password) {
26
28
  throw new Error("BlueBubbles password is required");
27
29
  }
28
- return { baseUrl, password };
30
+ return { baseUrl, password, accountId: account.accountId };
31
+ }
32
+
33
+ function assertPrivateApiEnabled(accountId: string, feature: string): void {
34
+ if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
35
+ throw new Error(
36
+ `BlueBubbles ${feature} requires Private API, but it is disabled on the BlueBubbles server.`,
37
+ );
38
+ }
29
39
  }
30
40
 
31
41
  export async function markBlueBubblesChatRead(
@@ -36,7 +46,10 @@ export async function markBlueBubblesChatRead(
36
46
  if (!trimmed) {
37
47
  return;
38
48
  }
39
- const { baseUrl, password } = resolveAccount(opts);
49
+ const { baseUrl, password, accountId } = resolveAccount(opts);
50
+ if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
51
+ return;
52
+ }
40
53
  const url = buildBlueBubblesApiUrl({
41
54
  baseUrl,
42
55
  path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
@@ -58,7 +71,10 @@ export async function sendBlueBubblesTyping(
58
71
  if (!trimmed) {
59
72
  return;
60
73
  }
61
- const { baseUrl, password } = resolveAccount(opts);
74
+ const { baseUrl, password, accountId } = resolveAccount(opts);
75
+ if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
76
+ return;
77
+ }
62
78
  const url = buildBlueBubblesApiUrl({
63
79
  baseUrl,
64
80
  path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
@@ -93,7 +109,8 @@ export async function editBlueBubblesMessage(
93
109
  throw new Error("BlueBubbles edit requires newText");
94
110
  }
95
111
 
96
- const { baseUrl, password } = resolveAccount(opts);
112
+ const { baseUrl, password, accountId } = resolveAccount(opts);
113
+ assertPrivateApiEnabled(accountId, "edit");
97
114
  const url = buildBlueBubblesApiUrl({
98
115
  baseUrl,
99
116
  path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
@@ -135,7 +152,8 @@ export async function unsendBlueBubblesMessage(
135
152
  throw new Error("BlueBubbles unsend requires messageGuid");
136
153
  }
137
154
 
138
- const { baseUrl, password } = resolveAccount(opts);
155
+ const { baseUrl, password, accountId } = resolveAccount(opts);
156
+ assertPrivateApiEnabled(accountId, "unsend");
139
157
  const url = buildBlueBubblesApiUrl({
140
158
  baseUrl,
141
159
  path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
@@ -175,7 +193,8 @@ export async function renameBlueBubblesChat(
175
193
  throw new Error("BlueBubbles rename requires chatGuid");
176
194
  }
177
195
 
178
- const { baseUrl, password } = resolveAccount(opts);
196
+ const { baseUrl, password, accountId } = resolveAccount(opts);
197
+ assertPrivateApiEnabled(accountId, "renameGroup");
179
198
  const url = buildBlueBubblesApiUrl({
180
199
  baseUrl,
181
200
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
@@ -215,7 +234,8 @@ export async function addBlueBubblesParticipant(
215
234
  throw new Error("BlueBubbles addParticipant requires address");
216
235
  }
217
236
 
218
- const { baseUrl, password } = resolveAccount(opts);
237
+ const { baseUrl, password, accountId } = resolveAccount(opts);
238
+ assertPrivateApiEnabled(accountId, "addParticipant");
219
239
  const url = buildBlueBubblesApiUrl({
220
240
  baseUrl,
221
241
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
@@ -255,7 +275,8 @@ export async function removeBlueBubblesParticipant(
255
275
  throw new Error("BlueBubbles removeParticipant requires address");
256
276
  }
257
277
 
258
- const { baseUrl, password } = resolveAccount(opts);
278
+ const { baseUrl, password, accountId } = resolveAccount(opts);
279
+ assertPrivateApiEnabled(accountId, "removeParticipant");
259
280
  const url = buildBlueBubblesApiUrl({
260
281
  baseUrl,
261
282
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
@@ -292,7 +313,8 @@ export async function leaveBlueBubblesChat(
292
313
  throw new Error("BlueBubbles leaveChat requires chatGuid");
293
314
  }
294
315
 
295
- const { baseUrl, password } = resolveAccount(opts);
316
+ const { baseUrl, password, accountId } = resolveAccount(opts);
317
+ assertPrivateApiEnabled(accountId, "leaveGroup");
296
318
  const url = buildBlueBubblesApiUrl({
297
319
  baseUrl,
298
320
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
@@ -325,7 +347,8 @@ export async function setGroupIconBlueBubbles(
325
347
  throw new Error("BlueBubbles setGroupIcon requires image buffer");
326
348
  }
327
349
 
328
- const { baseUrl, password } = resolveAccount(opts);
350
+ const { baseUrl, password, accountId } = resolveAccount(opts);
351
+ assertPrivateApiEnabled(accountId, "setGroupIcon");
329
352
  const url = buildBlueBubblesApiUrl({
330
353
  baseUrl,
331
354
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
@@ -354,26 +377,12 @@ export async function setGroupIconBlueBubbles(
354
377
  // Close multipart body
355
378
  parts.push(encoder.encode(`--${boundary}--\r\n`));
356
379
 
357
- // Combine into single buffer
358
- const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
359
- const body = new Uint8Array(totalLength);
360
- let offset = 0;
361
- for (const part of parts) {
362
- body.set(part, offset);
363
- offset += part.length;
364
- }
365
-
366
- const res = await blueBubblesFetchWithTimeout(
380
+ const res = await postMultipartFormData({
367
381
  url,
368
- {
369
- method: "POST",
370
- headers: {
371
- "Content-Type": `multipart/form-data; boundary=${boundary}`,
372
- },
373
- body,
374
- },
375
- opts.timeoutMs ?? 60_000, // longer timeout for file uploads
376
- );
382
+ boundary,
383
+ parts,
384
+ timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads
385
+ });
377
386
 
378
387
  if (!res.ok) {
379
388
  const errorText = await res.text().catch(() => "");
@@ -40,6 +40,7 @@ const bluebubblesAccountSchema = z.object({
40
40
  textChunkLimit: z.number().int().positive().optional(),
41
41
  chunkMode: z.enum(["length", "newline"]).optional(),
42
42
  mediaMaxMb: z.number().int().positive().optional(),
43
+ mediaLocalRoots: z.array(z.string()).optional(),
43
44
  sendReadReceipts: z.boolean().optional(),
44
45
  blockStreaming: z.boolean().optional(),
45
46
  groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),