From e629254b262049c17ed7a7a99eeb3905b12e4d86 Mon Sep 17 00:00:00 2001 From: Nick Sullivan Date: Thu, 29 Jan 2026 13:30:17 -0600 Subject: [PATCH] fix(whatsapp): normalize reaction peer IDs to match message routing Reactions now use the same peer ID normalization as messages (E.164 format for DMs) to ensure they land in the correct session key. Without this fix, per-peer DM scopes would route reactions to a JID-based session while messages go to an E.164-based session, making reactions invisible to the agent. - Add resolveReactionPeerId() matching resolvePeerId() logic - Apply E.164 normalization for DM reactions when senderE164 available - Add test verifying reactions land in the same session as messages - Fixes session routing mismatch caught by Codex review Co-Authored-By: Claude Opus 4.5 --- ...app-inbound-reaction-system-events.test.ts | 59 +++++++++++++++++++ src/web/auto-reply/monitor.ts | 4 +- src/web/auto-reply/monitor/peer.ts | 8 +++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/web/auto-reply.whatsapp-inbound-reaction-system-events.test.ts b/src/web/auto-reply.whatsapp-inbound-reaction-system-events.test.ts index d648855c9..8145e3e1d 100644 --- a/src/web/auto-reply.whatsapp-inbound-reaction-system-events.test.ts +++ b/src/web/auto-reply.whatsapp-inbound-reaction-system-events.test.ts @@ -184,4 +184,63 @@ describe("web auto-reply – inbound reaction system events", () => { expect(events).toHaveLength(1); expect(events[0]).toBe("WhatsApp reaction added: 🔥 by someone msg msg-noid"); }); + + it("normalizes DM reaction peer ID to E.164 matching message routing", async () => { + setLoadConfigMock(() => ({ + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: {}, + })); + + let capturedOnReaction: ((reaction: WebInboundReaction) => void) | undefined; + const listenerFactory = async (opts: { + onMessage: (...args: unknown[]) => Promise; + onReaction?: (reaction: WebInboundReaction) => void; + }) => { + capturedOnReaction = opts.onReaction; + return { close: vi.fn() }; + }; + + await monitorWebChannel(false, listenerFactory, false); + + const cfg = { channels: { whatsapp: { allowFrom: ["*"] } }, messages: {} }; + + // For DM reactions with senderE164, the peer ID should be normalized to E.164 + // to match how messages are routed (via resolvePeerId). + const normalizedRoute = resolveAgentRoute({ + cfg: cfg as Parameters[0]["cfg"], + channel: "whatsapp", + accountId: "default", + peer: { kind: "dm", id: "+19995551234" }, + }); + + // Drain both potential session keys to clear "gateway connected" events + drainSystemEvents(normalizedRoute.sessionKey); + const jidRoute = resolveAgentRoute({ + cfg: cfg as Parameters[0]["cfg"], + channel: "whatsapp", + accountId: "default", + peer: { kind: "dm", id: "19995551234@s.whatsapp.net" }, + }); + drainSystemEvents(jidRoute.sessionKey); + + capturedOnReaction!({ + messageId: "msg-normalized", + emoji: "✅", + chatJid: "19995551234@s.whatsapp.net", + chatType: "direct", + accountId: "default", + senderJid: "19995551234@s.whatsapp.net", + senderE164: "+19995551234", + timestamp: Date.now(), + }); + + // The reaction should land in the E.164-normalized session, not the JID-based one. + const events = peekSystemEvents(normalizedRoute.sessionKey); + expect(events).toHaveLength(1); + expect(events[0]).toBe("WhatsApp reaction added: ✅ by +19995551234 msg msg-normalized"); + + // Verify it did NOT land in a JID-based session key. + const jidEvents = peekSystemEvents(jidRoute.sessionKey); + expect(jidEvents).toHaveLength(0); + }); }); diff --git a/src/web/auto-reply/monitor.ts b/src/web/auto-reply/monitor.ts index ee02bc01d..2b5ab2315 100644 --- a/src/web/auto-reply/monitor.ts +++ b/src/web/auto-reply/monitor.ts @@ -28,6 +28,7 @@ import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js"; import { buildMentionConfig } from "./mentions.js"; import { createEchoTracker } from "./monitor/echo.js"; import { createWebOnMessageHandler } from "./monitor/on-message.js"; +import { resolveReactionPeerId } from "./monitor/peer.js"; import type { WebInboundReaction } from "../inbound.js"; import type { WebChannelStatus, WebInboundMsg, WebMonitorTuning } from "./types.js"; import { isLikelyWhatsAppCryptoError } from "./util.js"; @@ -205,13 +206,14 @@ export async function monitorWebChannel( onReaction: (reaction: WebInboundReaction) => { status.lastEventAt = Date.now(); emitStatus(); + const peerId = resolveReactionPeerId(reaction); const route = resolveAgentRoute({ cfg, channel: "whatsapp", accountId: reaction.accountId, peer: { kind: reaction.chatType === "group" ? "group" : "dm", - id: reaction.chatJid, + id: peerId, }, }); const senderLabel = reaction.senderE164 ?? reaction.senderJid ?? "someone"; diff --git a/src/web/auto-reply/monitor/peer.ts b/src/web/auto-reply/monitor/peer.ts index 0ebd8ac66..25ececb11 100644 --- a/src/web/auto-reply/monitor/peer.ts +++ b/src/web/auto-reply/monitor/peer.ts @@ -1,5 +1,6 @@ import { jidToE164, normalizeE164 } from "../../../utils.js"; import type { WebInboundMsg } from "../types.js"; +import type { WebInboundReaction } from "../../inbound/types.js"; export function resolvePeerId(msg: WebInboundMsg) { if (msg.chatType === "group") return msg.conversationId ?? msg.from; @@ -7,3 +8,10 @@ export function resolvePeerId(msg: WebInboundMsg) { if (msg.from.includes("@")) return jidToE164(msg.from) ?? msg.from; return normalizeE164(msg.from) ?? msg.from; } + +export function resolveReactionPeerId(reaction: WebInboundReaction) { + if (reaction.chatType === "group") return reaction.chatJid; + if (reaction.senderE164) return normalizeE164(reaction.senderE164) ?? reaction.senderE164; + if (reaction.chatJid.includes("@")) return jidToE164(reaction.chatJid) ?? reaction.chatJid; + return normalizeE164(reaction.chatJid) ?? reaction.chatJid; +}