@openclaw/bluebubbles 2026.1.29

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.
@@ -0,0 +1,651 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+
3
+ import { bluebubblesMessageActions } from "./actions.js";
4
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
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
+ }));
17
+
18
+ vi.mock("./reactions.js", () => ({
19
+ sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
20
+ }));
21
+
22
+ vi.mock("./send.js", () => ({
23
+ resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
24
+ sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
25
+ }));
26
+
27
+ vi.mock("./chat.js", () => ({
28
+ editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
29
+ unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
30
+ renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
31
+ setGroupIconBlueBubbles: vi.fn().mockResolvedValue(undefined),
32
+ addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
33
+ removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
34
+ leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
35
+ }));
36
+
37
+ vi.mock("./attachments.js", () => ({
38
+ sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
39
+ }));
40
+
41
+ vi.mock("./monitor.js", () => ({
42
+ resolveBlueBubblesMessageId: vi.fn((id: string) => id),
43
+ }));
44
+
45
+ describe("bluebubblesMessageActions", () => {
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ });
49
+
50
+ describe("listActions", () => {
51
+ it("returns empty array when account is not enabled", () => {
52
+ const cfg: OpenClawConfig = {
53
+ channels: { bluebubbles: { enabled: false } },
54
+ };
55
+ const actions = bluebubblesMessageActions.listActions({ cfg });
56
+ expect(actions).toEqual([]);
57
+ });
58
+
59
+ it("returns empty array when account is not configured", () => {
60
+ const cfg: OpenClawConfig = {
61
+ channels: { bluebubbles: { enabled: true } },
62
+ };
63
+ const actions = bluebubblesMessageActions.listActions({ cfg });
64
+ expect(actions).toEqual([]);
65
+ });
66
+
67
+ it("returns react action when enabled and configured", () => {
68
+ const cfg: OpenClawConfig = {
69
+ channels: {
70
+ bluebubbles: {
71
+ enabled: true,
72
+ serverUrl: "http://localhost:1234",
73
+ password: "test-password",
74
+ },
75
+ },
76
+ };
77
+ const actions = bluebubblesMessageActions.listActions({ cfg });
78
+ expect(actions).toContain("react");
79
+ });
80
+
81
+ it("excludes react action when reactions are gated off", () => {
82
+ const cfg: OpenClawConfig = {
83
+ channels: {
84
+ bluebubbles: {
85
+ enabled: true,
86
+ serverUrl: "http://localhost:1234",
87
+ password: "test-password",
88
+ actions: { reactions: false },
89
+ },
90
+ },
91
+ };
92
+ const actions = bluebubblesMessageActions.listActions({ cfg });
93
+ expect(actions).not.toContain("react");
94
+ // Other actions should still be present
95
+ expect(actions).toContain("edit");
96
+ expect(actions).toContain("unsend");
97
+ });
98
+ });
99
+
100
+ describe("supportsAction", () => {
101
+ it("returns true for react action", () => {
102
+ expect(bluebubblesMessageActions.supportsAction({ action: "react" })).toBe(true);
103
+ });
104
+
105
+ it("returns true for all supported actions", () => {
106
+ expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(true);
107
+ expect(bluebubblesMessageActions.supportsAction({ action: "unsend" })).toBe(true);
108
+ expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true);
109
+ expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true);
110
+ expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true);
111
+ expect(bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" })).toBe(true);
112
+ expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true);
113
+ expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true);
114
+ expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true);
115
+ expect(bluebubblesMessageActions.supportsAction({ action: "sendAttachment" })).toBe(true);
116
+ });
117
+
118
+ it("returns false for unsupported actions", () => {
119
+ expect(bluebubblesMessageActions.supportsAction({ action: "delete" })).toBe(false);
120
+ expect(bluebubblesMessageActions.supportsAction({ action: "unknown" })).toBe(false);
121
+ });
122
+ });
123
+
124
+ describe("extractToolSend", () => {
125
+ it("extracts send params from sendMessage action", () => {
126
+ const result = bluebubblesMessageActions.extractToolSend({
127
+ args: {
128
+ action: "sendMessage",
129
+ to: "+15551234567",
130
+ accountId: "test-account",
131
+ },
132
+ });
133
+ expect(result).toEqual({
134
+ to: "+15551234567",
135
+ accountId: "test-account",
136
+ });
137
+ });
138
+
139
+ it("returns null for non-sendMessage action", () => {
140
+ const result = bluebubblesMessageActions.extractToolSend({
141
+ args: { action: "react", to: "+15551234567" },
142
+ });
143
+ expect(result).toBeNull();
144
+ });
145
+
146
+ it("returns null when to is missing", () => {
147
+ const result = bluebubblesMessageActions.extractToolSend({
148
+ args: { action: "sendMessage" },
149
+ });
150
+ expect(result).toBeNull();
151
+ });
152
+ });
153
+
154
+ describe("handleAction", () => {
155
+ it("throws for unsupported actions", async () => {
156
+ const cfg: OpenClawConfig = {
157
+ channels: {
158
+ bluebubbles: {
159
+ serverUrl: "http://localhost:1234",
160
+ password: "test-password",
161
+ },
162
+ },
163
+ };
164
+ await expect(
165
+ bluebubblesMessageActions.handleAction({
166
+ action: "unknownAction",
167
+ params: {},
168
+ cfg,
169
+ accountId: null,
170
+ }),
171
+ ).rejects.toThrow("is not supported");
172
+ });
173
+
174
+ it("throws when emoji is missing for react action", async () => {
175
+ const cfg: OpenClawConfig = {
176
+ channels: {
177
+ bluebubbles: {
178
+ serverUrl: "http://localhost:1234",
179
+ password: "test-password",
180
+ },
181
+ },
182
+ };
183
+ await expect(
184
+ bluebubblesMessageActions.handleAction({
185
+ action: "react",
186
+ params: { messageId: "msg-123" },
187
+ cfg,
188
+ accountId: null,
189
+ }),
190
+ ).rejects.toThrow(/emoji/i);
191
+ });
192
+
193
+ it("throws when messageId is missing", async () => {
194
+ const cfg: OpenClawConfig = {
195
+ channels: {
196
+ bluebubbles: {
197
+ serverUrl: "http://localhost:1234",
198
+ password: "test-password",
199
+ },
200
+ },
201
+ };
202
+ await expect(
203
+ bluebubblesMessageActions.handleAction({
204
+ action: "react",
205
+ params: { emoji: "❤️" },
206
+ cfg,
207
+ accountId: null,
208
+ }),
209
+ ).rejects.toThrow("messageId");
210
+ });
211
+
212
+ it("throws when chatGuid cannot be resolved", async () => {
213
+ const { resolveChatGuidForTarget } = await import("./send.js");
214
+ vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null);
215
+
216
+ const cfg: OpenClawConfig = {
217
+ channels: {
218
+ bluebubbles: {
219
+ serverUrl: "http://localhost:1234",
220
+ password: "test-password",
221
+ },
222
+ },
223
+ };
224
+ await expect(
225
+ bluebubblesMessageActions.handleAction({
226
+ action: "react",
227
+ params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" },
228
+ cfg,
229
+ accountId: null,
230
+ }),
231
+ ).rejects.toThrow("chatGuid not found");
232
+ });
233
+
234
+ it("sends reaction successfully with chatGuid", async () => {
235
+ const { sendBlueBubblesReaction } = await import("./reactions.js");
236
+
237
+ const cfg: OpenClawConfig = {
238
+ channels: {
239
+ bluebubbles: {
240
+ serverUrl: "http://localhost:1234",
241
+ password: "test-password",
242
+ },
243
+ },
244
+ };
245
+ const result = await bluebubblesMessageActions.handleAction({
246
+ action: "react",
247
+ params: {
248
+ emoji: "❤️",
249
+ messageId: "msg-123",
250
+ chatGuid: "iMessage;-;+15551234567",
251
+ },
252
+ cfg,
253
+ accountId: null,
254
+ });
255
+
256
+ expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
257
+ expect.objectContaining({
258
+ chatGuid: "iMessage;-;+15551234567",
259
+ messageGuid: "msg-123",
260
+ emoji: "❤️",
261
+ }),
262
+ );
263
+ // jsonResult returns { content: [...], details: payload }
264
+ expect(result).toMatchObject({
265
+ details: { ok: true, added: "❤️" },
266
+ });
267
+ });
268
+
269
+ it("sends reaction removal successfully", async () => {
270
+ const { sendBlueBubblesReaction } = await import("./reactions.js");
271
+
272
+ const cfg: OpenClawConfig = {
273
+ channels: {
274
+ bluebubbles: {
275
+ serverUrl: "http://localhost:1234",
276
+ password: "test-password",
277
+ },
278
+ },
279
+ };
280
+ const result = await bluebubblesMessageActions.handleAction({
281
+ action: "react",
282
+ params: {
283
+ emoji: "❤️",
284
+ messageId: "msg-123",
285
+ chatGuid: "iMessage;-;+15551234567",
286
+ remove: true,
287
+ },
288
+ cfg,
289
+ accountId: null,
290
+ });
291
+
292
+ expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
293
+ expect.objectContaining({
294
+ remove: true,
295
+ }),
296
+ );
297
+ // jsonResult returns { content: [...], details: payload }
298
+ expect(result).toMatchObject({
299
+ details: { ok: true, removed: true },
300
+ });
301
+ });
302
+
303
+ it("resolves chatGuid from to parameter", async () => {
304
+ const { sendBlueBubblesReaction } = await import("./reactions.js");
305
+ const { resolveChatGuidForTarget } = await import("./send.js");
306
+ vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543");
307
+
308
+ const cfg: OpenClawConfig = {
309
+ channels: {
310
+ bluebubbles: {
311
+ serverUrl: "http://localhost:1234",
312
+ password: "test-password",
313
+ },
314
+ },
315
+ };
316
+ await bluebubblesMessageActions.handleAction({
317
+ action: "react",
318
+ params: {
319
+ emoji: "👍",
320
+ messageId: "msg-456",
321
+ to: "+15559876543",
322
+ },
323
+ cfg,
324
+ accountId: null,
325
+ });
326
+
327
+ expect(resolveChatGuidForTarget).toHaveBeenCalled();
328
+ expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
329
+ expect.objectContaining({
330
+ chatGuid: "iMessage;-;+15559876543",
331
+ }),
332
+ );
333
+ });
334
+
335
+ it("passes partIndex when provided", async () => {
336
+ const { sendBlueBubblesReaction } = await import("./reactions.js");
337
+
338
+ const cfg: OpenClawConfig = {
339
+ channels: {
340
+ bluebubbles: {
341
+ serverUrl: "http://localhost:1234",
342
+ password: "test-password",
343
+ },
344
+ },
345
+ };
346
+ await bluebubblesMessageActions.handleAction({
347
+ action: "react",
348
+ params: {
349
+ emoji: "😂",
350
+ messageId: "msg-789",
351
+ chatGuid: "iMessage;-;chat-guid",
352
+ partIndex: 2,
353
+ },
354
+ cfg,
355
+ accountId: null,
356
+ });
357
+
358
+ expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
359
+ expect.objectContaining({
360
+ partIndex: 2,
361
+ }),
362
+ );
363
+ });
364
+
365
+ it("uses toolContext currentChannelId when no explicit target is provided", async () => {
366
+ const { sendBlueBubblesReaction } = await import("./reactions.js");
367
+ const { resolveChatGuidForTarget } = await import("./send.js");
368
+ vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111");
369
+
370
+ const cfg: OpenClawConfig = {
371
+ channels: {
372
+ bluebubbles: {
373
+ serverUrl: "http://localhost:1234",
374
+ password: "test-password",
375
+ },
376
+ },
377
+ };
378
+ await bluebubblesMessageActions.handleAction({
379
+ action: "react",
380
+ params: {
381
+ emoji: "👍",
382
+ messageId: "msg-456",
383
+ },
384
+ cfg,
385
+ accountId: null,
386
+ toolContext: {
387
+ currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15550001111",
388
+ },
389
+ });
390
+
391
+ expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
392
+ expect.objectContaining({
393
+ target: { kind: "chat_guid", chatGuid: "iMessage;-;+15550001111" },
394
+ }),
395
+ );
396
+ expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
397
+ expect.objectContaining({
398
+ chatGuid: "iMessage;-;+15550001111",
399
+ }),
400
+ );
401
+ });
402
+
403
+ it("resolves short messageId before reacting", async () => {
404
+ const { resolveBlueBubblesMessageId } = await import("./monitor.js");
405
+ const { sendBlueBubblesReaction } = await import("./reactions.js");
406
+ vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid");
407
+
408
+ const cfg: OpenClawConfig = {
409
+ channels: {
410
+ bluebubbles: {
411
+ serverUrl: "http://localhost:1234",
412
+ password: "test-password",
413
+ },
414
+ },
415
+ };
416
+
417
+ await bluebubblesMessageActions.handleAction({
418
+ action: "react",
419
+ params: {
420
+ emoji: "❤️",
421
+ messageId: "1",
422
+ chatGuid: "iMessage;-;+15551234567",
423
+ },
424
+ cfg,
425
+ accountId: null,
426
+ });
427
+
428
+ expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true });
429
+ expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
430
+ expect.objectContaining({
431
+ messageGuid: "resolved-uuid",
432
+ }),
433
+ );
434
+ });
435
+
436
+ it("propagates short-id errors from the resolver", async () => {
437
+ const { resolveBlueBubblesMessageId } = await import("./monitor.js");
438
+ vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => {
439
+ throw new Error("short id expired");
440
+ });
441
+
442
+ const cfg: OpenClawConfig = {
443
+ channels: {
444
+ bluebubbles: {
445
+ serverUrl: "http://localhost:1234",
446
+ password: "test-password",
447
+ },
448
+ },
449
+ };
450
+
451
+ await expect(
452
+ bluebubblesMessageActions.handleAction({
453
+ action: "react",
454
+ params: {
455
+ emoji: "❤️",
456
+ messageId: "999",
457
+ chatGuid: "iMessage;-;+15551234567",
458
+ },
459
+ cfg,
460
+ accountId: null,
461
+ }),
462
+ ).rejects.toThrow("short id expired");
463
+ });
464
+
465
+ it("accepts message param for edit action", async () => {
466
+ const { editBlueBubblesMessage } = await import("./chat.js");
467
+
468
+ const cfg: OpenClawConfig = {
469
+ channels: {
470
+ bluebubbles: {
471
+ serverUrl: "http://localhost:1234",
472
+ password: "test-password",
473
+ },
474
+ },
475
+ };
476
+
477
+ await bluebubblesMessageActions.handleAction({
478
+ action: "edit",
479
+ params: { messageId: "msg-123", message: "updated" },
480
+ cfg,
481
+ accountId: null,
482
+ });
483
+
484
+ expect(editBlueBubblesMessage).toHaveBeenCalledWith(
485
+ "msg-123",
486
+ "updated",
487
+ expect.objectContaining({ cfg, accountId: undefined }),
488
+ );
489
+ });
490
+
491
+ it("accepts message/target aliases for sendWithEffect", async () => {
492
+ const { sendMessageBlueBubbles } = await import("./send.js");
493
+
494
+ const cfg: OpenClawConfig = {
495
+ channels: {
496
+ bluebubbles: {
497
+ serverUrl: "http://localhost:1234",
498
+ password: "test-password",
499
+ },
500
+ },
501
+ };
502
+
503
+ const result = await bluebubblesMessageActions.handleAction({
504
+ action: "sendWithEffect",
505
+ params: {
506
+ message: "peekaboo",
507
+ target: "+15551234567",
508
+ effect: "invisible ink",
509
+ },
510
+ cfg,
511
+ accountId: null,
512
+ });
513
+
514
+ expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
515
+ "+15551234567",
516
+ "peekaboo",
517
+ expect.objectContaining({ effectId: "invisible ink" }),
518
+ );
519
+ expect(result).toMatchObject({
520
+ details: { ok: true, messageId: "msg-123", effect: "invisible ink" },
521
+ });
522
+ });
523
+
524
+ it("passes asVoice through sendAttachment", async () => {
525
+ const { sendBlueBubblesAttachment } = await import("./attachments.js");
526
+
527
+ const cfg: OpenClawConfig = {
528
+ channels: {
529
+ bluebubbles: {
530
+ serverUrl: "http://localhost:1234",
531
+ password: "test-password",
532
+ },
533
+ },
534
+ };
535
+
536
+ const base64Buffer = Buffer.from("voice").toString("base64");
537
+
538
+ await bluebubblesMessageActions.handleAction({
539
+ action: "sendAttachment",
540
+ params: {
541
+ to: "+15551234567",
542
+ filename: "voice.mp3",
543
+ buffer: base64Buffer,
544
+ contentType: "audio/mpeg",
545
+ asVoice: true,
546
+ },
547
+ cfg,
548
+ accountId: null,
549
+ });
550
+
551
+ expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
552
+ expect.objectContaining({
553
+ filename: "voice.mp3",
554
+ contentType: "audio/mpeg",
555
+ asVoice: true,
556
+ }),
557
+ );
558
+ });
559
+
560
+ it("throws when buffer is missing for setGroupIcon", async () => {
561
+ const cfg: OpenClawConfig = {
562
+ channels: {
563
+ bluebubbles: {
564
+ serverUrl: "http://localhost:1234",
565
+ password: "test-password",
566
+ },
567
+ },
568
+ };
569
+
570
+ await expect(
571
+ bluebubblesMessageActions.handleAction({
572
+ action: "setGroupIcon",
573
+ params: { chatGuid: "iMessage;-;chat-guid" },
574
+ cfg,
575
+ accountId: null,
576
+ }),
577
+ ).rejects.toThrow(/requires an image/i);
578
+ });
579
+
580
+ it("sets group icon successfully with chatGuid and buffer", async () => {
581
+ const { setGroupIconBlueBubbles } = await import("./chat.js");
582
+
583
+ const cfg: OpenClawConfig = {
584
+ channels: {
585
+ bluebubbles: {
586
+ serverUrl: "http://localhost:1234",
587
+ password: "test-password",
588
+ },
589
+ },
590
+ };
591
+
592
+ // Base64 encode a simple test buffer
593
+ const testBuffer = Buffer.from("fake-image-data");
594
+ const base64Buffer = testBuffer.toString("base64");
595
+
596
+ const result = await bluebubblesMessageActions.handleAction({
597
+ action: "setGroupIcon",
598
+ params: {
599
+ chatGuid: "iMessage;-;chat-guid",
600
+ buffer: base64Buffer,
601
+ filename: "group-icon.png",
602
+ contentType: "image/png",
603
+ },
604
+ cfg,
605
+ accountId: null,
606
+ });
607
+
608
+ expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
609
+ "iMessage;-;chat-guid",
610
+ expect.any(Uint8Array),
611
+ "group-icon.png",
612
+ expect.objectContaining({ contentType: "image/png" }),
613
+ );
614
+ expect(result).toMatchObject({
615
+ details: { ok: true, chatGuid: "iMessage;-;chat-guid", iconSet: true },
616
+ });
617
+ });
618
+
619
+ it("uses default filename when not provided for setGroupIcon", async () => {
620
+ const { setGroupIconBlueBubbles } = await import("./chat.js");
621
+
622
+ const cfg: OpenClawConfig = {
623
+ channels: {
624
+ bluebubbles: {
625
+ serverUrl: "http://localhost:1234",
626
+ password: "test-password",
627
+ },
628
+ },
629
+ };
630
+
631
+ const base64Buffer = Buffer.from("test").toString("base64");
632
+
633
+ await bluebubblesMessageActions.handleAction({
634
+ action: "setGroupIcon",
635
+ params: {
636
+ chatGuid: "iMessage;-;chat-guid",
637
+ buffer: base64Buffer,
638
+ },
639
+ cfg,
640
+ accountId: null,
641
+ });
642
+
643
+ expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
644
+ "iMessage;-;chat-guid",
645
+ expect.any(Uint8Array),
646
+ "icon.png",
647
+ expect.anything(),
648
+ );
649
+ });
650
+ });
651
+ });