@openclaw/bluebubbles 2026.2.13 → 2026.2.14
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/package.json +1 -1
- package/src/accounts.ts +1 -1
- package/src/actions.test.ts +52 -0
- package/src/actions.ts +34 -1
- package/src/attachments.test.ts +32 -0
- package/src/attachments.ts +11 -53
- package/src/chat.test.ts +40 -0
- package/src/chat.ts +32 -10
- package/src/config-schema.ts +1 -0
- package/src/media-send.test.ts +256 -0
- package/src/media-send.ts +150 -7
- package/src/monitor-normalize.ts +107 -153
- package/src/monitor-processing.ts +36 -8
- package/src/monitor.test.ts +328 -3
- package/src/monitor.ts +124 -32
- package/src/probe.ts +12 -0
- package/src/reactions.ts +8 -2
- package/src/send-helpers.ts +53 -0
- package/src/send.test.ts +47 -0
- package/src/send.ts +18 -62
- package/src/targets.ts +50 -84
- package/src/types.ts +7 -1
package/src/monitor.test.ts
CHANGED
|
@@ -67,6 +67,7 @@ const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
|
|
|
67
67
|
template: "channel+name+time",
|
|
68
68
|
}));
|
|
69
69
|
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
|
70
|
+
const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
|
70
71
|
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
|
|
71
72
|
|
|
72
73
|
function createMockRuntime(): PluginRuntime {
|
|
@@ -124,12 +125,13 @@ function createMockRuntime(): PluginRuntime {
|
|
|
124
125
|
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
|
|
125
126
|
dispatchReplyFromConfig:
|
|
126
127
|
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
|
|
127
|
-
finalizeInboundContext:
|
|
128
|
-
|
|
128
|
+
finalizeInboundContext: vi.fn(
|
|
129
|
+
(ctx: Record<string, unknown>) => ctx,
|
|
130
|
+
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
|
129
131
|
formatAgentEnvelope:
|
|
130
132
|
mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
|
|
131
133
|
formatInboundEnvelope:
|
|
132
|
-
|
|
134
|
+
mockFormatInboundEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
|
|
133
135
|
resolveEnvelopeFormatOptions:
|
|
134
136
|
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
|
135
137
|
},
|
|
@@ -254,6 +256,9 @@ function createMockRequest(
|
|
|
254
256
|
body: unknown,
|
|
255
257
|
headers: Record<string, string> = {},
|
|
256
258
|
): IncomingMessage {
|
|
259
|
+
if (headers.host === undefined) {
|
|
260
|
+
headers.host = "localhost";
|
|
261
|
+
}
|
|
257
262
|
const parsedUrl = new URL(url, "http://localhost");
|
|
258
263
|
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
|
|
259
264
|
const hasAuthHeader =
|
|
@@ -557,6 +562,114 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
557
562
|
expect(res.statusCode).toBe(401);
|
|
558
563
|
});
|
|
559
564
|
|
|
565
|
+
it("rejects ambiguous routing when multiple targets match the same password", async () => {
|
|
566
|
+
const accountA = createMockAccount({ password: "secret-token" });
|
|
567
|
+
const accountB = createMockAccount({ password: "secret-token" });
|
|
568
|
+
const config: OpenClawConfig = {};
|
|
569
|
+
const core = createMockRuntime();
|
|
570
|
+
setBlueBubblesRuntime(core);
|
|
571
|
+
|
|
572
|
+
const sinkA = vi.fn();
|
|
573
|
+
const sinkB = 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 = {
|
|
586
|
+
remoteAddress: "192.168.1.100",
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const unregisterA = registerBlueBubblesWebhookTarget({
|
|
590
|
+
account: accountA,
|
|
591
|
+
config,
|
|
592
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
593
|
+
core,
|
|
594
|
+
path: "/bluebubbles-webhook",
|
|
595
|
+
statusSink: sinkA,
|
|
596
|
+
});
|
|
597
|
+
const unregisterB = registerBlueBubblesWebhookTarget({
|
|
598
|
+
account: accountB,
|
|
599
|
+
config,
|
|
600
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
601
|
+
core,
|
|
602
|
+
path: "/bluebubbles-webhook",
|
|
603
|
+
statusSink: sinkB,
|
|
604
|
+
});
|
|
605
|
+
unregister = () => {
|
|
606
|
+
unregisterA();
|
|
607
|
+
unregisterB();
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const res = createMockResponse();
|
|
611
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
612
|
+
|
|
613
|
+
expect(handled).toBe(true);
|
|
614
|
+
expect(res.statusCode).toBe(401);
|
|
615
|
+
expect(sinkA).not.toHaveBeenCalled();
|
|
616
|
+
expect(sinkB).not.toHaveBeenCalled();
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it("does not route to passwordless targets when a password-authenticated target matches", async () => {
|
|
620
|
+
const accountStrict = createMockAccount({ password: "secret-token" });
|
|
621
|
+
const accountFallback = createMockAccount({ password: undefined });
|
|
622
|
+
const config: OpenClawConfig = {};
|
|
623
|
+
const core = createMockRuntime();
|
|
624
|
+
setBlueBubblesRuntime(core);
|
|
625
|
+
|
|
626
|
+
const sinkStrict = vi.fn();
|
|
627
|
+
const sinkFallback = vi.fn();
|
|
628
|
+
|
|
629
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
|
630
|
+
type: "new-message",
|
|
631
|
+
data: {
|
|
632
|
+
text: "hello",
|
|
633
|
+
handle: { address: "+15551234567" },
|
|
634
|
+
isGroup: false,
|
|
635
|
+
isFromMe: false,
|
|
636
|
+
guid: "msg-1",
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
640
|
+
remoteAddress: "192.168.1.100",
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const unregisterStrict = registerBlueBubblesWebhookTarget({
|
|
644
|
+
account: accountStrict,
|
|
645
|
+
config,
|
|
646
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
647
|
+
core,
|
|
648
|
+
path: "/bluebubbles-webhook",
|
|
649
|
+
statusSink: sinkStrict,
|
|
650
|
+
});
|
|
651
|
+
const unregisterFallback = registerBlueBubblesWebhookTarget({
|
|
652
|
+
account: accountFallback,
|
|
653
|
+
config,
|
|
654
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
655
|
+
core,
|
|
656
|
+
path: "/bluebubbles-webhook",
|
|
657
|
+
statusSink: sinkFallback,
|
|
658
|
+
});
|
|
659
|
+
unregister = () => {
|
|
660
|
+
unregisterStrict();
|
|
661
|
+
unregisterFallback();
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const res = createMockResponse();
|
|
665
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
666
|
+
|
|
667
|
+
expect(handled).toBe(true);
|
|
668
|
+
expect(res.statusCode).toBe(200);
|
|
669
|
+
expect(sinkStrict).toHaveBeenCalledTimes(1);
|
|
670
|
+
expect(sinkFallback).not.toHaveBeenCalled();
|
|
671
|
+
});
|
|
672
|
+
|
|
560
673
|
it("requires authentication for loopback requests when password is configured", async () => {
|
|
561
674
|
const account = createMockAccount({ password: "secret-token" });
|
|
562
675
|
const config: OpenClawConfig = {};
|
|
@@ -594,6 +707,79 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
594
707
|
}
|
|
595
708
|
});
|
|
596
709
|
|
|
710
|
+
it("rejects passwordless targets when the request looks proxied (has forwarding headers)", async () => {
|
|
711
|
+
const account = createMockAccount({ password: undefined });
|
|
712
|
+
const config: OpenClawConfig = {};
|
|
713
|
+
const core = createMockRuntime();
|
|
714
|
+
setBlueBubblesRuntime(core);
|
|
715
|
+
|
|
716
|
+
const req = createMockRequest(
|
|
717
|
+
"POST",
|
|
718
|
+
"/bluebubbles-webhook",
|
|
719
|
+
{
|
|
720
|
+
type: "new-message",
|
|
721
|
+
data: {
|
|
722
|
+
text: "hello",
|
|
723
|
+
handle: { address: "+15551234567" },
|
|
724
|
+
isGroup: false,
|
|
725
|
+
isFromMe: false,
|
|
726
|
+
guid: "msg-1",
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
{ "x-forwarded-for": "203.0.113.10", host: "localhost" },
|
|
730
|
+
);
|
|
731
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
732
|
+
remoteAddress: "127.0.0.1",
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
736
|
+
account,
|
|
737
|
+
config,
|
|
738
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
739
|
+
core,
|
|
740
|
+
path: "/bluebubbles-webhook",
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
const res = createMockResponse();
|
|
744
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
745
|
+
expect(handled).toBe(true);
|
|
746
|
+
expect(res.statusCode).toBe(401);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("accepts passwordless targets for direct localhost loopback requests (no forwarding headers)", async () => {
|
|
750
|
+
const account = createMockAccount({ password: undefined });
|
|
751
|
+
const config: OpenClawConfig = {};
|
|
752
|
+
const core = createMockRuntime();
|
|
753
|
+
setBlueBubblesRuntime(core);
|
|
754
|
+
|
|
755
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
|
756
|
+
type: "new-message",
|
|
757
|
+
data: {
|
|
758
|
+
text: "hello",
|
|
759
|
+
handle: { address: "+15551234567" },
|
|
760
|
+
isGroup: false,
|
|
761
|
+
isFromMe: false,
|
|
762
|
+
guid: "msg-1",
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
766
|
+
remoteAddress: "127.0.0.1",
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
770
|
+
account,
|
|
771
|
+
config,
|
|
772
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
773
|
+
core,
|
|
774
|
+
path: "/bluebubbles-webhook",
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
const res = createMockResponse();
|
|
778
|
+
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
779
|
+
expect(handled).toBe(true);
|
|
780
|
+
expect(res.statusCode).toBe(200);
|
|
781
|
+
});
|
|
782
|
+
|
|
597
783
|
it("ignores unregistered webhook paths", async () => {
|
|
598
784
|
const req = createMockRequest("POST", "/unregistered-path", {});
|
|
599
785
|
const res = createMockResponse();
|
|
@@ -1261,6 +1447,145 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1261
1447
|
});
|
|
1262
1448
|
});
|
|
1263
1449
|
|
|
1450
|
+
describe("group sender identity in envelope", () => {
|
|
1451
|
+
it("includes sender in envelope body and group label as from for group messages", async () => {
|
|
1452
|
+
const account = createMockAccount({ groupPolicy: "open" });
|
|
1453
|
+
const config: OpenClawConfig = {};
|
|
1454
|
+
const core = createMockRuntime();
|
|
1455
|
+
setBlueBubblesRuntime(core);
|
|
1456
|
+
|
|
1457
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
1458
|
+
account,
|
|
1459
|
+
config,
|
|
1460
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
1461
|
+
core,
|
|
1462
|
+
path: "/bluebubbles-webhook",
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
const payload = {
|
|
1466
|
+
type: "new-message",
|
|
1467
|
+
data: {
|
|
1468
|
+
text: "hello everyone",
|
|
1469
|
+
handle: { address: "+15551234567" },
|
|
1470
|
+
senderName: "Alice",
|
|
1471
|
+
isGroup: true,
|
|
1472
|
+
isFromMe: false,
|
|
1473
|
+
guid: "msg-1",
|
|
1474
|
+
chatGuid: "iMessage;+;chat123456",
|
|
1475
|
+
chatName: "Family Chat",
|
|
1476
|
+
date: Date.now(),
|
|
1477
|
+
},
|
|
1478
|
+
};
|
|
1479
|
+
|
|
1480
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
1481
|
+
const res = createMockResponse();
|
|
1482
|
+
|
|
1483
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
1484
|
+
await flushAsync();
|
|
1485
|
+
|
|
1486
|
+
// formatInboundEnvelope should be called with group label + id as from, and sender info
|
|
1487
|
+
expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
|
|
1488
|
+
expect.objectContaining({
|
|
1489
|
+
from: "Family Chat id:iMessage;+;chat123456",
|
|
1490
|
+
chatType: "group",
|
|
1491
|
+
sender: { name: "Alice", id: "+15551234567" },
|
|
1492
|
+
}),
|
|
1493
|
+
);
|
|
1494
|
+
// ConversationLabel should be the group label + id, not the sender
|
|
1495
|
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
|
1496
|
+
expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456");
|
|
1497
|
+
expect(callArgs.ctx.SenderName).toBe("Alice");
|
|
1498
|
+
// BodyForAgent should be raw text, not the envelope-formatted body
|
|
1499
|
+
expect(callArgs.ctx.BodyForAgent).toBe("hello everyone");
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
it("falls back to group:peerId when chatName is missing", async () => {
|
|
1503
|
+
const account = createMockAccount({ groupPolicy: "open" });
|
|
1504
|
+
const config: OpenClawConfig = {};
|
|
1505
|
+
const core = createMockRuntime();
|
|
1506
|
+
setBlueBubblesRuntime(core);
|
|
1507
|
+
|
|
1508
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
1509
|
+
account,
|
|
1510
|
+
config,
|
|
1511
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
1512
|
+
core,
|
|
1513
|
+
path: "/bluebubbles-webhook",
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
const payload = {
|
|
1517
|
+
type: "new-message",
|
|
1518
|
+
data: {
|
|
1519
|
+
text: "hello",
|
|
1520
|
+
handle: { address: "+15551234567" },
|
|
1521
|
+
isGroup: true,
|
|
1522
|
+
isFromMe: false,
|
|
1523
|
+
guid: "msg-1",
|
|
1524
|
+
chatGuid: "iMessage;+;chat123456",
|
|
1525
|
+
date: Date.now(),
|
|
1526
|
+
},
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
1530
|
+
const res = createMockResponse();
|
|
1531
|
+
|
|
1532
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
1533
|
+
await flushAsync();
|
|
1534
|
+
|
|
1535
|
+
expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
|
|
1536
|
+
expect.objectContaining({
|
|
1537
|
+
from: expect.stringMatching(/^Group id:/),
|
|
1538
|
+
chatType: "group",
|
|
1539
|
+
sender: { name: undefined, id: "+15551234567" },
|
|
1540
|
+
}),
|
|
1541
|
+
);
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
it("uses sender as from label for DM messages", async () => {
|
|
1545
|
+
const account = createMockAccount();
|
|
1546
|
+
const config: OpenClawConfig = {};
|
|
1547
|
+
const core = createMockRuntime();
|
|
1548
|
+
setBlueBubblesRuntime(core);
|
|
1549
|
+
|
|
1550
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
1551
|
+
account,
|
|
1552
|
+
config,
|
|
1553
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
1554
|
+
core,
|
|
1555
|
+
path: "/bluebubbles-webhook",
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
const payload = {
|
|
1559
|
+
type: "new-message",
|
|
1560
|
+
data: {
|
|
1561
|
+
text: "hello",
|
|
1562
|
+
handle: { address: "+15551234567" },
|
|
1563
|
+
senderName: "Alice",
|
|
1564
|
+
isGroup: false,
|
|
1565
|
+
isFromMe: false,
|
|
1566
|
+
guid: "msg-1",
|
|
1567
|
+
date: Date.now(),
|
|
1568
|
+
},
|
|
1569
|
+
};
|
|
1570
|
+
|
|
1571
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
1572
|
+
const res = createMockResponse();
|
|
1573
|
+
|
|
1574
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
1575
|
+
await flushAsync();
|
|
1576
|
+
|
|
1577
|
+
expect(mockFormatInboundEnvelope).toHaveBeenCalledWith(
|
|
1578
|
+
expect.objectContaining({
|
|
1579
|
+
from: "Alice id:+15551234567",
|
|
1580
|
+
chatType: "direct",
|
|
1581
|
+
sender: { name: "Alice", id: "+15551234567" },
|
|
1582
|
+
}),
|
|
1583
|
+
);
|
|
1584
|
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
|
1585
|
+
expect(callArgs.ctx.ConversationLabel).toBe("Alice id:+15551234567");
|
|
1586
|
+
});
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1264
1589
|
describe("inbound debouncing", () => {
|
|
1265
1590
|
it("coalesces text-only then attachment webhook events by messageId", async () => {
|
|
1266
1591
|
vi.useFakeTimers();
|
package/src/monitor.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
3
4
|
import {
|
|
4
5
|
normalizeWebhookMessage,
|
|
5
6
|
normalizeWebhookReaction,
|
|
@@ -315,6 +316,73 @@ function maskSecret(value: string): string {
|
|
|
315
316
|
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
function normalizeAuthToken(raw: string): string {
|
|
320
|
+
const value = raw.trim();
|
|
321
|
+
if (!value) {
|
|
322
|
+
return "";
|
|
323
|
+
}
|
|
324
|
+
if (value.toLowerCase().startsWith("bearer ")) {
|
|
325
|
+
return value.slice("bearer ".length).trim();
|
|
326
|
+
}
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function safeEqualSecret(aRaw: string, bRaw: string): boolean {
|
|
331
|
+
const a = normalizeAuthToken(aRaw);
|
|
332
|
+
const b = normalizeAuthToken(bRaw);
|
|
333
|
+
if (!a || !b) {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
const bufA = Buffer.from(a, "utf8");
|
|
337
|
+
const bufB = Buffer.from(b, "utf8");
|
|
338
|
+
if (bufA.length !== bufB.length) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
return timingSafeEqual(bufA, bufB);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function getHostName(hostHeader?: string | string[]): string {
|
|
345
|
+
const host = (Array.isArray(hostHeader) ? hostHeader[0] : (hostHeader ?? ""))
|
|
346
|
+
.trim()
|
|
347
|
+
.toLowerCase();
|
|
348
|
+
if (!host) {
|
|
349
|
+
return "";
|
|
350
|
+
}
|
|
351
|
+
// Bracketed IPv6: [::1]:18789
|
|
352
|
+
if (host.startsWith("[")) {
|
|
353
|
+
const end = host.indexOf("]");
|
|
354
|
+
if (end !== -1) {
|
|
355
|
+
return host.slice(1, end);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const [name] = host.split(":");
|
|
359
|
+
return name ?? "";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isDirectLocalLoopbackRequest(req: IncomingMessage): boolean {
|
|
363
|
+
const remote = (req.socket?.remoteAddress ?? "").trim().toLowerCase();
|
|
364
|
+
const remoteIsLoopback =
|
|
365
|
+
remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
366
|
+
if (!remoteIsLoopback) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const host = getHostName(req.headers?.host);
|
|
371
|
+
const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
372
|
+
if (!hostIsLocal) {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// If a reverse proxy is in front, it will usually inject forwarding headers.
|
|
377
|
+
// Passwordless webhooks must never be accepted through a proxy.
|
|
378
|
+
const hasForwarded = Boolean(
|
|
379
|
+
req.headers?.["x-forwarded-for"] ||
|
|
380
|
+
req.headers?.["x-real-ip"] ||
|
|
381
|
+
req.headers?.["x-forwarded-host"],
|
|
382
|
+
);
|
|
383
|
+
return !hasForwarded;
|
|
384
|
+
}
|
|
385
|
+
|
|
318
386
|
export async function handleBlueBubblesWebhookRequest(
|
|
319
387
|
req: IncomingMessage,
|
|
320
388
|
res: ServerResponse,
|
|
@@ -398,23 +466,36 @@ export async function handleBlueBubblesWebhookRequest(
|
|
|
398
466
|
return true;
|
|
399
467
|
}
|
|
400
468
|
|
|
401
|
-
const
|
|
402
|
-
|
|
469
|
+
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
|
|
470
|
+
const headerToken =
|
|
471
|
+
req.headers["x-guid"] ??
|
|
472
|
+
req.headers["x-password"] ??
|
|
473
|
+
req.headers["x-bluebubbles-guid"] ??
|
|
474
|
+
req.headers["authorization"];
|
|
475
|
+
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
|
476
|
+
|
|
477
|
+
const strictMatches: WebhookTarget[] = [];
|
|
478
|
+
const passwordlessTargets: WebhookTarget[] = [];
|
|
479
|
+
for (const target of targets) {
|
|
480
|
+
const token = target.account.config.password?.trim() ?? "";
|
|
403
481
|
if (!token) {
|
|
404
|
-
|
|
482
|
+
passwordlessTargets.push(target);
|
|
483
|
+
continue;
|
|
405
484
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
req.headers["authorization"];
|
|
412
|
-
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
|
413
|
-
if (guid && guid.trim() === token) {
|
|
414
|
-
return true;
|
|
485
|
+
if (safeEqualSecret(guid, token)) {
|
|
486
|
+
strictMatches.push(target);
|
|
487
|
+
if (strictMatches.length > 1) {
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
415
490
|
}
|
|
416
|
-
|
|
417
|
-
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const matching =
|
|
494
|
+
strictMatches.length > 0
|
|
495
|
+
? strictMatches
|
|
496
|
+
: isDirectLocalLoopbackRequest(req)
|
|
497
|
+
? passwordlessTargets
|
|
498
|
+
: [];
|
|
418
499
|
|
|
419
500
|
if (matching.length === 0) {
|
|
420
501
|
res.statusCode = 401;
|
|
@@ -425,24 +506,30 @@ export async function handleBlueBubblesWebhookRequest(
|
|
|
425
506
|
return true;
|
|
426
507
|
}
|
|
427
508
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
509
|
+
if (matching.length > 1) {
|
|
510
|
+
res.statusCode = 401;
|
|
511
|
+
res.end("ambiguous webhook target");
|
|
512
|
+
console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const target = matching[0];
|
|
517
|
+
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
518
|
+
if (reaction) {
|
|
519
|
+
processReaction(reaction, target).catch((err) => {
|
|
520
|
+
target.runtime.error?.(
|
|
521
|
+
`[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
} else if (message) {
|
|
525
|
+
// Route messages through debouncer to coalesce rapid-fire events
|
|
526
|
+
// (e.g., text message + URL balloon arriving as separate webhooks)
|
|
527
|
+
const debouncer = getOrCreateDebouncer(target);
|
|
528
|
+
debouncer.enqueue({ message, target }).catch((err) => {
|
|
529
|
+
target.runtime.error?.(
|
|
530
|
+
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
|
|
531
|
+
);
|
|
532
|
+
});
|
|
446
533
|
}
|
|
447
534
|
|
|
448
535
|
res.statusCode = 200;
|
|
@@ -484,6 +571,11 @@ export async function monitorBlueBubblesProvider(
|
|
|
484
571
|
if (serverInfo?.os_version) {
|
|
485
572
|
runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`);
|
|
486
573
|
}
|
|
574
|
+
if (typeof serverInfo?.private_api === "boolean") {
|
|
575
|
+
runtime.log?.(
|
|
576
|
+
`[${account.accountId}] BlueBubbles Private API ${serverInfo.private_api ? "enabled" : "disabled"}`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
487
579
|
|
|
488
580
|
const unregister = registerBlueBubblesWebhookTarget({
|
|
489
581
|
account,
|
package/src/probe.ts
CHANGED
|
@@ -85,6 +85,18 @@ export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesS
|
|
|
85
85
|
return null;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Read cached private API capability for a BlueBubbles account.
|
|
90
|
+
* Returns null when capability is unknown (for example, before first probe).
|
|
91
|
+
*/
|
|
92
|
+
export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolean | null {
|
|
93
|
+
const info = getCachedBlueBubblesServerInfo(accountId);
|
|
94
|
+
if (!info || typeof info.private_api !== "boolean") {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return info.private_api;
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
/**
|
|
89
101
|
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
|
|
90
102
|
*/
|
package/src/reactions.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
2
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
|
3
|
+
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
3
4
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
|
4
5
|
|
|
5
6
|
export type BlueBubblesReactionOpts = {
|
|
@@ -123,7 +124,7 @@ function resolveAccount(params: BlueBubblesReactionOpts) {
|
|
|
123
124
|
if (!password) {
|
|
124
125
|
throw new Error("BlueBubbles password is required");
|
|
125
126
|
}
|
|
126
|
-
return { baseUrl, password };
|
|
127
|
+
return { baseUrl, password, accountId: account.accountId };
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
|
|
@@ -160,7 +161,12 @@ export async function sendBlueBubblesReaction(params: {
|
|
|
160
161
|
throw new Error("BlueBubbles reaction requires messageGuid.");
|
|
161
162
|
}
|
|
162
163
|
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
|
|
163
|
-
const { baseUrl, password } = resolveAccount(params.opts ?? {});
|
|
164
|
+
const { baseUrl, password, accountId } = resolveAccount(params.opts ?? {});
|
|
165
|
+
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
"BlueBubbles reaction requires Private API, but it is disabled on the BlueBubbles server.",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
164
170
|
const url = buildBlueBubblesApiUrl({
|
|
165
171
|
baseUrl,
|
|
166
172
|
path: "/api/v1/message/react",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { BlueBubblesSendTarget } from "./types.js";
|
|
2
|
+
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
|
3
|
+
|
|
4
|
+
export function resolveBlueBubblesSendTarget(raw: string): BlueBubblesSendTarget {
|
|
5
|
+
const parsed = parseBlueBubblesTarget(raw);
|
|
6
|
+
if (parsed.kind === "handle") {
|
|
7
|
+
return {
|
|
8
|
+
kind: "handle",
|
|
9
|
+
address: normalizeBlueBubblesHandle(parsed.to),
|
|
10
|
+
service: parsed.service,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
if (parsed.kind === "chat_id") {
|
|
14
|
+
return { kind: "chat_id", chatId: parsed.chatId };
|
|
15
|
+
}
|
|
16
|
+
if (parsed.kind === "chat_guid") {
|
|
17
|
+
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
|
18
|
+
}
|
|
19
|
+
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function extractBlueBubblesMessageId(payload: unknown): string {
|
|
23
|
+
if (!payload || typeof payload !== "object") {
|
|
24
|
+
return "unknown";
|
|
25
|
+
}
|
|
26
|
+
const record = payload as Record<string, unknown>;
|
|
27
|
+
const data =
|
|
28
|
+
record.data && typeof record.data === "object"
|
|
29
|
+
? (record.data as Record<string, unknown>)
|
|
30
|
+
: null;
|
|
31
|
+
const candidates = [
|
|
32
|
+
record.messageId,
|
|
33
|
+
record.messageGuid,
|
|
34
|
+
record.message_guid,
|
|
35
|
+
record.guid,
|
|
36
|
+
record.id,
|
|
37
|
+
data?.messageId,
|
|
38
|
+
data?.messageGuid,
|
|
39
|
+
data?.message_guid,
|
|
40
|
+
data?.message_id,
|
|
41
|
+
data?.guid,
|
|
42
|
+
data?.id,
|
|
43
|
+
];
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
46
|
+
return candidate.trim();
|
|
47
|
+
}
|
|
48
|
+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
49
|
+
return String(candidate);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return "unknown";
|
|
53
|
+
}
|