@openclaw/bluebubbles 2026.3.11 → 2026.3.13

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.
@@ -302,65 +302,102 @@ describe("BlueBubbles webhook monitor", () => {
302
302
  };
303
303
  }
304
304
 
305
- describe("webhook parsing + auth handling", () => {
306
- it("rejects non-POST requests", async () => {
307
- const account = createMockAccount();
308
- const config: OpenClawConfig = {};
309
- const core = createMockRuntime();
310
- setBlueBubblesRuntime(core);
305
+ async function dispatchWebhook(req: IncomingMessage) {
306
+ const res = createMockResponse();
307
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
308
+ return { handled, res };
309
+ }
310
+
311
+ function createWebhookRequestForTest(params?: {
312
+ method?: string;
313
+ url?: string;
314
+ body?: unknown;
315
+ headers?: Record<string, string>;
316
+ remoteAddress?: string;
317
+ }) {
318
+ const req = createMockRequest(
319
+ params?.method ?? "POST",
320
+ params?.url ?? "/bluebubbles-webhook",
321
+ params?.body ?? {},
322
+ params?.headers,
323
+ );
324
+ if (params?.remoteAddress) {
325
+ setRequestRemoteAddress(req, params.remoteAddress);
326
+ }
327
+ return req;
328
+ }
329
+
330
+ function createHangingWebhookRequest(url = "/bluebubbles-webhook?password=test-password") {
331
+ const req = new EventEmitter() as IncomingMessage;
332
+ const destroyMock = vi.fn();
333
+ req.method = "POST";
334
+ req.url = url;
335
+ req.headers = {};
336
+ req.destroy = destroyMock as unknown as IncomingMessage["destroy"];
337
+ setRequestRemoteAddress(req, "127.0.0.1");
338
+ return { req, destroyMock };
339
+ }
311
340
 
312
- unregister = registerBlueBubblesWebhookTarget({
341
+ function registerWebhookTargets(
342
+ params: Array<{
343
+ account: ResolvedBlueBubblesAccount;
344
+ statusSink?: (event: unknown) => void;
345
+ }>,
346
+ ) {
347
+ const config: OpenClawConfig = {};
348
+ const core = createMockRuntime();
349
+ setBlueBubblesRuntime(core);
350
+
351
+ const unregisterFns = params.map(({ account, statusSink }) =>
352
+ registerBlueBubblesWebhookTarget({
313
353
  account,
314
354
  config,
315
355
  runtime: { log: vi.fn(), error: vi.fn() },
316
356
  core,
317
357
  path: "/bluebubbles-webhook",
318
- });
358
+ statusSink,
359
+ }),
360
+ );
319
361
 
320
- const req = createMockRequest("GET", "/bluebubbles-webhook", {});
321
- const res = createMockResponse();
362
+ unregister = () => {
363
+ for (const unregisterFn of unregisterFns) {
364
+ unregisterFn();
365
+ }
366
+ };
367
+ }
322
368
 
323
- const handled = await handleBlueBubblesWebhookRequest(req, res);
369
+ async function expectWebhookStatus(
370
+ req: IncomingMessage,
371
+ expectedStatus: number,
372
+ expectedBody?: string,
373
+ ) {
374
+ const { handled, res } = await dispatchWebhook(req);
375
+ expect(handled).toBe(true);
376
+ expect(res.statusCode).toBe(expectedStatus);
377
+ if (expectedBody !== undefined) {
378
+ expect(res.body).toBe(expectedBody);
379
+ }
380
+ return res;
381
+ }
324
382
 
325
- expect(handled).toBe(true);
326
- expect(res.statusCode).toBe(405);
383
+ describe("webhook parsing + auth handling", () => {
384
+ it("rejects non-POST requests", async () => {
385
+ setupWebhookTarget();
386
+ const req = createWebhookRequestForTest({ method: "GET" });
387
+ await expectWebhookStatus(req, 405);
327
388
  });
328
389
 
329
390
  it("accepts POST requests with valid JSON payload", async () => {
330
391
  setupWebhookTarget();
331
392
  const payload = createNewMessagePayload({ date: Date.now() });
332
-
333
- const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
334
- const res = createMockResponse();
335
-
336
- const handled = await handleBlueBubblesWebhookRequest(req, res);
337
-
338
- expect(handled).toBe(true);
339
- expect(res.statusCode).toBe(200);
340
- expect(res.body).toBe("ok");
393
+ const req = createWebhookRequestForTest({ body: payload });
394
+ await expectWebhookStatus(req, 200, "ok");
341
395
  });
342
396
 
343
397
  it("rejects requests with invalid JSON", async () => {
344
- const account = createMockAccount();
345
- const config: OpenClawConfig = {};
346
- const core = createMockRuntime();
347
- setBlueBubblesRuntime(core);
348
-
349
- unregister = registerBlueBubblesWebhookTarget({
350
- account,
351
- config,
352
- runtime: { log: vi.fn(), error: vi.fn() },
353
- core,
354
- path: "/bluebubbles-webhook",
355
- });
356
-
357
- const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
358
- const res = createMockResponse();
359
-
360
- const handled = await handleBlueBubblesWebhookRequest(req, res);
361
-
362
- expect(handled).toBe(true);
363
- expect(res.statusCode).toBe(400);
398
+ setupWebhookTarget();
399
+ const req = createWebhookRequestForTest({ body: "invalid json {{" });
400
+ await expectWebhookStatus(req, 400);
364
401
  });
365
402
 
366
403
  it("accepts URL-encoded payload wrappers", async () => {
@@ -369,42 +406,17 @@ describe("BlueBubbles webhook monitor", () => {
369
406
  const encodedBody = new URLSearchParams({
370
407
  payload: JSON.stringify(payload),
371
408
  }).toString();
372
-
373
- const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
374
- const res = createMockResponse();
375
-
376
- const handled = await handleBlueBubblesWebhookRequest(req, res);
377
-
378
- expect(handled).toBe(true);
379
- expect(res.statusCode).toBe(200);
380
- expect(res.body).toBe("ok");
409
+ const req = createWebhookRequestForTest({ body: encodedBody });
410
+ await expectWebhookStatus(req, 200, "ok");
381
411
  });
382
412
 
383
413
  it("returns 408 when request body times out (Slow-Loris protection)", async () => {
384
414
  vi.useFakeTimers();
385
415
  try {
386
- const account = createMockAccount();
387
- const config: OpenClawConfig = {};
388
- const core = createMockRuntime();
389
- setBlueBubblesRuntime(core);
390
-
391
- unregister = registerBlueBubblesWebhookTarget({
392
- account,
393
- config,
394
- runtime: { log: vi.fn(), error: vi.fn() },
395
- core,
396
- path: "/bluebubbles-webhook",
397
- });
416
+ setupWebhookTarget();
398
417
 
399
418
  // Create a request that never sends data or ends (simulates slow-loris)
400
- const req = new EventEmitter() as IncomingMessage;
401
- req.method = "POST";
402
- req.url = "/bluebubbles-webhook?password=test-password";
403
- req.headers = {};
404
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
405
- remoteAddress: "127.0.0.1",
406
- };
407
- req.destroy = vi.fn();
419
+ const { req, destroyMock } = createHangingWebhookRequest();
408
420
 
409
421
  const res = createMockResponse();
410
422
 
@@ -416,7 +428,7 @@ describe("BlueBubbles webhook monitor", () => {
416
428
  const handled = await handledPromise;
417
429
  expect(handled).toBe(true);
418
430
  expect(res.statusCode).toBe(408);
419
- expect(req.destroy).toHaveBeenCalled();
431
+ expect(destroyMock).toHaveBeenCalled();
420
432
  } finally {
421
433
  vi.useRealTimers();
422
434
  }
@@ -424,140 +436,62 @@ describe("BlueBubbles webhook monitor", () => {
424
436
 
425
437
  it("rejects unauthorized requests before reading the body", async () => {
426
438
  const account = createMockAccount({ password: "secret-token" });
427
- const config: OpenClawConfig = {};
428
- const core = createMockRuntime();
429
- setBlueBubblesRuntime(core);
430
-
431
- unregister = registerBlueBubblesWebhookTarget({
432
- account,
433
- config,
434
- runtime: { log: vi.fn(), error: vi.fn() },
435
- core,
436
- path: "/bluebubbles-webhook",
437
- });
438
-
439
- const req = new EventEmitter() as IncomingMessage;
440
- req.method = "POST";
441
- req.url = "/bluebubbles-webhook?password=wrong-token";
442
- req.headers = {};
439
+ setupWebhookTarget({ account });
440
+ const { req } = createHangingWebhookRequest("/bluebubbles-webhook?password=wrong-token");
443
441
  const onSpy = vi.spyOn(req, "on");
444
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
445
- remoteAddress: "127.0.0.1",
446
- };
447
-
448
- const res = createMockResponse();
449
- const handled = await handleBlueBubblesWebhookRequest(req, res);
450
-
451
- expect(handled).toBe(true);
452
- expect(res.statusCode).toBe(401);
442
+ await expectWebhookStatus(req, 401);
453
443
  expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
454
444
  });
455
445
 
456
446
  it("authenticates via password query parameter", async () => {
457
447
  const account = createMockAccount({ password: "secret-token" });
458
-
459
- // Mock non-localhost request
460
- const req = createMockRequest(
461
- "POST",
462
- "/bluebubbles-webhook?password=secret-token",
463
- createNewMessagePayload(),
464
- );
465
- setRequestRemoteAddress(req, "192.168.1.100");
466
448
  setupWebhookTarget({ account });
467
-
468
- const res = createMockResponse();
469
- const handled = await handleBlueBubblesWebhookRequest(req, res);
470
-
471
- expect(handled).toBe(true);
472
- expect(res.statusCode).toBe(200);
449
+ const req = createWebhookRequestForTest({
450
+ url: "/bluebubbles-webhook?password=secret-token",
451
+ body: createNewMessagePayload(),
452
+ remoteAddress: "192.168.1.100",
453
+ });
454
+ await expectWebhookStatus(req, 200);
473
455
  });
474
456
 
475
457
  it("authenticates via x-password header", async () => {
476
458
  const account = createMockAccount({ password: "secret-token" });
477
-
478
- const req = createMockRequest(
479
- "POST",
480
- "/bluebubbles-webhook",
481
- createNewMessagePayload(),
482
- { "x-password": "secret-token" }, // pragma: allowlist secret
483
- );
484
- setRequestRemoteAddress(req, "192.168.1.100");
485
459
  setupWebhookTarget({ account });
486
-
487
- const res = createMockResponse();
488
- const handled = await handleBlueBubblesWebhookRequest(req, res);
489
-
490
- expect(handled).toBe(true);
491
- expect(res.statusCode).toBe(200);
460
+ const req = createWebhookRequestForTest({
461
+ body: createNewMessagePayload(),
462
+ headers: { "x-password": "secret-token" }, // pragma: allowlist secret
463
+ remoteAddress: "192.168.1.100",
464
+ });
465
+ await expectWebhookStatus(req, 200);
492
466
  });
493
467
 
494
468
  it("rejects unauthorized requests with wrong password", async () => {
495
469
  const account = createMockAccount({ password: "secret-token" });
496
- const req = createMockRequest(
497
- "POST",
498
- "/bluebubbles-webhook?password=wrong-token",
499
- createNewMessagePayload(),
500
- );
501
- setRequestRemoteAddress(req, "192.168.1.100");
502
470
  setupWebhookTarget({ account });
503
-
504
- const res = createMockResponse();
505
- const handled = await handleBlueBubblesWebhookRequest(req, res);
506
-
507
- expect(handled).toBe(true);
508
- expect(res.statusCode).toBe(401);
471
+ const req = createWebhookRequestForTest({
472
+ url: "/bluebubbles-webhook?password=wrong-token",
473
+ body: createNewMessagePayload(),
474
+ remoteAddress: "192.168.1.100",
475
+ });
476
+ await expectWebhookStatus(req, 401);
509
477
  });
510
478
 
511
479
  it("rejects ambiguous routing when multiple targets match the same password", async () => {
512
480
  const accountA = createMockAccount({ password: "secret-token" });
513
481
  const accountB = createMockAccount({ password: "secret-token" });
514
- const config: OpenClawConfig = {};
515
- const core = createMockRuntime();
516
- setBlueBubblesRuntime(core);
517
-
518
482
  const sinkA = vi.fn();
519
483
  const sinkB = vi.fn();
520
-
521
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
522
- type: "new-message",
523
- data: {
524
- text: "hello",
525
- handle: { address: "+15551234567" },
526
- isGroup: false,
527
- isFromMe: false,
528
- guid: "msg-1",
529
- },
530
- });
531
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
484
+ registerWebhookTargets([
485
+ { account: accountA, statusSink: sinkA },
486
+ { account: accountB, statusSink: sinkB },
487
+ ]);
488
+
489
+ const req = createWebhookRequestForTest({
490
+ url: "/bluebubbles-webhook?password=secret-token",
491
+ body: createNewMessagePayload(),
532
492
  remoteAddress: "192.168.1.100",
533
- };
534
-
535
- const unregisterA = registerBlueBubblesWebhookTarget({
536
- account: accountA,
537
- config,
538
- runtime: { log: vi.fn(), error: vi.fn() },
539
- core,
540
- path: "/bluebubbles-webhook",
541
- statusSink: sinkA,
542
493
  });
543
- const unregisterB = registerBlueBubblesWebhookTarget({
544
- account: accountB,
545
- config,
546
- runtime: { log: vi.fn(), error: vi.fn() },
547
- core,
548
- path: "/bluebubbles-webhook",
549
- statusSink: sinkB,
550
- });
551
- unregister = () => {
552
- unregisterA();
553
- unregisterB();
554
- };
555
-
556
- const res = createMockResponse();
557
- const handled = await handleBlueBubblesWebhookRequest(req, res);
558
-
559
- expect(handled).toBe(true);
560
- expect(res.statusCode).toBe(401);
494
+ await expectWebhookStatus(req, 401);
561
495
  expect(sinkA).not.toHaveBeenCalled();
562
496
  expect(sinkB).not.toHaveBeenCalled();
563
497
  });
@@ -565,107 +499,38 @@ describe("BlueBubbles webhook monitor", () => {
565
499
  it("ignores targets without passwords when a password-authenticated target matches", async () => {
566
500
  const accountStrict = createMockAccount({ password: "secret-token" });
567
501
  const accountWithoutPassword = createMockAccount({ password: undefined });
568
- const config: OpenClawConfig = {};
569
- const core = createMockRuntime();
570
- setBlueBubblesRuntime(core);
571
-
572
502
  const sinkStrict = vi.fn();
573
503
  const sinkWithoutPassword = vi.fn();
574
-
575
- const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
576
- type: "new-message",
577
- data: {
578
- text: "hello",
579
- handle: { address: "+15551234567" },
580
- isGroup: false,
581
- isFromMe: false,
582
- guid: "msg-1",
583
- },
584
- });
585
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
504
+ registerWebhookTargets([
505
+ { account: accountStrict, statusSink: sinkStrict },
506
+ { account: accountWithoutPassword, statusSink: sinkWithoutPassword },
507
+ ]);
508
+
509
+ const req = createWebhookRequestForTest({
510
+ url: "/bluebubbles-webhook?password=secret-token",
511
+ body: createNewMessagePayload(),
586
512
  remoteAddress: "192.168.1.100",
587
- };
588
-
589
- const unregisterStrict = registerBlueBubblesWebhookTarget({
590
- account: accountStrict,
591
- config,
592
- runtime: { log: vi.fn(), error: vi.fn() },
593
- core,
594
- path: "/bluebubbles-webhook",
595
- statusSink: sinkStrict,
596
513
  });
597
- const unregisterNoPassword = registerBlueBubblesWebhookTarget({
598
- account: accountWithoutPassword,
599
- config,
600
- runtime: { log: vi.fn(), error: vi.fn() },
601
- core,
602
- path: "/bluebubbles-webhook",
603
- statusSink: sinkWithoutPassword,
604
- });
605
- unregister = () => {
606
- unregisterStrict();
607
- unregisterNoPassword();
608
- };
609
-
610
- const res = createMockResponse();
611
- const handled = await handleBlueBubblesWebhookRequest(req, res);
612
-
613
- expect(handled).toBe(true);
614
- expect(res.statusCode).toBe(200);
514
+ await expectWebhookStatus(req, 200);
615
515
  expect(sinkStrict).toHaveBeenCalledTimes(1);
616
516
  expect(sinkWithoutPassword).not.toHaveBeenCalled();
617
517
  });
618
518
 
619
519
  it("requires authentication for loopback requests when password is configured", async () => {
620
520
  const account = createMockAccount({ password: "secret-token" });
621
- const config: OpenClawConfig = {};
622
- const core = createMockRuntime();
623
- setBlueBubblesRuntime(core);
521
+ setupWebhookTarget({ account });
624
522
  for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
625
- const req = createMockRequest("POST", "/bluebubbles-webhook", {
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 = {
523
+ const req = createWebhookRequestForTest({
524
+ body: createNewMessagePayload(),
636
525
  remoteAddress,
637
- };
638
-
639
- const loopbackUnregister = registerBlueBubblesWebhookTarget({
640
- account,
641
- config,
642
- runtime: { log: vi.fn(), error: vi.fn() },
643
- core,
644
- path: "/bluebubbles-webhook",
645
526
  });
646
-
647
- const res = createMockResponse();
648
- const handled = await handleBlueBubblesWebhookRequest(req, res);
649
- expect(handled).toBe(true);
650
- expect(res.statusCode).toBe(401);
651
-
652
- loopbackUnregister();
527
+ await expectWebhookStatus(req, 401);
653
528
  }
654
529
  });
655
530
 
656
531
  it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
657
532
  const account = createMockAccount({ password: undefined });
658
- const config: OpenClawConfig = {};
659
- const core = createMockRuntime();
660
- setBlueBubblesRuntime(core);
661
-
662
- unregister = registerBlueBubblesWebhookTarget({
663
- account,
664
- config,
665
- runtime: { log: vi.fn(), error: vi.fn() },
666
- core,
667
- path: "/bluebubbles-webhook",
668
- });
533
+ setupWebhookTarget({ account });
669
534
 
670
535
  const headerVariants: Record<string, string>[] = [
671
536
  { host: "localhost" },
@@ -673,28 +538,12 @@ describe("BlueBubbles webhook monitor", () => {
673
538
  { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
674
539
  ];
675
540
  for (const headers of headerVariants) {
676
- const req = createMockRequest(
677
- "POST",
678
- "/bluebubbles-webhook",
679
- {
680
- type: "new-message",
681
- data: {
682
- text: "hello",
683
- handle: { address: "+15551234567" },
684
- isGroup: false,
685
- isFromMe: false,
686
- guid: "msg-1",
687
- },
688
- },
541
+ const req = createWebhookRequestForTest({
542
+ body: createNewMessagePayload(),
689
543
  headers,
690
- );
691
- (req as unknown as { socket: { remoteAddress: string } }).socket = {
692
544
  remoteAddress: "127.0.0.1",
693
- };
694
- const res = createMockResponse();
695
- const handled = await handleBlueBubblesWebhookRequest(req, res);
696
- expect(handled).toBe(true);
697
- expect(res.statusCode).toBe(401);
545
+ });
546
+ await expectWebhookStatus(req, 401);
698
547
  }
699
548
  });
700
549
 
package/src/multipart.ts CHANGED
@@ -30,3 +30,11 @@ export async function postMultipartFormData(params: {
30
30
  params.timeoutMs,
31
31
  );
32
32
  }
33
+
34
+ export async function assertMultipartActionOk(response: Response, action: string): Promise<void> {
35
+ if (response.ok) {
36
+ return;
37
+ }
38
+ const errorText = await response.text().catch(() => "");
39
+ throw new Error(`BlueBubbles ${action} failed (${response.status}): ${errorText || "unknown"}`);
40
+ }
@@ -19,7 +19,7 @@ describe("reactions", () => {
19
19
  });
20
20
 
21
21
  describe("sendBlueBubblesReaction", () => {
22
- async function expectRemovedReaction(emoji: string) {
22
+ async function expectRemovedReaction(emoji: string, expectedReaction = "-love") {
23
23
  mockFetch.mockResolvedValueOnce({
24
24
  ok: true,
25
25
  text: () => Promise.resolve(""),
@@ -37,7 +37,7 @@ describe("reactions", () => {
37
37
  });
38
38
 
39
39
  const body = JSON.parse(mockFetch.mock.calls[0][1].body);
40
- expect(body.reaction).toBe("-love");
40
+ expect(body.reaction).toBe(expectedReaction);
41
41
  }
42
42
 
43
43
  it("throws when chatGuid is empty", async () => {
@@ -327,45 +327,11 @@ describe("reactions", () => {
327
327
 
328
328
  describe("reaction removal aliases", () => {
329
329
  it("handles emoji-based removal", async () => {
330
- mockFetch.mockResolvedValueOnce({
331
- ok: true,
332
- text: () => Promise.resolve(""),
333
- });
334
-
335
- await sendBlueBubblesReaction({
336
- chatGuid: "chat-123",
337
- messageGuid: "msg-123",
338
- emoji: "👍",
339
- remove: true,
340
- opts: {
341
- serverUrl: "http://localhost:1234",
342
- password: "test",
343
- },
344
- });
345
-
346
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
347
- expect(body.reaction).toBe("-like");
330
+ await expectRemovedReaction("👍", "-like");
348
331
  });
349
332
 
350
333
  it("handles text alias removal", async () => {
351
- mockFetch.mockResolvedValueOnce({
352
- ok: true,
353
- text: () => Promise.resolve(""),
354
- });
355
-
356
- await sendBlueBubblesReaction({
357
- chatGuid: "chat-123",
358
- messageGuid: "msg-123",
359
- emoji: "haha",
360
- remove: true,
361
- opts: {
362
- serverUrl: "http://localhost:1234",
363
- password: "test",
364
- },
365
- });
366
-
367
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
368
- expect(body.reaction).toBe("-laugh");
334
+ await expectRemovedReaction("haha", "-laugh");
369
335
  });
370
336
  });
371
337
  });