@openclaw/bluebubbles 2026.2.19 → 2026.2.22
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/actions.test.ts +4 -11
- package/src/attachments.test.ts +91 -4
- package/src/attachments.ts +47 -15
- package/src/chat.test.ts +193 -1
- package/src/chat.ts +74 -124
- package/src/config-schema.test.ts +55 -0
- package/src/config-schema.ts +33 -21
- package/src/history.ts +177 -0
- package/src/monitor-normalize.test.ts +78 -0
- package/src/monitor-normalize.ts +41 -12
- package/src/monitor-processing.ts +383 -127
- package/src/monitor.test.ts +473 -70
- package/src/monitor.ts +64 -130
- package/src/probe.ts +8 -0
- package/src/reactions.test.ts +4 -11
- package/src/request-url.ts +12 -0
- package/src/runtime.ts +20 -0
- package/src/send.test.ts +53 -3
- package/src/send.ts +49 -7
- package/src/targets.test.ts +19 -0
- package/src/targets.ts +46 -37
- package/src/test-harness.ts +31 -2
package/src/monitor.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
|
4
4
|
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
|
7
|
+
import { fetchBlueBubblesHistory } from "./history.js";
|
|
7
8
|
import {
|
|
8
9
|
handleBlueBubblesWebhookRequest,
|
|
9
10
|
registerBlueBubblesWebhookTarget,
|
|
@@ -38,6 +39,10 @@ vi.mock("./reactions.js", async () => {
|
|
|
38
39
|
};
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
vi.mock("./history.js", () => ({
|
|
43
|
+
fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }),
|
|
44
|
+
}));
|
|
45
|
+
|
|
41
46
|
// Mock runtime
|
|
42
47
|
const mockEnqueueSystemEvent = vi.fn();
|
|
43
48
|
const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE");
|
|
@@ -86,6 +91,7 @@ const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
|
|
|
86
91
|
const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
|
87
92
|
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
|
88
93
|
const mockResolveChunkMode = vi.fn(() => "length");
|
|
94
|
+
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
|
|
89
95
|
|
|
90
96
|
function createMockRuntime(): PluginRuntime {
|
|
91
97
|
return {
|
|
@@ -355,6 +361,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
355
361
|
vi.clearAllMocks();
|
|
356
362
|
// Reset short ID state between tests for predictable behavior
|
|
357
363
|
_resetBlueBubblesShortIdState();
|
|
364
|
+
mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
|
|
358
365
|
mockReadAllowFromStore.mockResolvedValue([]);
|
|
359
366
|
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
|
360
367
|
mockResolveRequireMention.mockReturnValue(false);
|
|
@@ -452,6 +459,45 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
452
459
|
expect(res.statusCode).toBe(400);
|
|
453
460
|
});
|
|
454
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
|
+
|
|
455
501
|
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
|
456
502
|
vi.useFakeTimers();
|
|
457
503
|
try {
|
|
@@ -659,15 +705,15 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
659
705
|
expect(sinkB).not.toHaveBeenCalled();
|
|
660
706
|
});
|
|
661
707
|
|
|
662
|
-
it("
|
|
708
|
+
it("ignores targets without passwords when a password-authenticated target matches", async () => {
|
|
663
709
|
const accountStrict = createMockAccount({ password: "secret-token" });
|
|
664
|
-
const
|
|
710
|
+
const accountWithoutPassword = createMockAccount({ password: undefined });
|
|
665
711
|
const config: OpenClawConfig = {};
|
|
666
712
|
const core = createMockRuntime();
|
|
667
713
|
setBlueBubblesRuntime(core);
|
|
668
714
|
|
|
669
715
|
const sinkStrict = vi.fn();
|
|
670
|
-
const
|
|
716
|
+
const sinkWithoutPassword = vi.fn();
|
|
671
717
|
|
|
672
718
|
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
|
673
719
|
type: "new-message",
|
|
@@ -691,17 +737,17 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
691
737
|
path: "/bluebubbles-webhook",
|
|
692
738
|
statusSink: sinkStrict,
|
|
693
739
|
});
|
|
694
|
-
const
|
|
695
|
-
account:
|
|
740
|
+
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
|
|
741
|
+
account: accountWithoutPassword,
|
|
696
742
|
config,
|
|
697
743
|
runtime: { log: vi.fn(), error: vi.fn() },
|
|
698
744
|
core,
|
|
699
745
|
path: "/bluebubbles-webhook",
|
|
700
|
-
statusSink:
|
|
746
|
+
statusSink: sinkWithoutPassword,
|
|
701
747
|
});
|
|
702
748
|
unregister = () => {
|
|
703
749
|
unregisterStrict();
|
|
704
|
-
|
|
750
|
+
unregisterNoPassword();
|
|
705
751
|
};
|
|
706
752
|
|
|
707
753
|
const res = createMockResponse();
|
|
@@ -710,7 +756,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
710
756
|
expect(handled).toBe(true);
|
|
711
757
|
expect(res.statusCode).toBe(200);
|
|
712
758
|
expect(sinkStrict).toHaveBeenCalledTimes(1);
|
|
713
|
-
expect(
|
|
759
|
+
expect(sinkWithoutPassword).not.toHaveBeenCalled();
|
|
714
760
|
});
|
|
715
761
|
|
|
716
762
|
it("requires authentication for loopback requests when password is configured", async () => {
|
|
@@ -750,65 +796,12 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
750
796
|
}
|
|
751
797
|
});
|
|
752
798
|
|
|
753
|
-
it("rejects
|
|
754
|
-
const account = createMockAccount({ password: undefined });
|
|
755
|
-
const config: OpenClawConfig = {};
|
|
756
|
-
const core = createMockRuntime();
|
|
757
|
-
setBlueBubblesRuntime(core);
|
|
758
|
-
|
|
759
|
-
const req = createMockRequest(
|
|
760
|
-
"POST",
|
|
761
|
-
"/bluebubbles-webhook",
|
|
762
|
-
{
|
|
763
|
-
type: "new-message",
|
|
764
|
-
data: {
|
|
765
|
-
text: "hello",
|
|
766
|
-
handle: { address: "+15551234567" },
|
|
767
|
-
isGroup: false,
|
|
768
|
-
isFromMe: false,
|
|
769
|
-
guid: "msg-1",
|
|
770
|
-
},
|
|
771
|
-
},
|
|
772
|
-
{ "x-forwarded-for": "203.0.113.10", host: "localhost" },
|
|
773
|
-
);
|
|
774
|
-
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
775
|
-
remoteAddress: "127.0.0.1",
|
|
776
|
-
};
|
|
777
|
-
|
|
778
|
-
unregister = registerBlueBubblesWebhookTarget({
|
|
779
|
-
account,
|
|
780
|
-
config,
|
|
781
|
-
runtime: { log: vi.fn(), error: vi.fn() },
|
|
782
|
-
core,
|
|
783
|
-
path: "/bluebubbles-webhook",
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
const res = createMockResponse();
|
|
787
|
-
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
788
|
-
expect(handled).toBe(true);
|
|
789
|
-
expect(res.statusCode).toBe(401);
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
it("accepts passwordless targets for direct localhost loopback requests (no forwarding headers)", async () => {
|
|
799
|
+
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
|
|
793
800
|
const account = createMockAccount({ password: undefined });
|
|
794
801
|
const config: OpenClawConfig = {};
|
|
795
802
|
const core = createMockRuntime();
|
|
796
803
|
setBlueBubblesRuntime(core);
|
|
797
804
|
|
|
798
|
-
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
|
799
|
-
type: "new-message",
|
|
800
|
-
data: {
|
|
801
|
-
text: "hello",
|
|
802
|
-
handle: { address: "+15551234567" },
|
|
803
|
-
isGroup: false,
|
|
804
|
-
isFromMe: false,
|
|
805
|
-
guid: "msg-1",
|
|
806
|
-
},
|
|
807
|
-
});
|
|
808
|
-
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
809
|
-
remoteAddress: "127.0.0.1",
|
|
810
|
-
};
|
|
811
|
-
|
|
812
805
|
unregister = registerBlueBubblesWebhookTarget({
|
|
813
806
|
account,
|
|
814
807
|
config,
|
|
@@ -817,10 +810,35 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
817
810
|
path: "/bluebubbles-webhook",
|
|
818
811
|
});
|
|
819
812
|
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
+
}
|
|
824
842
|
});
|
|
825
843
|
|
|
826
844
|
it("ignores unregistered webhook paths", async () => {
|
|
@@ -1006,9 +1024,86 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1006
1024
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
1007
1025
|
});
|
|
1008
1026
|
|
|
1027
|
+
it("blocks DM when dmPolicy=allowlist and allowFrom is empty", async () => {
|
|
1028
|
+
const account = createMockAccount({
|
|
1029
|
+
dmPolicy: "allowlist",
|
|
1030
|
+
allowFrom: [],
|
|
1031
|
+
});
|
|
1032
|
+
const config: OpenClawConfig = {};
|
|
1033
|
+
const core = createMockRuntime();
|
|
1034
|
+
setBlueBubblesRuntime(core);
|
|
1035
|
+
|
|
1036
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
1037
|
+
account,
|
|
1038
|
+
config,
|
|
1039
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
1040
|
+
core,
|
|
1041
|
+
path: "/bluebubbles-webhook",
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
const payload = {
|
|
1045
|
+
type: "new-message",
|
|
1046
|
+
data: {
|
|
1047
|
+
text: "hello from blocked sender",
|
|
1048
|
+
handle: { address: "+15551234567" },
|
|
1049
|
+
isGroup: false,
|
|
1050
|
+
isFromMe: false,
|
|
1051
|
+
guid: "msg-1",
|
|
1052
|
+
date: Date.now(),
|
|
1053
|
+
},
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
1057
|
+
const res = createMockResponse();
|
|
1058
|
+
|
|
1059
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
1060
|
+
await flushAsync();
|
|
1061
|
+
|
|
1062
|
+
expect(res.statusCode).toBe(200);
|
|
1063
|
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
1064
|
+
expect(mockUpsertPairingRequest).not.toHaveBeenCalled();
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it("triggers pairing flow for unknown sender when dmPolicy=pairing and allowFrom is empty", async () => {
|
|
1068
|
+
const account = createMockAccount({
|
|
1069
|
+
dmPolicy: "pairing",
|
|
1070
|
+
allowFrom: [],
|
|
1071
|
+
});
|
|
1072
|
+
const config: OpenClawConfig = {};
|
|
1073
|
+
const core = createMockRuntime();
|
|
1074
|
+
setBlueBubblesRuntime(core);
|
|
1075
|
+
|
|
1076
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
1077
|
+
account,
|
|
1078
|
+
config,
|
|
1079
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
1080
|
+
core,
|
|
1081
|
+
path: "/bluebubbles-webhook",
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
const payload = {
|
|
1085
|
+
type: "new-message",
|
|
1086
|
+
data: {
|
|
1087
|
+
text: "hello",
|
|
1088
|
+
handle: { address: "+15551234567" },
|
|
1089
|
+
isGroup: false,
|
|
1090
|
+
isFromMe: false,
|
|
1091
|
+
guid: "msg-1",
|
|
1092
|
+
date: Date.now(),
|
|
1093
|
+
},
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
1097
|
+
const res = createMockResponse();
|
|
1098
|
+
|
|
1099
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
1100
|
+
await flushAsync();
|
|
1101
|
+
|
|
1102
|
+
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
|
1103
|
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1009
1106
|
it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => {
|
|
1010
|
-
// Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
|
|
1011
|
-
// allowlist that doesn't include the sender
|
|
1012
1107
|
const account = createMockAccount({
|
|
1013
1108
|
dmPolicy: "pairing",
|
|
1014
1109
|
allowFrom: ["+15559999999"], // Different number than sender
|
|
@@ -1050,8 +1145,6 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1050
1145
|
it("does not resend pairing reply when request already exists", async () => {
|
|
1051
1146
|
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false });
|
|
1052
1147
|
|
|
1053
|
-
// Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty
|
|
1054
|
-
// allowlist that doesn't include the sender
|
|
1055
1148
|
const account = createMockAccount({
|
|
1056
1149
|
dmPolicy: "pairing",
|
|
1057
1150
|
allowFrom: ["+15559999999"], // Different number than sender
|
|
@@ -2616,6 +2709,43 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
2616
2709
|
});
|
|
2617
2710
|
|
|
2618
2711
|
describe("reaction events", () => {
|
|
2712
|
+
it("drops DM reactions when dmPolicy=pairing and allowFrom is empty", async () => {
|
|
2713
|
+
mockEnqueueSystemEvent.mockClear();
|
|
2714
|
+
|
|
2715
|
+
const account = createMockAccount({ dmPolicy: "pairing", allowFrom: [] });
|
|
2716
|
+
const config: OpenClawConfig = {};
|
|
2717
|
+
const core = createMockRuntime();
|
|
2718
|
+
setBlueBubblesRuntime(core);
|
|
2719
|
+
|
|
2720
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
2721
|
+
account,
|
|
2722
|
+
config,
|
|
2723
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
2724
|
+
core,
|
|
2725
|
+
path: "/bluebubbles-webhook",
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2728
|
+
const payload = {
|
|
2729
|
+
type: "message-reaction",
|
|
2730
|
+
data: {
|
|
2731
|
+
handle: { address: "+15551234567" },
|
|
2732
|
+
isGroup: false,
|
|
2733
|
+
isFromMe: false,
|
|
2734
|
+
associatedMessageGuid: "msg-original-123",
|
|
2735
|
+
associatedMessageType: 2000,
|
|
2736
|
+
date: Date.now(),
|
|
2737
|
+
},
|
|
2738
|
+
};
|
|
2739
|
+
|
|
2740
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
2741
|
+
const res = createMockResponse();
|
|
2742
|
+
|
|
2743
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
2744
|
+
await flushAsync();
|
|
2745
|
+
|
|
2746
|
+
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
|
2747
|
+
});
|
|
2748
|
+
|
|
2619
2749
|
it("enqueues system event for reaction added", async () => {
|
|
2620
2750
|
mockEnqueueSystemEvent.mockClear();
|
|
2621
2751
|
|
|
@@ -2868,6 +2998,279 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
2868
2998
|
});
|
|
2869
2999
|
});
|
|
2870
3000
|
|
|
3001
|
+
describe("history backfill", () => {
|
|
3002
|
+
it("scopes in-memory history by account to avoid cross-account leakage", async () => {
|
|
3003
|
+
mockFetchBlueBubblesHistory.mockImplementation(async (_chatIdentifier, _limit, opts) => {
|
|
3004
|
+
if (opts?.accountId === "acc-a") {
|
|
3005
|
+
return {
|
|
3006
|
+
resolved: true,
|
|
3007
|
+
entries: [
|
|
3008
|
+
{ sender: "A", body: "a-history", messageId: "a-history-1", timestamp: 1000 },
|
|
3009
|
+
],
|
|
3010
|
+
};
|
|
3011
|
+
}
|
|
3012
|
+
if (opts?.accountId === "acc-b") {
|
|
3013
|
+
return {
|
|
3014
|
+
resolved: true,
|
|
3015
|
+
entries: [
|
|
3016
|
+
{ sender: "B", body: "b-history", messageId: "b-history-1", timestamp: 1000 },
|
|
3017
|
+
],
|
|
3018
|
+
};
|
|
3019
|
+
}
|
|
3020
|
+
return { resolved: true, entries: [] };
|
|
3021
|
+
});
|
|
3022
|
+
|
|
3023
|
+
const accountA: ResolvedBlueBubblesAccount = {
|
|
3024
|
+
...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }),
|
|
3025
|
+
accountId: "acc-a",
|
|
3026
|
+
};
|
|
3027
|
+
const accountB: ResolvedBlueBubblesAccount = {
|
|
3028
|
+
...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }),
|
|
3029
|
+
accountId: "acc-b",
|
|
3030
|
+
};
|
|
3031
|
+
const config: OpenClawConfig = {};
|
|
3032
|
+
const core = createMockRuntime();
|
|
3033
|
+
setBlueBubblesRuntime(core);
|
|
3034
|
+
|
|
3035
|
+
const unregisterA = registerBlueBubblesWebhookTarget({
|
|
3036
|
+
account: accountA,
|
|
3037
|
+
config,
|
|
3038
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
3039
|
+
core,
|
|
3040
|
+
path: "/bluebubbles-webhook",
|
|
3041
|
+
});
|
|
3042
|
+
const unregisterB = registerBlueBubblesWebhookTarget({
|
|
3043
|
+
account: accountB,
|
|
3044
|
+
config,
|
|
3045
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
3046
|
+
core,
|
|
3047
|
+
path: "/bluebubbles-webhook",
|
|
3048
|
+
});
|
|
3049
|
+
unregister = () => {
|
|
3050
|
+
unregisterA();
|
|
3051
|
+
unregisterB();
|
|
3052
|
+
};
|
|
3053
|
+
|
|
3054
|
+
await handleBlueBubblesWebhookRequest(
|
|
3055
|
+
createMockRequest("POST", "/bluebubbles-webhook?password=password-a", {
|
|
3056
|
+
type: "new-message",
|
|
3057
|
+
data: {
|
|
3058
|
+
text: "message for account a",
|
|
3059
|
+
handle: { address: "+15551234567" },
|
|
3060
|
+
isGroup: false,
|
|
3061
|
+
isFromMe: false,
|
|
3062
|
+
guid: "a-msg-1",
|
|
3063
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
3064
|
+
date: Date.now(),
|
|
3065
|
+
},
|
|
3066
|
+
}),
|
|
3067
|
+
createMockResponse(),
|
|
3068
|
+
);
|
|
3069
|
+
await flushAsync();
|
|
3070
|
+
|
|
3071
|
+
await handleBlueBubblesWebhookRequest(
|
|
3072
|
+
createMockRequest("POST", "/bluebubbles-webhook?password=password-b", {
|
|
3073
|
+
type: "new-message",
|
|
3074
|
+
data: {
|
|
3075
|
+
text: "message for account b",
|
|
3076
|
+
handle: { address: "+15551234567" },
|
|
3077
|
+
isGroup: false,
|
|
3078
|
+
isFromMe: false,
|
|
3079
|
+
guid: "b-msg-1",
|
|
3080
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
3081
|
+
date: Date.now(),
|
|
3082
|
+
},
|
|
3083
|
+
}),
|
|
3084
|
+
createMockResponse(),
|
|
3085
|
+
);
|
|
3086
|
+
await flushAsync();
|
|
3087
|
+
|
|
3088
|
+
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
|
|
3089
|
+
const firstCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
|
3090
|
+
const secondCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[1]?.[0];
|
|
3091
|
+
const firstHistory = (firstCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
|
|
3092
|
+
const secondHistory = (secondCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
|
|
3093
|
+
expect(firstHistory.map((entry) => entry.body)).toContain("a-history");
|
|
3094
|
+
expect(secondHistory.map((entry) => entry.body)).toContain("b-history");
|
|
3095
|
+
expect(secondHistory.map((entry) => entry.body)).not.toContain("a-history");
|
|
3096
|
+
});
|
|
3097
|
+
|
|
3098
|
+
it("dedupes and caps merged history to dmHistoryLimit", async () => {
|
|
3099
|
+
mockFetchBlueBubblesHistory.mockResolvedValueOnce({
|
|
3100
|
+
resolved: true,
|
|
3101
|
+
entries: [
|
|
3102
|
+
{ sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 },
|
|
3103
|
+
{ sender: "Friend", body: "current text", messageId: "msg-1", timestamp: 2000 },
|
|
3104
|
+
],
|
|
3105
|
+
});
|
|
3106
|
+
|
|
3107
|
+
const account = createMockAccount({ dmHistoryLimit: 2 });
|
|
3108
|
+
const config: OpenClawConfig = {};
|
|
3109
|
+
const core = createMockRuntime();
|
|
3110
|
+
setBlueBubblesRuntime(core);
|
|
3111
|
+
|
|
3112
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
3113
|
+
account,
|
|
3114
|
+
config,
|
|
3115
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
3116
|
+
core,
|
|
3117
|
+
path: "/bluebubbles-webhook",
|
|
3118
|
+
});
|
|
3119
|
+
|
|
3120
|
+
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
|
3121
|
+
type: "new-message",
|
|
3122
|
+
data: {
|
|
3123
|
+
text: "current text",
|
|
3124
|
+
handle: { address: "+15551234567" },
|
|
3125
|
+
isGroup: false,
|
|
3126
|
+
isFromMe: false,
|
|
3127
|
+
guid: "msg-1",
|
|
3128
|
+
chatGuid: "iMessage;-;+15550002002",
|
|
3129
|
+
date: Date.now(),
|
|
3130
|
+
},
|
|
3131
|
+
});
|
|
3132
|
+
const res = createMockResponse();
|
|
3133
|
+
|
|
3134
|
+
await handleBlueBubblesWebhookRequest(req, res);
|
|
3135
|
+
await flushAsync();
|
|
3136
|
+
|
|
3137
|
+
const callArgs = getFirstDispatchCall();
|
|
3138
|
+
const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>;
|
|
3139
|
+
expect(inboundHistory).toHaveLength(2);
|
|
3140
|
+
expect(inboundHistory.map((entry) => entry.body)).toEqual(["older context", "current text"]);
|
|
3141
|
+
expect(inboundHistory.filter((entry) => entry.body === "current text")).toHaveLength(1);
|
|
3142
|
+
});
|
|
3143
|
+
|
|
3144
|
+
it("uses exponential backoff for unresolved backfill and stops after resolve", async () => {
|
|
3145
|
+
mockFetchBlueBubblesHistory
|
|
3146
|
+
.mockResolvedValueOnce({ resolved: false, entries: [] })
|
|
3147
|
+
.mockResolvedValueOnce({
|
|
3148
|
+
resolved: true,
|
|
3149
|
+
entries: [
|
|
3150
|
+
{ sender: "Friend", body: "older context", messageId: "hist-1", timestamp: 1000 },
|
|
3151
|
+
],
|
|
3152
|
+
});
|
|
3153
|
+
|
|
3154
|
+
const account = createMockAccount({ dmHistoryLimit: 4 });
|
|
3155
|
+
const config: OpenClawConfig = {};
|
|
3156
|
+
const core = createMockRuntime();
|
|
3157
|
+
setBlueBubblesRuntime(core);
|
|
3158
|
+
|
|
3159
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
3160
|
+
account,
|
|
3161
|
+
config,
|
|
3162
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
3163
|
+
core,
|
|
3164
|
+
path: "/bluebubbles-webhook",
|
|
3165
|
+
});
|
|
3166
|
+
|
|
3167
|
+
const mkPayload = (guid: string, text: string, now: number) => ({
|
|
3168
|
+
type: "new-message",
|
|
3169
|
+
data: {
|
|
3170
|
+
text,
|
|
3171
|
+
handle: { address: "+15551234567" },
|
|
3172
|
+
isGroup: false,
|
|
3173
|
+
isFromMe: false,
|
|
3174
|
+
guid,
|
|
3175
|
+
chatGuid: "iMessage;-;+15550003003",
|
|
3176
|
+
date: now,
|
|
3177
|
+
},
|
|
3178
|
+
});
|
|
3179
|
+
|
|
3180
|
+
let now = 1_700_000_000_000;
|
|
3181
|
+
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
3182
|
+
try {
|
|
3183
|
+
await handleBlueBubblesWebhookRequest(
|
|
3184
|
+
createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-1", "first text", now)),
|
|
3185
|
+
createMockResponse(),
|
|
3186
|
+
);
|
|
3187
|
+
await flushAsync();
|
|
3188
|
+
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1);
|
|
3189
|
+
|
|
3190
|
+
now += 1_000;
|
|
3191
|
+
await handleBlueBubblesWebhookRequest(
|
|
3192
|
+
createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-2", "second text", now)),
|
|
3193
|
+
createMockResponse(),
|
|
3194
|
+
);
|
|
3195
|
+
await flushAsync();
|
|
3196
|
+
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(1);
|
|
3197
|
+
|
|
3198
|
+
now += 6_000;
|
|
3199
|
+
await handleBlueBubblesWebhookRequest(
|
|
3200
|
+
createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-3", "third text", now)),
|
|
3201
|
+
createMockResponse(),
|
|
3202
|
+
);
|
|
3203
|
+
await flushAsync();
|
|
3204
|
+
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2);
|
|
3205
|
+
|
|
3206
|
+
const thirdCall = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[2]?.[0];
|
|
3207
|
+
const thirdHistory = (thirdCall?.ctx.InboundHistory ?? []) as Array<{ body: string }>;
|
|
3208
|
+
expect(thirdHistory.map((entry) => entry.body)).toContain("older context");
|
|
3209
|
+
expect(thirdHistory.map((entry) => entry.body)).toContain("third text");
|
|
3210
|
+
|
|
3211
|
+
now += 10_000;
|
|
3212
|
+
await handleBlueBubblesWebhookRequest(
|
|
3213
|
+
createMockRequest("POST", "/bluebubbles-webhook", mkPayload("msg-4", "fourth text", now)),
|
|
3214
|
+
createMockResponse(),
|
|
3215
|
+
);
|
|
3216
|
+
await flushAsync();
|
|
3217
|
+
expect(mockFetchBlueBubblesHistory).toHaveBeenCalledTimes(2);
|
|
3218
|
+
} finally {
|
|
3219
|
+
nowSpy.mockRestore();
|
|
3220
|
+
}
|
|
3221
|
+
});
|
|
3222
|
+
|
|
3223
|
+
it("caps inbound history payload size to reduce prompt-bomb risk", async () => {
|
|
3224
|
+
const huge = "x".repeat(8_000);
|
|
3225
|
+
mockFetchBlueBubblesHistory.mockResolvedValueOnce({
|
|
3226
|
+
resolved: true,
|
|
3227
|
+
entries: Array.from({ length: 20 }, (_, idx) => ({
|
|
3228
|
+
sender: `Friend ${idx}`,
|
|
3229
|
+
body: `${huge} ${idx}`,
|
|
3230
|
+
messageId: `hist-${idx}`,
|
|
3231
|
+
timestamp: idx + 1,
|
|
3232
|
+
})),
|
|
3233
|
+
});
|
|
3234
|
+
|
|
3235
|
+
const account = createMockAccount({ dmHistoryLimit: 20 });
|
|
3236
|
+
const config: OpenClawConfig = {};
|
|
3237
|
+
const core = createMockRuntime();
|
|
3238
|
+
setBlueBubblesRuntime(core);
|
|
3239
|
+
|
|
3240
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
3241
|
+
account,
|
|
3242
|
+
config,
|
|
3243
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
3244
|
+
core,
|
|
3245
|
+
path: "/bluebubbles-webhook",
|
|
3246
|
+
});
|
|
3247
|
+
|
|
3248
|
+
await handleBlueBubblesWebhookRequest(
|
|
3249
|
+
createMockRequest("POST", "/bluebubbles-webhook", {
|
|
3250
|
+
type: "new-message",
|
|
3251
|
+
data: {
|
|
3252
|
+
text: "latest text",
|
|
3253
|
+
handle: { address: "+15551234567" },
|
|
3254
|
+
isGroup: false,
|
|
3255
|
+
isFromMe: false,
|
|
3256
|
+
guid: "msg-bomb-1",
|
|
3257
|
+
chatGuid: "iMessage;-;+15550004004",
|
|
3258
|
+
date: Date.now(),
|
|
3259
|
+
},
|
|
3260
|
+
}),
|
|
3261
|
+
createMockResponse(),
|
|
3262
|
+
);
|
|
3263
|
+
await flushAsync();
|
|
3264
|
+
|
|
3265
|
+
const callArgs = getFirstDispatchCall();
|
|
3266
|
+
const inboundHistory = (callArgs.ctx.InboundHistory ?? []) as Array<{ body: string }>;
|
|
3267
|
+
const totalChars = inboundHistory.reduce((sum, entry) => sum + entry.body.length, 0);
|
|
3268
|
+
expect(inboundHistory.length).toBeLessThan(20);
|
|
3269
|
+
expect(totalChars).toBeLessThanOrEqual(12_000);
|
|
3270
|
+
expect(inboundHistory.every((entry) => entry.body.length <= 1_203)).toBe(true);
|
|
3271
|
+
});
|
|
3272
|
+
});
|
|
3273
|
+
|
|
2871
3274
|
describe("fromMe messages", () => {
|
|
2872
3275
|
it("ignores messages from self (fromMe=true)", async () => {
|
|
2873
3276
|
const account = createMockAccount();
|