BlueBubbles: require confirmed outbound for self-chat cache

This commit is contained in:
Vincent Koc
2026-03-12 03:22:42 -04:00
parent 0bcb95e8fa
commit 4dfd8eea90
2 changed files with 102 additions and 4 deletions

View File

@@ -487,6 +487,15 @@ export async function processMessage(
};
const cacheMessageId = message.messageId?.trim();
const confirmedOutboundCacheEntry = cacheMessageId
? resolveReplyContextFromCache({
accountId: account.accountId,
replyToId: cacheMessageId,
chatGuid: message.chatGuid,
chatIdentifier: message.chatIdentifier,
chatId: message.chatId,
})
: null;
let messageShortId: string | undefined;
const cacheInboundMessage = () => {
if (!cacheMessageId) {
@@ -508,6 +517,12 @@ export async function processMessage(
if (message.fromMe) {
// Cache from-me messages so reply context can resolve sender/body.
cacheInboundMessage();
const confirmedAssistantOutbound =
confirmedOutboundCacheEntry?.senderLabel === "me" &&
normalizeSnippet(confirmedOutboundCacheEntry.body ?? "") === normalizeSnippet(rawBody);
if (isSelfChatMessage && confirmedAssistantOutbound) {
rememberBlueBubblesSelfChatCopy(selfChatLookup);
}
if (cacheMessageId) {
const pending = consumePendingOutboundMessageId({
accountId: account.accountId,
@@ -517,9 +532,6 @@ 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

View File

@@ -2687,7 +2687,7 @@ describe("BlueBubbles webhook monitor", () => {
setBlueBubblesRuntime(core);
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "msg-self-1" });
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
@@ -2980,6 +2980,92 @@ describe("BlueBubbles webhook monitor", () => {
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("does not treat a pending text-only match as 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: "same text" }, { 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-race-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: "same text",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: true,
guid: "msg-self-race-1",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
createMockResponse(),
);
await flushAsync();
const reflectedPayload = {
type: "new-message",
data: {
text: "same text",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-self-race-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 = {};