Merge 79f799256d into 4583f88626
This commit is contained in:
commit
02eb10d0ae
@ -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 Wi‑Fi 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 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.
|
**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 don’t 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 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.
|
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 Twilio’s WhatsApp Business integration.
|
- Early Moltbot builds supported Twilio’s 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 24‑hour reply window; if you haven’t responded in the last 24 hours, the business number can’t initiate new messages.
|
- 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.
|
- 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. It’s 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. 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)
|
## 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.)
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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 { 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;
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
|
|||||||
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