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 <noreply@anthropic.com>
This commit is contained in:
Nick Sullivan 2026-01-29 13:30:17 -06:00
parent 5571967ebd
commit e629254b26
3 changed files with 70 additions and 1 deletions

View File

@ -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<void>;
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<typeof resolveAgentRoute>[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<typeof resolveAgentRoute>[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);
});
});

View File

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

View File

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