@openclaw/bluebubbles 2026.2.15 → 2026.2.17

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/monitor.ts CHANGED
@@ -1,6 +1,11 @@
1
+ import { timingSafeEqual } from "node:crypto";
1
2
  import type { IncomingMessage, ServerResponse } from "node:http";
2
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
- import { timingSafeEqual } from "node:crypto";
4
+ import {
5
+ registerWebhookTarget,
6
+ rejectNonPostWebhookRequest,
7
+ resolveWebhookTargets,
8
+ } from "openclaw/plugin-sdk";
4
9
  import {
5
10
  normalizeWebhookMessage,
6
11
  normalizeWebhookReaction,
@@ -226,20 +231,11 @@ function removeDebouncer(target: WebhookTarget): void {
226
231
  }
227
232
 
228
233
  export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
229
- const key = normalizeWebhookPath(target.path);
230
- const normalizedTarget = { ...target, path: key };
231
- const existing = webhookTargets.get(key) ?? [];
232
- const next = [...existing, normalizedTarget];
233
- webhookTargets.set(key, next);
234
+ const registered = registerWebhookTarget(webhookTargets, target);
234
235
  return () => {
235
- const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
236
- if (updated.length > 0) {
237
- webhookTargets.set(key, updated);
238
- } else {
239
- webhookTargets.delete(key);
240
- }
236
+ registered.unregister();
241
237
  // Clean up debouncer when target is unregistered
242
- removeDebouncer(normalizedTarget);
238
+ removeDebouncer(registered.target);
243
239
  };
244
240
  }
245
241
 
@@ -387,17 +383,14 @@ export async function handleBlueBubblesWebhookRequest(
387
383
  req: IncomingMessage,
388
384
  res: ServerResponse,
389
385
  ): Promise<boolean> {
390
- const url = new URL(req.url ?? "/", "http://localhost");
391
- const path = normalizeWebhookPath(url.pathname);
392
- const targets = webhookTargets.get(path);
393
- if (!targets || targets.length === 0) {
386
+ const resolved = resolveWebhookTargets(req, webhookTargets);
387
+ if (!resolved) {
394
388
  return false;
395
389
  }
390
+ const { path, targets } = resolved;
391
+ const url = new URL(req.url ?? "/", "http://localhost");
396
392
 
397
- if (req.method !== "POST") {
398
- res.statusCode = 405;
399
- res.setHeader("Allow", "POST");
400
- res.end("Method Not Allowed");
393
+ if (rejectNonPostWebhookRequest(req, res)) {
401
394
  return true;
402
395
  }
403
396
 
package/src/onboarding.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  DEFAULT_ACCOUNT_ID,
10
10
  addWildcardAllowFrom,
11
11
  formatDocsLink,
12
+ mergeAllowFromEntries,
12
13
  normalizeAccountId,
13
14
  promptAccountId,
14
15
  } from "openclaw/plugin-sdk";
@@ -127,7 +128,7 @@ async function promptBlueBubblesAllowFrom(params: {
127
128
  },
128
129
  });
129
130
  const parts = parseBlueBubblesAllowFromInput(String(entry));
130
- const unique = [...new Set(parts)];
131
+ const unique = mergeAllowFromEntries(undefined, parts);
131
132
  return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
132
133
  }
133
134
 
package/src/reactions.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { resolveBlueBubblesAccount } from "./accounts.js";
2
+ import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
3
3
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
4
4
  import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
5
5
 
@@ -112,19 +112,7 @@ const REACTION_EMOJIS = new Map<string, string>([
112
112
  ]);
113
113
 
114
114
  function resolveAccount(params: BlueBubblesReactionOpts) {
115
- const account = resolveBlueBubblesAccount({
116
- cfg: params.cfg ?? {},
117
- accountId: params.accountId,
118
- });
119
- const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
120
- const password = params.password?.trim() || account.config.password?.trim();
121
- if (!baseUrl) {
122
- throw new Error("BlueBubbles serverUrl is required");
123
- }
124
- if (!password) {
125
- throw new Error("BlueBubbles password is required");
126
- }
127
- return { baseUrl, password, accountId: account.accountId };
115
+ return resolveBlueBubblesServerAccount(params);
128
116
  }
