From 5571967ebd54968924e4e7d00e70f3e83a98aca6 Mon Sep 17 00:00:00 2001 From: Nick Sullivan <142708+TechNickAI@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:00:07 -0600 Subject: [PATCH 1/3] feat(whatsapp): inbound reaction events (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(whatsapp): subscribe to inbound reaction events Subscribe to Baileys `messages.reaction` events and surface them as system events for the agent session. Follows the same pattern used by Signal and Slack: listen → parse → route → enqueueSystemEvent. - Add `WebInboundReaction` type and `onReaction` callback to monitor - Parse emoji, sender JID, target message ID from Baileys event - Route via `resolveAgentRoute` and enqueue as system event - Handle self-reaction attribution (reactionKey.fromMe → selfJid) - Skip reaction removals (empty emoji) and status/broadcast JIDs - Outer + inner try/catch for resilience against malformed events - 13 unit tests (monitor-level + system event wiring) - Changelog entry and docs updates Co-Authored-By: Claude Opus 4.5 * revert changelog entry to avoid merge conflicts The inbound reaction feature is internal plumbing, not user-facing enough to warrant a changelog entry that will conflict with active PRs. Co-Authored-By: Claude Opus 4.5 * 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 * fix(whatsapp): gate reactions by DM/group access controls Address Codex review - reactions now respect the same access controls as messages (dmPolicy, allowlists, etc). Self-reactions bypass the check since they're our own actions, not inbound events. Co-Authored-By: Claude Opus 4.5 * chore: remove unused senderName field from reaction type Baileys reaction events don't include push names, so this field was dead interface pollution. (Cursor review) Co-Authored-By: Claude Opus 4.5 * fix(whatsapp): update reaction docs + suppress pairing for reactions - Update docs: reaction removals now emit events with isRemoval=true - Pass no-op sendMessage to access control to prevent pairing messages being sent when unknown users react (pairing is for messages, not reactions) (Cursor review) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Nick Sullivan Co-authored-by: Claude Opus 4.5 --- docs/channels/whatsapp.md | 15 + docs/tools/reactions.md | 2 + ...app-inbound-reaction-system-events.test.ts | 187 +++++++ src/web/auto-reply/monitor.ts | 33 +- src/web/inbound.ts | 6 +- src/web/inbound/monitor.ts | 115 ++++- src/web/inbound/types.ts | 21 + .../monitor-inbox.inbound-reactions.test.ts | 468 ++++++++++++++++++ 8 files changed, 844 insertions(+), 3 deletions(-) create mode 100644 src/web/auto-reply.whatsapp-inbound-reaction-system-events.test.ts create mode 100644 src/web/monitor-inbox.inbound-reactions.test.ts diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index cb104fda2..d9cafb0c6 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -269,6 +269,21 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately - Reaction removal semantics: see [/tools/reactions](/tools/reactions). - Tool gating: `channels.whatsapp.actions.reactions` (default: enabled). +## Inbound reaction notifications + +When someone reacts to a message in a WhatsApp chat, the gateway emits a system event so the agent sees it in its next prompt. System events appear as lines like: + +``` +WhatsApp reaction added: 👍 by +1234567890 msg BAE5ABC123 +WhatsApp reaction removed by +1234567890 msg BAE5ABC123 +``` + +- Reaction removals emit events with `isRemoval: true` and no emoji in the message. +- Self-reactions in DMs are correctly attributed to the bot's own JID. +- Group reactions include the participant who reacted. +- Events are deduplicated by message ID, sender, and emoji to avoid repeat notifications. +- No configuration required; inbound reactions are always surfaced when the gateway is running. + ## Limits - Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000). - Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md index 364f38695..e5696b18b 100644 --- a/docs/tools/reactions.md +++ b/docs/tools/reactions.md @@ -3,6 +3,7 @@ summary: "Reaction semantics shared across channels" read_when: - Working on reactions in any channel --- + # Reaction tooling Shared reaction semantics across channels: @@ -18,3 +19,4 @@ Channel notes: - **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation. - **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`). - **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled. +- **WhatsApp**: inbound reaction notifications are always surfaced as system events (no configuration required). 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 new file mode 100644 index 000000000..d648855c9 --- /dev/null +++ b/src/web/auto-reply.whatsapp-inbound-reaction-system-events.test.ts @@ -0,0 +1,187 @@ +import "./test-helpers.js"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../agents/pi-embedded.js", () => ({ + abortEmbeddedPiRun: vi.fn().mockReturnValue(false), + isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), + isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: vi.fn(), + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, +})); + +import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import { + drainSystemEvents, + peekSystemEvents, + resetSystemEventsForTest, +} from "../infra/system-events.js"; +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; +import { monitorWebChannel } from "./auto-reply.js"; +import type { WebInboundReaction } from "./inbound.js"; +import { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js"; + +let previousHome: string | undefined; +let tempHome: string | undefined; + +beforeEach(async () => { + resetInboundDedupe(); + resetSystemEventsForTest(); + previousHome = process.env.HOME; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-web-home-")); + process.env.HOME = tempHome; +}); + +afterEach(async () => { + process.env.HOME = previousHome; + if (tempHome) { + await fs.rm(tempHome, { recursive: true, force: true }).catch(() => {}); + tempHome = undefined; + } +}); + +describe("web auto-reply – inbound reaction system events", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetBaileysMocks(); + resetLoadConfigMock(); + }); + + afterEach(() => { + resetLogger(); + setLoggerOverride(null); + vi.useRealTimers(); + }); + + it("enqueues a system event when a reaction is received", 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); + expect(capturedOnReaction).toBeDefined(); + + const cfg = { channels: { whatsapp: { allowFrom: ["*"] } }, messages: {} }; + const route = resolveAgentRoute({ + cfg: cfg as Parameters[0]["cfg"], + channel: "whatsapp", + accountId: "default", + peer: { kind: "dm", id: "999@s.whatsapp.net" }, + }); + + // Drain the "WhatsApp gateway connected" event so we only check reaction events + drainSystemEvents(route.sessionKey); + + capturedOnReaction!({ + messageId: "msg-abc", + emoji: "👍", + chatJid: "999@s.whatsapp.net", + chatType: "direct", + accountId: "default", + senderJid: "999@s.whatsapp.net", + senderE164: "+999", + timestamp: Date.now(), + }); + + const events = peekSystemEvents(route.sessionKey); + expect(events).toHaveLength(1); + expect(events[0]).toBe("WhatsApp reaction added: 👍 by +999 msg msg-abc"); + }); + + it("uses senderJid when senderE164 is unavailable", 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: {} }; + const route = resolveAgentRoute({ + cfg: cfg as Parameters[0]["cfg"], + channel: "whatsapp", + accountId: "default", + peer: { kind: "dm", id: "999@s.whatsapp.net" }, + }); + + drainSystemEvents(route.sessionKey); + + capturedOnReaction!({ + messageId: "msg-xyz", + emoji: "❤️", + chatJid: "999@s.whatsapp.net", + chatType: "direct", + accountId: "default", + senderJid: "999@s.whatsapp.net", + timestamp: Date.now(), + }); + + const events = peekSystemEvents(route.sessionKey); + expect(events).toHaveLength(1); + expect(events[0]).toBe("WhatsApp reaction added: ❤️ by 999@s.whatsapp.net msg msg-xyz"); + }); + + it("falls back to 'someone' when no sender info available", 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: {} }; + const route = resolveAgentRoute({ + cfg: cfg as Parameters[0]["cfg"], + channel: "whatsapp", + accountId: "default", + peer: { kind: "dm", id: "999@s.whatsapp.net" }, + }); + + drainSystemEvents(route.sessionKey); + + capturedOnReaction!({ + messageId: "msg-noid", + emoji: "🔥", + chatJid: "999@s.whatsapp.net", + chatType: "direct", + accountId: "default", + timestamp: Date.now(), + }); + + const events = peekSystemEvents(route.sessionKey); + expect(events).toHaveLength(1); + expect(events[0]).toBe("WhatsApp reaction added: 🔥 by someone msg msg-noid"); + }); +}); diff --git a/src/web/auto-reply/monitor.ts b/src/web/auto-reply/monitor.ts index 6d05a1309..ee02bc01d 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 type { WebInboundReaction } from "../inbound.js"; import type { WebChannelStatus, WebInboundMsg, WebMonitorTuning } from "./types.js"; import { isLikelyWhatsAppCryptoError } from "./util.js"; @@ -173,7 +174,10 @@ export async function monitorWebChannel( account, }); - const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "whatsapp" }); + const inboundDebounceMs = resolveInboundDebounceMs({ + cfg, + channel: "whatsapp", + }); const shouldDebounce = (msg: WebInboundMsg) => { if (msg.mediaPath || msg.mediaType) return false; if (msg.location) return false; @@ -198,6 +202,33 @@ export async function monitorWebChannel( _lastInboundMsg = msg; await onMessage(msg); }, + onReaction: (reaction: WebInboundReaction) => { + status.lastEventAt = Date.now(); + emitStatus(); + const route = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: reaction.accountId, + peer: { + kind: reaction.chatType === "group" ? "group" : "dm", + id: reaction.chatJid, + }, + }); + const senderLabel = reaction.senderE164 ?? reaction.senderJid ?? "someone"; + 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", + action, + reaction.messageId, + reaction.senderJid ?? "unknown", + reaction.emoji || "removed", + reaction.chatJid, + ].join(":"); + enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey }); + }, }); status.connected = true; diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 39efe97f4..fc9a8e672 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -1,4 +1,8 @@ export { resetWebInboundDedupe } from "./inbound/dedupe.js"; export { extractLocationData, extractMediaPlaceholder, extractText } from "./inbound/extract.js"; export { monitorWebInbox } from "./inbound/monitor.js"; -export type { WebInboundMessage, WebListenerCloseReason } from "./inbound/types.js"; +export type { + WebInboundMessage, + WebInboundReaction, + WebListenerCloseReason, +} from "./inbound/types.js"; diff --git a/src/web/inbound/monitor.ts b/src/web/inbound/monitor.ts index 3633cbce9..1423d2135 100644 --- a/src/web/inbound/monitor.ts +++ b/src/web/inbound/monitor.ts @@ -20,13 +20,15 @@ import { } from "./extract.js"; import { downloadInboundMedia } from "./media.js"; import { createWebSendApi } from "./send-api.js"; -import type { WebInboundMessage, WebListenerCloseReason } from "./types.js"; +import type { WebInboundMessage, WebInboundReaction, WebListenerCloseReason } from "./types.js"; export async function monitorWebInbox(options: { verbose: boolean; accountId: string; authDir: string; onMessage: (msg: WebInboundMessage) => Promise; + /** Called when a reaction is received on a message. */ + onReaction?: (reaction: WebInboundReaction) => void; mediaMaxMb?: number; /** Send read receipts for incoming messages (default true). */ sendReadReceipts?: boolean; @@ -313,6 +315,112 @@ export async function monitorWebInbox(options: { }; sock.ev.on("messages.upsert", handleMessagesUpsert); + // Baileys emits messages.reaction when someone reacts to a message. + const handleMessagesReaction = async ( + reactions: Array<{ + key: proto.IMessageKey; + reaction: { text?: string; key?: proto.IMessageKey }; + }>, + ) => { + if (!options.onReaction) return; + try { + for (const entry of reactions) { + try { + const targetKey = entry.key; + const reactionKey = entry.reaction?.key; + const messageId = targetKey?.id; + if (!messageId) continue; + + const chatJid = targetKey.remoteJid; + if (!chatJid) continue; + if (chatJid.endsWith("@status") || chatJid.endsWith("@broadcast")) continue; + + const emoji = entry.reaction?.text ?? ""; + const isRemoval = !emoji; + + const group = isJidGroup(chatJid) === true; + // 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; + + // Gate reactions by the same access controls as messages (skip for our own reactions) + const isOwnReaction = Boolean(reactionKey?.fromMe); + if (!isOwnReaction) { + const from = group ? chatJid : await resolveInboundJid(chatJid); + if (!from) continue; + // No-op sendMessage: reactions shouldn't trigger pairing replies + const access = await checkInboundAccessControl({ + accountId: options.accountId, + from, + selfE164, + senderE164, + group, + isFromMe: false, + connectedAtMs, + sock: { sendMessage: async () => ({}) }, + remoteJid: chatJid, + }); + if (!access.allowed) { + inboundLogger.debug( + { chatJid, senderJid, group }, + "reaction blocked by access control", + ); + continue; + } + } + + 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, + accountId: options.accountId, + senderJid: senderJid ?? undefined, + senderE164: senderE164 ?? undefined, + reactedToFromMe: targetKey.fromMe ?? undefined, + timestamp: Date.now(), + }); + } catch (err) { + inboundLogger.error( + { + error: String(err), + messageId: entry.key?.id, + chatJid: entry.key?.remoteJid, + }, + "failed handling inbound reaction", + ); + } + } + } catch (outerErr) { + inboundLogger.error({ error: String(outerErr) }, "reaction handler crashed"); + } + }; + sock.ev.on("messages.reaction", handleMessagesReaction as (...args: unknown[]) => void); + const handleConnectionUpdate = ( update: Partial, ) => { @@ -350,14 +458,19 @@ export async function monitorWebInbox(options: { const messagesUpsertHandler = handleMessagesUpsert as unknown as ( ...args: unknown[] ) => void; + const messagesReactionHandler = handleMessagesReaction as unknown as ( + ...args: unknown[] + ) => void; const connectionUpdateHandler = handleConnectionUpdate as unknown as ( ...args: unknown[] ) => void; if (typeof ev.off === "function") { ev.off("messages.upsert", messagesUpsertHandler); + ev.off("messages.reaction", messagesReactionHandler); ev.off("connection.update", connectionUpdateHandler); } else if (typeof ev.removeListener === "function") { ev.removeListener("messages.upsert", messagesUpsertHandler); + ev.removeListener("messages.reaction", messagesReactionHandler); ev.removeListener("connection.update", connectionUpdateHandler); } sock.ws?.close(); diff --git a/src/web/inbound/types.ts b/src/web/inbound/types.ts index 5f861fcc8..eea9905bd 100644 --- a/src/web/inbound/types.ts +++ b/src/web/inbound/types.ts @@ -40,3 +40,24 @@ export type WebInboundMessage = { mediaUrl?: string; wasMentioned?: boolean; }; + +export type WebInboundReaction = { + /** Message ID being reacted to. */ + messageId: string; + /** 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"; + /** Account that received the reaction. */ + accountId: string; + /** JID of the person who reacted. */ + senderJid?: string; + /** E.164 of the person who reacted. */ + senderE164?: string; + /** Whether the reacted message was sent by us. */ + reactedToFromMe?: boolean; + timestamp?: number; +}; diff --git a/src/web/monitor-inbox.inbound-reactions.test.ts b/src/web/monitor-inbox.inbound-reactions.test.ts new file mode 100644 index 000000000..60530b199 --- /dev/null +++ b/src/web/monitor-inbox.inbound-reactions.test.ts @@ -0,0 +1,468 @@ +import { vi } from "vitest"; + +vi.mock("../media/store.js", () => ({ + saveMediaBuffer: vi.fn().mockResolvedValue({ + id: "mid", + path: "/tmp/mid", + size: 1, + contentType: "image/jpeg", + }), +})); + +const mockLoadConfig = vi.fn().mockReturnValue({ + channels: { + whatsapp: { + allowFrom: ["*"], + }, + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + }, +}); + +const readAllowFromStoreMock = vi.fn().mockResolvedValue([]); +const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true }); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => mockLoadConfig(), + }; +}); + +vi.mock("../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), + upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), +})); + +vi.mock("./session.js", () => { + const { EventEmitter } = require("node:events"); + const ev = new EventEmitter(); + const sock = { + ev, + ws: { close: vi.fn() }, + sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + readMessages: vi.fn().mockResolvedValue(undefined), + updateMediaMessage: vi.fn(), + logger: {}, + signalRepository: { + lidMapping: { + getPNForLID: vi.fn().mockResolvedValue(null), + }, + }, + user: { id: "123@s.whatsapp.net" }, + groupMetadata: vi.fn().mockResolvedValue({ subject: "Test Group", participants: [] }), + }; + return { + createWaSocket: vi.fn().mockResolvedValue(sock), + waitForWaConnection: vi.fn().mockResolvedValue(undefined), + getStatusCode: vi.fn(() => 500), + }; +}); + +const { createWaSocket } = await import("./session.js"); + +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js"; + +const ACCOUNT_ID = "default"; +let authDir: string; + +describe("web monitor inbox – inbound reactions", () => { + beforeEach(() => { + vi.clearAllMocks(); + readAllowFromStoreMock.mockResolvedValue([]); + resetWebInboundDedupe(); + authDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "moltbot-auth-")); + }); + + afterEach(() => { + resetLogger(); + setLoggerOverride(null); + vi.useRealTimers(); + fsSync.rmSync(authDir, { recursive: true, force: true }); + }); + + it("calls onReaction for inbound reaction events", async () => { + const onMessage = vi.fn(async () => {}); + const onReaction = vi.fn(); + + const listener = await monitorWebInbox({ + verbose: false, + onMessage, + onReaction, + accountId: ACCOUNT_ID, + authDir, + }); + const sock = await createWaSocket(); + + const reactionEvent = [ + { + key: { + id: "msg-123", + remoteJid: "999@s.whatsapp.net", + fromMe: false, + }, + reaction: { + text: "👍", + key: { + remoteJid: "888@s.whatsapp.net", + participant: "888@s.whatsapp.net", + }, + }, + }, + ]; + + sock.ev.emit("messages.reaction", reactionEvent); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onReaction).toHaveBeenCalledTimes(1); + expect(onReaction).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "msg-123", + emoji: "👍", + chatJid: "999@s.whatsapp.net", + chatType: "direct", + accountId: ACCOUNT_ID, + senderJid: "888@s.whatsapp.net", + reactedToFromMe: false, + }), + ); + expect(onMessage).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("handles reaction removals (empty emoji)", async () => { + const onReaction = vi.fn(); + + const listener = await monitorWebInbox({ + verbose: false, + onMessage: vi.fn(async () => {}), + onReaction, + accountId: ACCOUNT_ID, + authDir, + }); + const sock = await createWaSocket(); + + sock.ev.emit("messages.reaction", [ + { + key: { + id: "msg-123", + remoteJid: "999@s.whatsapp.net", + fromMe: false, + }, + reaction: { + text: "", + key: { remoteJid: "888@s.whatsapp.net" }, + }, + }, + ]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onReaction).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "msg-123", + emoji: "", + isRemoval: true, + chatJid: "999@s.whatsapp.net", + }), + ); + + await listener.close(); + }); + + it("skips reactions on status/broadcast JIDs", async () => { + const onReaction = vi.fn(); + + const listener = await monitorWebInbox({ + verbose: false, + onMessage: vi.fn(async () => {}), + onReaction, + accountId: ACCOUNT_ID, + authDir, + }); + const sock = await createWaSocket(); + + sock.ev.emit("messages.reaction", [ + { + key: { + id: "msg-123", + remoteJid: "status@status", + fromMe: false, + }, + reaction: { + text: "👍", + key: { remoteJid: "888@s.whatsapp.net" }, + }, + }, + ]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onReaction).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("identifies group reactions by chatType", async () => { + const onReaction = vi.fn(); + + const listener = await monitorWebInbox({ + verbose: false, + onMessage: vi.fn(async () => {}), + onReaction, + accountId: ACCOUNT_ID, + authDir, + }); + const sock = await createWaSocket(); + + sock.ev.emit("messages.reaction", [ + { + key: { + id: "msg-456", + remoteJid: "120363@g.us", + fromMe: true, + }, + reaction: { + text: "❤️", + key: { + remoteJid: "120363@g.us", + participant: "777@s.whatsapp.net", + }, + }, + }, + ]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onReaction).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "msg-456", + emoji: "❤️", + chatJid: "120363@g.us", + chatType: "group", + senderJid: "777@s.whatsapp.net", + reactedToFromMe: true, + }), + ); + + await listener.close(); + }); + + it("resolves self-reactions in DMs to own JID", async () => { + const onReaction = vi.fn(); + + const listener = await monitorWebInbox({ + verbose: false, + onMessage: vi.fn(async () => {}), + onReaction, + accountId: ACCOUNT_ID, + authDir, + }); + const sock = await createWaSocket(); + + // Self-reaction: reactionKey.fromMe = true, remoteJid is the partner + sock.ev.emit("messages.reaction", [ + { + key: { + id: "msg-789", + remoteJid: "999@s.whatsapp.net", + fromMe: false, + }, + reaction: { + text: "👍", + key: { + remoteJid: "999@s.whatsapp.net", + fromMe: true, + }, + }, + }, + ]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onReaction).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "msg-789", + emoji: "👍", + // senderJid should be our own JID, not the chat partner + senderJid: "123@s.whatsapp.net", + }), + ); + + await listener.close(); + }); + + it("continues processing remaining reactions when callback throws", async () => { + const onReaction = vi.fn().mockImplementationOnce(() => { + throw new Error("boom"); + }); + + const listener = await monitorWebInbox({ + verbose: false, + onMessage: vi.fn(async () => {}), + onReaction, + accountId: ACCOUNT_ID, + authDir, + }); + const sock = await createWaSocket(); + + sock.ev.emit("messages.reaction", [ + { + key: { id: "msg-1", remoteJid: "999@s.whatsapp.net", fromMe: false }, + reaction: { text: "👍", key: { remoteJid: "888@s.whatsapp.net" } }, + }, + { + key: { id: "msg-2", remoteJid: "999@s.whatsapp.net", fromMe: false }, + reaction: { text: "❤️", key: { remoteJid: "888@s.whatsapp.net" } }, + }, + ]); + await new Promise((resolve) => setImmediate(resolve)); + + // Both reactions should be attempted despite first throwing + expect(onReaction).toHaveBeenCalledTimes(2); + + await listener.close(); + }); + + it("skips reactions with missing messageId", async () => { + const onReaction = vi.fn(); + + const listener = await monitorWebInbox({ + verbose: false, + onMessage: vi.fn(async () => {}), + onReaction, + accountId: ACCOUNT_ID, + authDir, + }); + const sock = await createWaSocket(); + + sock.ev.emit("messages.reaction", [ + { + key: { + id: undefined, + remoteJid: "999@s.whatsapp.net", + fromMe: false, + }, + reaction: { + text: "👍", + key: { remoteJid: "888@s.whatsapp.net" }, + }, + }, + ]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onReaction).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("skips reactions with missing remoteJid", async () => { + const onReaction = vi.fn(); + + const listener = await monitorWebInbox({ + verbose: false, + onMessage: vi.fn(async () => {}), + onReaction, + accountId: ACCOUNT_ID, + authDir, + }); + const sock = await createWaSocket(); + + sock.ev.emit("messages.reaction", [ + { + key: { + id: "msg-123", + remoteJid: undefined, + fromMe: false, + }, + reaction: { + text: "👍", + key: { remoteJid: "888@s.whatsapp.net" }, + }, + }, + ]); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onReaction).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("handles missing reaction.key gracefully (senderJid undefined)", async () => { + const onReaction = vi.fn(); + + const listener = await monitorWebInbox({ + verbose: false, + onMessage: vi.fn(async () => {}), + onReaction, + accountId: ACCOUNT_ID, + authDir, + }); + const sock = await createWaSocket(); + + sock.ev.emit("messages.reaction", [ + { + key: { + id: "msg-123", + remoteJid: "999@s.whatsapp.net", + fromMe: false, + }, + reaction: { + text: "🔥", + // no key — sender unknown + }, + }, + ]); + 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: "999@s.whatsapp.net", + chatType: "direct", + }), + ); + + await listener.close(); + }); + + it("works without onReaction callback (no-op)", async () => { + const onMessage = vi.fn(async () => {}); + + const listener = await monitorWebInbox({ + verbose: false, + onMessage, + accountId: ACCOUNT_ID, + authDir, + }); + const sock = await createWaSocket(); + + // Should not throw when no onReaction is provided + sock.ev.emit("messages.reaction", [ + { + key: { + id: "msg-123", + remoteJid: "999@s.whatsapp.net", + fromMe: false, + }, + reaction: { + text: "👍", + key: { remoteJid: "888@s.whatsapp.net" }, + }, + }, + ]); + await new Promise((resolve) => setImmediate(resolve)); + + await listener.close(); + }); +}); From e629254b262049c17ed7a7a99eeb3905b12e4d86 Mon Sep 17 00:00:00 2001 From: Nick Sullivan Date: Thu, 29 Jan 2026 13:30:17 -0600 Subject: [PATCH 2/3] 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; +} From 05527303513530b1fd2bac3f50f140a103d409a5 Mon Sep 17 00:00:00 2001 From: Nick Sullivan Date: Thu, 29 Jan 2026 13:41:23 -0600 Subject: [PATCH 3/3] test(whatsapp): fix reaction normalization test with per-peer dmScope The test was using default dmScope (main), which routes all DMs to the same session key regardless of peer ID. This masked the bug we were testing for. Now uses session.dmScope='per-peer' so normalized vs JID peer IDs actually produce different session keys, properly testing that reactions land in the E.164-normalized session. Co-Authored-By: Claude Opus 4.5 --- ...o-reply.whatsapp-inbound-reaction-system-events.test.ts | 7 ++++++- 1 file changed, 6 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 8145e3e1d..8c7ca5bf7 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 @@ -189,6 +189,7 @@ describe("web auto-reply – inbound reaction system events", () => { setLoadConfigMock(() => ({ channels: { whatsapp: { allowFrom: ["*"] } }, messages: {}, + session: { dmScope: "per-peer" }, })); let capturedOnReaction: ((reaction: WebInboundReaction) => void) | undefined; @@ -202,7 +203,11 @@ describe("web auto-reply – inbound reaction system events", () => { await monitorWebChannel(false, listenerFactory, false); - const cfg = { channels: { whatsapp: { allowFrom: ["*"] } }, messages: {} }; + const cfg = { + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: {}, + session: { dmScope: "per-peer" }, + }; // For DM reactions with senderE164, the peer ID should be normalized to E.164 // to match how messages are routed (via resolvePeerId).