@openclaw/bluebubbles 2026.3.11 → 2026.3.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/bluebubbles",
3
- "version": "2026.3.11",
3
+ "version": "2026.3.12",
4
4
  "description": "OpenClaw BlueBubbles channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -17,9 +17,28 @@ describe("normalizeWebhookMessage", () => {
17
17
 
18
18
  expect(result).not.toBeNull();
19
19
  expect(result?.senderId).toBe("+15551234567");
20
+ expect(result?.senderIdExplicit).toBe(false);
20
21
  expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
21
22
  });
22
23
 
24
+ it("marks explicit sender handles as explicit identity", () => {
25
+ const result = normalizeWebhookMessage({
26
+ type: "new-message",
27
+ data: {
28
+ guid: "msg-explicit-1",
29
+ text: "hello",
30
+ isGroup: false,
31
+ isFromMe: true,
32
+ handle: { address: "+15551234567" },
33
+ chatGuid: "iMessage;-;+15551234567",
34
+ },
35
+ });
36
+
37
+ expect(result).not.toBeNull();
38
+ expect(result?.senderId).toBe("+15551234567");
39
+ expect(result?.senderIdExplicit).toBe(true);
40
+ });
41
+
23
42
  it("does not infer sender from group chatGuid when sender handle is missing", () => {
24
43
  const result = normalizeWebhookMessage({
25
44
  type: "new-message",
@@ -72,6 +91,7 @@ describe("normalizeWebhookReaction", () => {
72
91
 
73
92
  expect(result).not.toBeNull();
74
93
  expect(result?.senderId).toBe("+15551234567");
94
+ expect(result?.senderIdExplicit).toBe(false);
75
95
  expect(result?.messageId).toBe("p:0/msg-1");
76
96
  expect(result?.action).toBe("added");
77
97
  });
@@ -191,12 +191,13 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
191
191
 
192
192
  function extractSenderInfo(message: Record<string, unknown>): {
193
193
  senderId: string;
194
+ senderIdExplicit: boolean;
194
195
  senderName?: string;
195
196
  } {
196
197
  const handleValue = message.handle ?? message.sender;
197
198
  const handle =
198
199
  asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
199
- const senderId =
200
+ const senderIdRaw =
200
201
  readString(handle, "address") ??
201
202
  readString(handle, "handle") ??
202
203
  readString(handle, "id") ??
@@ -204,13 +205,18 @@ function extractSenderInfo(message: Record<string, unknown>): {
204
205
  readString(message, "sender") ??
205
206
  readString(message, "from") ??
206
207
  "";
208
+ const senderId = senderIdRaw.trim();
207
209
  const senderName =
208
210
  readString(handle, "displayName") ??
209
211
  readString(handle, "name") ??
210
212
  readString(message, "senderName") ??
211
213
  undefined;
212
214
 
213
- return { senderId, senderName };
215
+ return {
216
+ senderId,
217
+ senderIdExplicit: Boolean(senderId),
218
+ senderName,
219
+ };
214
220
  }
215
221
 
216
222
  function extractChatContext(message: Record<string, unknown>): {
@@ -441,6 +447,7 @@ export type BlueBubblesParticipant = {
441
447
  export type NormalizedWebhookMessage = {
442
448
  text: string;
443
449
  senderId: string;
450
+ senderIdExplicit: boolean;
444
451
  senderName?: string;
445
452
  messageId?: string;
446
453
  timestamp?: number;
@@ -466,6 +473,7 @@ export type NormalizedWebhookReaction = {
466
473
  action: "added" | "removed";
467
474
  emoji: string;
468
475
  senderId: string;
476
+ senderIdExplicit: boolean;
469
477
  senderName?: string;
470
478
  messageId: string;
471
479
  timestamp?: number;
@@ -672,7 +680,7 @@ export function normalizeWebhookMessage(
672
680
  readString(message, "subject") ??
673
681
  "";
674
682
 
675
- const { senderId, senderName } = extractSenderInfo(message);
683
+ const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
676
684
  const { chatGuid, chatIdentifier, chatId, chatName, isGroup, participants } =
677
685
  extractChatContext(message);
678
686
  const normalizedParticipants = normalizeParticipantList(participants);
@@ -717,7 +725,7 @@ export function normalizeWebhookMessage(
717
725
 
718
726
  // BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
719
727
  const senderFallbackFromChatGuid =
720
- !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
728
+ !senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
721
729
  const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
722
730
  if (!normalizedSender) {
723
731
  return null;
@@ -727,6 +735,7 @@ export function normalizeWebhookMessage(
727
735
  return {
728
736
  text,
729
737
  senderId: normalizedSender,
738
+ senderIdExplicit,
730
739
  senderName,
731
740
  messageId,
732
741
  timestamp,
@@ -777,7 +786,7 @@ export function normalizeWebhookReaction(
777
786
  const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
778
787
  const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
779
788
 
780
- const { senderId, senderName } = extractSenderInfo(message);
789
+ const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
781
790
  const { chatGuid, chatIdentifier, chatId, chatName, isGroup } = extractChatContext(message);
782
791
 
783
792
  const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
@@ -793,7 +802,7 @@ export function normalizeWebhookReaction(
793
802
  : undefined;
794
803
 
795
804
  const senderFallbackFromChatGuid =
796
- !senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
805
+ !senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
797
806
  const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
798
807
  if (!normalizedSender) {
799
808
  return null;
@@ -803,6 +812,7 @@ export function normalizeWebhookReaction(
803
812
  action,
804
813
  emoji,
805
814
  senderId: normalizedSender,
815
+ senderIdExplicit,
806
816
  senderName,
807
817
  messageId: associatedGuid,
808
818
  timestamp,
@@ -38,6 +38,10 @@ import {
38
38
  resolveBlueBubblesMessageId,
39
39
  resolveReplyContextFromCache,
40
40
  } from "./monitor-reply-cache.js";
41
+ import {
42
+ hasBlueBubblesSelfChatCopy,
43
+ rememberBlueBubblesSelfChatCopy,
44
+ } from "./monitor-self-chat-cache.js";
41
45
  import type {
42
46
  BlueBubblesCoreRuntime,
43
47
  BlueBubblesRuntimeEnv,
@@ -47,7 +51,12 @@ import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
47
51
  import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
48
52
  import { normalizeSecretInputString } from "./secret-input.js";
49
53
  import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
50
- import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
54
+ import {
55
+ extractHandleFromChatGuid,
56
+ formatBlueBubblesChatTarget,
57
+ isAllowedBlueBubblesSender,
58
+ normalizeBlueBubblesHandle,
59
+ } from "./targets.js";
51
60
 
52
61
  const DEFAULT_TEXT_LIMIT = 4000;
53
62
  const invalidAckReactions = new Set<string>();
@@ -80,6 +89,19 @@ function normalizeSnippet(value: string): string {
80
89
  return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase();
81
90
  }
82
91
 
92
+ function isBlueBubblesSelfChatMessage(
93
+ message: NormalizedWebhookMessage,
94
+ isGroup: boolean,
95
+ ): boolean {
96
+ if (isGroup || !message.senderIdExplicit) {
97
+ return false;
98
+ }
99
+ const chatHandle =
100
+ (message.chatGuid ? extractHandleFromChatGuid(message.chatGuid) : null) ??
101
+ normalizeBlueBubblesHandle(message.chatIdentifier ?? "");
102
+ return Boolean(chatHandle) && chatHandle === message.senderId;
103
+ }
104
+
83
105
  function prunePendingOutboundMessageIds(now = Date.now()): void {
84
106
  const cutoff = now - PENDING_OUTBOUND_MESSAGE_ID_TTL_MS;
85
107
  for (let i = pendingOutboundMessageIds.length - 1; i >= 0; i--) {
@@ -453,8 +475,27 @@ export async function processMessage(
453
475
  ? `removed ${tapbackParsed.emoji} reaction`
454
476
  : `reacted with ${tapbackParsed.emoji}`
455
477
  : text || placeholder;
478
+ const isSelfChatMessage = isBlueBubblesSelfChatMessage(message, isGroup);
479
+ const selfChatLookup = {
480
+ accountId: account.accountId,
481
+ chatGuid: message.chatGuid,
482
+ chatIdentifier: message.chatIdentifier,
483
+ chatId: message.chatId,
484
+ senderId: message.senderId,
485
+ body: rawBody,
486
+ timestamp: message.timestamp,
487
+ };
456
488
 
457
489
  const cacheMessageId = message.messageId?.trim();
490
+ const confirmedOutboundCacheEntry = cacheMessageId
491
+ ? resolveReplyContextFromCache({
492
+ accountId: account.accountId,
493
+ replyToId: cacheMessageId,
494
+ chatGuid: message.chatGuid,
495
+ chatIdentifier: message.chatIdentifier,
496
+ chatId: message.chatId,
497
+ })
498
+ : null;
458
499
  let messageShortId: string | undefined;
459
500
  const cacheInboundMessage = () => {
460
501
  if (!cacheMessageId) {
@@ -476,6 +517,12 @@ export async function processMessage(
476
517
  if (message.fromMe) {
477
518
  // Cache from-me messages so reply context can resolve sender/body.
478
519
  cacheInboundMessage();
520
+ const confirmedAssistantOutbound =
521
+ confirmedOutboundCacheEntry?.senderLabel === "me" &&
522
+ normalizeSnippet(confirmedOutboundCacheEntry.body ?? "") === normalizeSnippet(rawBody);
523
+ if (isSelfChatMessage && confirmedAssistantOutbound) {
524
+ rememberBlueBubblesSelfChatCopy(selfChatLookup);
525
+ }
479
526
  if (cacheMessageId) {
480
527
  const pending = consumePendingOutboundMessageId({
481
528
  accountId: account.accountId,
@@ -499,6 +546,11 @@ export async function processMessage(
499
546
  return;
500
547
  }
501
548
 
549
+ if (isSelfChatMessage && hasBlueBubblesSelfChatCopy(selfChatLookup)) {
550
+ logVerbose(core, runtime, `drop: reflected self-chat duplicate sender=${message.senderId}`);
551
+ return;
552
+ }
553
+
502
554
  if (!rawBody) {
503
555
  logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
504
556
  return;
@@ -0,0 +1,190 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ hasBlueBubblesSelfChatCopy,
4
+ rememberBlueBubblesSelfChatCopy,
5
+ resetBlueBubblesSelfChatCache,
6
+ } from "./monitor-self-chat-cache.js";
7
+
8
+ describe("BlueBubbles self-chat cache", () => {
9
+ const directLookup = {
10
+ accountId: "default",
11
+ chatGuid: "iMessage;-;+15551234567",
12
+ senderId: "+15551234567",
13
+ } as const;
14
+
15
+ afterEach(() => {
16
+ resetBlueBubblesSelfChatCache();
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ it("matches repeated lookups for the same scope, timestamp, and text", () => {
21
+ vi.useFakeTimers();
22
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
23
+
24
+ rememberBlueBubblesSelfChatCopy({
25
+ ...directLookup,
26
+ body: " hello\r\nworld ",
27
+ timestamp: 123,
28
+ });
29
+
30
+ expect(
31
+ hasBlueBubblesSelfChatCopy({
32
+ ...directLookup,
33
+ body: "hello\nworld",
34
+ timestamp: 123,
35
+ }),
36
+ ).toBe(true);
37
+ });
38
+
39
+ it("canonicalizes DM scope across chatIdentifier and chatGuid", () => {
40
+ vi.useFakeTimers();
41
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
42
+
43
+ rememberBlueBubblesSelfChatCopy({
44
+ accountId: "default",
45
+ chatIdentifier: "+15551234567",
46
+ senderId: "+15551234567",
47
+ body: "hello",
48
+ timestamp: 123,
49
+ });
50
+
51
+ expect(
52
+ hasBlueBubblesSelfChatCopy({
53
+ accountId: "default",
54
+ chatGuid: "iMessage;-;+15551234567",
55
+ senderId: "+15551234567",
56
+ body: "hello",
57
+ timestamp: 123,
58
+ }),
59
+ ).toBe(true);
60
+
61
+ resetBlueBubblesSelfChatCache();
62
+
63
+ rememberBlueBubblesSelfChatCopy({
64
+ accountId: "default",
65
+ chatGuid: "iMessage;-;+15551234567",
66
+ senderId: "+15551234567",
67
+ body: "hello",
68
+ timestamp: 123,
69
+ });
70
+
71
+ expect(
72
+ hasBlueBubblesSelfChatCopy({
73
+ accountId: "default",
74
+ chatIdentifier: "+15551234567",
75
+ senderId: "+15551234567",
76
+ body: "hello",
77
+ timestamp: 123,
78
+ }),
79
+ ).toBe(true);
80
+ });
81
+
82
+ it("expires entries after the ttl window", () => {
83
+ vi.useFakeTimers();
84
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
85
+
86
+ rememberBlueBubblesSelfChatCopy({
87
+ ...directLookup,
88
+ body: "hello",
89
+ timestamp: 123,
90
+ });
91
+
92
+ vi.advanceTimersByTime(11_001);
93
+
94
+ expect(
95
+ hasBlueBubblesSelfChatCopy({
96
+ ...directLookup,
97
+ body: "hello",
98
+ timestamp: 123,
99
+ }),
100
+ ).toBe(false);
101
+ });
102
+
103
+ it("evicts older entries when the cache exceeds its cap", () => {
104
+ vi.useFakeTimers();
105
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
106
+
107
+ for (let i = 0; i < 513; i += 1) {
108
+ rememberBlueBubblesSelfChatCopy({
109
+ ...directLookup,
110
+ body: `message-${i}`,
111
+ timestamp: i,
112
+ });
113
+ vi.advanceTimersByTime(1_001);
114
+ }
115
+
116
+ expect(
117
+ hasBlueBubblesSelfChatCopy({
118
+ ...directLookup,
119
+ body: "message-0",
120
+ timestamp: 0,
121
+ }),
122
+ ).toBe(false);
123
+ expect(
124
+ hasBlueBubblesSelfChatCopy({
125
+ ...directLookup,
126
+ body: "message-512",
127
+ timestamp: 512,
128
+ }),
129
+ ).toBe(true);
130
+ });
131
+
132
+ it("enforces the cache cap even when cleanup is throttled", () => {
133
+ vi.useFakeTimers();
134
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
135
+
136
+ for (let i = 0; i < 513; i += 1) {
137
+ rememberBlueBubblesSelfChatCopy({
138
+ ...directLookup,
139
+ body: `burst-${i}`,
140
+ timestamp: i,
141
+ });
142
+ }
143
+
144
+ expect(
145
+ hasBlueBubblesSelfChatCopy({
146
+ ...directLookup,
147
+ body: "burst-0",
148
+ timestamp: 0,
149
+ }),
150
+ ).toBe(false);
151
+ expect(
152
+ hasBlueBubblesSelfChatCopy({
153
+ ...directLookup,
154
+ body: "burst-512",
155
+ timestamp: 512,
156
+ }),
157
+ ).toBe(true);
158
+ });
159
+
160
+ it("does not collide long texts that differ only in the middle", () => {
161
+ vi.useFakeTimers();
162
+ vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
163
+
164
+ const prefix = "a".repeat(256);
165
+ const suffix = "b".repeat(256);
166
+ const longBodyA = `${prefix}${"x".repeat(300)}${suffix}`;
167
+ const longBodyB = `${prefix}${"y".repeat(300)}${suffix}`;
168
+
169
+ rememberBlueBubblesSelfChatCopy({
170
+ ...directLookup,
171
+ body: longBodyA,
172
+ timestamp: 123,
173
+ });
174
+
175
+ expect(
176
+ hasBlueBubblesSelfChatCopy({
177
+ ...directLookup,
178
+ body: longBodyA,
179
+ timestamp: 123,
180
+ }),
181
+ ).toBe(true);
182
+ expect(
183
+ hasBlueBubblesSelfChatCopy({
184
+ ...directLookup,
185
+ body: longBodyB,
186
+ timestamp: 123,
187
+ }),
188
+ ).toBe(false);
189
+ });
190
+ });
@@ -0,0 +1,127 @@
1
+ import { createHash } from "node:crypto";
2
+ import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
3
+
4
+ type SelfChatCacheKeyParts = {
5
+ accountId: string;
6
+ chatGuid?: string;
7
+ chatIdentifier?: string;
8
+ chatId?: number;
9
+ senderId: string;
10
+ };
11
+
12
+ type SelfChatLookup = SelfChatCacheKeyParts & {
13
+ body?: string;
14
+ timestamp?: number;
15
+ };
16
+
17
+ const SELF_CHAT_TTL_MS = 10_000;
18
+ const MAX_SELF_CHAT_CACHE_ENTRIES = 512;
19
+ const CLEANUP_MIN_INTERVAL_MS = 1_000;
20
+ const MAX_SELF_CHAT_BODY_CHARS = 32_768;
21
+ const cache = new Map<string, number>();
22
+ let lastCleanupAt = 0;
23
+
24
+ function normalizeBody(body: string | undefined): string | null {
25
+ if (!body) {
26
+ return null;
27
+ }
28
+ const bounded =
29
+ body.length > MAX_SELF_CHAT_BODY_CHARS ? body.slice(0, MAX_SELF_CHAT_BODY_CHARS) : body;
30
+ const normalized = bounded.replace(/\r\n?/g, "\n").trim();
31
+ return normalized ? normalized : null;
32
+ }
33
+
34
+ function isUsableTimestamp(timestamp: number | undefined): timestamp is number {
35
+ return typeof timestamp === "number" && Number.isFinite(timestamp);
36
+ }
37
+
38
+ function digestText(text: string): string {
39
+ return createHash("sha256").update(text).digest("base64url");
40
+ }
41
+
42
+ function trimOrUndefined(value?: string | null): string | undefined {
43
+ const trimmed = value?.trim();
44
+ return trimmed ? trimmed : undefined;
45
+ }
46
+
47
+ function resolveCanonicalChatTarget(parts: SelfChatCacheKeyParts): string | null {
48
+ const handleFromGuid = parts.chatGuid ? extractHandleFromChatGuid(parts.chatGuid) : null;
49
+ if (handleFromGuid) {
50
+ return handleFromGuid;
51
+ }
52
+
53
+ const normalizedIdentifier = normalizeBlueBubblesHandle(parts.chatIdentifier ?? "");
54
+ if (normalizedIdentifier) {
55
+ return normalizedIdentifier;
56
+ }
57
+
58
+ return (
59
+ trimOrUndefined(parts.chatGuid) ??
60
+ trimOrUndefined(parts.chatIdentifier) ??
61
+ (typeof parts.chatId === "number" ? String(parts.chatId) : null)
62
+ );
63
+ }
64
+
65
+ function buildScope(parts: SelfChatCacheKeyParts): string {
66
+ const target = resolveCanonicalChatTarget(parts) ?? parts.senderId;
67
+ return `${parts.accountId}:${target}`;
68
+ }
69
+
70
+ function cleanupExpired(now = Date.now()): void {
71
+ if (
72
+ lastCleanupAt !== 0 &&
73
+ now >= lastCleanupAt &&
74
+ now - lastCleanupAt < CLEANUP_MIN_INTERVAL_MS
75
+ ) {
76
+ return;
77
+ }
78
+ lastCleanupAt = now;
79
+ for (const [key, seenAt] of cache.entries()) {
80
+ if (now - seenAt > SELF_CHAT_TTL_MS) {
81
+ cache.delete(key);
82
+ }
83
+ }
84
+ }
85
+
86
+ function enforceSizeCap(): void {
87
+ while (cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
88
+ const oldestKey = cache.keys().next().value;
89
+ if (typeof oldestKey !== "string") {
90
+ break;
91
+ }
92
+ cache.delete(oldestKey);
93
+ }
94
+ }
95
+
96
+ function buildKey(lookup: SelfChatLookup): string | null {
97
+ const body = normalizeBody(lookup.body);
98
+ if (!body || !isUsableTimestamp(lookup.timestamp)) {
99
+ return null;
100
+ }
101
+ return `${buildScope(lookup)}:${lookup.timestamp}:${digestText(body)}`;
102
+ }
103
+
104
+ export function rememberBlueBubblesSelfChatCopy(lookup: SelfChatLookup): void {
105
+ cleanupExpired();
106
+ const key = buildKey(lookup);
107
+ if (!key) {
108
+ return;
109
+ }
110
+ cache.set(key, Date.now());
111
+ enforceSizeCap();
112
+ }
113
+
114
+ export function hasBlueBubblesSelfChatCopy(lookup: SelfChatLookup): boolean {
115
+ cleanupExpired();
116
+ const key = buildKey(lookup);
117
+ if (!key) {
118
+ return false;
119
+ }
120
+ const seenAt = cache.get(key);
121
+ return typeof seenAt === "number" && Date.now() - seenAt <= SELF_CHAT_TTL_MS;
122
+ }
123
+
124
+ export function resetBlueBubblesSelfChatCache(): void {
125
+ cache.clear();
126
+ lastCleanupAt = 0;
127
+ }
@@ -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
  });