@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.
@@ -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("does not route to passwordless targets when a password-authenticated target matches", async () => {
708
+ it("ignores targets without passwords when a password-authenticated target matches", async () => {
663
709
  const accountStrict = createMockAccount({ password: "secret-token" });
664
- const accountFallback = createMockAccount({ password: undefined });
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 sinkFallback = vi.fn();
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 unregisterFallback = registerBlueBubblesWebhookTarget({
695
- account: accountFallback,
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: sinkFallback,
746
+ statusSink: sinkWithoutPassword,
701
747
  });
702
748
  unregister = () => {
703
749
  unregisterStrict();
704
- unregisterFallback();
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(sinkFallback).not.toHaveBeenCalled();
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 passwordless targets when the request looks proxied (has forwarding headers)", async () => {
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 res = createMockResponse();
821
- const handled = await handleBlueBubblesWebhookRequest(req, res);
822
- expect(handled).toBe(true);
823
- expect(res.statusCode).toBe(200);
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();