@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/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
 
@@ -12,6 +22,44 @@ installBlueBubblesFetchTestHooks({
12
22
  });
13
23
 
14
24
  describe("chat", () => {
25
+ function mockOkTextResponse() {
26
+ mockFetch.mockResolvedValueOnce({
27
+ ok: true,
28
+ text: () => Promise.resolve(""),
29
+ });
30
+ }
31
+
32
+ async function expectCalledUrlIncludesPassword(params: {
33
+ password: string;
34
+ invoke: () => Promise<void>;
35
+ }) {
36
+ mockOkTextResponse();
37
+ await params.invoke();
38
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
39
+ expect(calledUrl).toContain(`password=${params.password}`);
40
+ }
41
+
42
+ async function expectCalledUrlUsesConfigCredentials(params: {
43
+ serverHost: string;
44
+ password: string;
45
+ invoke: (cfg: {
46
+ channels: { bluebubbles: { serverUrl: string; password: string } };
47
+ }) => Promise<void>;
48
+ }) {
49
+ mockOkTextResponse();
50
+ await params.invoke({
51
+ channels: {
52
+ bluebubbles: {
53
+ serverUrl: `http://${params.serverHost}`,
54
+ password: params.password,
55
+ },
56
+ },
57
+ });
58
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
59
+ expect(calledUrl).toContain(params.serverHost);
60
+ expect(calledUrl).toContain(`password=${params.password}`);
61
+ }
62
+
15
63
  describe("markBlueBubblesChatRead", () => {
16
64
  it("does nothing when chatGuid is empty or whitespace", async () => {
17
65
  for (const chatGuid of ["", " "]) {
@@ -63,18 +111,14 @@ describe("chat", () => {
63
111
  });
64
112
 
65
113
  it("includes password in URL query", async () => {
66
- mockFetch.mockResolvedValueOnce({
67
- ok: true,
68
- text: () => Promise.resolve(""),
69
- });
70
-
71
- await markBlueBubblesChatRead("chat-123", {
72
- serverUrl: "http://localhost:1234",
114
+ await expectCalledUrlIncludesPassword({
73
115
  password: "my-secret",
116
+ invoke: () =>
117
+ markBlueBubblesChatRead("chat-123", {
118
+ serverUrl: "http://localhost:1234",
119
+ password: "my-secret",
120
+ }),
74
121
  });
75
-
76
- const calledUrl = mockFetch.mock.calls[0][0] as string;
77
- expect(calledUrl).toContain("password=my-secret");
78
122
  });
79
123
 
80
124
  it("throws on non-ok response", async () => {
@@ -109,25 +153,14 @@ describe("chat", () => {
109
153
  });
110
154
 
111
155
  it("resolves credentials from config", async () => {
112
- mockFetch.mockResolvedValueOnce({
113
- ok: true,
114
- text: () => Promise.resolve(""),
115
- });
116
-
117
- await markBlueBubblesChatRead("chat-123", {
118
- cfg: {
119
- channels: {
120
- bluebubbles: {
121
- serverUrl: "http://config-server:9999",
122
- password: "config-pass",
123
- },
124
- },
125
- },
156
+ await expectCalledUrlUsesConfigCredentials({
157
+ serverHost: "config-server:9999",
158
+ password: "config-pass",
159
+ invoke: (cfg) =>
160
+ markBlueBubblesChatRead("chat-123", {
161
+ cfg,
162
+ }),
126
163
  });
127
-
128
- const calledUrl = mockFetch.mock.calls[0][0] as string;
129
- expect(calledUrl).toContain("config-server:9999");
130
- expect(calledUrl).toContain("password=config-pass");
131
164
  });
132
165
  });
133
166
 
@@ -278,6 +311,188 @@ describe("chat", () => {
278
311
  });
279
312
  });
280
313
 
314
+ describe("editBlueBubblesMessage", () => {
315
+ it("throws when required args are missing", async () => {
316
+ await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid");
317
+ await expect(editBlueBubblesMessage("message-guid", " ", {})).rejects.toThrow("newText");
318
+ });
319
+
320
+ it("sends edit request with default payload values", async () => {
321
+ mockFetch.mockResolvedValueOnce({
322
+ ok: true,
323
+ text: () => Promise.resolve(""),
324
+ });
325
+
326
+ await editBlueBubblesMessage(" message-guid ", " updated text ", {
327
+ serverUrl: "http://localhost:1234",
328
+ password: "test-password",
329
+ });
330
+
331
+ expect(mockFetch).toHaveBeenCalledWith(
332
+ expect.stringContaining("/api/v1/message/message-guid/edit"),
333
+ expect.objectContaining({
334
+ method: "POST",
335
+ headers: { "Content-Type": "application/json" },
336
+ }),
337
+ );
338
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
339
+ expect(body).toEqual({
340
+ editedMessage: "updated text",
341
+ backwardsCompatibilityMessage: "Edited to: updated text",
342
+ partIndex: 0,
343
+ });
344
+ });
345
+
346
+ it("supports custom part index and backwards compatibility message", async () => {
347
+ mockFetch.mockResolvedValueOnce({
348
+ ok: true,
349
+ text: () => Promise.resolve(""),
350
+ });
351
+
352
+ await editBlueBubblesMessage("message-guid", "new text", {
353
+ serverUrl: "http://localhost:1234",
354
+ password: "test-password",
355
+ partIndex: 3,
356
+ backwardsCompatMessage: "custom-backwards-message",
357
+ });
358
+
359
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
360
+ expect(body.partIndex).toBe(3);
361
+ expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message");
362
+ });
363
+
364
+ it("throws on non-ok response", async () => {
365
+ mockFetch.mockResolvedValueOnce({
366
+ ok: false,
367
+ status: 422,
368
+ text: () => Promise.resolve("Unprocessable"),
369
+ });
370
+
371
+ await expect(
372
+ editBlueBubblesMessage("message-guid", "new text", {
373
+ serverUrl: "http://localhost:1234",
374
+ password: "test-password",
375
+ }),
376
+ ).rejects.toThrow("edit failed (422): Unprocessable");
377
+ });
378
+ });
379
+
380
+ describe("unsendBlueBubblesMessage", () => {
381
+ it("throws when messageGuid is missing", async () => {
382
+ await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid");
383
+ });
384
+
385
+ it("sends unsend request with default part index", async () => {
386
+ mockFetch.mockResolvedValueOnce({
387
+ ok: true,
388
+ text: () => Promise.resolve(""),
389
+ });
390
+
391
+ await unsendBlueBubblesMessage(" msg-123 ", {
392
+ serverUrl: "http://localhost:1234",
393
+ password: "test-password",
394
+ });
395
+
396
+ expect(mockFetch).toHaveBeenCalledWith(
397
+ expect.stringContaining("/api/v1/message/msg-123/unsend"),
398
+ expect.objectContaining({
399
+ method: "POST",
400
+ headers: { "Content-Type": "application/json" },
401
+ }),
402
+ );
403
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
404
+ expect(body.partIndex).toBe(0);
405
+ });
406
+
407
+ it("uses custom part index", async () => {
408
+ mockFetch.mockResolvedValueOnce({
409
+ ok: true,
410
+ text: () => Promise.resolve(""),
411
+ });
412
+
413
+ await unsendBlueBubblesMessage("msg-123", {
414
+ serverUrl: "http://localhost:1234",
415
+ password: "test-password",
416
+ partIndex: 2,
417
+ });
418
+
419
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
420
+ expect(body.partIndex).toBe(2);
421
+ });
422
+ });
423
+
424
+ describe("group chat mutation actions", () => {
425
+ it("renames chat", async () => {
426
+ mockFetch.mockResolvedValueOnce({
427
+ ok: true,
428
+ text: () => Promise.resolve(""),
429
+ });
430
+
431
+ await renameBlueBubblesChat(" chat-guid ", "New Group Name", {
432
+ serverUrl: "http://localhost:1234",
433
+ password: "test-password",
434
+ });
435
+
436
+ expect(mockFetch).toHaveBeenCalledWith(
437
+ expect.stringContaining("/api/v1/chat/chat-guid"),
438
+ expect.objectContaining({ method: "PUT" }),
439
+ );
440
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
441
+ expect(body.displayName).toBe("New Group Name");
442
+ });
443
+
444
+ it("adds and removes participant using matching endpoint", async () => {
445
+ mockFetch
446
+ .mockResolvedValueOnce({
447
+ ok: true,
448
+ text: () => Promise.resolve(""),
449
+ })
450
+ .mockResolvedValueOnce({
451
+ ok: true,
452
+ text: () => Promise.resolve(""),
453
+ });
454
+
455
+ await addBlueBubblesParticipant("chat-guid", "+15551234567", {
456
+ serverUrl: "http://localhost:1234",
457
+ password: "test-password",
458
+ });
459
+ await removeBlueBubblesParticipant("chat-guid", "+15551234567", {
460
+ serverUrl: "http://localhost:1234",
461
+ password: "test-password",
462
+ });
463
+
464
+ expect(mockFetch).toHaveBeenCalledTimes(2);
465
+ expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant");
466
+ expect(mockFetch.mock.calls[0][1].method).toBe("POST");
467
+ expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant");
468
+ expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
469
+
470
+ const addBody = JSON.parse(mockFetch.mock.calls[0][1].body);
471
+ const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body);
472
+ expect(addBody.address).toBe("+15551234567");
473
+ expect(removeBody.address).toBe("+15551234567");
474
+ });
475
+
476
+ it("leaves chat without JSON body", async () => {
477
+ mockFetch.mockResolvedValueOnce({
478
+ ok: true,
479
+ text: () => Promise.resolve(""),
480
+ });
481
+
482
+ await leaveBlueBubblesChat("chat-guid", {
483
+ serverUrl: "http://localhost:1234",
484
+ password: "test-password",
485
+ });
486
+
487
+ expect(mockFetch).toHaveBeenCalledWith(
488
+ expect.stringContaining("/api/v1/chat/chat-guid/leave"),
489
+ expect.objectContaining({ method: "POST" }),
490
+ );
491
+ expect(mockFetch.mock.calls[0][1].body).toBeUndefined();
492
+ expect(mockFetch.mock.calls[0][1].headers).toBeUndefined();
493
+ });
494
+ });
495
+
281
496
  describe("setGroupIconBlueBubbles", () => {
282
497
  it("throws when chatGuid is empty", async () => {
283
498
  await expect(
@@ -344,18 +559,14 @@ describe("chat", () => {
344
559
  });
345
560
 
346
561
  it("includes password in URL query", async () => {
347
- mockFetch.mockResolvedValueOnce({
348
- ok: true,
349
- text: () => Promise.resolve(""),
350
- });
351
-
352
- await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
353
- serverUrl: "http://localhost:1234",
562
+ await expectCalledUrlIncludesPassword({
354
563
  password: "my-secret",
564
+ invoke: () =>
565
+ setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
566
+ serverUrl: "http://localhost:1234",
567
+ password: "my-secret",
568
+ }),
355
569
  });
356
-
357
- const calledUrl = mockFetch.mock.calls[0][0] as string;
358
- expect(calledUrl).toContain("password=my-secret");
359
570
  });
360
571
 
361
572
  it("throws on non-ok response", async () => {
@@ -390,25 +601,14 @@ describe("chat", () => {
390
601
  });
391
602
 
392
603
  it("resolves credentials from config", async () => {
393
- mockFetch.mockResolvedValueOnce({
394
- ok: true,
395
- text: () => Promise.resolve(""),
396
- });
397
-
398
- await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
399
- cfg: {
400
- channels: {
401
- bluebubbles: {
402
- serverUrl: "http://config-server:9999",
403
- password: "config-pass",
404
- },
405
- },
406
- },
604
+ await expectCalledUrlUsesConfigCredentials({
605
+ serverHost: "config-server:9999",
606
+ password: "config-pass",
607
+ invoke: (cfg) =>
608
+ setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
609
+ cfg,
610
+ }),
407
611
  });
408
-
409
- const calledUrl = mockFetch.mock.calls[0][0] as string;
410
- expect(calledUrl).toContain("config-server:9999");
411
- expect(calledUrl).toContain("password=config-pass");
412
612
  });
413
613
 
414
614
  it("includes filename in multipart body", async () => {
package/src/chat.ts CHANGED
@@ -26,6 +26,41 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void {
26
26
  }
27
27
  }
28
28
 
29
+ function resolvePartIndex(partIndex: number | undefined): number {
30
+ return typeof partIndex === "number" ? partIndex : 0;
31
+ }
32
+
33
+ async function sendPrivateApiJsonRequest(params: {
34
+ opts: BlueBubblesChatOpts;
35
+ feature: string;
36
+ action: string;
37
+ path: string;
38
+ method: "POST" | "PUT" | "DELETE";
39
+ payload?: unknown;
40
+ }): Promise<void> {
41
+ const { baseUrl, password, accountId } = resolveAccount(params.opts);
42
+ assertPrivateApiEnabled(accountId, params.feature);
43
+ const url = buildBlueBubblesApiUrl({
44
+ baseUrl,
45
+ path: params.path,
46
+ password,
47
+ });
48
+
49
+ const request: RequestInit = { method: params.method };
50
+ if (params.payload !== undefined) {
51
+ request.headers = { "Content-Type": "application/json" };
52
+ request.body = JSON.stringify(params.payload);
53
+ }
54
+
55
+ const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs);
56
+ if (!res.ok) {
57
+ const errorText = await res.text().catch(() => "");
58
+ throw new Error(
59
+ `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
60
+ );
61
+ }
62
+ }
63
+
29
64
  export async function markBlueBubblesChatRead(
30
65
  chatGuid: string,
31
66
  opts: BlueBubblesChatOpts = {},
@@ -97,34 +132,18 @@ export async function editBlueBubblesMessage(
97
132
  throw new Error("BlueBubbles edit requires newText");
98
133
  }
99
134
 
100
- const { baseUrl, password, accountId } = resolveAccount(opts);
101
- assertPrivateApiEnabled(accountId, "edit");
102
- const url = buildBlueBubblesApiUrl({
103
- baseUrl,
135
+ await sendPrivateApiJsonRequest({
136
+ opts,
137
+ feature: "edit",
138
+ action: "edit",
139
+ method: "POST",
104
140
  path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
105
- password,
106
- });
107
-
108
- const payload = {
109
- editedMessage: trimmedText,
110
- backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
111
- partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
112
- };
113
-
114
- const res = await blueBubblesFetchWithTimeout(
115
- url,
116
- {
117
- method: "POST",
118
- headers: { "Content-Type": "application/json" },
119
- body: JSON.stringify(payload),
141
+ payload: {
142
+ editedMessage: trimmedText,
143
+ backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`,
144
+ partIndex: resolvePartIndex(opts.partIndex),
120
145
  },
121
- opts.timeoutMs,
122
- );
123
-
124
- if (!res.ok) {
125
- const errorText = await res.text().catch(() => "");
126
- throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`);
127
- }
146
+ });
128
147
  }
129
148
 
130
149
  /**
@@ -140,32 +159,14 @@ export async function unsendBlueBubblesMessage(
140
159
  throw new Error("BlueBubbles unsend requires messageGuid");
141
160
  }
142
161
 
143
- const { baseUrl, password, accountId } = resolveAccount(opts);
144
- assertPrivateApiEnabled(accountId, "unsend");
145
- const url = buildBlueBubblesApiUrl({
146
- baseUrl,
162
+ await sendPrivateApiJsonRequest({
163
+ opts,
164
+ feature: "unsend",
165
+ action: "unsend",
166
+ method: "POST",
147
167
  path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
148
- password,
168
+ payload: { partIndex: resolvePartIndex(opts.partIndex) },
149
169
  });
150
-
151
- const payload = {
152
- partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0,
153
- };
154
-
155
- const res = await blueBubblesFetchWithTimeout(
156
- url,
157
- {
158
- method: "POST",
159
- headers: { "Content-Type": "application/json" },
160
- body: JSON.stringify(payload),
161
- },
162
- opts.timeoutMs,
163
- );
164
-
165
- if (!res.ok) {
166
- const errorText = await res.text().catch(() => "");
167
- throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`);
168
- }
169
170
  }
170
171
 
171
172
  /**
@@ -181,28 +182,14 @@ export async function renameBlueBubblesChat(
181
182
  throw new Error("BlueBubbles rename requires chatGuid");
182
183
  }
183
184
 
184
- const { baseUrl, password, accountId } = resolveAccount(opts);
185
- assertPrivateApiEnabled(accountId, "renameGroup");
186
- const url = buildBlueBubblesApiUrl({
187
- baseUrl,
185
+ await sendPrivateApiJsonRequest({
186
+ opts,
187
+ feature: "renameGroup",
188
+ action: "rename",
189
+ method: "PUT",
188
190
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
189
- password,
191
+ payload: { displayName },
190
192
  });
191
-
192
- const res = await blueBubblesFetchWithTimeout(
193
- url,
194
- {
195
- method: "PUT",
196
- headers: { "Content-Type": "application/json" },
197
- body: JSON.stringify({ displayName }),
198
- },
199
- opts.timeoutMs,
200
- );
201
-
202
- if (!res.ok) {
203
- const errorText = await res.text().catch(() => "");
204
- throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`);
205
- }
206
193
  }
207
194
 
208
195
  /**
@@ -222,28 +209,14 @@ export async function addBlueBubblesParticipant(
222
209
  throw new Error("BlueBubbles addParticipant requires address");
223
210
  }
224
211
 
225
- const { baseUrl, password, accountId } = resolveAccount(opts);
226
- assertPrivateApiEnabled(accountId, "addParticipant");
227
- const url = buildBlueBubblesApiUrl({
228
- baseUrl,
212
+ await sendPrivateApiJsonRequest({
213
+ opts,
214
+ feature: "addParticipant",
215
+ action: "addParticipant",
216
+ method: "POST",
229
217
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
230
- password,
218
+ payload: { address: trimmedAddress },
231
219
  });
232
-
233
- const res = await blueBubblesFetchWithTimeout(
234
- url,
235
- {
236
- method: "POST",
237
- headers: { "Content-Type": "application/json" },
238
- body: JSON.stringify({ address: trimmedAddress }),
239
- },
240
- opts.timeoutMs,
241
- );
242
-
243
- if (!res.ok) {
244
- const errorText = await res.text().catch(() => "");
245
- throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`);
246
- }
247
220
  }
248
221
 
249
222
  /**
@@ -263,30 +236,14 @@ export async function removeBlueBubblesParticipant(
263
236
  throw new Error("BlueBubbles removeParticipant requires address");
264
237
  }
265
238
 
266
- const { baseUrl, password, accountId } = resolveAccount(opts);
267
- assertPrivateApiEnabled(accountId, "removeParticipant");
268
- const url = buildBlueBubblesApiUrl({
269
- baseUrl,
239
+ await sendPrivateApiJsonRequest({
240
+ opts,
241
+ feature: "removeParticipant",
242
+ action: "removeParticipant",
243
+ method: "DELETE",
270
244
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
271
- password,
245
+ payload: { address: trimmedAddress },
272
246
  });
273
-
274
- const res = await blueBubblesFetchWithTimeout(
275
- url,
276
- {
277
- method: "DELETE",
278
- headers: { "Content-Type": "application/json" },
279
- body: JSON.stringify({ address: trimmedAddress }),
280
- },
281
- opts.timeoutMs,
282
- );
283
-
284
- if (!res.ok) {
285
- const errorText = await res.text().catch(() => "");
286
- throw new Error(
287
- `BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`,
288
- );
289
- }
290
247
  }
291
248
 
292
249
  /**
@@ -301,20 +258,13 @@ export async function leaveBlueBubblesChat(
301
258
  throw new Error("BlueBubbles leaveChat requires chatGuid");
302
259
  }
303
260
 
304
- const { baseUrl, password, accountId } = resolveAccount(opts);
305
- assertPrivateApiEnabled(accountId, "leaveGroup");
306
- const url = buildBlueBubblesApiUrl({
307
- baseUrl,
261
+ await sendPrivateApiJsonRequest({
262
+ opts,
263
+ feature: "leaveGroup",
264
+ action: "leaveChat",
265
+ method: "POST",
308
266
  path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
309
- password,
310
267
  });
311
-
312
- const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
313
-
314
- if (!res.ok) {
315
- const errorText = await res.text().catch(() => "");
316
- throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
317
- }
318
268
  }
319
269
 
320
270
  /**
@@ -43,6 +43,7 @@ const bluebubblesAccountSchema = z
43
43
  mediaMaxMb: z.number().int().positive().optional(),
44
44
  mediaLocalRoots: z.array(z.string()).optional(),
45
45
  sendReadReceipts: z.boolean().optional(),
46
+ allowPrivateNetwork: z.boolean().optional(),
46
47
  blockStreaming: z.boolean().optional(),
47
48
  groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(),
48
49
  })