@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.
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
6
6
  import type { ResolvedBlueBubblesAccount } from "./accounts.js";
7
7
  import { fetchBlueBubblesHistory } from "./history.js";
8
+ import { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js";
8
9
  import {
9
10
  handleBlueBubblesWebhookRequest,
10
11
  registerBlueBubblesWebhookTarget,
@@ -246,6 +247,7 @@ describe("BlueBubbles webhook monitor", () => {
246
247
  vi.clearAllMocks();
247
248
  // Reset short ID state between tests for predictable behavior
248
249
  _resetBlueBubblesShortIdState();
250
+ resetBlueBubblesSelfChatCache();
249
251
  mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
250
252
  mockReadAllowFromStore.mockResolvedValue([]);
251
253
  mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
@@ -259,6 +261,7 @@ describe("BlueBubbles webhook monitor", () => {
259
261
 
260
262
  afterEach(() => {
261
263
  unregister?.();
264
+ vi.useRealTimers();
262
265
  });
263
266
 
264
267
  describe("DM pairing behavior vs allowFrom", () => {
@@ -2676,5 +2679,449 @@ describe("BlueBubbles webhook monitor", () => {
2676
2679
 
2677
2680
  expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
2678
2681
  });
2682
+
2683
+ it("drops reflected self-chat duplicates after a confirmed assistant outbound", async () => {
2684
+ const account = createMockAccount({ dmPolicy: "open" });
2685
+ const config: OpenClawConfig = {};
2686
+ const core = createMockRuntime();
2687
+ setBlueBubblesRuntime(core);
2688
+
2689
+ const { sendMessageBlueBubbles } = await import("./send.js");
2690
+ vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "msg-self-1" });
2691
+
2692
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2693
+ await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
2694
+ return EMPTY_DISPATCH_RESULT;
2695
+ });
2696
+
2697
+ unregister = registerBlueBubblesWebhookTarget({
2698
+ account,
2699
+ config,
2700
+ runtime: { log: vi.fn(), error: vi.fn() },
2701
+ core,
2702
+ path: "/bluebubbles-webhook",
2703
+ });
2704
+
2705
+ const timestamp = Date.now();
2706
+ const inboundPayload = {
2707
+ type: "new-message",
2708
+ data: {
2709
+ text: "hello",
2710
+ handle: { address: "+15551234567" },
2711
+ isGroup: false,
2712
+ isFromMe: false,
2713
+ guid: "msg-self-0",
2714
+ chatGuid: "iMessage;-;+15551234567",
2715
+ date: timestamp,
2716
+ },
2717
+ };
2718
+
2719
+ await handleBlueBubblesWebhookRequest(
2720
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
2721
+ createMockResponse(),
2722
+ );
2723
+ await flushAsync();
2724
+
2725
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
2726
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
2727
+
2728
+ const fromMePayload = {
2729
+ type: "new-message",
2730
+ data: {
2731
+ text: "replying now",
2732
+ handle: { address: "+15551234567" },
2733
+ isGroup: false,
2734
+ isFromMe: true,
2735
+ guid: "msg-self-1",
2736
+ chatGuid: "iMessage;-;+15551234567",
2737
+ date: timestamp,
2738
+ },
2739
+ };
2740
+
2741
+ await handleBlueBubblesWebhookRequest(
2742
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
2743
+ createMockResponse(),
2744
+ );
2745
+ await flushAsync();
2746
+
2747
+ const reflectedPayload = {
2748
+ type: "new-message",
2749
+ data: {
2750
+ text: "replying now",
2751
+ handle: { address: "+15551234567" },
2752
+ isGroup: false,
2753
+ isFromMe: false,
2754
+ guid: "msg-self-2",
2755
+ chatGuid: "iMessage;-;+15551234567",
2756
+ date: timestamp,
2757
+ },
2758
+ };
2759
+
2760
+ await handleBlueBubblesWebhookRequest(
2761
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
2762
+ createMockResponse(),
2763
+ );
2764
+ await flushAsync();
2765
+
2766
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
2767
+ });
2768
+
2769
+ it("does not drop inbound messages when no fromMe self-chat copy was seen", async () => {
2770
+ const account = createMockAccount({ dmPolicy: "open" });
2771
+ const config: OpenClawConfig = {};
2772
+ const core = createMockRuntime();
2773
+ setBlueBubblesRuntime(core);
2774
+
2775
+ unregister = registerBlueBubblesWebhookTarget({
2776
+ account,
2777
+ config,
2778
+ runtime: { log: vi.fn(), error: vi.fn() },
2779
+ core,
2780
+ path: "/bluebubbles-webhook",
2781
+ });
2782
+
2783
+ const inboundPayload = {
2784
+ type: "new-message",
2785
+ data: {
2786
+ text: "genuinely new message",
2787
+ handle: { address: "+15551234567" },
2788
+ isGroup: false,
2789
+ isFromMe: false,
2790
+ guid: "msg-inbound-1",
2791
+ chatGuid: "iMessage;-;+15551234567",
2792
+ date: Date.now(),
2793
+ },
2794
+ };
2795
+
2796
+ await handleBlueBubblesWebhookRequest(
2797
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
2798
+ createMockResponse(),
2799
+ );
2800
+ await flushAsync();
2801
+
2802
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
2803
+ });
2804
+
2805
+ it("does not drop reflected copies after the self-chat cache TTL expires", async () => {
2806
+ vi.useFakeTimers();
2807
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
2808
+
2809
+ const account = createMockAccount({ dmPolicy: "open" });
2810
+ const config: OpenClawConfig = {};
2811
+ const core = createMockRuntime();
2812
+ setBlueBubblesRuntime(core);
2813
+
2814
+ unregister = registerBlueBubblesWebhookTarget({
2815
+ account,
2816
+ config,
2817
+ runtime: { log: vi.fn(), error: vi.fn() },
2818
+ core,
2819
+ path: "/bluebubbles-webhook",
2820
+ });
2821
+
2822
+ const timestamp = Date.now();
2823
+ const fromMePayload = {
2824
+ type: "new-message",
2825
+ data: {
2826
+ text: "ttl me",
2827
+ handle: { address: "+15551234567" },
2828
+ isGroup: false,
2829
+ isFromMe: true,
2830
+ guid: "msg-self-ttl-1",
2831
+ chatGuid: "iMessage;-;+15551234567",
2832
+ date: timestamp,
2833
+ },
2834
+ };
2835
+
2836
+ await handleBlueBubblesWebhookRequest(
2837
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
2838
+ createMockResponse(),
2839
+ );
2840
+ await vi.runAllTimersAsync();
2841
+
2842
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
2843
+ vi.advanceTimersByTime(10_001);
2844
+
2845
+ const reflectedPayload = {
2846
+ type: "new-message",
2847
+ data: {
2848
+ text: "ttl me",
2849
+ handle: { address: "+15551234567" },
2850
+ isGroup: false,
2851
+ isFromMe: false,
2852
+ guid: "msg-self-ttl-2",
2853
+ chatGuid: "iMessage;-;+15551234567",
2854
+ date: timestamp,
2855
+ },
2856
+ };
2857
+
2858
+ await handleBlueBubblesWebhookRequest(
2859
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
2860
+ createMockResponse(),
2861
+ );
2862
+ await vi.runAllTimersAsync();
2863
+
2864
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
2865
+ });
2866
+
2867
+ it("does not cache regular fromMe DMs as self-chat reflections", async () => {
2868
+ const account = createMockAccount({ dmPolicy: "open" });
2869
+ const config: OpenClawConfig = {};
2870
+ const core = createMockRuntime();
2871
+ setBlueBubblesRuntime(core);
2872
+
2873
+ unregister = registerBlueBubblesWebhookTarget({
2874
+ account,
2875
+ config,
2876
+ runtime: { log: vi.fn(), error: vi.fn() },
2877
+ core,
2878
+ path: "/bluebubbles-webhook",
2879
+ });
2880
+
2881
+ const timestamp = Date.now();
2882
+ const fromMePayload = {
2883
+ type: "new-message",
2884
+ data: {
2885
+ text: "shared text",
2886
+ handle: { address: "+15557654321" },
2887
+ isGroup: false,
2888
+ isFromMe: true,
2889
+ guid: "msg-normal-fromme",
2890
+ chatGuid: "iMessage;-;+15551234567",
2891
+ date: timestamp,
2892
+ },
2893
+ };
2894
+
2895
+ await handleBlueBubblesWebhookRequest(
2896
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
2897
+ createMockResponse(),
2898
+ );
2899
+ await flushAsync();
2900
+
2901
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
2902
+
2903
+ const inboundPayload = {
2904
+ type: "new-message",
2905
+ data: {
2906
+ text: "shared text",
2907
+ handle: { address: "+15551234567" },
2908
+ isGroup: false,
2909
+ isFromMe: false,
2910
+ guid: "msg-normal-inbound",
2911
+ chatGuid: "iMessage;-;+15551234567",
2912
+ date: timestamp,
2913
+ },
2914
+ };
2915
+
2916
+ await handleBlueBubblesWebhookRequest(
2917
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
2918
+ createMockResponse(),
2919
+ );
2920
+ await flushAsync();
2921
+
2922
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
2923
+ });
2924
+
2925
+ it("does not drop user-authored self-chat prompts without a confirmed assistant outbound", async () => {
2926
+ const account = createMockAccount({ dmPolicy: "open" });
2927
+ const config: OpenClawConfig = {};
2928
+ const core = createMockRuntime();
2929
+ setBlueBubblesRuntime(core);
2930
+
2931
+ unregister = registerBlueBubblesWebhookTarget({
2932
+ account,
2933
+ config,
2934
+ runtime: { log: vi.fn(), error: vi.fn() },
2935
+ core,
2936
+ path: "/bluebubbles-webhook",
2937
+ });
2938
+
2939
+ const timestamp = Date.now();
2940
+ const fromMePayload = {
2941
+ type: "new-message",
2942
+ data: {
2943
+ text: "user-authored self prompt",
2944
+ handle: { address: "+15551234567" },
2945
+ isGroup: false,
2946
+ isFromMe: true,
2947
+ guid: "msg-self-user-1",
2948
+ chatGuid: "iMessage;-;+15551234567",
2949
+ date: timestamp,
2950
+ },
2951
+ };
2952
+
2953
+ await handleBlueBubblesWebhookRequest(
2954
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
2955
+ createMockResponse(),
2956
+ );
2957
+ await flushAsync();
2958
+
2959
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
2960
+
2961
+ const reflectedPayload = {
2962
+ type: "new-message",
2963
+ data: {
2964
+ text: "user-authored self prompt",
2965
+ handle: { address: "+15551234567" },
2966
+ isGroup: false,
2967
+ isFromMe: false,
2968
+ guid: "msg-self-user-2",
2969
+ chatGuid: "iMessage;-;+15551234567",
2970
+ date: timestamp,
2971
+ },
2972
+ };
2973
+
2974
+ await handleBlueBubblesWebhookRequest(
2975
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
2976
+ createMockResponse(),
2977
+ );
2978
+ await flushAsync();
2979
+
2980
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
2981
+ });
2982
+
2983
+ it("does not treat a pending text-only match as confirmed assistant outbound", async () => {
2984
+ const account = createMockAccount({ dmPolicy: "open" });
2985
+ const config: OpenClawConfig = {};
2986
+ const core = createMockRuntime();
2987
+ setBlueBubblesRuntime(core);
2988
+
2989
+ const { sendMessageBlueBubbles } = await import("./send.js");
2990
+ vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
2991
+
2992
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
2993
+ await params.dispatcherOptions.deliver({ text: "same text" }, { kind: "final" });
2994
+ return EMPTY_DISPATCH_RESULT;
2995
+ });
2996
+
2997
+ unregister = registerBlueBubblesWebhookTarget({
2998
+ account,
2999
+ config,
3000
+ runtime: { log: vi.fn(), error: vi.fn() },
3001
+ core,
3002
+ path: "/bluebubbles-webhook",
3003
+ });
3004
+
3005
+ const timestamp = Date.now();
3006
+ const inboundPayload = {
3007
+ type: "new-message",
3008
+ data: {
3009
+ text: "hello",
3010
+ handle: { address: "+15551234567" },
3011
+ isGroup: false,
3012
+ isFromMe: false,
3013
+ guid: "msg-self-race-0",
3014
+ chatGuid: "iMessage;-;+15551234567",
3015
+ date: timestamp,
3016
+ },
3017
+ };
3018
+
3019
+ await handleBlueBubblesWebhookRequest(
3020
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
3021
+ createMockResponse(),
3022
+ );
3023
+ await flushAsync();
3024
+
3025
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
3026
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
3027
+
3028
+ const fromMePayload = {
3029
+ type: "new-message",
3030
+ data: {
3031
+ text: "same text",
3032
+ handle: { address: "+15551234567" },
3033
+ isGroup: false,
3034
+ isFromMe: true,
3035
+ guid: "msg-self-race-1",
3036
+ chatGuid: "iMessage;-;+15551234567",
3037
+ date: timestamp,
3038
+ },
3039
+ };
3040
+
3041
+ await handleBlueBubblesWebhookRequest(
3042
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
3043
+ createMockResponse(),
3044
+ );
3045
+ await flushAsync();
3046
+
3047
+ const reflectedPayload = {
3048
+ type: "new-message",
3049
+ data: {
3050
+ text: "same text",
3051
+ handle: { address: "+15551234567" },
3052
+ isGroup: false,
3053
+ isFromMe: false,
3054
+ guid: "msg-self-race-2",
3055
+ chatGuid: "iMessage;-;+15551234567",
3056
+ date: timestamp,
3057
+ },
3058
+ };
3059
+
3060
+ await handleBlueBubblesWebhookRequest(
3061
+ createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
3062
+ createMockResponse(),
3063
+ );
3064
+ await flushAsync();
3065
+
3066
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
3067
+ });
3068
+
3069
+ it("does not treat chatGuid-inferred sender ids as self-chat evidence", async () => {
3070
+ const account = createMockAccount({ dmPolicy: "open" });
3071
+ const config: OpenClawConfig = {};
3072
+ const core = createMockRuntime();
3073
+ setBlueBubblesRuntime(core);
3074
+
3075
+ unregister = registerBlueBubblesWebhookTarget({
3076
+ account,
3077
+ config,
3078
+ runtime: { log: vi.fn(), error: vi.fn() },
3079
+ core,
3080
+ path: "/bluebubbles-webhook",
3081
+ });
3082
+
3083
+ const timestamp = Date.now();
3084
+ const fromMePayload = {
3085
+ type: "new-message",
3086
+ data: {
3087
+ text: "shared inferred text",
3088
+ handle: null,
3089
+ isGroup: false,
3090
+ isFromMe: true,
3091
+ guid: "msg-inferred-fromme",
3092
+ chatGuid: "iMessage;-;+15551234567",
3093
+ date: timestamp,
3094
+ },
3095
+ };
3096
+
3097
+ await handleBlueBubblesWebhookRequest(
3098
+ createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
3099
+ createMockResponse(),
3100
+ );
3101
+ await flushAsync();
3102
+
3103
+ mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
3104
+
3105
+ const inboundPayload = {
3106
+ type: "new-message",
3107
+ data: {
3108
+ text: "shared inferred text",
3109
+ handle: { address: "+15551234567" },
3110
+ isGroup: false,
3111
+ isFromMe: false,
3112
+ guid: "msg-inferred-inbound",
3113
+ chatGuid: "iMessage;-;+15551234567",
3114
+ date: timestamp,
3115
+ },
3116
+ };
3117
+
3118
+ await handleBlueBubblesWebhookRequest(
3119
+ createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
3120
+ createMockResponse(),
3121
+ );
3122
+ await flushAsync();
3123
+
3124
+ expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
3125
+ });
2679
3126
  });
2680
3127
  });