This commit is contained in:
Nick Sullivan 2026-01-29 19:00:20 +00:00 committed by GitHub
commit 02eb10d0ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 903 additions and 21 deletions

View File

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

View File

@ -3,6 +3,7 @@ summary: "Reaction semantics shared across channels"
read_when: read_when:
- Working on reactions in any channel - Working on reactions in any channel
--- ---
# Reaction tooling # Reaction tooling
Shared reaction semantics across channels: 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. - **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`). - **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. - **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled.
- **WhatsApp**: inbound reaction notifications are always surfaced as system events (no configuration required).

View File

@ -0,0 +1,187 @@
import "./test-helpers.js";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
}));
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import {
drainSystemEvents,
peekSystemEvents,
resetSystemEventsForTest,
} from "../infra/system-events.js";
import { resetLogger, setLoggerOverride } from "../logging.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { monitorWebChannel } from "./auto-reply.js";
import type { WebInboundReaction } from "./inbound.js";
import { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
let previousHome: string | undefined;
let tempHome: string | undefined;
beforeEach(async () => {
resetInboundDedupe();
resetSystemEventsForTest();
previousHome = process.env.HOME;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-web-home-"));
process.env.HOME = tempHome;
});
afterEach(async () => {
process.env.HOME = previousHome;
if (tempHome) {
await fs.rm(tempHome, { recursive: true, force: true }).catch(() => {});
tempHome = undefined;
}
});
describe("web auto-reply inbound reaction system events", () => {
beforeEach(() => {
vi.clearAllMocks();
resetBaileysMocks();
resetLoadConfigMock();
});
afterEach(() => {
resetLogger();
setLoggerOverride(null);
vi.useRealTimers();
});
it("enqueues a system event when a reaction is received", async () => {
setLoadConfigMock(() => ({
channels: { whatsapp: { allowFrom: ["*"] } },
messages: {},
}));
let capturedOnReaction: ((reaction: WebInboundReaction) => void) | undefined;
const listenerFactory = async (opts: {
onMessage: (...args: unknown[]) => Promise<void>;
onReaction?: (reaction: WebInboundReaction) => void;
}) => {
capturedOnReaction = opts.onReaction;
return { close: vi.fn() };
};
await monitorWebChannel(false, listenerFactory, false);
expect(capturedOnReaction).toBeDefined();
const cfg = { channels: { whatsapp: { allowFrom: ["*"] } }, messages: {} };
const route = resolveAgentRoute({
cfg: cfg as Parameters<typeof resolveAgentRoute>[0]["cfg"],
channel: "whatsapp",
accountId: "default",
peer: { kind: "dm", id: "999@s.whatsapp.net" },
});
// Drain the "WhatsApp gateway connected" event so we only check reaction events
drainSystemEvents(route.sessionKey);
capturedOnReaction!({
messageId: "msg-abc",
emoji: "👍",
chatJid: "999@s.whatsapp.net",
chatType: "direct",
accountId: "default",
senderJid: "999@s.whatsapp.net",
senderE164: "+999",
timestamp: Date.now(),
});
const events = peekSystemEvents(route.sessionKey);
expect(events).toHaveLength(1);
expect(events[0]).toBe("WhatsApp reaction added: 👍 by +999 msg msg-abc");
});
it("uses senderJid when senderE164 is unavailable", async () => {
setLoadConfigMock(() => ({
channels: { whatsapp: { allowFrom: ["*"] } },
messages: {},
}));
let capturedOnReaction: ((reaction: WebInboundReaction) => void) | undefined;
const listenerFactory = async (opts: {
onMessage: (...args: unknown[]) => Promise<void>;
onReaction?: (reaction: WebInboundReaction) => void;
}) => {
capturedOnReaction = opts.onReaction;
return { close: vi.fn() };
};
await monitorWebChannel(false, listenerFactory, false);
const cfg = { channels: { whatsapp: { allowFrom: ["*"] } }, messages: {} };
const route = resolveAgentRoute({
cfg: cfg as Parameters<typeof resolveAgentRoute>[0]["cfg"],
channel: "whatsapp",
accountId: "default",
peer: { kind: "dm", id: "999@s.whatsapp.net" },
});
drainSystemEvents(route.sessionKey);
capturedOnReaction!({
messageId: "msg-xyz",
emoji: "❤️",
chatJid: "999@s.whatsapp.net",
chatType: "direct",
accountId: "default",
senderJid: "999@s.whatsapp.net",
timestamp: Date.now(),
});
const events = peekSystemEvents(route.sessionKey);
expect(events).toHaveLength(1);
expect(events[0]).toBe("WhatsApp reaction added: ❤️ by 999@s.whatsapp.net msg msg-xyz");
});
it("falls back to 'someone' when no sender info available", async () => {
setLoadConfigMock(() => ({
channels: { whatsapp: { allowFrom: ["*"] } },
messages: {},
}));
let capturedOnReaction: ((reaction: WebInboundReaction) => void) | undefined;
const listenerFactory = async (opts: {
onMessage: (...args: unknown[]) => Promise<void>;
onReaction?: (reaction: WebInboundReaction) => void;
}) => {
capturedOnReaction = opts.onReaction;
return { close: vi.fn() };
};
await monitorWebChannel(false, listenerFactory, false);
const cfg = { channels: { whatsapp: { allowFrom: ["*"] } }, messages: {} };
const route = resolveAgentRoute({
cfg: cfg as Parameters<typeof resolveAgentRoute>[0]["cfg"],
channel: "whatsapp",
accountId: "default",
peer: { kind: "dm", id: "999@s.whatsapp.net" },
});
drainSystemEvents(route.sessionKey);
capturedOnReaction!({
messageId: "msg-noid",
emoji: "🔥",
chatJid: "999@s.whatsapp.net",
chatType: "direct",
accountId: "default",
timestamp: Date.now(),
});
const events = peekSystemEvents(route.sessionKey);
expect(events).toHaveLength(1);
expect(events[0]).toBe("WhatsApp reaction added: 🔥 by someone msg msg-noid");
});
});

View File

@ -28,6 +28,7 @@ import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js";
import { buildMentionConfig } from "./mentions.js"; import { buildMentionConfig } from "./mentions.js";
import { createEchoTracker } from "./monitor/echo.js"; import { createEchoTracker } from "./monitor/echo.js";
import { createWebOnMessageHandler } from "./monitor/on-message.js"; import { createWebOnMessageHandler } from "./monitor/on-message.js";
import type { WebInboundReaction } from "../inbound.js";
import type { WebChannelStatus, WebInboundMsg, WebMonitorTuning } from "./types.js"; import type { WebChannelStatus, WebInboundMsg, WebMonitorTuning } from "./types.js";
import { isLikelyWhatsAppCryptoError } from "./util.js"; import { isLikelyWhatsAppCryptoError } from "./util.js";
@ -173,7 +174,10 @@ export async function monitorWebChannel(
account, account,
}); });
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "whatsapp" }); const inboundDebounceMs = resolveInboundDebounceMs({
cfg,
channel: "whatsapp",
});
const shouldDebounce = (msg: WebInboundMsg) => { const shouldDebounce = (msg: WebInboundMsg) => {
if (msg.mediaPath || msg.mediaType) return false; if (msg.mediaPath || msg.mediaType) return false;
if (msg.location) return false; if (msg.location) return false;
@ -198,6 +202,33 @@ export async function monitorWebChannel(
_lastInboundMsg = msg; _lastInboundMsg = msg;
await onMessage(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; status.connected = true;

View File

@ -1,4 +1,8 @@
export { resetWebInboundDedupe } from "./inbound/dedupe.js"; export { resetWebInboundDedupe } from "./inbound/dedupe.js";
export { extractLocationData, extractMediaPlaceholder, extractText } from "./inbound/extract.js"; export { extractLocationData, extractMediaPlaceholder, extractText } from "./inbound/extract.js";
export { monitorWebInbox } from "./inbound/monitor.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"; } from "./extract.js";
import { downloadInboundMedia } from "./media.js"; import { downloadInboundMedia } from "./media.js";
import { createWebSendApi } from "./send-api.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: { export async function monitorWebInbox(options: {
verbose: boolean; verbose: boolean;
accountId: string; accountId: string;
authDir: string; authDir: string;
onMessage: (msg: WebInboundMessage) => Promise<void>; onMessage: (msg: WebInboundMessage) => Promise<void>;
/** Called when a reaction is received on a message. */
onReaction?: (reaction: WebInboundReaction) => void;
mediaMaxMb?: number; mediaMaxMb?: number;
/** Send read receipts for incoming messages (default true). */ /** Send read receipts for incoming messages (default true). */
sendReadReceipts?: boolean; sendReadReceipts?: boolean;
@ -313,6 +315,112 @@ export async function monitorWebInbox(options: {
}; };
sock.ev.on("messages.upsert", handleMessagesUpsert); 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 = ( const handleConnectionUpdate = (
update: Partial<import("@whiskeysockets/baileys").ConnectionState>, update: Partial<import("@whiskeysockets/baileys").ConnectionState>,
) => { ) => {
@ -350,14 +458,19 @@ export async function monitorWebInbox(options: {
const messagesUpsertHandler = handleMessagesUpsert as unknown as ( const messagesUpsertHandler = handleMessagesUpsert as unknown as (
...args: unknown[] ...args: unknown[]
) => void; ) => void;
const messagesReactionHandler = handleMessagesReaction as unknown as (
...args: unknown[]
) => void;
const connectionUpdateHandler = handleConnectionUpdate as unknown as ( const connectionUpdateHandler = handleConnectionUpdate as unknown as (
...args: unknown[] ...args: unknown[]
) => void; ) => void;
if (typeof ev.off === "function") { if (typeof ev.off === "function") {
ev.off("messages.upsert", messagesUpsertHandler); ev.off("messages.upsert", messagesUpsertHandler);
ev.off("messages.reaction", messagesReactionHandler);
ev.off("connection.update", connectionUpdateHandler); ev.off("connection.update", connectionUpdateHandler);
} else if (typeof ev.removeListener === "function") { } else if (typeof ev.removeListener === "function") {
ev.removeListener("messages.upsert", messagesUpsertHandler); ev.removeListener("messages.upsert", messagesUpsertHandler);
ev.removeListener("messages.reaction", messagesReactionHandler);
ev.removeListener("connection.update", connectionUpdateHandler); ev.removeListener("connection.update", connectionUpdateHandler);
} }
sock.ws?.close(); sock.ws?.close();

View File

@ -40,3 +40,24 @@ export type WebInboundMessage = {
mediaUrl?: string; mediaUrl?: string;
wasMentioned?: boolean; 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();
});
});