From 9193a8ea6bd606e388341fd289e8025408cd7759 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 7 Mar 2026 08:20:44 -0800 Subject: [PATCH] BlueBubbles: gate self-chat cache to confirmed outbound sends --- .../bluebubbles/src/monitor-processing.ts | 6 +- .../src/monitor-self-chat-cache.test.ts | 31 ++++++ .../src/monitor-self-chat-cache.ts | 11 +-- extensions/bluebubbles/src/monitor.test.ts | 96 ++++++++++++++++++- 4 files changed, 126 insertions(+), 18 deletions(-) diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index e856e665ee9..e0258827ce4 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -504,9 +504,6 @@ export async function processMessage( }; if (message.fromMe) { - if (isSelfChatMessage) { - rememberBlueBubblesSelfChatCopy(selfChatLookup); - } // Cache from-me messages so reply context can resolve sender/body. cacheInboundMessage(); if (cacheMessageId) { @@ -518,6 +515,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 diff --git a/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts b/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts index da67da8832c..1fe4d7917f7 100644 --- a/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts +++ b/extensions/bluebubbles/src/monitor-self-chat-cache.test.ts @@ -85,4 +85,35 @@ describe("BlueBubbles self-chat cache", () => { }), ).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); + }); }); diff --git a/extensions/bluebubbles/src/monitor-self-chat-cache.ts b/extensions/bluebubbles/src/monitor-self-chat-cache.ts index 59111eaa589..95559022d4e 100644 --- a/extensions/bluebubbles/src/monitor-self-chat-cache.ts +++ b/extensions/bluebubbles/src/monitor-self-chat-cache.ts @@ -16,8 +16,6 @@ type SelfChatLookup = SelfChatCacheKeyParts & { const SELF_CHAT_TTL_MS = 10_000; const MAX_SELF_CHAT_CACHE_ENTRIES = 512; const CLEANUP_MIN_INTERVAL_MS = 1_000; -const DIGEST_TEXT_HEAD_CHARS = 256; -const DIGEST_TEXT_TAIL_CHARS = 256; const cache = new Map(); let lastCleanupAt = 0; @@ -33,15 +31,8 @@ function isUsableTimestamp(timestamp: number | undefined): timestamp is number { return typeof timestamp === "number" && Number.isFinite(timestamp); } -function buildDigestSource(text: string): string { - if (text.length <= DIGEST_TEXT_HEAD_CHARS + DIGEST_TEXT_TAIL_CHARS) { - return text; - } - return `${text.slice(0, DIGEST_TEXT_HEAD_CHARS)}:${text.length}:${text.slice(-DIGEST_TEXT_TAIL_CHARS)}`; -} - function digestText(text: string): string { - return createHash("sha256").update(buildDigestSource(text)).digest("base64url"); + return createHash("sha256").update(text).digest("base64url"); } function trimOrUndefined(value?: string | null): string | undefined { diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 5f44e027bee..cb08e9cc839 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -2680,12 +2680,20 @@ describe("BlueBubbles webhook monitor", () => { expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); - it("drops reflected self-chat duplicates after seeing the fromMe copy", async () => { + 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, @@ -2695,10 +2703,32 @@ describe("BlueBubbles webhook monitor", () => { }); 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: "loop me", + text: "replying now", handle: { address: "+15551234567" }, isGroup: false, isFromMe: true, @@ -2714,12 +2744,10 @@ describe("BlueBubbles webhook monitor", () => { ); await flushAsync(); - expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - const reflectedPayload = { type: "new-message", data: { - text: "loop me", + text: "replying now", handle: { address: "+15551234567" }, isGroup: false, isFromMe: false, @@ -2894,6 +2922,64 @@ describe("BlueBubbles webhook monitor", () => { 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 = {};