@openclaw/bluebubbles 2026.3.1 → 2026.3.7

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.
@@ -1,8 +1,8 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
- import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
4
- import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
5
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
6
6
  import type { ResolvedBlueBubblesAccount } from "./accounts.js";
7
7
  import { fetchBlueBubblesHistory } from "./history.js";
8
8
  import {
@@ -50,8 +50,11 @@ const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
50
50
  const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true });
51
51
  const mockResolveAgentRoute = vi.fn(() => ({
52
52
  agentId: "main",
53
+ channel: "bluebubbles",
53
54
  accountId: "default",
54
55
  sessionKey: "agent:main:bluebubbles:dm:+15551234567",
56
+ mainSessionKey: "agent:main:main",
57
+ matchedBy: "default",
55
58
  }));
56
59
  const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
57
60
  const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
@@ -66,127 +69,57 @@ const mockMatchesMentionWithExplicit = vi.fn(
66
69
  },
67
70
  );
68
71
  const mockResolveRequireMention = vi.fn(() => false);
69
- const mockResolveGroupPolicy = vi.fn(() => "open");
72
+ const mockResolveGroupPolicy = vi.fn(() => "open" as const);
70
73
  type DispatchReplyParams = Parameters<
71
74
  PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
72
75
  >[0];
76
+ const EMPTY_DISPATCH_RESULT = {
77
+ queuedFinal: false,
78
+ counts: { tool: 0, block: 0, final: 0 },
79
+ } as const;
73
80
  const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
74
- async (_params: DispatchReplyParams): Promise<void> => undefined,
81
+ async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT,
75
82
  );
76
83
  const mockHasControlCommand = vi.fn(() => false);
77
84
  const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
78
85
  const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
86
+ id: "test-media.jpg",
79
87
  path: "/tmp/test-media.jpg",
88
+ size: Buffer.byteLength("test"),
80
89
  contentType: "image/jpeg",
81
90
  });
82
91
  const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json");
83
92
  const mockReadSessionUpdatedAt = vi.fn(() => undefined);
84
- const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
85
- template: "channel+name+time",
86
- }));
93
+ const mockResolveEnvelopeFormatOptions = vi.fn(() => ({}));
87
94
  const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
88
95
  const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
89
96
  const mockChunkMarkdownText = vi.fn((text: string) => [text]);
90
97
  const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
91
98
  const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
92
99
  const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
93
- const mockResolveChunkMode = vi.fn(() => "length");
100
+ const mockResolveChunkMode = vi.fn(() => "length" as const);
94
101
  const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
95
102
 
