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] 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(); + }); +});