diff --git a/extensions/bluebubbles/src/catchup.ts b/extensions/bluebubbles/src/catchup.ts index aa6ee26e385..0aa70342264 100644 --- a/extensions/bluebubbles/src/catchup.ts +++ b/extensions/bluebubbles/src/catchup.ts @@ -498,19 +498,27 @@ async function runBlueBubblesCatchupInner( continue; } - // Skip balloon/tapback/reaction events: they carry an + // Skip tapback/reaction/balloon events. These carry an // `associatedMessageGuid` pointing at the parent text message and // have a different `guid` of their own. The live webhook path handles - // them via the debouncer, which coalesces balloons with their parent. + // balloons via the debouncer, which coalesces them with their parent. // Without debouncing here, replaying a balloon would dispatch it as a // standalone message — producing a duplicate reply to the parent. + // + // Guard: only skip when `associatedMessageType` is set (tapbacks and + // reactions — e.g., "like", 2000) OR `balloonBundleId` is set (URL + // previews, stickers). iMessage threaded replies use a separate + // `threadOriginatorGuid` field and do NOT set either of these, so + // they pass through for correct catchup replay. const assocGuid = typeof rec.associatedMessageGuid === "string" ? rec.associatedMessageGuid.trim() : typeof rec.associated_message_guid === "string" ? rec.associated_message_guid.trim() : ""; - if (assocGuid) { + const assocType = rec.associatedMessageType ?? rec.associated_message_type; + const balloonId = typeof rec.balloonBundleId === "string" ? rec.balloonBundleId.trim() : ""; + if (assocGuid && (assocType != null || balloonId)) { continue; } diff --git a/src/plugin-sdk/persistent-dedupe.ts b/src/plugin-sdk/persistent-dedupe.ts index f109a27d647..651a5b9193f 100644 --- a/src/plugin-sdk/persistent-dedupe.ts +++ b/src/plugin-sdk/persistent-dedupe.ts @@ -170,11 +170,17 @@ export function createPersistentDedupe(options: PersistentDedupeOptions): Persis const prev = fileWriteQueues.get(filePath) ?? Promise.resolve(); const next = prev.then(fn, fn); fileWriteQueues.set(filePath, next); - void next.finally(() => { - if (fileWriteQueues.get(filePath) === next) { - fileWriteQueues.delete(filePath); - } - }); + // Cleanup: remove the queue entry once this link settles, but only if + // no newer work was chained after us. The `.catch(() => {})` prevents + // an unhandled rejection when `next` rejects — callers still observe + // the rejection through the returned `next` promise directly. + next + .finally(() => { + if (fileWriteQueues.get(filePath) === next) { + fileWriteQueues.delete(filePath); + } + }) + .catch(() => {}); return next; }