96
103
  function createMockRuntime(): PluginRuntime {
97
- return {
98
- version: "1.0.0",
99
- config: {
100
- loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
101
- writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
102
- },
104
+ return createPluginRuntimeMock({
103
105
  system: {
104
- enqueueSystemEvent:
105
- mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
106
- runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
107
- formatNativeDependencyHint: vi.fn(
108
- () => "",
109
- ) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
110
- },
111
- media: {
112
- loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
113
- detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
114
- mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
115
- isVoiceCompatibleAudio:
116
- vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
117
- getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
118
- resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
119
- },
120
- tts: {
121
- textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
122
- },
123
- tools: {
124
- createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
125
- createMemorySearchTool:
126
- vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"],
127
- registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"],
106
+ enqueueSystemEvent: mockEnqueueSystemEvent,
128
107
  },
129
108
  channel: {
130
109
  text: {
131
- chunkMarkdownText:
132
- mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
133
- chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
134
- chunkByNewline:
135
- mockChunkByNewline as unknown as PluginRuntime["channel"]["text"]["chunkByNewline"],
136
- chunkMarkdownTextWithMode:
137
- mockChunkMarkdownTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownTextWithMode"],
138
- chunkTextWithMode:
139
- mockChunkTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkTextWithMode"],
110
+ chunkMarkdownText: mockChunkMarkdownText,
111
+ chunkByNewline: mockChunkByNewline,
112
+ chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
113
+ chunkTextWithMode: mockChunkTextWithMode,
140
114
  resolveChunkMode:
141
115
  mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
142
- resolveTextChunkLimit: vi.fn(
143
- () => 4000,
144
- ) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
145
- hasControlCommand:
146
- mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
147
- resolveMarkdownTableMode: vi.fn(
148
- () => "code",
149
- ) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
150
- convertMarkdownTables: vi.fn(
151
- (text: string) => text,
152
- ) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
116
+ hasControlCommand: mockHasControlCommand,
153
117
  },
154
118
  reply: {
155
119
  dispatchReplyWithBufferedBlockDispatcher:
156
120
  mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
157
- createReplyDispatcherWithTyping:
158
- vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
159
- resolveEffectiveMessagesConfig:
160
- vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
161
- resolveHumanDelayConfig:
162
- vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
163
- dispatchReplyFromConfig:
164
- vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
165
- withReplyDispatcher: vi.fn(
166
- async ({
167
- dispatcher,
168
- run,
169
- onSettled,
170
- }: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
171
- try {
172
- return await run();
173
- } finally {
174
- dispatcher.markComplete();
175
- try {
176
- await dispatcher.waitForIdle();
177
- } finally {
178
- await onSettled?.();
179
- }
180
- }
181
- },
182
- ) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
183
- finalizeInboundContext: vi.fn(
184
- (ctx: Record<string, unknown>) => ctx,
185
- ) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
186
- formatAgentEnvelope:
187
- mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
188
- formatInboundEnvelope:
189
- mockFormatInboundEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
121
+ formatAgentEnvelope: mockFormatAgentEnvelope,
122
+ formatInboundEnvelope: mockFormatInboundEnvelope,
190
123
  resolveEnvelopeFormatOptions:
191
124
  mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
192
125
  },
@@ -195,99 +128,33 @@ function createMockRuntime(): PluginRuntime {
195
128
  mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
196
129
  },
197
130
  pairing: {
198
- buildPairingReply:
199
- mockBuildPairingReply as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
200
- readAllowFromStore:
201
- mockReadAllowFromStore as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
202
- upsertPairingRequest:
203
- mockUpsertPairingRequest as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
131
+ buildPairingReply: mockBuildPairingReply,
132
+ readAllowFromStore: mockReadAllowFromStore,
133
+ upsertPairingRequest: mockUpsertPairingRequest,
204
134
  },
205
135
  media: {
206
- fetchRemoteMedia:
207
- vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
208
136
  saveMediaBuffer:
209
137
  mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
210
138
  },
211
139
  session: {
212
- resolveStorePath:
213
- mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
214
- readSessionUpdatedAt:
215
- mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
216
- recordInboundSession:
217
- vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
218
- recordSessionMetaFromInbound:
219
- vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
220
- updateLastRoute:
221
- vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
140
+ resolveStorePath: mockResolveStorePath,
141
+ readSessionUpdatedAt: mockReadSessionUpdatedAt,
222
142
  },
223
143
  mentions: {
224
- buildMentionRegexes:
225
- mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
226
- matchesMentionPatterns:
227
- mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
228
- matchesMentionWithExplicit:
229
- mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
230
- },
231
- reactions: {
232
- shouldAckReaction,
233
- removeAckReactionAfterReply,
144
+ buildMentionRegexes: mockBuildMentionRegexes,
145
+ matchesMentionPatterns: mockMatchesMentionPatterns,
146
+ matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
234
147
  },
235
148
  groups: {
236
149
  resolveGroupPolicy:
237
150
  mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
238
- resolveRequireMention:
239
- mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
240
- },
241
- debounce: {
242
- // Create a pass-through debouncer that immediately calls onFlush
243
- createInboundDebouncer: vi.fn(
244
- (params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
245
- enqueue: async (item: unknown) => {
246
- await params.onFlush([item]);
247
- },
248
- flushKey: vi.fn(),
249
- }),
250
- ) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
251
- resolveInboundDebounceMs: vi.fn(
252
- () => 0,
253
- ) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
151
+ resolveRequireMention: mockResolveRequireMention,
254
152
  },
255
153
  commands: {
256
- resolveCommandAuthorizedFromAuthorizers:
257
- mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
258
- isControlCommandMessage:
259
- vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
260
- shouldComputeCommandAuthorized:
261
- vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
262
- shouldHandleTextCommands:
263
- vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
154
+ resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
264
155
  },
265
- discord: {} as PluginRuntime["channel"]["discord"],
266
- activity: {} as PluginRuntime["channel"]["activity"],
267
- line: {} as PluginRuntime["channel"]["line"],
268
- slack: {} as PluginRuntime["channel"]["slack"],
269
- telegram: {} as PluginRuntime["channel"]["telegram"],
270
- signal: {} as PluginRuntime["channel"]["signal"],
271
- imessage: {} as PluginRuntime["channel"]["imessage"],
272
- whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
273
156
  },
274
- logging: {
275
- shouldLogVerbose: vi.fn(
276
- () => false,
277
- ) as unknown as PluginRuntime["logging"]["shouldLogVerbose"],
278
- getChildLogger: vi.fn(() => ({
279
- info: vi.fn(),
280
- warn: vi.fn(),
281
- error: vi.fn(),
282
- debug: vi.fn(),
283
- })) as unknown as PluginRuntime["logging"]["getChildLogger"],
284
- },
285
- state: {
286
- resolveStateDir: vi.fn(
287
- () => "/tmp/openclaw",
288
- ) as unknown as PluginRuntime["state"]["resolveStateDir"],
289
- },
290
- };
157
+ });
291
158
  }
