This commit is contained in:
Nick Sullivan 2026-01-30 10:44:09 -06:00 committed by GitHub
commit 1cb11c6d2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 918 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,251 @@
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");
});
it("normalizes DM reaction peer ID to E.164 matching message routing", async () => {
setLoadConfigMock(() => ({
channels: { whatsapp: { allowFrom: ["*"] } },
messages: {},
session: { dmScope: "per-peer" },
}));
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: {},
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).
const normalizedRoute = resolveAgentRoute({
cfg: cfg as Parameters<typeof resolveAgentRoute>[0]["cfg"],
channel: "whatsapp",
accountId: "default",
peer: { kind: "dm", id: "+19995551234" },
});
// Drain both potential session keys to clear "gateway connected" events
drainSystemEvents(normalizedRoute.sessionKey);
const jidRoute = resolveAgentRoute({
cfg: cfg as Parameters<typeof resolveAgentRoute>[0]["cfg"],
channel: "whatsapp",
accountId: "default",
peer: { kind: "dm", id: "19995551234@s.whatsapp.net" },
});
drainSystemEvents(jidRoute.sessionKey);
capturedOnReaction!({
messageId: "msg-normalized",
emoji: "✅",
chatJid: "19995551234@s.whatsapp.net",
chatType: "direct",
accountId: "default",
senderJid: "19995551234@s.whatsapp.net",
senderE164: "+19995551234",
timestamp: Date.now(),
});
// The reaction should land in the E.164-normalized session, not the JID-based one.
const events = peekSystemEvents(normalizedRoute.sessionKey);
expect(events).toHaveLength(1);
expect(events[0]).toBe("WhatsApp reaction added: ✅ by +19995551234 msg msg-normalized");
// Verify it did NOT land in a JID-based session key.
const jidEvents = peekSystemEvents(jidRoute.sessionKey);
expect(jidEvents).toHaveLength(0);
});
});

View File

@ -28,6 +28,8 @@ 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";
@ -173,7 +175,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 +203,34 @@ export async function monitorWebChannel(
_lastInboundMsg = msg;
await onMessage(msg);
},
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: peerId,
},
});
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,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;
}

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