feat(whatsapp): handle reaction removals and improve sender detection

- Emit reaction events with isRemoval flag instead of skipping them
- Fall back to chatJid for DM sender when reaction.key is missing
- Add chatType and accountId to reaction logs for observability
- Update tests to reflect new removal handling behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nick Sullivan 2026-01-29 12:21:09 -06:00
parent 6b20d5640b
commit a0d3aaf541
4 changed files with 47 additions and 16 deletions

View File

@ -215,14 +215,16 @@ export async function monitorWebChannel(
}, },
}); });
const senderLabel = reaction.senderE164 ?? reaction.senderJid ?? "someone"; 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 = [ const contextKey = [
"whatsapp", "whatsapp",
"reaction", "reaction",
"added", action,
reaction.messageId, reaction.messageId,
reaction.senderJid ?? "unknown", reaction.senderJid ?? "unknown",
reaction.emoji, reaction.emoji || "removed",
reaction.chatJid, reaction.chatJid,
].join(":"); ].join(":");
enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey }); enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey });

View File

@ -336,23 +336,42 @@ export async function monitorWebInbox(options: {
if (chatJid.endsWith("@status") || chatJid.endsWith("@broadcast")) continue; if (chatJid.endsWith("@status") || chatJid.endsWith("@broadcast")) continue;
const emoji = entry.reaction?.text ?? ""; const emoji = entry.reaction?.text ?? "";
// Empty emoji = reaction removed; skip removals (matches Signal behavior) const isRemoval = !emoji;
if (!emoji) continue;
const group = isJidGroup(chatJid) === true; const group = isJidGroup(chatJid) === true;
// In DMs, reactionKey.fromMe means we reacted (remoteJid is the partner, not us) // Determine who sent the reaction:
const senderJid = reactionKey?.fromMe // - fromMe=true → we reacted (use selfJid)
? (selfJid ?? undefined) // - Otherwise: use reaction.key metadata, fallback to chatJid for DMs
: (reactionKey?.participant ?? reactionKey?.remoteJid ?? undefined); 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; 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({ options.onReaction({
messageId, messageId,
emoji, emoji,
isRemoval,
chatJid, chatJid,
chatType: group ? "group" : "direct", chatType,
accountId: options.accountId, accountId: options.accountId,
senderJid: senderJid ?? undefined, senderJid: senderJid ?? undefined,
senderE164: senderE164 ?? undefined, senderE164: senderE164 ?? undefined,

View File

@ -44,8 +44,10 @@ export type WebInboundMessage = {
export type WebInboundReaction = { export type WebInboundReaction = {
/** Message ID being reacted to. */ /** Message ID being reacted to. */
messageId: string; messageId: string;
/** Emoji text (empty string = reaction removed). */ /** Emoji text (empty string when isRemoval=true). */
emoji: string; emoji: string;
/** True if this is a reaction removal (emoji will be empty). */
isRemoval?: boolean;
/** JID of the chat where the reaction occurred. */ /** JID of the chat where the reaction occurred. */
chatJid: string; chatJid: string;
chatType: "direct" | "group"; chatType: "direct" | "group";

View File

@ -142,7 +142,7 @@ describe("web monitor inbox inbound reactions", () => {
await listener.close(); await listener.close();
}); });
it("skips reaction removals (empty emoji)", async () => { it("handles reaction removals (empty emoji)", async () => {
const onReaction = vi.fn(); const onReaction = vi.fn();
const listener = await monitorWebInbox({ const listener = await monitorWebInbox({
@ -169,7 +169,14 @@ describe("web monitor inbox inbound reactions", () => {
]); ]);
await new Promise((resolve) => setImmediate(resolve)); 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(); await listener.close();
}); });
@ -416,12 +423,13 @@ describe("web monitor inbox inbound reactions", () => {
]); ]);
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
// In DMs without reaction.key, we use chatJid as the sender
expect(onReaction).toHaveBeenCalledWith( expect(onReaction).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
messageId: "msg-123", messageId: "msg-123",
emoji: "🔥", emoji: "🔥",
senderJid: undefined, senderJid: "999@s.whatsapp.net",
senderE164: undefined, chatType: "direct",
}), }),
); );