diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index d2b98befdaa..50c6cadeb81 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -1909,6 +1909,26 @@ export async function processReaction( return; } + // Group reaction with no chat identifiers cannot be routed safely. The + // peerId fallback below would degrade to the literal string "group", and + // resolveBlueBubblesConversationRoute would then synthesize a session key + // unrelated to any real binding — worse, an isGroup=false misclassification + // upstream would have routed this to the sender's DM session, surfacing + // a group tapback inside an unrelated 1:1 transcript. Drop+log instead. + if ( + reaction.isGroup && + !reaction.chatGuid && + reaction.chatId == null && + !reaction.chatIdentifier + ) { + logVerbose( + core, + runtime, + `dropping group reaction with no chat identifiers (senderId=${reaction.senderId} messageId=${reaction.messageId} action=${reaction.action})`, + ); + return; + } + const dmPolicy = account.config.dmPolicy ?? "pairing"; const groupPolicy = account.config.groupPolicy ?? "allowlist"; const storeAllowFrom = await readStoreAllowFromForDmPolicy({ diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index bb6164302f8..3c57352ddad 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -2233,6 +2233,56 @@ describe("BlueBubbles webhook monitor", () => { expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); }); + it("drops group reactions that arrive with no chat identifiers", async () => { + // Real-world failure mode: BlueBubbles fires a reaction webhook with + // isGroup=true but omits chatGuid AND chatId AND chatIdentifier. The + // legacy code falls peerId back to the literal string "group" and + // resolves a session key unrelated to any real binding; if isGroup + // had been misclassified as false the same payload would have been + // routed to the sender's DM session instead — surfacing a group + // tapback inside an unrelated 1:1 transcript. Either way the event + // cannot be routed correctly, so drop it. + mockEnqueueSystemEvent.mockClear(); + mockResolveRequireMention.mockReturnValue(false); + + setupWebhookTarget({ + account: createMockAccount({ groupPolicy: "open" }), + }); + + const payload = createTimestampedMessageReactionPayloadForTest({ + isGroup: true, + // chatGuid / chatId / chatIdentifier intentionally omitted + associatedMessageType: 2000, + handle: { address: "+15559999999" }, + }); + + await dispatchWebhookPayload(payload); + + expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("still enqueues group reactions when at least one chat identifier is present", async () => { + // Sanity check: the drop guard must not fire when the webhook does + // include a chatGuid. + mockEnqueueSystemEvent.mockClear(); + mockResolveRequireMention.mockReturnValue(false); + + setupWebhookTarget({ + account: createMockAccount({ groupPolicy: "open" }), + }); + + const payload = createTimestampedMessageReactionPayloadForTest({ + isGroup: true, + chatGuid: "iMessage;+;chat-known-123", + associatedMessageType: 2000, + handle: { address: "+15559999999" }, + }); + + await dispatchWebhookPayload(payload); + + expect(mockEnqueueSystemEvent).toHaveBeenCalled(); + }); + it("maps reaction types to correct emojis", async () => { mockEnqueueSystemEvent.mockClear();