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:
parent
09be5d45d5
commit
5571967ebd
@ -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.
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
468
src/web/monitor-inbox.inbound-reactions.test.ts
Normal file
468
src/web/monitor-inbox.inbound-reactions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user