From 59e6064006beaf6cc7be09d42427a0917941d768 Mon Sep 17 00:00:00 2001 From: Kasper Neist Christjansen Date: Sat, 10 Jan 2026 04:47:09 +0100 Subject: [PATCH 1/2] fix(signal): handle reactions in dataMessage.reaction format (#1) * fix(signal): handle reactions inside dataMessage.reaction Signal reactions can arrive in two formats: 1. envelope.reactionMessage (already handled) 2. envelope.dataMessage.reaction (now handled) The signal-cli SSE events use the second format, which was being misinterpreted as a message with attachments, leading to 'broken media / attachments' errors. Changes: - Add reaction property to SignalDataMessage type - Check both envelope.reactionMessage and dataMessage.reaction - Improve body content detection to properly identify reaction-only messages - Add test for dataMessage.reaction format * fix(signal): reaction notifications work when account is phone number When reactionNotifications mode is 'own', notifications would never fire because resolveSignalReactionTarget() returned a UUID but shouldEmitSignalReactionNotification() compared it against the account phone number, which never matched. The fix: - Add optional 'phone' field to SignalReactionTarget type - Extract phone number first in resolveSignalReactionTarget(), include it even when UUID is present - In shouldEmitSignalReactionNotification() 'own' mode, check phone match first before falling back to UUID comparison This ensures reactions to your own messages are properly detected when the Signal account is configured as a phone number and the reaction event contains both targetAuthor (phone) and targetAuthorUuid. * fix(signal): include phone in reaction target for own-mode matching When targetAuthorUuid is present, also store targetAuthor phone number in the reaction target. This allows own-mode reaction notifications to match when comparing account phone against UUID-based targets. --- src/signal/monitor.tool-result.test.ts | 39 ++++++++++++++++++++++++++ src/signal/monitor.ts | 12 ++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/signal/monitor.tool-result.test.ts b/src/signal/monitor.tool-result.test.ts index b9a6a24bf..07a3fa262 100644 --- a/src/signal/monitor.tool-result.test.ts +++ b/src/signal/monitor.tool-result.test.ts @@ -197,6 +197,45 @@ describe("monitorSignalProvider tool results", () => { expect(updateLastRouteMock).not.toHaveBeenCalled(); }); + it("ignores reaction-only messages when reactions live in dataMessage", async () => { + const abortController = new AbortController(); + + streamMock.mockImplementation(async ({ onEvent }) => { + const payload = { + envelope: { + sourceNumber: "+15550001111", + sourceName: "Ada", + timestamp: 1, + dataMessage: { + reaction: { + emoji: "👍", + targetAuthor: "+15550002222", + targetSentTimestamp: 2, + }, + attachments: [{}], + }, + }, + }; + await onEvent({ + event: "receive", + data: JSON.stringify(payload), + }); + abortController.abort(); + }); + + await monitorSignalProvider({ + autoStart: false, + baseUrl: "http://127.0.0.1:8080", + abortSignal: abortController.signal, + }); + + await flush(); + + expect(replyMock).not.toHaveBeenCalled(); + expect(sendMock).not.toHaveBeenCalled(); + expect(updateLastRouteMock).not.toHaveBeenCalled(); + }); + it("enqueues system events for reaction notifications", async () => { config = { ...config, diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 022219ed9..fe27016cb 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -70,6 +70,7 @@ type SignalDataMessage = { groupName?: string | null; } | null; quote?: { text?: string | null } | null; + reaction?: SignalReactionMessage | null; }; type SignalAttachment = { @@ -403,8 +404,14 @@ export async function monitorSignalProvider( } const dataMessage = envelope.dataMessage ?? envelope.editMessage?.dataMessage; - if (envelope.reactionMessage && !dataMessage) { - const reaction = envelope.reactionMessage; + const reaction = + envelope.reactionMessage ?? dataMessage?.reaction ?? null; + const messageText = (dataMessage?.message ?? "").trim(); + const quoteText = dataMessage?.quote?.text?.trim() ?? ""; + const hasBodyContent = + Boolean(messageText || quoteText) || + Boolean(!reaction && dataMessage?.attachments?.length); + if (reaction && !hasBodyContent) { if (reaction.isRemove) return; // Ignore reaction removals const emojiLabel = reaction.emoji?.trim() || "emoji"; const senderDisplay = formatSignalSenderDisplay(sender); @@ -550,7 +557,6 @@ export async function monitorSignalProvider( ? isSignalSenderAllowed(sender, effectiveGroupAllow) : true : dmAllowed; - const messageText = (dataMessage.message ?? "").trim(); let mediaPath: string | undefined; let mediaType: string | undefined; From 8dbb22cc930ffeeb87df78daa1c45311466f84f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 19:13:23 +0100 Subject: [PATCH 2/2] fix: signal handle dataMessage.reaction safely (#637) (thanks @neist) --- CHANGELOG.md | 1 + src/signal/monitor.ts | 21 +++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 100fa3575..bb3086bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ - iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski. - iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks). - Telegram: serialize media-group processing to avoid missed albums under load. +- Signal: handle `dataMessage.reaction` events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist. - Docs: showcase entries for ParentPay, R2 Upload, iOS TestFlight, and Oura Health. (#650) — thanks @henrino3. ## 2026.1.9 diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index fe27016cb..34f0c7f44 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -144,6 +144,20 @@ function resolveSignalReactionTargets( return targets; } +function isSignalReactionMessage( + reaction: SignalReactionMessage | null | undefined, +): reaction is SignalReactionMessage { + if (!reaction) return false; + const emoji = reaction.emoji?.trim(); + const timestamp = reaction.targetSentTimestamp; + const hasTarget = Boolean( + reaction.targetAuthor?.trim() || reaction.targetAuthorUuid?.trim(), + ); + return Boolean( + emoji && typeof timestamp === "number" && timestamp > 0 && hasTarget, + ); +} + function shouldEmitSignalReactionNotification(params: { mode?: SignalReactionNotificationMode; account?: string | null; @@ -404,8 +418,11 @@ export async function monitorSignalProvider( } const dataMessage = envelope.dataMessage ?? envelope.editMessage?.dataMessage; - const reaction = - envelope.reactionMessage ?? dataMessage?.reaction ?? null; + const reaction = isSignalReactionMessage(envelope.reactionMessage) + ? envelope.reactionMessage + : isSignalReactionMessage(dataMessage?.reaction) + ? dataMessage?.reaction + : null; const messageText = (dataMessage?.message ?? "").trim(); const quoteText = dataMessage?.quote?.text?.trim() ?? ""; const hasBodyContent =