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.
This commit is contained in:
Kasper Neist Christjansen
2026-01-10 04:47:09 +01:00
committed by Peter Steinberger
parent f648267dd9
commit 59e6064006
2 changed files with 48 additions and 3 deletions

View File

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

View File

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