diff --git a/src/web/auto-reply/monitor.ts b/src/web/auto-reply/monitor.ts index 53ac624e0..427780846 100644 --- a/src/web/auto-reply/monitor.ts +++ b/src/web/auto-reply/monitor.ts @@ -215,14 +215,16 @@ export async function monitorWebChannel( }, }); const senderLabel = reaction.senderE164 ?? reaction.senderJid ?? "someone"; - const text = `WhatsApp reaction added: ${reaction.emoji} by ${senderLabel} msg ${reaction.messageId}`; + const action = reaction.isRemoval ? "removed" : "added"; + const emojiPart = reaction.isRemoval ? "" : `: ${reaction.emoji}`; + const text = `WhatsApp reaction ${action}${emojiPart} by ${senderLabel} msg ${reaction.messageId}`; const contextKey = [ "whatsapp", "reaction", - "added", + action, reaction.messageId, reaction.senderJid ?? "unknown", - reaction.emoji, + reaction.emoji || "removed", reaction.chatJid, ].join(":"); enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey }); diff --git a/src/web/inbound/monitor.ts b/src/web/inbound/monitor.ts index 7a6562799..aabc6d5fc 100644 --- a/src/web/inbound/monitor.ts +++ b/src/web/inbound/monitor.ts @@ -336,23 +336,42 @@ export async function monitorWebInbox(options: { if (chatJid.endsWith("@status") || chatJid.endsWith("@broadcast")) continue; const emoji = entry.reaction?.text ?? ""; - // Empty emoji = reaction removed; skip removals (matches Signal behavior) - if (!emoji) continue; + const isRemoval = !emoji; const group = isJidGroup(chatJid) === true; - // In DMs, reactionKey.fromMe means we reacted (remoteJid is the partner, not us) - const senderJid = reactionKey?.fromMe - ? (selfJid ?? undefined) - : (reactionKey?.participant ?? reactionKey?.remoteJid ?? undefined); + // Determine who sent the reaction: + // - fromMe=true → we reacted (use selfJid) + // - Otherwise: use reaction.key metadata, fallback to chatJid for DMs + let senderJid: string | undefined; + if (reactionKey?.fromMe) { + senderJid = selfJid ?? undefined; + } else { + // Prefer explicit sender info from reaction.key, fall back to chatJid for DMs + senderJid = + reactionKey?.participant ?? reactionKey?.remoteJid ?? (group ? undefined : chatJid); + } const senderE164 = senderJid ? await resolveInboundJid(senderJid) : null; - inboundLogger.info({ emoji, messageId, chatJid, senderJid }, "inbound reaction"); + const chatType = group ? "group" : "direct"; + inboundLogger.info( + { + emoji: emoji || "(removed)", + messageId, + chatJid, + chatType, + senderJid, + isRemoval, + accountId: options.accountId, + }, + isRemoval ? "inbound reaction removed" : "inbound reaction added", + ); options.onReaction({ messageId, emoji, + isRemoval, chatJid, - chatType: group ? "group" : "direct", + chatType, accountId: options.accountId, senderJid: senderJid ?? undefined, senderE164: senderE164 ?? undefined, diff --git a/src/web/inbound/types.ts b/src/web/inbound/types.ts index 54e7a3c12..63c03b2b0 100644 --- a/src/web/inbound/types.ts +++ b/src/web/inbound/types.ts @@ -44,8 +44,10 @@ export type WebInboundMessage = { export type WebInboundReaction = { /** Message ID being reacted to. */ messageId: string; - /** Emoji text (empty string = reaction removed). */ + /** Emoji text (empty string when isRemoval=true). */ emoji: string; + /** True if this is a reaction removal (emoji will be empty). */ + isRemoval?: boolean; /** JID of the chat where the reaction occurred. */ chatJid: string; chatType: "direct" | "group"; diff --git a/src/web/monitor-inbox.inbound-reactions.test.ts b/src/web/monitor-inbox.inbound-reactions.test.ts index cb6a6aedf..60530b199 100644 --- a/src/web/monitor-inbox.inbound-reactions.test.ts +++ b/src/web/monitor-inbox.inbound-reactions.test.ts @@ -142,7 +142,7 @@ describe("web monitor inbox – inbound reactions", () => { await listener.close(); }); - it("skips reaction removals (empty emoji)", async () => { + it("handles reaction removals (empty emoji)", async () => { const onReaction = vi.fn(); const listener = await monitorWebInbox({ @@ -169,7 +169,14 @@ describe("web monitor inbox – inbound reactions", () => { ]); await new Promise((resolve) => setImmediate(resolve)); - expect(onReaction).not.toHaveBeenCalled(); + expect(onReaction).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "msg-123", + emoji: "", + isRemoval: true, + chatJid: "999@s.whatsapp.net", + }), + ); await listener.close(); }); @@ -416,12 +423,13 @@ describe("web monitor inbox – inbound reactions", () => { ]); await new Promise((resolve) => setImmediate(resolve)); + // In DMs without reaction.key, we use chatJid as the sender expect(onReaction).toHaveBeenCalledWith( expect.objectContaining({ messageId: "msg-123", emoji: "🔥", - senderJid: undefined, - senderE164: undefined, + senderJid: "999@s.whatsapp.net", + chatType: "direct", }), );