diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index b6ae260ce..34ab16613 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -3,45 +3,51 @@ summary: "WhatsApp (web channel) integration: login, inbox, replies, media, and read_when: - Working on WhatsApp/web channel behavior or inbox routing --- -# WhatsApp (web channel) +# WhatsApp (web channel) Status: WhatsApp Web via Baileys only. Gateway owns the session(s). ## Quick setup (beginner) -1) Use a **separate phone number** if possible (recommended). -2) Configure WhatsApp in `~/.clawdbot/moltbot.json`. -3) Run `moltbot channels login` to scan the QR code (Linked Devices). -4) Start the gateway. + +1. Use a **separate phone number** if possible (recommended). +2. Configure WhatsApp in `~/.clawdbot/moltbot.json`. +3. Run `moltbot channels login` to scan the QR code (Linked Devices). +4. Start the gateway. Minimal config: + ```json5 { channels: { whatsapp: { dmPolicy: "allowlist", - allowFrom: ["+15551234567"] - } - } + allowFrom: ["+15551234567"], + }, + }, } ``` ## Goals + - Multiple WhatsApp accounts (multi-account) in one Gateway process. - Deterministic routing: replies return to WhatsApp, no model routing. - Model sees enough context to understand quoted replies. ## Config writes + By default, WhatsApp is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`). Disable with: + ```json5 { - channels: { whatsapp: { configWrites: false } } + channels: { whatsapp: { configWrites: false } }, } ``` ## Architecture (who owns what) + - **Gateway** owns the Baileys socket and inbox loop. - **CLI / macOS app** talk to the gateway; no direct Baileys use. - **Active listener** is required for outbound sends; otherwise send fails fast. @@ -51,19 +57,21 @@ Disable with: WhatsApp requires a real mobile number for verification. VoIP and virtual numbers are usually blocked. There are two supported ways to run Moltbot on WhatsApp: ### Dedicated number (recommended) + Use a **separate phone number** for Moltbot. Best UX, clean routing, no self-chat quirks. Ideal setup: **spare/old Android phone + eSIM**. Leave it on Wi‑Fi and power, and link it via QR. **WhatsApp Business:** You can use WhatsApp Business on the same device with a different number. Great for keeping your personal WhatsApp separate — install WhatsApp Business and register the Moltbot number there. **Sample config (dedicated number, single-user allowlist):** + ```json5 { channels: { whatsapp: { dmPolicy: "allowlist", - allowFrom: ["+15551234567"] - } - } + allowFrom: ["+15551234567"], + }, + }, } ``` @@ -72,10 +80,12 @@ If you want pairing instead of allowlist, set `channels.whatsapp.dmPolicy` to `p `moltbot pairing approve whatsapp ` ### Personal number (fallback) + Quick fallback: run Moltbot on **your own number**. Message yourself (WhatsApp “Message yourself”) for testing so you don’t spam contacts. Expect to read verification codes on your main phone during setup and experiments. **Must enable self-chat mode.** When the wizard asks for your personal WhatsApp number, enter the phone you will message from (the owner/sender), not the assistant number. **Sample config (personal number, self-chat):** + ```json { "whatsapp": { @@ -91,6 +101,7 @@ if `messages.responsePrefix` is unset. Set it explicitly to customize or disable the prefix (use `""` to remove it). ### Number sourcing tips + - **Local eSIM** from your country's mobile carrier (most reliable) - Austria: [hot.at](https://www.hot.at) - UK: [giffgaff](https://www.giffgaff.com) — free SIM, no contract @@ -101,6 +112,7 @@ the prefix (use `""` to remove it). **Tip:** The number only needs to receive one verification SMS. After that, WhatsApp Web sessions persist via `creds.json`. ## Why Not Twilio? + - Early Moltbot builds supported Twilio’s WhatsApp Business integration. - WhatsApp Business numbers are a poor fit for a personal assistant. - Meta enforces a 24‑hour reply window; if you haven’t responded in the last 24 hours, the business number can’t initiate new messages. @@ -108,6 +120,7 @@ the prefix (use `""` to remove it). - Result: unreliable delivery and frequent blocks, so support was removed. ## Login + credentials + - Login command: `moltbot channels login` (QR via Linked Devices). - Multi-account login: `moltbot channels login --account ` (`` = `accountId`). - Default account (when `--account` is omitted): `default` if present, otherwise the first configured account id (sorted). @@ -118,6 +131,7 @@ the prefix (use `""` to remove it). - Logged-out socket => error instructs re-link. ## Inbound flow (DM + group) + - WhatsApp events come from `messages.upsert` (Baileys). - Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts. - Status/broadcast chats are ignored. @@ -128,38 +142,44 @@ the prefix (use `""` to remove it). - Your linked WhatsApp number is implicitly trusted, so self messages skip ⁠`channels.whatsapp.dmPolicy` and `channels.whatsapp.allowFrom` checks. ### Personal-number mode (fallback) + If you run Moltbot on your **personal WhatsApp number**, enable `channels.whatsapp.selfChatMode` (see sample above). Behavior: + - Outbound DMs never trigger pairing replies (prevents spamming contacts). - Inbound unknown senders still follow `channels.whatsapp.dmPolicy`. - Self-chat mode (allowFrom includes your number) avoids auto read receipts and ignores mention JIDs. - Read receipts sent for non-self-chat DMs. ## Read receipts + By default, the gateway marks inbound WhatsApp messages as read (blue ticks) once they are accepted. Disable globally: + ```json5 { - channels: { whatsapp: { sendReadReceipts: false } } + channels: { whatsapp: { sendReadReceipts: false } }, } ``` Disable per account: + ```json5 { channels: { whatsapp: { accounts: { - personal: { sendReadReceipts: false } - } - } - } + personal: { sendReadReceipts: false }, + }, + }, + }, } ``` Notes: + - Self-chat mode always skips read receipts. ## WhatsApp FAQ: sending messages + pairing @@ -169,6 +189,7 @@ No. Default DM policy is **pairing**, so unknown senders only get a pairing code **How does pairing work on WhatsApp?** Pairing is a DM gate for unknown senders: + - First DM from a new sender returns a short code (message is not processed). - Approve with: `moltbot pairing approve whatsapp ` (list with `moltbot pairing list whatsapp`). - Codes expire after 1 hour; pending requests are capped at 3 per channel. @@ -180,6 +201,7 @@ Yes, by routing each sender to a different agent via `bindings` (peer `kind: "dm The wizard uses it to set your **allowlist/owner** so your own DMs are permitted. It’s not used for auto-sending. If you run on your personal WhatsApp number, use that same number and enable `channels.whatsapp.selfChatMode`. ## Message normalization (what the model sees) + - `Body` is the current message body with envelope. - Quoted reply context is **always appended**: ``` @@ -195,6 +217,7 @@ The wizard uses it to set your **allowlist/owner** so your own DMs are permitted - `` ## Groups + - Groups map to `agent::whatsapp:group:` sessions. - Group policy: `channels.whatsapp.groupPolicy = open|disabled|allowlist` (default `allowlist`). - Activation modes: @@ -203,7 +226,7 @@ The wizard uses it to set your **allowlist/owner** so your own DMs are permitted - `/activation mention|always` is owner-only and must be sent as a standalone message. - Owner = `channels.whatsapp.allowFrom` (or self E.164 if unset). - **History injection** (pending-only): - - Recent *unprocessed* messages (default 50) inserted under: + - Recent _unprocessed_ messages (default 50) inserted under: `[Chat messages since your last reply - for context]` (messages already in the session are not re-injected) - Current message under: `[Current message - respond to this]` @@ -211,6 +234,7 @@ The wizard uses it to set your **allowlist/owner** so your own DMs are permitted - Group metadata cached 5 min (subject + participants). ## Reply delivery (threading) + - WhatsApp Web sends standard messages (no quoted reply threading in the current gateway). - Reply tags are ignored on this channel. @@ -219,6 +243,7 @@ The wizard uses it to set your **allowlist/owner** so your own DMs are permitted WhatsApp can automatically send emoji reactions to incoming messages immediately upon receipt, before the bot generates a reply. This provides instant feedback to users that their message was received. **Configuration:** + ```json { "whatsapp": { @@ -232,6 +257,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately ``` **Options:** + - `emoji` (string): Emoji to use for acknowledgment (e.g., "👀", "✅", "📨"). Empty or omitted = feature disabled. - `direct` (boolean, default: `true`): Send reactions in direct/DM chats. - `group` (string, default: `"mentions"`): Group chat behavior: @@ -240,6 +266,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately - `"never"`: Never react in groups **Per-account override:** + ```json { "whatsapp": { @@ -257,6 +284,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately ``` **Behavior notes:** + - Reactions are sent **immediately** upon message receipt, before typing indicators or bot replies. - In groups with `requireMention: false` (activation: always), `group: "mentions"` will react to all messages (not just @mentions). - Fire-and-forget: reaction failures are logged but don't prevent the bot from replying. @@ -264,18 +292,36 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately - WhatsApp ignores `messages.ackReaction`; use `channels.whatsapp.ackReaction` instead. ## Agent tool (reactions) + - Tool: `whatsapp` with `react` action (`chatJid`, `messageId`, `emoji`, optional `remove`). - Optional: `participant` (group sender), `fromMe` (reacting to your own message), `accountId` (multi-account). - 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. - Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB). - Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB). ## Outbound send (text + media) + - Uses active web listener; error if gateway not running. - Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`, optional `channels.whatsapp.chunkMode`). - Media: @@ -288,17 +334,21 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately - Gateway: `send` params include `gifPlayback: true` ## Voice notes (PTT audio) + WhatsApp sends audio as **voice notes** (PTT bubble). + - Best results: OGG/Opus. Moltbot rewrites `audio/ogg` to `audio/ogg; codecs=opus`. - `[[audio_as_voice]]` is ignored for WhatsApp (audio already ships as voice note). ## Media limits + optimization + - Default outbound cap: 5 MB (per media item). - Override: `agents.defaults.mediaMaxMb`. - Images are auto-optimized to JPEG under cap (resize + quality sweep). - Oversize media => error; media reply falls back to text warning. ## Heartbeats + - **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s). - **Agent heartbeat** can be configured per agent (`agents.list[].heartbeat`) or globally via `agents.defaults.heartbeat` (fallback when no per-agent entries are set). @@ -306,12 +356,14 @@ WhatsApp sends audio as **voice notes** (PTT bubble). - Delivery defaults to the last used channel (or configured target). ## Reconnect behavior + - Backoff policy: `web.reconnect`: - `initialMs`, `maxMs`, `factor`, `jitter`, `maxAttempts`. - If maxAttempts reached, web monitoring stops (degraded). - Logged-out => stop and require re-link. ## Config quick map + - `channels.whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled). - `channels.whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number). - `channels.whatsapp.allowFrom` (DM allowlist). WhatsApp uses E.164 phone numbers (no usernames). @@ -343,6 +395,7 @@ WhatsApp sends audio as **voice notes** (PTT bubble). - `web.reconnect.*` ## Logs + troubleshooting + - Subsystems: `whatsapp/inbound`, `whatsapp/outbound`, `web-heartbeat`, `web-reconnect`. - Log file: `/tmp/moltbot/moltbot-YYYY-MM-DD.log` (configurable). - Troubleshooting guide: [Gateway troubleshooting](/gateway/troubleshooting). @@ -350,13 +403,16 @@ WhatsApp sends audio as **voice notes** (PTT bubble). ## Troubleshooting (quick) **Not linked / QR login required** + - Symptom: `channels status` shows `linked: false` or warns “Not linked”. - Fix: run `moltbot channels login` on the gateway host and scan the QR (WhatsApp → Settings → Linked Devices). **Linked but disconnected / reconnect loop** + - Symptom: `channels status` shows `running, disconnected` or warns “Linked but disconnected”. - Fix: `moltbot doctor` (or restart the gateway). If it persists, relink via `channels login` and inspect `moltbot logs --follow`. **Bun runtime** + - Bun is **not recommended**. WhatsApp (Baileys) and Telegram are unreliable on Bun. Run the gateway with **Node**. (See Getting Started runtime note.) 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 791b38967..427780846 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(); + }); +});