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.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..34f0c7f44 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 = { @@ -143,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; @@ -403,8 +418,17 @@ export async function monitorSignalProvider( } const dataMessage = envelope.dataMessage ?? envelope.editMessage?.dataMessage; - if (envelope.reactionMessage && !dataMessage) { - const reaction = envelope.reactionMessage; + 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 = + 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 +574,6 @@ export async function monitorSignalProvider( ? isSignalSenderAllowed(sender, effectiveGroupAllow) : true : dmAllowed; - const messageText = (dataMessage.message ?? "").trim(); let mediaPath: string | undefined; let mediaType: string | undefined;