@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.
- package/package.json +1 -1
- package/src/attachments.test.ts +24 -36
- package/src/attachments.ts +2 -7
- package/src/chat.test.ts +7 -18
- package/src/chat.ts +4 -17
- package/src/media-send.test.ts +91 -45
- package/src/monitor-normalize.test.ts +32 -10
- package/src/monitor-normalize.ts +45 -28
- package/src/monitor-processing.ts +53 -1
- package/src/monitor-self-chat-cache.test.ts +190 -0
- package/src/monitor-self-chat-cache.ts +127 -0
- package/src/monitor.test.ts +447 -0
- package/src/monitor.webhook-auth.test.ts +132 -283
- package/src/multipart.ts +8 -0
- package/src/reactions.test.ts +4 -38
|
@@ -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
|
+
}
|