@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
|
@@ -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
|
});
|
package/src/monitor-normalize.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
!
|
|
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
|
-
!
|
|
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 {
|
|
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
|
+
}
|
package/src/monitor.test.ts
CHANGED
|
@@ -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
|
});
|