mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(bluebubbles): dedupe reflected self-chat duplicates (#38442)
* BlueBubbles: drop reflected self-chat duplicates * Changelog: add BlueBubbles self-chat echo dedupe entry * BlueBubbles: gate self-chat cache and expand coverage * BlueBubbles: require explicit sender ids for self-chat dedupe * BlueBubbles: harden self-chat cache * BlueBubbles: move self-chat cache identity into cache * BlueBubbles: gate self-chat cache to confirmed outbound sends * Update CHANGELOG.md * BlueBubbles: bound self-chat cache input work * Tests: cover BlueBubbles cache cap under cleanup throttle * BlueBubbles: canonicalize self-chat DM scope * Tests: cover BlueBubbles mixed self-chat scope aliases
This commit is contained in:
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
|
||||
- TUI/chat log: reuse the active assistant message component for the same streaming run so `openclaw tui` no longer renders duplicate assistant replies. (#35364) Thanks @lisitan.
|
||||
- macOS/Reminders: add the missing `NSRemindersUsageDescription` to the bundled app so `apple-reminders` can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.
|
||||
- iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching `is_from_me` event was just seen for the same chat, text, and `created_at`, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.
|
||||
- BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching `fromMe` event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc.
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
|
||||
@@ -17,9 +17,28 @@ describe("normalizeWebhookMessage", () => {
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.senderId).toBe("+15551234567");
|
||||
expect(result?.senderIdExplicit).toBe(false);
|
||||
expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
|
||||
});
|
||||
|
||||
it("marks explicit sender handles as explicit identity", () => {
|
||||
const result = normalizeWebhookMessage({
|
||||
type: "new-message",
|
||||
data: {
|
||||
guid: "msg-explicit-1",
|
||||
text: "hello",
|
||||
isGroup: false,
|
||||
isFromMe: true,
|
||||
handle: { address: "+15551234567" },
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.senderId).toBe("+15551234567");
|
||||
expect(result?.senderIdExplicit).toBe(true);
|
||||
});
|
||||
|
||||
it("does not infer sender from group chatGuid when sender handle is missing", () => {
|
||||
const result = normalizeWebhookMessage({
|
||||
type: "new-message",
|
||||
@@ -72,6 +91,7 @@ describe("normalizeWebhookReaction", () => {
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.senderId).toBe("+15551234567");
|
||||
expect(result?.senderIdExplicit).toBe(false);
|
||||
expect(result?.messageId).toBe("p:0/msg-1");
|
||||
expect(result?.action).toBe("added");
|
||||
});
|
||||
|
||||
@@ -191,12 +191,13 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
|
||||
|
||||
function extractSenderInfo(message: Record<string, unknown>): {
|
||||
senderId: string;
|
||||
senderIdExplicit: boolean;
|
||||
senderName?: string;
|
||||
} {
|
||||
const handleValue = message.handle ?? message.sender;
|
||||
const handle =
|
||||
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
|
||||
const senderId =
|
||||
const senderIdRaw =
|
||||
readString(handle, "address") ??
|
||||
readString(handle, "handle") ??
|
||||
readString(handle, "id") ??
|
||||
@@ -204,13 +205,18 @@ function extractSenderInfo(message: Record<string, unknown>): {
|
||||
readString(message, "sender") ??
|
||||
readString(message, "from") ??
|
||||
"";
|
||||
const senderId = senderIdRaw.trim();
|
||||
const senderName =
|
||||
readString(handle, "displayName") ??
|
||||
readString(handle, "name") ??
|
||||
readString(message, "senderName") ??
|
||||
undefined;
|
||||
|
||||
return { senderId, senderName };
|
||||
return {
|
||||
senderId,
|
||||
senderIdExplicit: Boolean(senderId),
|
||||
senderName,
|
||||
};
|
||||
}
|
||||
|
||||
function extractChatContext(message: Record<string, unknown>): {
|
||||
@@ -441,6 +447,7 @@ export type BlueBubblesParticipant = {
|
||||
export type NormalizedWebhookMessage = {
|
||||
text: string;
|
||||
senderId: string;
|
||||
senderIdExplicit: boolean;
|
||||
senderName?: string;
|
||||
messageId?: string;
|
||||
timestamp?: number;
|
||||
@@ -466,6 +473,7 @@ export type NormalizedWebhookReaction = {
|
||||
action: "added" | "removed";
|
||||
emoji: string;
|
||||
senderId: string;
|
||||
senderIdExplicit: boolean;
|
||||
senderName?: string;
|
||||
messageId: string;
|
||||
timestamp?: number;
|
||||
@@ -672,7 +680,7 @@ export function normalizeWebhookMessage(
|
||||
readString(message, "subject") ??
|
||||
"";
|
||||
|
||||
const { senderId, senderName } = extractSenderInfo(message);
|
||||
const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
|
||||
const { chatGuid, chatIdentifier, chatId, chatName, isGroup, participants } =
|
||||
extractChatContext(message);
|
||||
const normalizedParticipants = normalizeParticipantList(participants);
|
||||
@@ -717,7 +725,7 @@ export function normalizeWebhookMessage(
|
||||
|
||||
// BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
|
||||
const senderFallbackFromChatGuid =
|
||||
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
||||
!senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
||||
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
|
||||
if (!normalizedSender) {
|
||||
return null;
|
||||
@@ -727,6 +735,7 @@ export function normalizeWebhookMessage(
|
||||
return {
|
||||
text,
|
||||
senderId: normalizedSender,
|
||||
senderIdExplicit,
|
||||
senderName,
|
||||
messageId,
|
||||
timestamp,
|
||||
@@ -777,7 +786,7 @@ export function normalizeWebhookReaction(
|
||||
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
|
||||
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
|
||||
|
||||
const { senderId, senderName } = extractSenderInfo(message);
|
||||
const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
|
||||
const { chatGuid, chatIdentifier, chatId, chatName, isGroup } = extractChatContext(message);
|
||||
|
||||
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
||||
@@ -793,7 +802,7 @@ export function normalizeWebhookReaction(
|
||||
: undefined;
|
||||
|
||||
const senderFallbackFromChatGuid =
|
||||
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
||||
!senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
||||
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
|
||||
if (!normalizedSender) {
|
||||
return null;
|
||||
@@ -803,6 +812,7 @@ export function normalizeWebhookReaction(
|
||||
action,
|
||||
emoji,
|
||||
senderId: normalizedSender,
|
||||
senderIdExplicit,
|
||||
senderName,
|
||||
messageId: associatedGuid,
|
||||
timestamp,
|
||||
|
||||
@@ -38,6 +38,10 @@ import {
|
||||
resolveBlueBubblesMessageId,
|
||||
resolveReplyContextFromCache,
|
||||
} from "./monitor-reply-cache.js";
|
||||
import {
|
||||
hasBlueBubblesSelfChatCopy,
|
||||
rememberBlueBubblesSelfChatCopy,
|
||||
} from "./monitor-self-chat-cache.js";
|
||||
import type {
|
||||
BlueBubblesCoreRuntime,
|
||||
BlueBubblesRuntimeEnv,
|
||||
@@ -47,7 +51,12 @@ import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
|
||||
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
|
||||
import {
|
||||
extractHandleFromChatGuid,
|
||||
formatBlueBubblesChatTarget,
|
||||
isAllowedBlueBubblesSender,
|
||||
normalizeBlueBubblesHandle,
|
||||
} from "./targets.js";
|
||||
|
||||
const DEFAULT_TEXT_LIMIT = 4000;
|
||||
const invalidAckReactions = new Set<string>();
|
||||
@@ -80,6 +89,19 @@ function normalizeSnippet(value: string): string {
|
||||
return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isBlueBubblesSelfChatMessage(
|
||||
message: NormalizedWebhookMessage,
|
||||
isGroup: boolean,
|
||||
): boolean {
|
||||
if (isGroup || !message.senderIdExplicit) {
|
||||
return false;
|
||||
}
|
||||
const chatHandle =
|
||||
(message.chatGuid ? extractHandleFromChatGuid(message.chatGuid) : null) ??
|
||||
normalizeBlueBubblesHandle(message.chatIdentifier ?? "");
|
||||
return Boolean(chatHandle) && chatHandle === message.senderId;
|
||||
}
|
||||
|
||||
function prunePendingOutboundMessageIds(now = Date.now()): void {
|
||||
const cutoff = now - PENDING_OUTBOUND_MESSAGE_ID_TTL_MS;
|
||||
for (let i = pendingOutboundMessageIds.length - 1; i >= 0; i--) {
|
||||
@@ -453,6 +475,16 @@ export async function processMessage(
|
||||
? `removed ${tapbackParsed.emoji} reaction`
|
||||
: `reacted with ${tapbackParsed.emoji}`
|
||||
: text || placeholder;
|
||||
const isSelfChatMessage = isBlueBubblesSelfChatMessage(message, isGroup);
|
||||
const selfChatLookup = {
|
||||
accountId: account.accountId,
|
||||
chatGuid: message.chatGuid,
|
||||
chatIdentifier: message.chatIdentifier,
|
||||
chatId: message.chatId,
|
||||
senderId: message.senderId,
|
||||
body: rawBody,
|
||||
timestamp: message.timestamp,
|
||||
};
|
||||
|
||||
const cacheMessageId = message.messageId?.trim();
|
||||
let messageShortId: string | undefined;
|
||||
@@ -485,6 +517,9 @@ export async function processMessage(
|
||||
body: rawBody,
|
||||
});
|
||||
if (pending) {
|
||||
if (isSelfChatMessage) {
|
||||
rememberBlueBubblesSelfChatCopy(selfChatLookup);
|
||||
}
|
||||
const displayId = getShortIdForUuid(cacheMessageId) || cacheMessageId;
|
||||
const previewSource = pending.snippetRaw || rawBody;
|
||||
const preview = previewSource
|
||||
@@ -499,6 +534,11 @@ export async function processMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelfChatMessage && hasBlueBubblesSelfChatCopy(selfChatLookup)) {
|
||||
logVerbose(core, runtime, `drop: reflected self-chat duplicate sender=${message.senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rawBody) {
|
||||
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
|
||||
return;
|
||||
|
||||
190
extensions/bluebubbles/src/monitor-self-chat-cache.test.ts
Normal file
190
extensions/bluebubbles/src/monitor-self-chat-cache.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
hasBlueBubblesSelfChatCopy,
|
||||
rememberBlueBubblesSelfChatCopy,
|
||||
resetBlueBubblesSelfChatCache,
|
||||
} from "./monitor-self-chat-cache.js";
|
||||
|
||||
describe("BlueBubbles self-chat cache", () => {
|
||||
const directLookup = {
|
||||
accountId: "default",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
senderId: "+15551234567",
|
||||
} as const;
|
||||
|
||||
afterEach(() => {
|
||||
resetBlueBubblesSelfChatCache();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("matches repeated lookups for the same scope, timestamp, and text", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
|
||||
|
||||
rememberBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: " hello\r\nworld ",
|
||||
timestamp: 123,
|
||||
});
|
||||
|
||||
expect(
|
||||
hasBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: "hello\nworld",
|
||||
timestamp: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("canonicalizes DM scope across chatIdentifier and chatGuid", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
|
||||
|
||||
rememberBlueBubblesSelfChatCopy({
|
||||
accountId: "default",
|
||||
chatIdentifier: "+15551234567",
|
||||
senderId: "+15551234567",
|
||||
body: "hello",
|
||||
timestamp: 123,
|
||||
});
|
||||
|
||||
expect(
|
||||
hasBlueBubblesSelfChatCopy({
|
||||
accountId: "default",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
senderId: "+15551234567",
|
||||
body: "hello",
|
||||
timestamp: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
resetBlueBubblesSelfChatCache();
|
||||
|
||||
rememberBlueBubblesSelfChatCopy({
|
||||
accountId: "default",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
senderId: "+15551234567",
|
||||
body: "hello",
|
||||
timestamp: 123,
|
||||
});
|
||||
|
||||
expect(
|
||||
hasBlueBubblesSelfChatCopy({
|
||||
accountId: "default",
|
||||
chatIdentifier: "+15551234567",
|
||||
senderId: "+15551234567",
|
||||
body: "hello",
|
||||
timestamp: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("expires entries after the ttl window", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
|
||||
|
||||
rememberBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: "hello",
|
||||
timestamp: 123,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(11_001);
|
||||
|
||||
expect(
|
||||
hasBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: "hello",
|
||||
timestamp: 123,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("evicts older entries when the cache exceeds its cap", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
|
||||
|
||||
for (let i = 0; i < 513; i += 1) {
|
||||
rememberBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: `message-${i}`,
|
||||
timestamp: i,
|
||||
});
|
||||
vi.advanceTimersByTime(1_001);
|
||||
}
|
||||
|
||||
expect(
|
||||
hasBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: "message-0",
|
||||
timestamp: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: "message-512",
|
||||
timestamp: 512,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("enforces the cache cap even when cleanup is throttled", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
|
||||
|
||||
for (let i = 0; i < 513; i += 1) {
|
||||
rememberBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: `burst-${i}`,
|
||||
timestamp: i,
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
hasBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: "burst-0",
|
||||
timestamp: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: "burst-512",
|
||||
timestamp: 512,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not collide long texts that differ only in the middle", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
|
||||
|
||||
const prefix = "a".repeat(256);
|
||||
const suffix = "b".repeat(256);
|
||||
const longBodyA = `${prefix}${"x".repeat(300)}${suffix}`;
|
||||
const longBodyB = `${prefix}${"y".repeat(300)}${suffix}`;
|
||||
|
||||
rememberBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: longBodyA,
|
||||
timestamp: 123,
|
||||
});
|
||||
|
||||
expect(
|
||||
hasBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: longBodyA,
|
||||
timestamp: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasBlueBubblesSelfChatCopy({
|
||||
...directLookup,
|
||||
body: longBodyB,
|
||||
timestamp: 123,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
127
extensions/bluebubbles/src/monitor-self-chat-cache.ts
Normal file
127
extensions/bluebubbles/src/monitor-self-chat-cache.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
|
||||
type SelfChatCacheKeyParts = {
|
||||
accountId: string;
|
||||
chatGuid?: string;
|
||||
chatIdentifier?: string;
|
||||
chatId?: number;
|
||||
senderId: string;
|
||||
};
|
||||
|
||||
type SelfChatLookup = SelfChatCacheKeyParts & {
|
||||
body?: string;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
const SELF_CHAT_TTL_MS = 10_000;
|
||||
const MAX_SELF_CHAT_CACHE_ENTRIES = 512;
|
||||
const CLEANUP_MIN_INTERVAL_MS = 1_000;
|
||||
const MAX_SELF_CHAT_BODY_CHARS = 32_768;
|
||||
const cache = new Map<string, number>();
|
||||
let lastCleanupAt = 0;
|
||||
|
||||
function normalizeBody(body: string | undefined): string | null {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
const bounded =
|
||||
body.length > MAX_SELF_CHAT_BODY_CHARS ? body.slice(0, MAX_SELF_CHAT_BODY_CHARS) : body;
|
||||
const normalized = bounded.replace(/\r\n?/g, "\n").trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function isUsableTimestamp(timestamp: number | undefined): timestamp is number {
|
||||
return typeof timestamp === "number" && Number.isFinite(timestamp);
|
||||
}
|
||||
|
||||
function digestText(text: string): string {
|
||||
return createHash("sha256").update(text).digest("base64url");
|
||||
}
|
||||
|
||||
function trimOrUndefined(value?: string | null): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function resolveCanonicalChatTarget(parts: SelfChatCacheKeyParts): string | null {
|
||||
const handleFromGuid = parts.chatGuid ? extractHandleFromChatGuid(parts.chatGuid) : null;
|
||||
if (handleFromGuid) {
|
||||
return handleFromGuid;
|
||||
}
|
||||
|
||||
const normalizedIdentifier = normalizeBlueBubblesHandle(parts.chatIdentifier ?? "");
|
||||
if (normalizedIdentifier) {
|
||||
return normalizedIdentifier;
|
||||
}
|
||||
|
||||
return (
|
||||
trimOrUndefined(parts.chatGuid) ??
|
||||
trimOrUndefined(parts.chatIdentifier) ??
|
||||
(typeof parts.chatId === "number" ? String(parts.chatId) : null)
|
||||
);
|
||||
}
|
||||
|
||||
function buildScope(parts: SelfChatCacheKeyParts): string {
|
||||
const target = resolveCanonicalChatTarget(parts) ?? parts.senderId;
|
||||
return `${parts.accountId}:${target}`;
|
||||
}
|
||||
|
||||
function cleanupExpired(now = Date.now()): void {
|
||||
if (
|
||||
lastCleanupAt !== 0 &&
|
||||
now >= lastCleanupAt &&
|
||||
now - lastCleanupAt < CLEANUP_MIN_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastCleanupAt = now;
|
||||
for (const [key, seenAt] of cache.entries()) {
|
||||
if (now - seenAt > SELF_CHAT_TTL_MS) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enforceSizeCap(): void {
|
||||
while (cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (typeof oldestKey !== "string") {
|
||||
break;
|
||||
}
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function buildKey(lookup: SelfChatLookup): string | null {
|
||||
const body = normalizeBody(lookup.body);
|
||||
if (!body || !isUsableTimestamp(lookup.timestamp)) {
|
||||
return null;
|
||||
}
|
||||
return `${buildScope(lookup)}:${lookup.timestamp}:${digestText(body)}`;
|
||||
}
|
||||
|
||||
export function rememberBlueBubblesSelfChatCopy(lookup: SelfChatLookup): void {
|
||||
cleanupExpired();
|
||||
const key = buildKey(lookup);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
cache.set(key, Date.now());
|
||||
enforceSizeCap();
|
||||
}
|
||||
|
||||
export function hasBlueBubblesSelfChatCopy(lookup: SelfChatLookup): boolean {
|
||||
cleanupExpired();
|
||||
const key = buildKey(lookup);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
const seenAt = cache.get(key);
|
||||
return typeof seenAt === "number" && Date.now() - seenAt <= SELF_CHAT_TTL_MS;
|
||||
}
|
||||
|
||||
export function resetBlueBubblesSelfChatCache(): void {
|
||||
cache.clear();
|
||||
lastCleanupAt = 0;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { fetchBlueBubblesHistory } from "./history.js";
|
||||
import { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js";
|
||||
import {
|
||||
handleBlueBubblesWebhookRequest,
|
||||
registerBlueBubblesWebhookTarget,
|
||||
@@ -246,6 +247,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
vi.clearAllMocks();
|
||||
// Reset short ID state between tests for predictable behavior
|
||||
_resetBlueBubblesShortIdState();
|
||||
resetBlueBubblesSelfChatCache();
|
||||
mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
|
||||
mockReadAllowFromStore.mockResolvedValue([]);
|
||||
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
||||
@@ -259,6 +261,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
|
||||
afterEach(() => {
|
||||
unregister?.();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("DM pairing behavior vs allowFrom", () => {
|
||||
@@ -2676,5 +2679,363 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops reflected self-chat duplicates after a confirmed assistant outbound", async () => {
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const { sendMessageBlueBubbles } = await import("./send.js");
|
||||
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||
return EMPTY_DISPATCH_RESULT;
|
||||
});
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const timestamp = Date.now();
|
||||
const inboundPayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-self-0",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||
|
||||
const fromMePayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "replying now",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: true,
|
||||
guid: "msg-self-1",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
const reflectedPayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "replying now",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-self-2",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not drop inbound messages when no fromMe self-chat copy was seen", async () => {
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const inboundPayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "genuinely new message",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-inbound-1",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not drop reflected copies after the self-chat cache TTL expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
|
||||
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const timestamp = Date.now();
|
||||
const fromMePayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "ttl me",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: true,
|
||||
guid: "msg-self-ttl-1",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||
vi.advanceTimersByTime(10_001);
|
||||
|
||||
const reflectedPayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "ttl me",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-self-ttl-2",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not cache regular fromMe DMs as self-chat reflections", async () => {
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const timestamp = Date.now();
|
||||
const fromMePayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "shared text",
|
||||
handle: { address: "+15557654321" },
|
||||
isGroup: false,
|
||||
isFromMe: true,
|
||||
guid: "msg-normal-fromme",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||
|
||||
const inboundPayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "shared text",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-normal-inbound",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not drop user-authored self-chat prompts without a confirmed assistant outbound", async () => {
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const timestamp = Date.now();
|
||||
const fromMePayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "user-authored self prompt",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: true,
|
||||
guid: "msg-self-user-1",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||
|
||||
const reflectedPayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "user-authored self prompt",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-self-user-2",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not treat chatGuid-inferred sender ids as self-chat evidence", async () => {
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const timestamp = Date.now();
|
||||
const fromMePayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "shared inferred text",
|
||||
handle: null,
|
||||
isGroup: false,
|
||||
isFromMe: true,
|
||||
guid: "msg-inferred-fromme",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||
|
||||
const inboundPayload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "shared inferred text",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-inferred-inbound",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
await handleBlueBubblesWebhookRequest(
|
||||
createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
|
||||
createMockResponse(),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user