292
159
 
293
160
  function createMockAccount(
@@ -394,573 +261,6 @@ describe("BlueBubbles webhook monitor", () => {
394
261
  unregister?.();
395
262
  });
396
263
 
397
- describe("webhook parsing + auth handling", () => {
398
- it("rejects non-POST requests", async () => {
399
- const account = createMockAccount();
400
- const config: OpenClawConfig = {};
401
- const core = createMockRuntime();
402
- setBlueBubblesRuntime(core);
403
-
404
- unregister = registerBlueBubblesWebhookTarget({
405
- account,
406
- config,
407
- runtime: { log: vi.fn(), error: vi.fn() },
408
- core,
409
- path: "/bluebubbles-webhook",
410
- });
411
-
412
- const req = createMockRequest("GET", "/bluebubbles-webhook", {});
413
- const res = createMockResponse();
414
-
415
- const handled = await handleBlueBubblesWebhookRequest(req, res);
416
-
417
- expect(handled).toBe(true);
418
- expect(res.statusCode).toBe(405);
419
- });
420
-
421
- it("accepts POST requests with valid JSON payload", async () => {
422
- const account = createMockAccount();
423
- const config: OpenClawConfig = {};
424
- const core = createMockRuntime();
425
- setBlueBubblesRuntime(core);
426
-
427
- unregister = registerBlueBubblesWebhookTarget({
428
- account,
429
- config,
430
- runtime: { log: vi.fn(), error: vi.fn() },
431
- core,
432
- path: "/bluebubbles-webhook",
433
- });
434
-
435
- const payload = {
436
- type: "new-message",
437
- data: {
438
- text: "hello",
439
- handle: { address: "+15551234567" },
440
- isGroup: false,
441
- isFromMe: false,
442
- guid: "msg-1",
443
- date: Date.now(),
444
- },
445
- };
446
-
447
- const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
448
- const res = createMockResponse();
449
-
450
- const handled = await handleBlueBubblesWebhookRequest(req, res);
451
-
452
- expect(handled).toBe(true);
453
- expect(res.statusCode).toBe(200);
454
- expect(res.body).toBe("ok");
455
- });
456
-
457
- it("rejects requests with invalid JSON", async () => {
458
- const account = createMockAccount();
459
- const config: OpenClawConfig = {};
460
- const core = createMockRuntime();
461
- setBlueBubblesRuntime(core);
462
-
463
- unregister = registerBlueBubblesWebhookTarget({
464
- account,
465
- config,
466
- runtime: { log: vi.fn(), error: vi.fn() },
467
- core,
468
- path: "/bluebubbles-webhook",
469
- });
470
-
471
- const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
472
- const res = createMockResponse();
473
-
474
- const handled = await handleBlueBubblesWebhookRequest(req, res);
475
-
476
- expect(handled).toBe(true);
477
- expect(res.statusCode).toBe(400);
478
- });
479
-
480
- it("accepts URL-encoded payload wrappers", async () => {
481
- const account = createMockAccount();
482
- const config: OpenClawConfig = {};
483
- const core = createMockRuntime();
484
- setBlueBubblesRuntime(core);
485
-
486
- unregister = registerBlueBubblesWebhookTarget({
487
- account,
488
- config,
489
- runtime: { log: vi.fn(), error: vi.fn() },
490
- core,
491
- path: "/bluebubbles-webhook",
492
- });
493
-
494
- const payload = {
495
- type: "new-message",
496
- data: {
497
- text: "hello",
498
- handle: { address: "+15551234567" },
499
- isGroup: false,
500
- isFromMe: false,
501
- guid: "msg-1",
502
- date: Date.now(),
503
- },
504
- };
505
- const encodedBody = new URLSearchParams({
506
- payload: JSON.stringify(payload),
507
- }).toString();
508
-
509
- const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
510
- const res = createMockResponse();
511
-
512
- const handled = await handleBlueBubblesWebhookRequest(req, res);
513
-
514
- expect(handled).toBe(true);
515
- expect(res.statusCode).toBe(200);
516
- expect(res.body).toBe("ok");
517
- });
518
-
519
- it("returns 408 when request body times out (Slow-Loris protection)", async () => {
520
- vi.useFakeTimers();
521
- try {
522
- const account = createMockAccount();
523
- const config: OpenClawConfig = {};
524
- const core = createMockRuntime();
525
- setBlueBubblesRuntime(core);
526
-
527
- unregister = registerBlueBubblesWebhookTarget({
528
- account,
529
- config,
530
- runtime: { log: vi.fn(), error: vi.fn() },
531
- core,
532
- path: "/bluebubbles-webhook",
533
- });
534
-
535
- // Create a request that never sends data or ends (simulates slow-loris)
536
- const req = new EventEmitter() as IncomingMessage;
537
- req.method = "POST";
538
- req.url = "/bluebubbles-webhook";
539
- req.headers = {};
540
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
541
- remoteAddress: "127.0.0.1",
542
- };
543
- req.destroy = vi.fn();
544
-
545
- const res = createMockResponse();
546
-
547
- const handledPromise = handleBlueBubblesWebhookRequest(req, res);
548
-
549
- // Advance past the 30s timeout
550
- await vi.advanceTimersByTimeAsync(31_000);
551
-
552
- const handled = await handledPromise;
553
- expect(handled).toBe(true);
554
- expect(res.statusCode).toBe(408);
555
- expect(req.destroy).toHaveBeenCalled();
556
- } finally {
557
- vi.useRealTimers();
558
- }
559
- });
560
-
561
- it("authenticates via password query parameter", async () => {
562
- const account = createMockAccount({ password: "secret-token" });
563
- const config: OpenClawConfig = {};
564
- const core = createMockRuntime();
565
- setBlueBubblesRuntime(core);
566
-
567
- // Mock non-localhost request
568
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
569
- type: "new-message",
570
- data: {
571
- text: "hello",
572
- handle: { address: "+15551234567" },
573
- isGroup: false,
574
- isFromMe: false,
575
- guid: "msg-1",
576
- },
577
- });
578
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
579
- remoteAddress: "192.168.1.100",
580
- };
581
-
582
- unregister = registerBlueBubblesWebhookTarget({
583
- account,
584
- config,
585
- runtime: { log: vi.fn(), error: vi.fn() },
586
- core,
587
- path: "/bluebubbles-webhook",
588
- });
589
-
590
- const res = createMockResponse();
591
- const handled = await handleBlueBubblesWebhookRequest(req, res);
592
-
593
- expect(handled).toBe(true);
594
- expect(res.statusCode).toBe(200);
595
- });
596
-
597
- it("authenticates via x-password header", async () => {
598
- const account = createMockAccount({ password: "secret-token" });
599
- const config: OpenClawConfig = {};
600
- const core = createMockRuntime();
601
- setBlueBubblesRuntime(core);
602
-
603
- const req = createMockRequest(
604
- "POST",
605
- "/bluebubbles-webhook",
606
- {
607
- type: "new-message",
608
- data: {
609
- text: "hello",
610
- handle: { address: "+15551234567" },
611
- isGroup: false,
612
- isFromMe: false,
613
- guid: "msg-1",
614
- },
615
- },
616
- { "x-password": "secret-token" },
617
- );
618
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
619
- remoteAddress: "192.168.1.100",
620
- };
621
-
622
- unregister = registerBlueBubblesWebhookTarget({
623
- account,
624
- config,
625
- runtime: { log: vi.fn(), error: vi.fn() },
626
- core,
627
- path: "/bluebubbles-webhook",
628
- });
629
-
630
- const res = createMockResponse();
631
- const handled = await handleBlueBubblesWebhookRequest(req, res);
632
-
633
- expect(handled).toBe(true);
634
- expect(res.statusCode).toBe(200);
635
- });
636
-
637
- it("rejects unauthorized requests with wrong password", async () => {
638
- const account = createMockAccount({ password: "secret-token" });
639
- const config: OpenClawConfig = {};
640
- const core = createMockRuntime();
641
- setBlueBubblesRuntime(core);
642
-
643
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
644
- type: "new-message",
645
- data: {
646
- text: "hello",
647
- handle: { address: "+15551234567" },
648
- isGroup: false,
649
- isFromMe: false,
650
- guid: "msg-1",
651
- },
652
- });
653
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
654
- remoteAddress: "192.168.1.100",
655
- };
656
-
657
- unregister = registerBlueBubblesWebhookTarget({
658
- account,
659
- config,
660
- runtime: { log: vi.fn(), error: vi.fn() },
661
- core,
662
- path: "/bluebubbles-webhook",
663
- });
664
-
665
- const res = createMockResponse();
666
- const handled = await handleBlueBubblesWebhookRequest(req, res);
667
-
668
- expect(handled).toBe(true);
669
- expect(res.statusCode).toBe(401);
670
- });
671
-
672
- it("rejects ambiguous routing when multiple targets match the same password", async () => {
673
- const accountA = createMockAccount({ password: "secret-token" });
674
- const accountB = createMockAccount({ password: "secret-token" });
675
- const config: OpenClawConfig = {};
676
- const core = createMockRuntime();
677
- setBlueBubblesRuntime(core);
678
-
679
- const sinkA = vi.fn();
680
- const sinkB = vi.fn();
681
-
682
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
683
- type: "new-message",
684
- data: {
685
- text: "hello",
686
- handle: { address: "+15551234567" },
687
- isGroup: false,
688
- isFromMe: false,
689
- guid: "msg-1",
690
- },
691
- });
692
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
693
- remoteAddress: "192.168.1.100",
694
- };
695
-
696
- const unregisterA = registerBlueBubblesWebhookTarget({
697
- account: accountA,
698
- config,
699
- runtime: { log: vi.fn(), error: vi.fn() },
700
- core,
701
- path: "/bluebubbles-webhook",
702
- statusSink: sinkA,
703
- });
704
- const unregisterB = registerBlueBubblesWebhookTarget({
705
- account: accountB,
706
- config,
707
- runtime: { log: vi.fn(), error: vi.fn() },
708
- core,
709
- path: "/bluebubbles-webhook",
710
- statusSink: sinkB,
711
- });
712
- unregister = () => {
713
- unregisterA();
714
- unregisterB();
715
- };
716
-
717
- const res = createMockResponse();
718
- const handled = await handleBlueBubblesWebhookRequest(req, res);
719
-
720
- expect(handled).toBe(true);
721
- expect(res.statusCode).toBe(401);
722
- expect(sinkA).not.toHaveBeenCalled();
723
- expect(sinkB).not.toHaveBeenCalled();
724
- });
725
-
726
- it("ignores targets without passwords when a password-authenticated target matches", async () => {
727
- const accountStrict = createMockAccount({ password: "secret-token" });
728
- const accountWithoutPassword = createMockAccount({ password: undefined });
729
- const config: OpenClawConfig = {};
730
- const core = createMockRuntime();
731
- setBlueBubblesRuntime(core);
732
-
733
- const sinkStrict = vi.fn();
734
- const sinkWithoutPassword = vi.fn();
735
-
736
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
737
- type: "new-message",
738
- data: {
739
- text: "hello",
740
- handle: { address: "+15551234567" },
741
- isGroup: false,
742
- isFromMe: false,
743
- guid: "msg-1",
744
- },
745
- });
746
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
747
- remoteAddress: "192.168.1.100",
748
- };
749
-
750
- const unregisterStrict = registerBlueBubblesWebhookTarget({
751
- account: accountStrict,
752
- config,
753
- runtime: { log: vi.fn(), error: vi.fn() },
754
- core,
755
- path: "/bluebubbles-webhook",
756
- statusSink: sinkStrict,
757
- });
758
- const unregisterNoPassword = registerBlueBubblesWebhookTarget({
759
- account: accountWithoutPassword,
760
- config,
761
- runtime: { log: vi.fn(), error: vi.fn() },
762
- core,
763
- path: "/bluebubbles-webhook",
764
- statusSink: sinkWithoutPassword,
765
- });
766
- unregister = () => {
767
- unregisterStrict();
768
- unregisterNoPassword();
769
- };
770
-
771
- const res = createMockResponse();
772
- const handled = await handleBlueBubblesWebhookRequest(req, res);
773
-
774
- expect(handled).toBe(true);
775
- expect(res.statusCode).toBe(200);
776
- expect(sinkStrict).toHaveBeenCalledTimes(1);
777
- expect(sinkWithoutPassword).not.toHaveBeenCalled();
778
- });
779
-
780
- it("requires authentication for loopback requests when password is configured", async () => {
781
- const account = createMockAccount({ password: "secret-token" });
782
- const config: OpenClawConfig = {};
783
- const core = createMockRuntime();
784
- setBlueBubblesRuntime(core);
785
- for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
786
- const req = createMockRequest("POST", "/bluebubbles-webhook", {
787
- type: "new-message",
788
- data: {
789
- text: "hello",
790
- handle: { address: "+15551234567" },
791
- isGroup: false,
792
- isFromMe: false,
793
- guid: "msg-1",
794
- },
795
- });
796
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
797
- remoteAddress,
798
- };
799
-
800
- const loopbackUnregister = registerBlueBubblesWebhookTarget({
801
- account,
802
- config,
803
- runtime: { log: vi.fn(), error: vi.fn() },
804
- core,
805
- path: "/bluebubbles-webhook",
806
- });
807
-
808
- const res = createMockResponse();
809
- const handled = await handleBlueBubblesWebhookRequest(req, res);
810
- expect(handled).toBe(true);
811
- expect(res.statusCode).toBe(401);
812
-
813
- loopbackUnregister();
814
- }
815
- });
816
-
817
- it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
818
- const account = createMockAccount({ password: undefined });
819
- const config: OpenClawConfig = {};
820
- const core = createMockRuntime();
821
- setBlueBubblesRuntime(core);
822
-
823
- unregister = registerBlueBubblesWebhookTarget({
824
- account,
825
- config,
826
- runtime: { log: vi.fn(), error: vi.fn() },
827
- core,
828
- path: "/bluebubbles-webhook",
829
- });
830
-
831
- const headerVariants: Record<string, string>[] = [
832
- { host: "localhost" },
833
- { host: "localhost", "x-forwarded-for": "203.0.113.10" },
834
- { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
835
- ];
836
- for (const headers of headerVariants) {
837
- const req = createMockRequest(
838
- "POST",
839
- "/bluebubbles-webhook",
840
- {
841
- type: "new-message",
842
- data: {
843
- text: "hello",
844
- handle: { address: "+15551234567" },
845
- isGroup: false,
846
- isFromMe: false,
847
- guid: "msg-1",
848
- },
849
- },
850
- headers,
851
- );
852
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
853
- remoteAddress: "127.0.0.1",
854
- };
855
- const res = createMockResponse();
856
- const handled = await handleBlueBubblesWebhookRequest(req, res);
857
- expect(handled).toBe(true);
858
- expect(res.statusCode).toBe(401);
859
- }
860
- });
861
-
862
- it("ignores unregistered webhook paths", async () => {
863
- const req = createMockRequest("POST", "/unregistered-path", {});
864
- const res = createMockResponse();
865
-
866
- const handled = await handleBlueBubblesWebhookRequest(req, res);
867
-
868
- expect(handled).toBe(false);
869
- });
870
-
871
- it("parses chatId when provided as a string (webhook variant)", async () => {
872
- const { resolveChatGuidForTarget } = await import("./send.js");
873
- vi.mocked(resolveChatGuidForTarget).mockClear();
874
-
875
- const account = createMockAccount({ groupPolicy: "open" });
876
- const config: OpenClawConfig = {};
877
- const core = createMockRuntime();
878
- setBlueBubblesRuntime(core);
879
-
880
- unregister = registerBlueBubblesWebhookTarget({
881
- account,
882
- config,
883
- runtime: { log: vi.fn(), error: vi.fn() },
884
- core,
885
- path: "/bluebubbles-webhook",
886
- });
887
-
888
- const payload = {
889
- type: "new-message",
890
- data: {
891
- text: "hello from group",
892
- handle: { address: "+15551234567" },
893
- isGroup: true,
894
- isFromMe: false,
895
- guid: "msg-1",
896
- chatId: "123",
897
- date: Date.now(),
898
- },
899
- };
900
-
901
- const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
902
- const res = createMockResponse();
903
-
904
- await handleBlueBubblesWebhookRequest(req, res);
905
- await flushAsync();
906
-
907
- expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
908
- expect.objectContaining({
909
- target: { kind: "chat_id", chatId: 123 },
910
- }),
911
- );
912
- });
913
-
914
- it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
915
- const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
916
- vi.mocked(sendMessageBlueBubbles).mockClear();
917
- vi.mocked(resolveChatGuidForTarget).mockClear();
918
-
919
- mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
920
- await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
921
- });
922
-
923
- const account = createMockAccount({ groupPolicy: "open" });
924
- const config: OpenClawConfig = {};
925
- const core = createMockRuntime();
926
- setBlueBubblesRuntime(core);
927
-
928
- unregister = registerBlueBubblesWebhookTarget({
929
- account,
930
- config,
931
- runtime: { log: vi.fn(), error: vi.fn() },
932
- core,
933
- path: "/bluebubbles-webhook",
934
- });
935
-
936
- const payload = {
937
- type: "new-message",
938
- data: {
939
- text: "hello from group",
940
- handle: { address: "+15551234567" },
941
- isGroup: true,
942
- isFromMe: false,
943
- guid: "msg-1",
944
- chat: { chatGuid: "iMessage;+;chat123456" },
945
- date: Date.now(),
946
- },
947
- };
948
-
949
- const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
950
- const res = createMockResponse();
951
-
952
- await handleBlueBubblesWebhookRequest(req, res);
953
- await flushAsync();
954
-
955
- expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
956
- expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
957
- "chat_guid:iMessage;+;chat123456",
958
- expect.any(String),
959
- expect.any(Object),
960
- );
961
- });
962
- });
963
-
964
264
  describe("DM pairing behavior vs allowFrom", () => {
965
265
  it("allows DM from sender in allowFrom list", async () => {
966
266
  const account = createMockAccount({
@@ -2467,6 +1767,7 @@ describe("BlueBubbles webhook monitor", () => {
2467
1767
 
2468
1768
  mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2469
1769
  await params.dispatcherOptions.onReplyStart?.();
1770
+ return EMPTY_DISPATCH_RESULT;
2470
1771
  });
2471
1772
 
2472
1773
  const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
@@ -2517,6 +1818,7 @@ describe("BlueBubbles webhook monitor", () => {
2517
1818
  await params.dispatcherOptions.onReplyStart?.();
2518
1819
  await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
2519
1820
  await params.dispatcherOptions.onIdle?.();
1821
+ return EMPTY_DISPATCH_RESULT;
2520
1822
  });
2521
1823
 
2522
1824
  const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
@@ -2562,7 +1864,9 @@ describe("BlueBubbles webhook monitor", () => {
2562
1864
  },
2563
1865
  };
2564
1866
 
2565
- mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async () => undefined);
1867
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
1868
+ async () => EMPTY_DISPATCH_RESULT,
1869
+ );
2566
1870
 
2567
1871
  const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
2568
1872
  const res = createMockResponse();
@@ -2584,6 +1888,7 @@ describe("BlueBubbles webhook monitor", () => {
2584
1888
 
2585
1889
  mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2586
1890
  await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
1891
+ return EMPTY_DISPATCH_RESULT;
2587
1892
  });
2588
1893
 
2589
1894
  const account = createMockAccount();
@@ -2635,6 +1940,7 @@ describe("BlueBubbles webhook monitor", () => {
2635
1940
 
2636
1941
  mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2637
1942
  await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
1943
+ return EMPTY_DISPATCH_RESULT;
2638
1944
  });
2639
1945
 
2640
1946
  const account = createMockAccount();
@@ -2707,6 +2013,7 @@ describe("BlueBubbles webhook monitor", () => {
2707
2013
 
2708
2014
  mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2709
2015
  await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
2016
+ return EMPTY_DISPATCH_RESULT;
2710
2017
  });
2711
2018
 
2712
2019
  const account = createMockAccount();
@@ -3084,11 +2391,11 @@ describe("BlueBubbles webhook monitor", () => {
3084
2391
  });
3085
2392
 
3086
2393
  const accountA: ResolvedBlueBubblesAccount = {
3087
- ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }),
2394
+ ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }), // pragma: allowlist secret
3088
2395
  accountId: "acc-a",
3089
2396
  };
3090
2397
  const accountB: ResolvedBlueBubblesAccount = {
3091
- ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }),
2398
+ ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), // pragma: allowlist secret
3092
2399
  accountId: "acc-b",
3093
2400
  };
3094
2401
  const config: OpenClawConfig = {};