129
117
 
130
118
  export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
@@ -1,5 +1,5 @@
1
- import type { BlueBubblesSendTarget } from "./types.js";
2
1
  import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
2
+ import type { BlueBubblesSendTarget } from "./types.js";
3
3
 
4
4
  export function resolveBlueBubblesSendTarget(raw: string): BlueBubblesSendTarget {
5
5
  const parsed = parseBlueBubblesTarget(raw);
package/src/send.test.ts CHANGED
@@ -1,39 +1,62 @@
1
- import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
- import type { BlueBubblesSendTarget } from "./types.js";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import "./test-mocks.js";
3
3
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
4
4
  import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
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("./probe.js", () => ({
19
- getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
20
- }));
5
+ import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
6
+ import type { BlueBubblesSendTarget } from "./types.js";
21
7
 
22
8
  const mockFetch = vi.fn();
23
9
 
24
- describe("send", () => {
25
- beforeEach(() => {
26
- vi.stubGlobal("fetch", mockFetch);
27
- mockFetch.mockReset();
28
- vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
29
- vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
10
+ installBlueBubblesFetchTestHooks({
11
+ mockFetch,
12
+ privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
13
+ });
14
+
15
+ function mockResolvedHandleTarget(
16
+ guid: string = "iMessage;-;+15551234567",
17
+ address: string = "+15551234567",
18
+ ) {
19
+ mockFetch.mockResolvedValueOnce({
20
+ ok: true,
21
+ json: () =>
22
+ Promise.resolve({
23
+ data: [
24
+ {
25
+ guid,
26
+ participants: [{ address }],
27
+ },
28
+ ],
29
+ }),
30
30
  });
31
+ }
31
32
 
32
- afterEach(() => {
33
- vi.unstubAllGlobals();
33
+ function mockSendResponse(body: unknown) {
34
+ mockFetch.mockResolvedValueOnce({
35
+ ok: true,
36
+ text: () => Promise.resolve(JSON.stringify(body)),
34
37
  });
38
+ }
35
39
 
40
+ describe("send", () => {
36
41
  describe("resolveChatGuidForTarget", () => {
42
+ const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
43
+ mockFetch.mockResolvedValueOnce({
44
+ ok: true,
45
+ json: () => Promise.resolve({ data }),
46
+ });
47
+
48
+ const target: BlueBubblesSendTarget = {
49
+ kind: "handle",
50
+ address: "+15551234567",
51
+ service: "imessage",
52
+ };
53
+ return await resolveChatGuidForTarget({
54
+ baseUrl: "http://localhost:1234",
55
+ password: "test",
56
+ target,
57
+ });
58
+ };
59
+
37
60
  it("returns chatGuid directly for chat_guid target", async () => {
38
61
  const target: BlueBubblesSendTarget = {
39
62
  kind: "chat_guid",
@@ -130,65 +153,31 @@ describe("send", () => {
130
153
  });
131
154
 
132
155
  it("resolves handle target by matching participant", async () => {
133
- mockFetch.mockResolvedValueOnce({
134
- ok: true,
135
- json: () =>
136
- Promise.resolve({
137
- data: [
138
- {
139
- guid: "iMessage;-;+15559999999",
140
- participants: [{ address: "+15559999999" }],
141
- },
142
- {
143
- guid: "iMessage;-;+15551234567",
144
- participants: [{ address: "+15551234567" }],
145
- },
146
- ],
147
- }),
148
- });
149
-
150
- const target: BlueBubblesSendTarget = {
151
- kind: "handle",
152
- address: "+15551234567",
153
- service: "imessage",
154
- };
155
- const result = await resolveChatGuidForTarget({
156
- baseUrl: "http://localhost:1234",
157
- password: "test",
158
- target,
159
- });
156
+ const result = await resolveHandleTargetGuid([
157
+ {
158
+ guid: "iMessage;-;+15559999999",
159
+ participants: [{ address: "+15559999999" }],
160
+ },
161
+ {
162
+ guid: "iMessage;-;+15551234567",
163
+ participants: [{ address: "+15551234567" }],
164
+ },
165
+ ]);
160
166
 
161
167
  expect(result).toBe("iMessage;-;+15551234567");
162
168
  });
163
169
 
164
170
  it("prefers direct chat guid when handle also appears in a group chat", async () => {
165
- mockFetch.mockResolvedValueOnce({
166
- ok: true,
167
- json: () =>
168
- Promise.resolve({
169
- data: [
170
- {
171
- guid: "iMessage;+;group-123",
172
- participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
173
- },
174
- {
175
- guid: "iMessage;-;+15551234567",
176
- participants: [{ address: "+15551234567" }],
177
- },
178
- ],
179
- }),
180
- });
181
-
182
- const target: BlueBubblesSendTarget = {
183
- kind: "handle",
184
- address: "+15551234567",
185
- service: "imessage",
186
- };
187
- const result = await resolveChatGuidForTarget({
188
- baseUrl: "http://localhost:1234",
189
- password: "test",
190
- target,
191
- });
171
+ const result = await resolveHandleTargetGuid([
172
+ {
173
+ guid: "iMessage;+;group-123",
174
+ participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
175
+ },
176
+ {
177
+ guid: "iMessage;-;+15551234567",
178
+ participants: [{ address: "+15551234567" }],
179
+ },
180
+ ]);
192
181
 
193
182
  expect(result).toBe("iMessage;-;+15551234567");
194
183
  });
@@ -416,28 +405,8 @@ describe("send", () => {
416
405
  });
417
406
 
418
407
  it("sends message successfully", async () => {
419
- mockFetch
420
- .mockResolvedValueOnce({
421
- ok: true,
422
- json: () =>
423
- Promise.resolve({
424
- data: [
425
- {
426
- guid: "iMessage;-;+15551234567",
427
- participants: [{ address: "+15551234567" }],
428
- },
429
- ],
430
- }),
431
- })
432
- .mockResolvedValueOnce({
433
- ok: true,
434
- text: () =>
435
- Promise.resolve(
436
- JSON.stringify({
437
- data: { guid: "msg-uuid-123" },
438
- }),
439
- ),
440
- });
408
+ mockResolvedHandleTarget();
409
+ mockSendResponse({ data: { guid: "msg-uuid-123" } });
441
410
 
442
411
  const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
443
412
  serverUrl: "http://localhost:1234",
@@ -456,28 +425,8 @@ describe("send", () => {
456
425
  });
457
426
 
458
427
  it("strips markdown formatting from outbound messages", async () => {
459
- mockFetch
460
- .mockResolvedValueOnce({
461
- ok: true,
462
- json: () =>
463
- Promise.resolve({
464
- data: [
465
- {
466
- guid: "iMessage;-;+15551234567",
467
- participants: [{ address: "+15551234567" }],
468
- },
469
- ],
470
- }),
471
- })
472
- .mockResolvedValueOnce({
473
- ok: true,
474
- text: () =>
475
- Promise.resolve(
476
- JSON.stringify({
477
- data: { guid: "msg-uuid-stripped" },
478
- }),
479
- ),
480
- });
428
+ mockResolvedHandleTarget();
429
+ mockSendResponse({ data: { guid: "msg-uuid-stripped" } });
481
430
 
482
431
  const result = await sendMessageBlueBubbles(
483
432
  "+15551234567",
@@ -578,28 +527,8 @@ describe("send", () => {
578
527
  });
579
528
 
580
529
  it("uses private-api when reply metadata is present", async () => {
581
- mockFetch
582
- .mockResolvedValueOnce({
583
- ok: true,
584
- json: () =>
585
- Promise.resolve({
586
- data: [
587
- {
588
- guid: "iMessage;-;+15551234567",
589
- participants: [{ address: "+15551234567" }],
590
- },
591
- ],
592
- }),
593
- })
594
- .mockResolvedValueOnce({
595
- ok: true,
596
- text: () =>
597
- Promise.resolve(
598
- JSON.stringify({
599
- data: { guid: "msg-uuid-124" },
600
- }),
601
- ),
602
- });
530
+ mockResolvedHandleTarget();
531
+ mockSendResponse({ data: { guid: "msg-uuid-124" } });
603
532
 
604
533
  const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
605
534
  serverUrl: "http://localhost:1234",
@@ -620,28 +549,8 @@ describe("send", () => {
620
549
 
621
550
  it("downgrades threaded reply to plain send when private API is disabled", async () => {
622
551
  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
- });
552
+ mockResolvedHandleTarget();
553
+ mockSendResponse({ data: { guid: "msg-uuid-plain" } });
645
554
 
646
555
  const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
647
556
  serverUrl: "http://localhost:1234",
@@ -659,28 +568,8 @@ describe("send", () => {
659
568
  });
660
569
 
661
570
  it("normalizes effect names and uses private-api for effects", async () => {
662
- mockFetch
663
- .mockResolvedValueOnce({
664
- ok: true,
665
- json: () =>
666
- Promise.resolve({
667
- data: [
668
- {
669
- guid: "iMessage;-;+15551234567",
670
- participants: [{ address: "+15551234567" }],
671
- },
672
- ],
673
- }),
674
- })
675
- .mockResolvedValueOnce({
676
- ok: true,
677
- text: () =>
678
- Promise.resolve(
679
- JSON.stringify({
680
- data: { guid: "msg-uuid-125" },
681
- }),
682
- ),
683
- });
571
+ mockResolvedHandleTarget();
572
+ mockSendResponse({ data: { guid: "msg-uuid-125" } });
684
573
 
685
574
  const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
686
575
  serverUrl: "http://localhost:1234",
@@ -722,24 +611,12 @@ describe("send", () => {
722
611
  });
723
612
 
724
613
  it("handles send failure", async () => {
725
- mockFetch
726
- .mockResolvedValueOnce({
727
- ok: true,
728
- json: () =>
729
- Promise.resolve({
730
- data: [
731
- {
732
- guid: "iMessage;-;+15551234567",
733
- participants: [{ address: "+15551234567" }],
734
- },
735
- ],
736
- }),
737
- })
738
- .mockResolvedValueOnce({
739
- ok: false,
740
- status: 500,
741
- text: () => Promise.resolve("Internal server error"),
742
- });
614
+ mockResolvedHandleTarget();
615
+ mockFetch.mockResolvedValueOnce({
616
+ ok: false,
617
+ status: 500,
618
+ text: () => Promise.resolve("Internal server error"),
619
+ });
743
620
 
744
621
  await expect(
745
622
  sendMessageBlueBubbles("+15551234567", "Hello", {
@@ -750,23 +627,11 @@ describe("send", () => {
750
627
  });
751
628
 
752
629
  it("handles empty response body", async () => {
753
- mockFetch
754
- .mockResolvedValueOnce({
755
- ok: true,
756
- json: () =>
757
- Promise.resolve({
758
- data: [
759
- {
760
- guid: "iMessage;-;+15551234567",
761
- participants: [{ address: "+15551234567" }],
762
- },
763
- ],
764
- }),
765
- })
766
- .mockResolvedValueOnce({
767
- ok: true,
768
- text: () => Promise.resolve(""),
769
- });
630
+ mockResolvedHandleTarget();
631
+ mockFetch.mockResolvedValueOnce({
632
+ ok: true,
633
+ text: () => Promise.resolve(""),
634
+ });
770
635
 
771
636
  const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
772
637
  serverUrl: "http://localhost:1234",
@@ -777,23 +642,11 @@ describe("send", () => {
777
642
  });
778
643
 
779
644
  it("handles invalid JSON response body", async () => {
780
- mockFetch
781
- .mockResolvedValueOnce({
782
- ok: true,
783
- json: () =>
784
- Promise.resolve({
785
- data: [
786
- {
787
- guid: "iMessage;-;+15551234567",
788
- participants: [{ address: "+15551234567" }],
789
- },
790
- ],
791
- }),
792
- })
793
- .mockResolvedValueOnce({
794
- ok: true,
795
- text: () => Promise.resolve("not valid json"),
796
- });
645
+ mockResolvedHandleTarget();
646
+ mockFetch.mockResolvedValueOnce({
647
+ ok: true,
648
+ text: () => Promise.resolve("not valid json"),
649
+ });
797
650
 
798
651
  const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
799
652
  serverUrl: "http://localhost:1234",
@@ -804,28 +657,8 @@ describe("send", () => {
804
657
  });
805
658
 
806
659
  it("extracts messageId from various response formats", async () => {
807
- mockFetch
808
- .mockResolvedValueOnce({
809
- ok: true,
810
- json: () =>
811
- Promise.resolve({
812
- data: [
813
- {
814
- guid: "iMessage;-;+15551234567",
815
- participants: [{ address: "+15551234567" }],
816
- },
817
- ],
818
- }),
819
- })
820
- .mockResolvedValueOnce({
821
- ok: true,
822
- text: () =>
823
- Promise.resolve(
824
- JSON.stringify({
825
- id: "numeric-id-456",
826
- }),
827
- ),
828
- });
660
+ mockResolvedHandleTarget();
661
+ mockSendResponse({ id: "numeric-id-456" });
829
662
 
830
663
  const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
831
664
  serverUrl: "http://localhost:1234",
@@ -836,28 +669,8 @@ describe("send", () => {
836
669
  });
837
670
 
838
671
  it("extracts messageGuid from response payload", async () => {
839
- mockFetch
840
- .mockResolvedValueOnce({
841
- ok: true,
842
- json: () =>
843
- Promise.resolve({
844
- data: [
845
- {
846
- guid: "iMessage;-;+15551234567",
847
- participants: [{ address: "+15551234567" }],
848
- },
849
- ],
850
- }),
851
- })
852
- .mockResolvedValueOnce({
853
- ok: true,
854
- text: () =>
855
- Promise.resolve(
856
- JSON.stringify({
857
- data: { messageGuid: "msg-guid-789" },
858
- }),
859
- ),
860
- });
672
+ mockResolvedHandleTarget();
673
+ mockSendResponse({ data: { messageGuid: "msg-guid-789" } });
861
674
 
862
675
  const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
863
676
  serverUrl: "http://localhost:1234",
@@ -868,23 +681,8 @@ describe("send", () => {
868
681
  });
869
682
 
870
683
  it("resolves credentials from config", async () => {
871
- mockFetch
872
- .mockResolvedValueOnce({
873
- ok: true,
874
- json: () =>
875
- Promise.resolve({
876
- data: [
877
- {
878
- guid: "iMessage;-;+15551234567",
879
- participants: [{ address: "+15551234567" }],
880
- },
881
- ],
882
- }),
883
- })
884
- .mockResolvedValueOnce({
885
- ok: true,
886
- text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
887
- });
684
+ mockResolvedHandleTarget();
685
+ mockSendResponse({ data: { guid: "msg-123" } });
888
686
 
889
687
  const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
890
688
  cfg: {
@@ -903,23 +701,8 @@ describe("send", () => {
903
701
  });
904
702
 
905
703
  it("includes tempGuid in request payload", async () => {
906
- mockFetch
907
- .mockResolvedValueOnce({
908
- ok: true,
909
- json: () =>
910
- Promise.resolve({
911
- data: [
912
- {
913
- guid: "iMessage;-;+15551234567",
914
- participants: [{ address: "+15551234567" }],
915
- },
916
- ],
917
- }),
918
- })
919
- .mockResolvedValueOnce({
920
- ok: true,
921
- text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
922
- });
704
+ mockResolvedHandleTarget();
705
+ mockSendResponse({ data: { guid: "msg" } });
923
706
 
924
707
  await sendMessageBlueBubbles("+15551234567", "Hello", {
925
708
  serverUrl: "http://localhost:1234",
package/src/send.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
1
  import crypto from "node:crypto";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
3
  import { stripMarkdown } from "openclaw/plugin-sdk";
4
4
  import { resolveBlueBubblesAccount } from "./accounts.js";
5
5
  import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";