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:
Vincent Koc
2026-03-12 03:11:43 -04:00
committed by GitHub
parent 6c196c913f
commit 241e8cc553
7 changed files with 756 additions and 7 deletions

View File

@@ -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;