@openclaw/bluebubbles 2026.2.25 → 2026.3.2

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
3
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
4
- import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
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,109 +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
- finalizeInboundContext: vi.fn(
166
- (ctx: Record<string, unknown>) => ctx,
167
- ) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
168
- formatAgentEnvelope:
169
- mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
170
- formatInboundEnvelope:
171
- mockFormatInboundEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
121
+ formatAgentEnvelope: mockFormatAgentEnvelope,
122
+ formatInboundEnvelope: mockFormatInboundEnvelope,
172
123
  resolveEnvelopeFormatOptions:
173
124
  mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
174
125
  },
@@ -177,99 +128,33 @@ function createMockRuntime(): PluginRuntime {
177
128
  mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
178
129
  },
179
130
  pairing: {
180
- buildPairingReply:
181
- mockBuildPairingReply as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
182
- readAllowFromStore:
183
- mockReadAllowFromStore as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
184
- upsertPairingRequest:
185
- mockUpsertPairingRequest as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
131
+ buildPairingReply: mockBuildPairingReply,
132
+ readAllowFromStore: mockReadAllowFromStore,
133
+ upsertPairingRequest: mockUpsertPairingRequest,
186
134
  },
187
135
  media: {
188
- fetchRemoteMedia:
189
- vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
190
136
  saveMediaBuffer:
191
137
  mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
192
138
  },
193
139
  session: {
194
- resolveStorePath:
195
- mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
196
- readSessionUpdatedAt:
197
- mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
198
- recordInboundSession:
199
- vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
200
- recordSessionMetaFromInbound:
201
- vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
202
- updateLastRoute:
203
- vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
140
+ resolveStorePath: mockResolveStorePath,
141
+ readSessionUpdatedAt: mockReadSessionUpdatedAt,
204
142
  },
205
143
  mentions: {
206
- buildMentionRegexes:
207
- mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
208
- matchesMentionPatterns:
209
- mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
210
- matchesMentionWithExplicit:
211
- mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
212
- },
213
- reactions: {
214
- shouldAckReaction,
215
- removeAckReactionAfterReply,
144
+ buildMentionRegexes: mockBuildMentionRegexes,
145
+ matchesMentionPatterns: mockMatchesMentionPatterns,
146
+ matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
216
147
  },
217
148
  groups: {
218
149
  resolveGroupPolicy:
219
150
  mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
220
- resolveRequireMention:
221
- mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
222
- },
223
- debounce: {
224
- // Create a pass-through debouncer that immediately calls onFlush
225
- createInboundDebouncer: vi.fn(
226
- (params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
227
- enqueue: async (item: unknown) => {
228
- await params.onFlush([item]);
229
- },
230
- flushKey: vi.fn(),
231
- }),
232
- ) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
233
- resolveInboundDebounceMs: vi.fn(
234
- () => 0,
235
- ) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
151
+ resolveRequireMention: mockResolveRequireMention,
236
152
  },
237
153
  commands: {
238
- resolveCommandAuthorizedFromAuthorizers:
239
- mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
240
- isControlCommandMessage:
241
- vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
242
- shouldComputeCommandAuthorized:
243
- vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
244
- shouldHandleTextCommands:
245
- vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
154
+ resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
246
155
  },
247
- discord: {} as PluginRuntime["channel"]["discord"],
248
- activity: {} as PluginRuntime["channel"]["activity"],
249
- line: {} as PluginRuntime["channel"]["line"],
250
- slack: {} as PluginRuntime["channel"]["slack"],
251
- telegram: {} as PluginRuntime["channel"]["telegram"],
252
- signal: {} as PluginRuntime["channel"]["signal"],
253
- imessage: {} as PluginRuntime["channel"]["imessage"],
254
- whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
255
- },
256
- logging: {
257
- shouldLogVerbose: vi.fn(
258
- () => false,
259
- ) as unknown as PluginRuntime["logging"]["shouldLogVerbose"],
260
- getChildLogger: vi.fn(() => ({
261
- info: vi.fn(),
262
- warn: vi.fn(),
263
- error: vi.fn(),
264
- debug: vi.fn(),
265
- })) as unknown as PluginRuntime["logging"]["getChildLogger"],
266
- },
267
- state: {
268
- resolveStateDir: vi.fn(
269
- () => "/tmp/openclaw",
270
- ) as unknown as PluginRuntime["state"]["resolveStateDir"],
271
156
  },
272
- };
157
+ });
273
158
  }
274
159
 
