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 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 });

View File

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

View File

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

View File

@ -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",
}),
);