feat(whatsapp): inbound reaction events (#1)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Nick Sullivan <nick@technick.ai>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nick Sullivan 2026-01-29 13:00:07 -06:00 committed by Nick Sullivan
parent 09be5d45d5
commit 5571967ebd
8 changed files with 844 additions and 3 deletions

View File

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

View File

@ -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).

View File

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

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

View File

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

View File

@ -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<void>;
/** 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<import("@whiskeysockets/baileys").ConnectionState>,
) => {
@ -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();

View File

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

View File

@ -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<typeof import("../config/config.js")>();
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();
});
});