275
160
  function createMockAccount(
@@ -376,573 +261,6 @@ describe("BlueBubbles webhook monitor", () => {
376
261
  unregister?.();
377
262
  });
378
263
 
379
- describe("webhook parsing + auth handling", () => {
380
- it("rejects non-POST requests", async () => {
381
- const account = createMockAccount();
382
- const config: OpenClawConfig = {};
383
- const core = createMockRuntime();
384
- setBlueBubblesRuntime(core);
385
-
386
- unregister = registerBlueBubblesWebhookTarget({
387
- account,
388
- config,
389
- runtime: { log: vi.fn(), error: vi.fn() },
390
- core,
391
- path: "/bluebubbles-webhook",
392
- });
393
-
394
- const req = createMockRequest("GET", "/bluebubbles-webhook", {});
395
- const res = createMockResponse();
396
-
397
- const handled = await handleBlueBubblesWebhookRequest(req, res);
398
-
399
- expect(handled).toBe(true);
400
- expect(res.statusCode).toBe(405);
401
- });
402
-
403
- it("accepts POST requests with valid JSON payload", async () => {
404
- const account = createMockAccount();
405
- const config: OpenClawConfig = {};
406
- const core = createMockRuntime();
407
- setBlueBubblesRuntime(core);
408
-
409
- unregister = registerBlueBubblesWebhookTarget({
410
- account,
411
- config,
412
- runtime: { log: vi.fn(), error: vi.fn() },
413
- core,
414
- path: "/bluebubbles-webhook",
415
- });
416
-
417
- const payload = {
418
- type: "new-message",
419
- data: {
420
- text: "hello",
421
- handle: { address: "+15551234567" },
422
- isGroup: false,
423
- isFromMe: false,
424
- guid: "msg-1",
425
- date: Date.now(),
426
- },
427
- };
428
-
429
- const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
430
- const res = createMockResponse();
431
-
432
- const handled = await handleBlueBubblesWebhookRequest(req, res);
433
-
434
- expect(handled).toBe(true);
435
- expect(res.statusCode).toBe(200);
436
- expect(res.body).toBe("ok");
437
- });
438
-
439
- it("rejects requests with invalid JSON", async () => {
440
- const account = createMockAccount();
441
- const config: OpenClawConfig = {};
442
- const core = createMockRuntime();
443
- setBlueBubblesRuntime(core);
444
-
445
- unregister = registerBlueBubblesWebhookTarget({
446
- account,
447
- config,
448
- runtime: { log: vi.fn(), error: vi.fn() },
449
- core,
450
- path: "/bluebubbles-webhook",
451
- });
452
-
453
- const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
454
- const res = createMockResponse();
455
-
456
- const handled = await handleBlueBubblesWebhookRequest(req, res);
457
-
458
- expect(handled).toBe(true);
459
- expect(res.statusCode).toBe(400);
460
- });
461
-
462
- it("accepts URL-encoded payload wrappers", async () => {
463
- const account = createMockAccount();
464
- const config: OpenClawConfig = {};
465
- const core = createMockRuntime();
466
- setBlueBubblesRuntime(core);
467
-
468
- unregister = registerBlueBubblesWebhookTarget({
469
- account,
470
- config,
471
- runtime: { log: vi.fn(), error: vi.fn() },
472
- core,
473
- path: "/bluebubbles-webhook",
474
- });
475
-
476
- const payload = {
477
- type: "new-message",
478
- data: {
479
- text: "hello",
480
- handle: { address: "+15551234567" },
481
- isGroup: false,
482
- isFromMe: false,
483
- guid: "msg-1",
484
- date: Date.now(),
485
- },
486
- };
487
- const encodedBody = new URLSearchParams({
488
- payload: JSON.stringify(payload),
489
- }).toString();
490
-
491
- const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
492
- const res = createMockResponse();
493
-
494
- const handled = await handleBlueBubblesWebhookRequest(req, res);
495
-
496
- expect(handled).toBe(true);
497
- expect(res.statusCode).toBe(200);
498
- expect(res.body).toBe("ok");
499
- });
500
-
501
- it("returns 408 when request body times out (Slow-Loris protection)", async () => {
502
- vi.useFakeTimers();
503
- try {
504
- const account = createMockAccount();
505
- const config: OpenClawConfig = {};
506
- const core = createMockRuntime();
507
- setBlueBubblesRuntime(core);
508
-
509
- unregister = registerBlueBubblesWebhookTarget({
510
- account,
511
- config,
512
- runtime: { log: vi.fn(), error: vi.fn() },
513
- core,
514
- path: "/bluebubbles-webhook",
515
- });
516
-
517
- // Create a request that never sends data or ends (simulates slow-loris)
518
- const req = new EventEmitter() as IncomingMessage;
519
- req.method = "POST";
520
- req.url = "/bluebubbles-webhook";
521
- req.headers = {};
522
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
523
- remoteAddress: "127.0.0.1",
524
- };
525
- req.destroy = vi.fn();
526
-
527
- const res = createMockResponse();
528
-
529
- const handledPromise = handleBlueBubblesWebhookRequest(req, res);
530
-
531
- // Advance past the 30s timeout
532
- await vi.advanceTimersByTimeAsync(31_000);
533
-
534
- const handled = await handledPromise;
535
- expect(handled).toBe(true);
536
- expect(res.statusCode).toBe(408);
537
- expect(req.destroy).toHaveBeenCalled();
538
- } finally {
539
- vi.useRealTimers();
540
- }
541
- });
542
-
543
- it("authenticates via password query parameter", async () => {
544
- const account = createMockAccount({ password: "secret-token" });
545
- const config: OpenClawConfig = {};
546
- const core = createMockRuntime();
547
- setBlueBubblesRuntime(core);
548
-
549
- // Mock non-localhost request
550
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
551
- type: "new-message",
552
- data: {
553
- text: "hello",
554
- handle: { address: "+15551234567" },
555
- isGroup: false,
556
- isFromMe: false,
557
- guid: "msg-1",
558
- },
559
- });
560
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
561
- remoteAddress: "192.168.1.100",
562
- };
563
-
564
- unregister = registerBlueBubblesWebhookTarget({
565
- account,
566
- config,
567
- runtime: { log: vi.fn(), error: vi.fn() },
568
- core,
569
- path: "/bluebubbles-webhook",
570
- });
571
-
572
- const res = createMockResponse();
573
- const handled = await handleBlueBubblesWebhookRequest(req, res);
574
-
575
- expect(handled).toBe(true);
576
- expect(res.statusCode).toBe(200);
577
- });
578
-
579
- it("authenticates via x-password header", async () => {
580
- const account = createMockAccount({ password: "secret-token" });
581
- const config: OpenClawConfig = {};
582
- const core = createMockRuntime();
583
- setBlueBubblesRuntime(core);
584
-
585
- const req = createMockRequest(
586
- "POST",
587
- "/bluebubbles-webhook",
588
- {
589
- type: "new-message",
590
- data: {
591
- text: "hello",
592
- handle: { address: "+15551234567" },
593
- isGroup: false,
594
- isFromMe: false,
595
- guid: "msg-1",
596
- },
597
- },
598
- { "x-password": "secret-token" },
599
- );
600
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
601
- remoteAddress: "192.168.1.100",
602
- };
603
-
604
- unregister = registerBlueBubblesWebhookTarget({
605
- account,
606
- config,
607
- runtime: { log: vi.fn(), error: vi.fn() },
608
- core,
609
- path: "/bluebubbles-webhook",
610
- });
611
-
612
- const res = createMockResponse();
613
- const handled = await handleBlueBubblesWebhookRequest(req, res);
614
-
615
- expect(handled).toBe(true);
616
- expect(res.statusCode).toBe(200);
617
- });
618
-
619
- it("rejects unauthorized requests with wrong password", async () => {
620
- const account = createMockAccount({ password: "secret-token" });
621
- const config: OpenClawConfig = {};
622
- const core = createMockRuntime();
623
- setBlueBubblesRuntime(core);
624
-
625
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
626
- type: "new-message",
627
- data: {
628
- text: "hello",
629
- handle: { address: "+15551234567" },
630
- isGroup: false,
631
- isFromMe: false,
632
- guid: "msg-1",
633
- },
634
- });
635
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
636
- remoteAddress: "192.168.1.100",
637
- };
638
-
639
- unregister = registerBlueBubblesWebhookTarget({
640
- account,
641
- config,
642
- runtime: { log: vi.fn(), error: vi.fn() },
643
- core,
644
- path: "/bluebubbles-webhook",
645
- });
646
-
647
- const res = createMockResponse();
648
- const handled = await handleBlueBubblesWebhookRequest(req, res);
649
-
650
- expect(handled).toBe(true);
651
- expect(res.statusCode).toBe(401);
652
- });
653
-
654
- it("rejects ambiguous routing when multiple targets match the same password", async () => {
655
- const accountA = createMockAccount({ password: "secret-token" });
656
- const accountB = createMockAccount({ password: "secret-token" });
657
- const config: OpenClawConfig = {};
658
- const core = createMockRuntime();
659
- setBlueBubblesRuntime(core);
660
-
661
- const sinkA = vi.fn();
662
- const sinkB = vi.fn();
663
-
664
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
665
- type: "new-message",
666
- data: {
667
- text: "hello",
668
- handle: { address: "+15551234567" },
669
- isGroup: false,
670
- isFromMe: false,
671
- guid: "msg-1",
672
- },
673
- });
674
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
675
- remoteAddress: "192.168.1.100",
676
- };
677
-
678
- const unregisterA = registerBlueBubblesWebhookTarget({
679
- account: accountA,
680
- config,
681
- runtime: { log: vi.fn(), error: vi.fn() },
682
- core,
683
- path: "/bluebubbles-webhook",
684
- statusSink: sinkA,
685
- });
686
- const unregisterB = registerBlueBubblesWebhookTarget({
687
- account: accountB,
688
- config,
689
- runtime: { log: vi.fn(), error: vi.fn() },
690
- core,
691
- path: "/bluebubbles-webhook",
692
- statusSink: sinkB,
693
- });
694
- unregister = () => {
695
- unregisterA();
696
- unregisterB();
697
- };
698
-
699
- const res = createMockResponse();
700
- const handled = await handleBlueBubblesWebhookRequest(req, res);
701
-
702
- expect(handled).toBe(true);
703
- expect(res.statusCode).toBe(401);
704
- expect(sinkA).not.toHaveBeenCalled();
705
- expect(sinkB).not.toHaveBeenCalled();
706
- });
707
-
708
- it("ignores targets without passwords when a password-authenticated target matches", async () => {
709
- const accountStrict = createMockAccount({ password: "secret-token" });
710
- const accountWithoutPassword = createMockAccount({ password: undefined });
711
- const config: OpenClawConfig = {};
712
- const core = createMockRuntime();
713
- setBlueBubblesRuntime(core);
714
-
715
- const sinkStrict = vi.fn();
716
- const sinkWithoutPassword = vi.fn();
717
-
718
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
719
- type: "new-message",
720
- data: {
721
- text: "hello",
722
- handle: { address: "+15551234567" },
723
- isGroup: false,
724
- isFromMe: false,
725
- guid: "msg-1",
726
- },
727
- });
728
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
729
- remoteAddress: "192.168.1.100",
730
- };
731
-
732
- const unregisterStrict = registerBlueBubblesWebhookTarget({
733
- account: accountStrict,
734
- config,
735
- runtime: { log: vi.fn(), error: vi.fn() },
736
- core,
737
- path: "/bluebubbles-webhook",
738
- statusSink: sinkStrict,
739
- });
740
- const unregisterNoPassword = registerBlueBubblesWebhookTarget({
741
- account: accountWithoutPassword,
742
- config,
743
- runtime: { log: vi.fn(), error: vi.fn() },
744
- core,
745
- path: "/bluebubbles-webhook",
746
- statusSink: sinkWithoutPassword,
747
- });
748
- unregister = () => {
749
- unregisterStrict();
750
- unregisterNoPassword();
751
- };
752
-
753
- const res = createMockResponse();
754
- const handled = await handleBlueBubblesWebhookRequest(req, res);
755
-
756
- expect(handled).toBe(true);
757
- expect(res.statusCode).toBe(200);
758
- expect(sinkStrict).toHaveBeenCalledTimes(1);
759
- expect(sinkWithoutPassword).not.toHaveBeenCalled();
760
- });
761
-
762
- it("requires authentication for loopback requests when password is configured", async () => {
763
- const account = createMockAccount({ password: "secret-token" });
764
- const config: OpenClawConfig = {};
765
- const core = createMockRuntime();
766
- setBlueBubblesRuntime(core);
767
- for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
768
- const req = createMockRequest("POST", "/bluebubbles-webhook", {
769
- type: "new-message",
770
- data: {
771
- text: "hello",
772
- handle: { address: "+15551234567" },
773
- isGroup: false,
774
- isFromMe: false,
775
- guid: "msg-1",
776
- },
777
- });
778
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
779
- remoteAddress,
780
- };
781
-
782
- const loopbackUnregister = registerBlueBubblesWebhookTarget({
783
- account,
784
- config,
785
- runtime: { log: vi.fn(), error: vi.fn() },
786
- core,
787
- path: "/bluebubbles-webhook",
788
- });
789
-
790
- const res = createMockResponse();
791
- const handled = await handleBlueBubblesWebhookRequest(req, res);
792
- expect(handled).toBe(true);
793
- expect(res.statusCode).toBe(401);
794
-
795
- loopbackUnregister();
796
- }
797
- });
798
-
799
- it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
800
- const account = createMockAccount({ password: undefined });
801
- const config: OpenClawConfig = {};
802
- const core = createMockRuntime();
803
- setBlueBubblesRuntime(core);
804
-
805
- unregister = registerBlueBubblesWebhookTarget({
806
- account,
807
- config,
808
- runtime: { log: vi.fn(), error: vi.fn() },
809
- core,
810
- path: "/bluebubbles-webhook",
811
- });
812
-
813
- const headerVariants: Record<string, string>[] = [
814
- { host: "localhost" },
815
- { host: "localhost", "x-forwarded-for": "203.0.113.10" },
816
- { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
817
- ];
818
- for (const headers of headerVariants) {
819
- const req = createMockRequest(
820
- "POST",
821
- "/bluebubbles-webhook",
822
- {
823
- type: "new-message",
824
- data: {
825
- text: "hello",
826
- handle: { address: "+15551234567" },
827
- isGroup: false,
828
- isFromMe: false,
829
- guid: "msg-1",
830
- },
831
- },
832
- headers,
833
- );
834
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
835
- remoteAddress: "127.0.0.1",
836
- };
837
- const res = createMockResponse();
838
- const handled = await handleBlueBubblesWebhookRequest(req, res);
839
- expect(handled).toBe(true);
840
- expect(res.statusCode).toBe(401);
841
- }
842
- });
843
-
844
- it("ignores unregistered webhook paths", async () => {
845
- const req = createMockRequest("POST", "/unregistered-path", {});
846
- const res = createMockResponse();
847
-
848
- const handled = await handleBlueBubblesWebhookRequest(req, res);
849
-
850
- expect(handled).toBe(false);
851
- });
852
-
853
- it("parses chatId when provided as a string (webhook variant)", async () => {
854
- const { resolveChatGuidForTarget } = await import("./send.js");
855
- vi.mocked(resolveChatGuidForTarget).mockClear();
856
-
857
- const account = createMockAccount({ groupPolicy: "open" });
858
- const config: OpenClawConfig = {};
859
- const core = createMockRuntime();
860
- setBlueBubblesRuntime(core);
861
-
862
- unregister = registerBlueBubblesWebhookTarget({
863
- account,
864
- config,
865
- runtime: { log: vi.fn(), error: vi.fn() },
866
- core,
867
- path: "/bluebubbles-webhook",
868
- });
869
-
870
- const payload = {
871
- type: "new-message",
872
- data: {
873
- text: "hello from group",
874
- handle: { address: "+15551234567" },
875
- isGroup: true,
876
- isFromMe: false,
877
- guid: "msg-1",
878
- chatId: "123",
879
- date: Date.now(),
880
- },
881
- };
882
-
883
- const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
884
- const res = createMockResponse();
885
-
886
- await handleBlueBubblesWebhookRequest(req, res);
887
- await flushAsync();
888
-
889
- expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
890
- expect.objectContaining({
891
- target: { kind: "chat_id", chatId: 123 },
892
- }),
893
- );
894
- });
895
-
896
- it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
897
- const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
898
- vi.mocked(sendMessageBlueBubbles).mockClear();
899
- vi.mocked(resolveChatGuidForTarget).mockClear();
900
-
901
- mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
902
- await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
903
- });
904
-
905
- const account = createMockAccount({ groupPolicy: "open" });
906
- const config: OpenClawConfig = {};
907
- const core = createMockRuntime();
908
- setBlueBubblesRuntime(core);
909
-
910
- unregister = registerBlueBubblesWebhookTarget({
911
- account,
912
- config,
913
- runtime: { log: vi.fn(), error: vi.fn() },
914
- core,
915
- path: "/bluebubbles-webhook",
916
- });
917
-
918
- const payload = {
919
- type: "new-message",
920
- data: {
921
- text: "hello from group",
922
- handle: { address: "+15551234567" },
923
- isGroup: true,
924
- isFromMe: false,
925
- guid: "msg-1",
926
- chat: { chatGuid: "iMessage;+;chat123456" },
927
- date: Date.now(),
928
- },
929
- };
930
-
931
- const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
932
- const res = createMockResponse();
933
-
934
- await handleBlueBubblesWebhookRequest(req, res);
935
- await flushAsync();
936
-
937
- expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
938
- expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
939
- "chat_guid:iMessage;+;chat123456",
940
- expect.any(String),
941
- expect.any(Object),
942
- );
943
- });
944
- });
945
-
946
264
  describe("DM pairing behavior vs allowFrom", () => {
947
265
  it("allows DM from sender in allowFrom list", async () => {
948
266
  const account = createMockAccount({
@@ -2287,6 +1605,51 @@ describe("BlueBubbles webhook monitor", () => {
2287
1605
 
2288
1606
  expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
2289
1607
  });
1608
+
1609
+ it("does not auto-authorize DM control commands in open mode without allowlists", async () => {
1610
+ mockHasControlCommand.mockReturnValue(true);
1611
+
1612
+ const account = createMockAccount({
1613
+ dmPolicy: "open",
1614
+ allowFrom: [],
1615
+ });
1616
+ const config: OpenClawConfig = {};
1617
+ const core = createMockRuntime();
1618
+ setBlueBubblesRuntime(core);
1619
+
1620
+ unregister = registerBlueBubblesWebhookTarget({
1621
+ account,
1622
+ config,
1623
+ runtime: { log: vi.fn(), error: vi.fn() },
1624
+ core,
1625
+ path: "/bluebubbles-webhook",
1626
+ });
1627
+
1628
+ const payload = {
1629
+ type: "new-message",
1630
+ data: {
1631
+ text: "/status",
1632
+ handle: { address: "+15559999999" },
1633
+ isGroup: false,
1634
+ isFromMe: false,
1635
+ guid: "msg-dm-open-unauthorized",
1636
+ date: Date.now(),
1637
+ },
1638
+ };
1639
+
1640
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
1641
+ const res = createMockResponse();
1642
+
1643
+ await handleBlueBubblesWebhookRequest(req, res);
1644
+ await flushAsync();
1645
+
1646
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
1647
+ const latestDispatch =
1648
+ mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[
1649
+ mockDispatchReplyWithBufferedBlockDispatcher.mock.calls.length - 1
1650
+ ]?.[0];
1651
+ expect(latestDispatch?.ctx?.CommandAuthorized).toBe(false);
1652
+ });
2290
1653
  });
2291
1654
 
2292
1655
  describe("typing/read receipt toggles", () => {
@@ -2404,6 +1767,7 @@ describe("BlueBubbles webhook monitor", () => {
2404
1767
 
2405
1768
  mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2406
1769
  await params.dispatcherOptions.onReplyStart?.();
1770
+ return EMPTY_DISPATCH_RESULT;
2407
1771
  });
2408
1772
 
2409
1773
  const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
@@ -2454,6 +1818,7 @@ describe("BlueBubbles webhook monitor", () => {
2454
1818
  await params.dispatcherOptions.onReplyStart?.();
2455
1819
  await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
2456
1820
  await params.dispatcherOptions.onIdle?.();
1821
+ return EMPTY_DISPATCH_RESULT;
2457
1822
  });
2458
1823
 
2459
1824
  const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
@@ -2499,7 +1864,9 @@ describe("BlueBubbles webhook monitor", () => {
2499
1864
  },
2500
1865
  };
2501
1866
 
2502
- mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async () => undefined);
1867
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
1868
+ async () => EMPTY_DISPATCH_RESULT,
1869
+ );
2503
1870
 
2504
1871
  const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
2505
1872
  const res = createMockResponse();
@@ -2521,6 +1888,7 @@ describe("BlueBubbles webhook monitor", () => {
2521
1888
 
2522
1889
  mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2523
1890
  await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
1891
+ return EMPTY_DISPATCH_RESULT;
2524
1892
  });
2525
1893
 
2526
1894
  const account = createMockAccount();
@@ -2572,6 +1940,7 @@ describe("BlueBubbles webhook monitor", () => {
2572
1940
 
2573
1941
  mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2574
1942
  await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
1943
+ return EMPTY_DISPATCH_RESULT;
2575
1944
  });
2576
1945
 
2577
1946
  const account = createMockAccount();
@@ -2644,6 +2013,7 @@ describe("BlueBubbles webhook monitor", () => {
2644
2013
 
2645
2014
  mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2646
2015
  await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
2016
+ return EMPTY_DISPATCH_RESULT;
2647
2017
  });
2648
2018
 
2649
2019
  const account = createMockAccount();