@bot-wa/baileys 0.0.0-stage → 0.2.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +283 -2
- package/lib/BaileysProvider.d.ts +246 -0
- package/lib/BaileysProvider.js +858 -0
- package/lib/MediaProcessor.d.ts +60 -0
- package/lib/MediaProcessor.js +137 -0
- package/lib/RateLimiter.d.ts +115 -0
- package/lib/RateLimiter.js +286 -0
- package/lib/boundedMap.d.ts +13 -0
- package/lib/boundedMap.js +22 -0
- package/lib/browser.d.ts +15 -0
- package/lib/browser.js +41 -0
- package/lib/identity.d.ts +52 -0
- package/lib/identity.js +107 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.js +28 -0
- package/lib/jid.d.ts +70 -0
- package/lib/jid.js +119 -0
- package/lib/logger.d.ts +34 -0
- package/lib/logger.js +44 -0
- package/lib/mediaRef.d.ts +50 -0
- package/lib/mediaRef.js +136 -0
- package/lib/polls.d.ts +147 -0
- package/lib/polls.js +243 -0
- package/lib/reconnect.d.ts +41 -0
- package/lib/reconnect.js +51 -0
- package/lib/sessionPath.d.ts +14 -0
- package/lib/sessionPath.js +52 -0
- package/lib/toCallEvent.d.ts +19 -0
- package/lib/toCallEvent.js +46 -0
- package/lib/toInbound.d.ts +19 -0
- package/lib/toInbound.js +177 -0
- package/lib/toOutbound.d.ts +10 -0
- package/lib/toOutbound.js +62 -0
- package/package.json +70 -3
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Andres Aya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,284 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @bot-wa/baileys
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The **WhatsApp via [Baileys 7](https://github.com/WhiskeySockets/Baileys)** transport ([`IProvider` v2](https://github.com/andresayac/bot-wa-baileys/blob/main/packages/core/src/core/provider.ts)) for [`@bot-wa/core`](https://github.com/andresayac/bot-wa-baileys/tree/main/packages/core). It links the bot as a WhatsApp Web device (QR or pairing code) and opens its own WebSocket: no public server and no webhook needed.
|
|
4
|
+
|
|
5
|
+
> **Unofficial client.** Baileys implements the WhatsApp Web protocol by reverse engineering; neither WhatsApp nor Meta endorses it. Automating an account may break the [WhatsApp Terms of Service](https://www.whatsapp.com/legal/terms-of-service) and end in **a banned number**. Use a dedicated number, write only to people who asked you to (opt-in), do not send bulk messages and keep the rate limiter on. If you need an official route with no ban risk, use [`@bot-wa/meta`](https://github.com/andresayac/bot-wa-baileys/tree/main/packages/meta-provider) (WhatsApp Cloud API).
|
|
6
|
+
|
|
7
|
+
- **Ingress:** own socket with reconnect and backoff; Baileys events are translated into neutral `InboundEvent`s: messages, reactions, poll votes (decrypted by the provider), group changes, calls and presence.
|
|
8
|
+
- **Outbound:** text, image, video, audio, voice note (transcoded to Opus), document, sticker (converted with `sharp`), location, contact, **native polls** and reactions. Buttons, lists and cards degrade to numbered text.
|
|
9
|
+
- **Actions:** read receipts, typing/recording, deleting messages and downloading inbound media.
|
|
10
|
+
- **Stable identity:** the provider resolves WhatsApp's LID/PN duality before emitting every event, so the core always sees the same `senderId` for the same person.
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
- **Node ≥ 22** (ESM-only).
|
|
15
|
+
- **`@whiskeysockets/baileys` 7.0.0-rc14 or newer.** Since late July 2026 WhatsApp closes connections that use the previous handshake with `428 Connection Terminated`, even before showing the QR. rc14 brings the updated handshake (among other things, the WhatsApp Web version bundled in the package). If your lockfile resolves an older rc, upgrade it.
|
|
16
|
+
- Network egress to `wss://web.whatsapp.com` and, at startup, to `raw.githubusercontent.com`: the provider queries the latest WhatsApp Web version published by Baileys. Without access it uses the version bundled in the installed release, which is why that release must be ≥ rc14.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
In the monorepo the package is already available as a workspace. From an app:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm add @bot-wa/core @bot-wa/baileys
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`ffmpeg` is bundled (`@ffmpeg-installer/ffmpeg`) for voice notes, and `sharp` for stickers.
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { Bot } from '@bot-wa/core'
|
|
32
|
+
import { BaileysProvider } from '@bot-wa/baileys'
|
|
33
|
+
|
|
34
|
+
const bot = new Bot({ provider: new BaileysProvider({ session: 'my-bot' }) })
|
|
35
|
+
|
|
36
|
+
bot.on('ready', () => console.log('✅ Connected'))
|
|
37
|
+
bot.when('hola', (ctx) => ctx.reply('👋 Hello!'))
|
|
38
|
+
|
|
39
|
+
await bot.start() // the first time it prints the QR in the terminal: scan it from WhatsApp → Linked devices
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The session is stored in `./sessions/my-bot`; later starts reconnect without a QR. There is a full example (commands, flows, media, hooks, DI and scheduler) in [`examples/bot.ts`](https://github.com/andresayac/bot-wa-baileys/blob/main/packages/baileys-provider/examples/bot.ts) (`pnpm --filter @bot-wa/baileys example`).
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
interface BaileysProviderConfig {
|
|
48
|
+
session?: string // session id, 'default' by default
|
|
49
|
+
sessionsDir?: string // root folder for sessions, './sessions' by default
|
|
50
|
+
pairingCode?: string // phone in international format: pair with a code instead of a QR
|
|
51
|
+
qr?: QrConfig // how to show the QR (terminal, small size by default)
|
|
52
|
+
rateLimit?: RateLimitConfig // anti-ban send pacing (on by default)
|
|
53
|
+
reconnect?: boolean // true by default
|
|
54
|
+
browser?: BrowserConfig // device identity; better leave it alone (see below)
|
|
55
|
+
markOnlineOnConnect?: boolean // false by default: the phone keeps receiving notifications
|
|
56
|
+
syncFullHistory?: boolean // false by default
|
|
57
|
+
ignoreBroadcasts?: boolean // true by default (status@broadcast is always ignored)
|
|
58
|
+
ignoreNewsletters?: boolean // true by default
|
|
59
|
+
pollStore?: PollStore // poll persistence to read votes after a restart (see Polls and votes)
|
|
60
|
+
durableMedia?: { viewOnce?: boolean } // with durability: journal view-once media too (off by default)
|
|
61
|
+
debug?: boolean // 'debug' level on the default logger (otherwise 'error')
|
|
62
|
+
logger?: LoggerLike // any logger with debug/info/warn/error (pino, console…); Baileys gets an adapter (toBaileysLogger)
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Session
|
|
67
|
+
|
|
68
|
+
Each session lives in `<sessionsDir>/<session>` and holds the linked device's credentials. For several numbers, use a different `session` per number. The validation rules are in [Session security](#session-security).
|
|
69
|
+
|
|
70
|
+
### QR or pairing code
|
|
71
|
+
|
|
72
|
+
By default the provider emits a QR. If the server has no camera or terminal at hand, ask for a **pairing code**:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const provider = new BaileysProvider({
|
|
76
|
+
session: 'my-bot',
|
|
77
|
+
pairingCode: '+57 300 123 4567', // only the digits count
|
|
78
|
+
qr: { terminal: false },
|
|
79
|
+
})
|
|
80
|
+
const bot = new Bot({ provider })
|
|
81
|
+
bot.on('qr', (artifact) => {
|
|
82
|
+
// Both the QR (long strings) and the 8-character code arrive here.
|
|
83
|
+
if (artifact.length === 8) notifyOperator(artifact)
|
|
84
|
+
})
|
|
85
|
+
await bot.start()
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
On the phone: **Linked devices → Link with phone number** and enter the code. The code is requested once per start, together with the first QR. If the request fails, the provider logs it and keeps offering the QR. The QR and the code also arrive in the `connection` event, as `authPayload`.
|
|
89
|
+
|
|
90
|
+
### QR rendering
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
interface QrConfig {
|
|
94
|
+
terminal?: boolean // draw the QR on stdout
|
|
95
|
+
small?: boolean // compact QR (true by default)
|
|
96
|
+
file?: boolean | string // PNG: true → '<session>.qr.png' in the CWD; a string fixes the path
|
|
97
|
+
handler?: (qr: string) => void | Promise<void> // your own render (web, Slack…)
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Without `qr`, it is drawn in the terminal. If you pass a `qr` object, only what you set is enabled. Errors writing the PNG are logged, never thrown.
|
|
102
|
+
|
|
103
|
+
> The QR and the pairing code grant access to the account while they are valid. Never log them or send them over unencrypted channels, and delete the PNG when you no longer need it.
|
|
104
|
+
|
|
105
|
+
### Browser identity (`browser`)
|
|
106
|
+
|
|
107
|
+
**Leave it undefined.** The provider then omits the option and Baileys uses its default identity (`Browsers.macOS('Chrome')`), which is the one the rc14 handshake validates. The `'macOS' | 'ubuntu' | 'windows' | 'appropriate'` presets announce **Chrome** on that operating system. A custom tuple `[os, browser, version]` is passed through and leaves a warning in the log: with non-standard identities (e.g. `'Desktop'` or an invented name), WhatsApp may close the connection with `428` before the QR.
|
|
108
|
+
|
|
109
|
+
### Rate limiter (anti-ban)
|
|
110
|
+
|
|
111
|
+
Every `send()` goes through a token bucket before reaching the socket. Sends that exceed the limit are **queued, not dropped**, and a throttled chat does not hold the others back.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
new BaileysProvider({
|
|
115
|
+
session: 'my-bot',
|
|
116
|
+
rateLimit: {
|
|
117
|
+
perChat: { max: 5, window: '10s' }, // default
|
|
118
|
+
global: { max: 50, window: '60s' }, // default
|
|
119
|
+
jitter: { min: 500, max: 1500 }, // "human" pause between sends (off by default)
|
|
120
|
+
maxWait: '30s', // after this long in the queue the send fails with RateLimiterTimeout (a RateLimitedError)
|
|
121
|
+
// enabled: false // turn it off completely (not recommended)
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
More detail in the [Rate limiting](https://github.com/andresayac/bot-wa-baileys/blob/main/docs/guide/rate-limiting.md) guide.
|
|
127
|
+
|
|
128
|
+
### Reconnect and backoff
|
|
129
|
+
|
|
130
|
+
When the socket closes, the provider emits `connection` with `state: 'closed'` and decides based on the Baileys code:
|
|
131
|
+
|
|
132
|
+
| Code | Meaning | What the provider does | `reason` |
|
|
133
|
+
| --- | --- | --- | --- |
|
|
134
|
+
| 401 `loggedOut`, 500 `badSession`, 411 `multideviceMismatch` | The session is no longer valid | Deletes **only** that session's folder and starts over (new QR) | `sessionInvalid` |
|
|
135
|
+
| 440 `connectionReplaced`, 403 `forbidden` | Another instance uses the session, or forbidden | **Does not reconnect** (fighting the other instance risks the number) | `sessionTakenOver` |
|
|
136
|
+
| 515 `restartRequired` | Normal restart after pairing | Reconnects instantly | `transient` |
|
|
137
|
+
| Everything else (428, 408, 503…) | Transient drop | Exponential backoff: 1 s, 2 s, 4 s… up to 30 s, at most 10 attempts | `transient` |
|
|
138
|
+
|
|
139
|
+
If `start()` fails during a reconnect (for example, no network), it is retried with the same backoff and the same cap. When it is exhausted, a final `closed` is emitted with `detail.message: 'reconnect exhausted'`. With `reconnect: false` it never retries. The counter goes back to zero every time the connection opens.
|
|
140
|
+
|
|
141
|
+
## Identity: LID and PN
|
|
142
|
+
|
|
143
|
+
WhatsApp identifies every user in two ways: the phone number (**PN**, `573001234567@s.whatsapp.net`) and an opaque identifier (**LID**, `…@lid`). The same contact may arrive as a PN, as a LID with the PN next to it (`remoteJidAlt`/`participantAlt`) or as a bare LID, and the form can change from one message to the next. If the bot used the raw `remoteJid`, the same person would be split into two keys and flows would break (the `ask` never receives the answer).
|
|
144
|
+
|
|
145
|
+
The provider resolves it with an `IdentityStore` **before** emitting every event:
|
|
146
|
+
|
|
147
|
+
- It learns the LID↔PN pair when it sees it: in the message key, in `lid-mapping.update` events and by querying Baileys' mapping store (stored in the session folder, so it survives restarts).
|
|
148
|
+
- It canonicalizes: when it knows a LID's PN, `chatId` and `senderId` use the **PN**. Otherwise they use the **LID**. Always normalized, without the device suffix (`:12`).
|
|
149
|
+
- In groups, `chatId` is the group (`…@g.us`) and `senderId` is the canonicalized participant.
|
|
150
|
+
- `ctx.senderInfo.platformData` carries `{ phoneNumber?, lid? }`: the PN when known and the raw LID as it arrived.
|
|
151
|
+
- The other events go through the **same** canonicalization, so a person has the same id everywhere: the author of a reaction or a vote, the participants and the author (`actor`) of a group change, the caller (`call.from`) and the chat and keys of `presences`. The provider takes advantage of the PN Baileys includes in each event (`participantAlt`, `authorPn`, `callerPn`, the participant's `phoneNumber`) and, when it does not, queries the mapping store.
|
|
152
|
+
- In a reaction, `senderId` is **whoever reacts** and `ref.id` is the reacted message (previously the author of that message was reported, by mistake).
|
|
153
|
+
- Mentions (`mentions`) and the author of the quoted message (`quoted.senderId`) are canonical too: in a group addressed by LID, WhatsApp mentions and quotes with LIDs, and the provider swaps them for the PN when it knows it (querying the mapping store if needed). That way `ctx.quoted?.senderId === provider.selfId` detects a reply to a message sent by the bot. The quote and the mentions are also read from captions of photos, videos and documents, and `quoted.ref` points at the quoted message (usable as `quoted` to reply in the thread).
|
|
154
|
+
- Wrapped messages (disappearing mode, *view once*, document with caption) are unwrapped before being classified, so they arrive with their real `type`, `body` and `media`.
|
|
155
|
+
- `provider.selfId` is the bot's normalized PN (`573000000000@s.whatsapp.net`, without the device suffix `:12` that `creds.me.id` stores), the same id its own reactions and votes carry. With `groupMode: 'mentions'`, the core compares mentions against `selfId`, so the bot answers whether it is mentioned by its number or by its LID.
|
|
156
|
+
- In `group`, the provider translates Baileys' `modify` action (number change) into `action: 'update'`, but rc14 never gets to emit it: while processing the number change, Baileys runs `JSON.parse` on the notice's raw JIDs and throws before emitting the event. In practice, with rc14 a number change produces no `group` event at all.
|
|
157
|
+
- When sending to a LID, the provider uses the PN if it knows it and, otherwise, sends directly to the LID.
|
|
158
|
+
|
|
159
|
+
For advanced cases, `IdentityStore`, `provider.getPNForLID(lid)` and the JID helpers (`isPn`, `isLid`, `normalizeJid`, `sameUser`…) are exported.
|
|
160
|
+
|
|
161
|
+
### Contacts with a username (2026)
|
|
162
|
+
|
|
163
|
+
With WhatsApp usernames, some contacts **never** expose their number: they always arrive as `@lid`, with no PN next to it and no known mapping. For them the identity is **stable on the LID**: all their messages produce the same `chatId`/`senderId`, `platformData.phoneNumber` stays `undefined` and sends go to the LID. Do not assume `senderId` is a phone: check it with `isPn()` before using it as one.
|
|
164
|
+
|
|
165
|
+
**Residual behaviour:** if the LID→PN mapping is learned **later** (a message arrives with the PN next to it or a `lid-mapping.update` comes in), from that moment the person is identified by their PN. A conversation that started under the LID keeps waiting on the LID key and will not see the following messages, which arrive with the PN. In practice:
|
|
166
|
+
|
|
167
|
+
- Set `.timeout()` on flows (or `timeout` on `c.ask`/`c.waitText`) so an orphaned conversation closes itself. The user restarts it with the trigger or with a command such as `/reset`.
|
|
168
|
+
- If you store per-person data (CRM, preferences), store `platformData.phoneNumber` and `platformData.lid` too, so you can reconcile the two records.
|
|
169
|
+
|
|
170
|
+
## Polls and votes
|
|
171
|
+
|
|
172
|
+
`ctx.poll(question, options, multiSelect?)` sends a native poll. Votes reach the bot as a `poll` event, already decrypted and with the full tally:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
await ctx.poll('Pizza or sushi?', ['Pizza', 'Sushi'])
|
|
176
|
+
|
|
177
|
+
bot.on('poll', (vote) => {
|
|
178
|
+
// vote.ref.id → poll id (vote.ref.native is the poll message)
|
|
179
|
+
// vote.ref.chatId → chat where the vote was cast, the same chatId its messages use
|
|
180
|
+
// vote.senderId → whoever just voted, with the canonical identity of their messages
|
|
181
|
+
// vote.options → current tally: [{ name, votes, voters }]
|
|
182
|
+
const chosen = vote.options.filter((o) => o.voters?.includes(vote.senderId)).map((o) => o.name)
|
|
183
|
+
console.log(chosen.length ? `Chose: ${chosen.join(', ')}` : 'Withdrew their vote')
|
|
184
|
+
})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
WhatsApp encrypts every vote end to end with a key derived from the poll secret (`messageSecret`), its id and the creator's and voter's JIDs. Baileys 7 ships that decryption commented out, so the provider does it on its own:
|
|
188
|
+
|
|
189
|
+
- **It remembers the polls it sees:** those the bot sends (the `send()` result and the `append` echo Baileys emits) and those others create in the chats the provider listens to. Polls from ignored chats (statuses, broadcast lists, channels; see `ignoreBroadcasts`/`ignoreNewsletters`) take no room, except the bot's own. It keeps the **500** most recent in memory; a poll that receives votes stays among the recent ones.
|
|
190
|
+
- **It ties every vote to its poll:** a vote only names the poll by its id, and any member of a chat knows the id and secret of its polls. That is why the provider requires the vote to arrive through the **same chat** as the poll (counting the LID and PN forms of one chat) and to name the **same creator**. Another poll reusing the id (ids are chosen by the client that sends) cannot add votes to the one already in memory. Votes that do not comply are dropped with a `debug`-level log.
|
|
191
|
+
- **It decrypts by trying the LID and PN forms** of the creator and the voter. In a group addressed by LID the vote is signed with the LIDs; in a chat by PN, with the numbers. A wrong pair does not pass the AES-GCM authentication, so trying them produces no false positives.
|
|
192
|
+
- **It keeps each person's last vote:** a new vote replaces the previous one, an empty selection withdraws it and a vote older than the stored one is dropped. A person counts once even if they vote first as a LID and later show up with their PN.
|
|
193
|
+
- **Every vote counts once:** Baileys redelivers offline messages as `append`, so the provider remembers the ids of the last 5000 counted votes and does not emit a repeated one. A different vote from the same person with the same timestamp does update the tally.
|
|
194
|
+
- The encrypted vote no longer arrives as a `poll`-type message. `bot.onPoll` only fires for polls created by someone else, with the question in `body`.
|
|
195
|
+
|
|
196
|
+
Limitations:
|
|
197
|
+
|
|
198
|
+
- Without a `pollStore`, only votes for polls **this process** saw are decrypted. After a restart memory starts empty, so votes for earlier polls (or ones evicted from the cache) are dropped with a `debug`-level log. To keep them, configure a [`pollStore`](#persisting-polls-pollstore). A vote that cannot be decrypted leaves a `warn` with the poll id, no JID and no content.
|
|
199
|
+
- **The bot cannot vote.** Baileys 7 has no API to build an encrypted vote (no vote content in `sendMessage`, no `decryptPollVote` counterpart for encrypting); you would have to reimplement the encryption and send it with `relayMessage`. That is why there is no `ctx.vote()`.
|
|
200
|
+
|
|
201
|
+
### Persisting polls (`pollStore`)
|
|
202
|
+
|
|
203
|
+
To decrypt votes that arrive **after a restart**, pass a `pollStore`: a minimal async interface, keyed by the poll message id, that you can back with Redis, SQL or a file.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import type { PollStore, StoredPoll } from '@bot-wa/baileys'
|
|
207
|
+
|
|
208
|
+
const pollStore: PollStore = {
|
|
209
|
+
get: async (id) => {
|
|
210
|
+
const raw = await redis.get(`poll:${id}`)
|
|
211
|
+
return raw ? (JSON.parse(raw) as StoredPoll) : undefined
|
|
212
|
+
},
|
|
213
|
+
set: async (id, poll) => { await redis.set(`poll:${id}`, JSON.stringify(poll), 'EX', 30 * 24 * 3600) },
|
|
214
|
+
delete: async (id) => { await redis.del(`poll:${id}`) },
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
new BaileysProvider({ session: 'my-bot', pollStore })
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- `StoredPoll` is plain JSON: the poll key (chat and creator, with their LID/PN forms), the question, the `messageSecret` in base64, the options with their hash and each person's last vote. It stores no other messages.
|
|
221
|
+
- The provider calls `set` when it sees a new poll and after every counted vote; `get` when a vote arrives for a poll that is not in memory (or a new poll reuses an id); and `delete` when an entry is malformed. The in-memory cache of 500 polls still sits in front of the store.
|
|
222
|
+
- After a restart, the tally continues where it left off and the same rules hold: the vote must arrive through the same chat and name the same creator, and a later poll with the same id does not overwrite the stored one.
|
|
223
|
+
- Retention (TTL, cleanup) is up to you. If the store fails, the provider logs it as a `warn` and continues with the in-memory cache: it never breaks a send or the reception.
|
|
224
|
+
- **The entry holds the poll secret**, with which its votes are decrypted: treat the store as carefully as the session folder.
|
|
225
|
+
|
|
226
|
+
`vote.ref.native` of a poll loaded from the store is a reconstructed poll message (key, question, options and secret), enough to quote it.
|
|
227
|
+
|
|
228
|
+
## Session security
|
|
229
|
+
|
|
230
|
+
The session folder (`creds.json` and the Signal keys) **is** the account: whoever copies it can read and send messages as your number.
|
|
231
|
+
|
|
232
|
+
- **Path containment.** `session` is an opaque id: 1 to 128 characters among letters, digits, `.`, `_` and `-`, starting with a letter or digit. It admits no separators and no `..`. It is validated in the constructor, before touching the disk. `sessionsDir` cannot be the filesystem root. Before reading, writing or deleting, the provider rejects a session folder that is a symbolic link and checks with the real path that it stays inside `sessionsDir`.
|
|
233
|
+
- **`stop()` vs `logoutAndDeleteSession()`.** `stop()` closes the socket and **keeps** the session: the next `start()` reconnects without a QR. `logoutAndDeleteSession()` unlinks the device in WhatsApp and deletes **only** that session's folder, so the next start asks for a new QR. Use it only when you really want to unlink.
|
|
234
|
+
- **Automatic deletion.** On 401/500/411 (invalid session) the provider deletes the session folder on its own so it can pair again.
|
|
235
|
+
- Add `sessions/` to `.gitignore`, restrict the folder permissions (e.g. `chmod 700`) and do not run two instances with the same session: WhatsApp would close one with `440 conflict`.
|
|
236
|
+
- The provider logs no QR, codes or message bodies. With `debug: true` Baileys' log is very verbose; do not leave it on in production.
|
|
237
|
+
|
|
238
|
+
## Capabilities
|
|
239
|
+
|
|
240
|
+
| Capability | Value | Notes |
|
|
241
|
+
| --- | --- | --- |
|
|
242
|
+
| `buttons` | ❌ | degrade to numbered text |
|
|
243
|
+
| `lists` | ❌ | degrade to numbered text |
|
|
244
|
+
| `polls` | ✅ | native poll sending and vote reading with the `poll` event (see [Polls and votes](#polls-and-votes)) |
|
|
245
|
+
| `reactions` | ✅ | send and receive; `senderId` is whoever reacts |
|
|
246
|
+
| `edit` | ❌ | |
|
|
247
|
+
| `typing` | ✅ | `composing` / `recording` / `paused` |
|
|
248
|
+
| `presence` | ✅ | `presence` events |
|
|
249
|
+
| `readReceipts` | ✅ | `markRead` |
|
|
250
|
+
| `groups` | ✅ | group messages and participant events |
|
|
251
|
+
| `templates` | ❌ | they require the Cloud API (`@bot-wa/meta`) |
|
|
252
|
+
| `interactionsRequireAck` | ❌ | |
|
|
253
|
+
| `pushIngress` | ❌ | the provider keeps its own socket |
|
|
254
|
+
|
|
255
|
+
## Error mapping
|
|
256
|
+
|
|
257
|
+
`send()` only rejects with `ProviderError` subclasses from `@bot-wa/core`. Failures from `sock.sendMessage`, the rate limiter and content preparation are converted like this:
|
|
258
|
+
|
|
259
|
+
| Cause | Becomes |
|
|
260
|
+
| --- | --- |
|
|
261
|
+
| Baileys 429 | `RateLimitedError` (`retryAfterMs` comes from `retry-after`, 1 s by default) |
|
|
262
|
+
| 413 or *too large* | `PayloadTooLargeError` |
|
|
263
|
+
| 401 / 403, *forbidden*, *not-authorized*, *blocked* | `RecipientUnreachableError` |
|
|
264
|
+
| `kind: 'template'` or `'native'` | `UnsupportedContentError` |
|
|
265
|
+
| wait in the rate limiter queue longer than `maxWait` | `RateLimiterTimeout`, a subclass of `RateLimitedError` |
|
|
266
|
+
| `stop()` with sends still queued | `ProviderError('RateLimiter reset')` |
|
|
267
|
+
| no connected socket | `ProviderError('Provider not connected')` |
|
|
268
|
+
| anything else | `ProviderError`, with the original error in `cause` |
|
|
269
|
+
|
|
270
|
+
`RateLimiterTimeout` keeps its `name` and its `chatId` and `waitedMs` fields, so code that already caught it keeps working. Because it is a `RateLimitedError`, it is also caught by a transport-portable `catch (e instanceof RateLimitedError)`; its `retryAfterMs` is the time left until the bucket blocking it recovers a token.
|
|
271
|
+
|
|
272
|
+
## Limitations
|
|
273
|
+
|
|
274
|
+
- **No native buttons or lists.** WhatsApp Web stopped supporting them for unofficial clients. Use numbered menus with `choose()` (in the DSL `flow().choose(...)` or in conversations `c.choose(...)`) or a native poll with `ctx.poll(question, options)`. `ctx.buttons(...)` and the `list` content still work, but arrive as numbered text.
|
|
275
|
+
- **Poll votes:** without a `pollStore`, only those for polls the process has seen since it started are read, and the bot cannot vote (see [Polls and votes](#polls-and-votes)). If the answer must survive a restart, configure a `pollStore` or use `choose()`.
|
|
276
|
+
- No templates and no 24-hour window: that belongs to the Cloud API.
|
|
277
|
+
- Sent messages cannot be edited (`edit: false`).
|
|
278
|
+
|
|
279
|
+
## Notes
|
|
280
|
+
|
|
281
|
+
- ESM only (`"type": "module"`); relative imports carry the `.js` extension.
|
|
282
|
+
- To answer WhatsApp's resend requests (`getMessage`), the provider keeps the last 2000 messages of the chats it listens to and of the bot itself in memory. Messages from ignored chats (statuses, broadcast lists, channels) take no room.
|
|
283
|
+
- Errors are logged under pino's `err` key, so the log shows message and stack. If you inject your own `logger`, keep pino's error serializer.
|
|
284
|
+
- `provider.socket` exposes the Baileys socket for operations the `IProvider` does not cover (groups, profile…). Use it carefully: whatever you do there bypasses the rate limiter and the error mapping.
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BaileysProvider — WhatsApp transport implementing {@link IProvider} v2.
|
|
3
|
+
*
|
|
4
|
+
* Owns the Baileys socket lifecycle (connect, QR/pairing, reconnect), translates
|
|
5
|
+
* every Baileys event into a normalized {@link InboundEvent}, maps outbound
|
|
6
|
+
* {@link OutboundContent} to Baileys content, and resolves LID/PN identity
|
|
7
|
+
* internally so core never sees a WhatsApp concept.
|
|
8
|
+
*/
|
|
9
|
+
import makeWASocket, { type WAMessage } from '@whiskeysockets/baileys';
|
|
10
|
+
import type { ChatId, IProvider, InboundEvent, LoggerLike, MediaRef, MessageRef, OutboundContent, ProviderCapabilities, SendOptions, SendResult, SerializedMediaRef, UserId } from '@bot-wa/core';
|
|
11
|
+
import { ProviderError } from '@bot-wa/core';
|
|
12
|
+
import { type BrowserConfig } from './browser.js';
|
|
13
|
+
import { type PollStore } from './polls.js';
|
|
14
|
+
import type { RemoteMediaConfig } from './MediaProcessor.js';
|
|
15
|
+
import { type RateLimitConfig } from './RateLimiter.js';
|
|
16
|
+
/** Terminal/file QR rendering options. */
|
|
17
|
+
export interface QrConfig {
|
|
18
|
+
terminal?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Write the QR as a PNG. `true` → `<session>.qr.png` in the CWD; a string sets
|
|
21
|
+
* an explicit output path (recommended — the CWD default is often not writable
|
|
22
|
+
* or not where you expect). Write errors are logged, never thrown.
|
|
23
|
+
*/
|
|
24
|
+
file?: boolean | string;
|
|
25
|
+
small?: boolean;
|
|
26
|
+
handler?: (qr: string) => void | Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/** Configuration for the WhatsApp (Baileys) transport. */
|
|
29
|
+
export interface BaileysProviderConfig {
|
|
30
|
+
session?: string;
|
|
31
|
+
sessionsDir?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Browser identity advertised in the WhatsApp Web handshake. Leave it unset
|
|
34
|
+
* (recommended): Baileys >= 7.0.0-rc14 then uses its own default,
|
|
35
|
+
* `Browsers.macOS('Chrome')`. A preset advertises Chrome on that OS. A custom
|
|
36
|
+
* `[os, browserName, osVersion]` tuple is passed through verbatim and logged
|
|
37
|
+
* as a warning: since late July 2026 WhatsApp closes non-standard clients
|
|
38
|
+
* with 428 "Connection Terminated" before the QR.
|
|
39
|
+
*/
|
|
40
|
+
browser?: BrowserConfig;
|
|
41
|
+
markOnlineOnConnect?: boolean;
|
|
42
|
+
syncFullHistory?: boolean;
|
|
43
|
+
ignoreNewsletters?: boolean;
|
|
44
|
+
ignoreBroadcasts?: boolean;
|
|
45
|
+
reconnect?: boolean;
|
|
46
|
+
qr?: QrConfig;
|
|
47
|
+
/** Phone number (international format) for pairing-code login instead of QR. */
|
|
48
|
+
pairingCode?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Persistence for polls, so votes cast after a restart can still be
|
|
51
|
+
* decrypted and tallied (vote decryption needs each poll's secret). Without
|
|
52
|
+
* it, only polls seen by the running process are known (a bounded in-memory
|
|
53
|
+
* cache of {@link POLL_CACHE_MAX} polls). See {@link PollStore}.
|
|
54
|
+
*/
|
|
55
|
+
pollStore?: PollStore;
|
|
56
|
+
/** Anti-ban outbound pacing. */
|
|
57
|
+
rateLimit?: RateLimitConfig;
|
|
58
|
+
/**
|
|
59
|
+
* How outbound `{ url }` media is fetched. By default the provider downloads
|
|
60
|
+
* it through `@bot-wa/http` (redirects vetted, private hosts refused, 64 MiB
|
|
61
|
+
* cap) instead of letting Baileys follow redirects blindly. See
|
|
62
|
+
* {@link RemoteMediaConfig}.
|
|
63
|
+
*/
|
|
64
|
+
remoteMedia?: RemoteMediaConfig;
|
|
65
|
+
/**
|
|
66
|
+
* Durable inbox (`BotConfig.durability`): which media references may be journaled so a
|
|
67
|
+
* replayed message can still download its media. By default every media message is except
|
|
68
|
+
* view-once media, whose key is never written to a journal; `{ viewOnce: true }` journals
|
|
69
|
+
* those too. Turn journaling off for every media with `durability.inbox.mediaRefs: false`.
|
|
70
|
+
*/
|
|
71
|
+
durableMedia?: {
|
|
72
|
+
viewOnce?: boolean;
|
|
73
|
+
};
|
|
74
|
+
debug?: boolean;
|
|
75
|
+
/** Any `LoggerLike` (pino, `console`, your own). Baileys itself receives a `BaileysLogger` adapter (see {@link toBaileysLogger}). */
|
|
76
|
+
logger?: LoggerLike;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Map a raw error thrown by `sock.sendMessage` to a typed {@link ProviderError},
|
|
80
|
+
* so callers can `catch (e instanceof RateLimitedError)` portably across
|
|
81
|
+
* transports. Unknown errors are wrapped in a generic ProviderError (with the
|
|
82
|
+
* original attached as `cause`) rather than leaking a Baileys/Boom internal.
|
|
83
|
+
*/
|
|
84
|
+
export declare function mapSendError(err: unknown, chatId: ChatId): ProviderError;
|
|
85
|
+
export declare class BaileysProvider implements IProvider<WAMessage> {
|
|
86
|
+
readonly id = "baileys";
|
|
87
|
+
readonly capabilities: ProviderCapabilities;
|
|
88
|
+
private readonly config;
|
|
89
|
+
private readonly logger;
|
|
90
|
+
/** The logger handed to Baileys (`makeWASocket`, the signal key store, media downloads). */
|
|
91
|
+
private readonly waLogger;
|
|
92
|
+
private sock;
|
|
93
|
+
private msgRetryCounterCache;
|
|
94
|
+
private authState;
|
|
95
|
+
private version;
|
|
96
|
+
private reconnectPending;
|
|
97
|
+
/** Timer of the scheduled reconnect, cleared by stop() so it never outlives the session. */
|
|
98
|
+
private reconnectTimer;
|
|
99
|
+
private reconnectAttempts;
|
|
100
|
+
private isShuttingDown;
|
|
101
|
+
private pairingCodeRequested;
|
|
102
|
+
private lidCache;
|
|
103
|
+
private msgCache;
|
|
104
|
+
/** Polls seen by this process (sent or received), so their votes can be decrypted. */
|
|
105
|
+
private polls;
|
|
106
|
+
/** One pending {@link PollStore} read per poll id, shared by concurrent votes. */
|
|
107
|
+
private pollLoads;
|
|
108
|
+
/** Tail of each poll's save chain, so writes reach the store in order. */
|
|
109
|
+
private pollSaves;
|
|
110
|
+
private identity;
|
|
111
|
+
/** Call id → group JID of group calls in progress (see {@link toCallEvent}). */
|
|
112
|
+
private callGroups;
|
|
113
|
+
private rateLimiter;
|
|
114
|
+
private listeners;
|
|
115
|
+
constructor(config?: BaileysProviderConfig);
|
|
116
|
+
get connection(): unknown | null;
|
|
117
|
+
/**
|
|
118
|
+
* The bot's canonical id: its normalised PN (`creds.me.id` without the device
|
|
119
|
+
* suffix), the same id its own reactions and votes carry. Core matches
|
|
120
|
+
* `groupMode: 'mentions'` against it, and mentions are canonical too.
|
|
121
|
+
*/
|
|
122
|
+
get selfId(): UserId | null;
|
|
123
|
+
get socket(): ReturnType<typeof makeWASocket> | null;
|
|
124
|
+
get auth(): {
|
|
125
|
+
state: import("@whiskeysockets/baileys").AuthenticationState;
|
|
126
|
+
saveCreds: () => Promise<void>;
|
|
127
|
+
};
|
|
128
|
+
get sessionDir(): string;
|
|
129
|
+
onEvent(listener: (e: InboundEvent<WAMessage>) => void | Promise<void>): () => void;
|
|
130
|
+
private fan;
|
|
131
|
+
start(): Promise<void>;
|
|
132
|
+
stop(): Promise<void>;
|
|
133
|
+
/**
|
|
134
|
+
* Explicit destructive logout. Unlike {@link stop}, this unlinks the device
|
|
135
|
+
* and removes its local auth state, so a new QR/pairing flow will be required.
|
|
136
|
+
*/
|
|
137
|
+
logoutAndDeleteSession(): Promise<void>;
|
|
138
|
+
send(chatId: ChatId, content: OutboundContent, options?: SendOptions): Promise<SendResult<WAMessage>>;
|
|
139
|
+
/**
|
|
140
|
+
* Download inbound media. `ref.token` is the original `WAMessage` (set by the
|
|
141
|
+
* inbound mapper); expired media is re-requested from the sender's phone.
|
|
142
|
+
*/
|
|
143
|
+
downloadMedia(ref: MediaRef): Promise<Buffer | NodeJS.ReadableStream>;
|
|
144
|
+
/**
|
|
145
|
+
* Durable inbox: the media content (no thumbnail, caption or quoted context) and the raw
|
|
146
|
+
* message key, enough to download the media again in another process. `undefined` for
|
|
147
|
+
* view-once media unless `durableMedia.viewOnce` is set.
|
|
148
|
+
*/
|
|
149
|
+
serializeMediaRef(ref: MediaRef): SerializedMediaRef | undefined;
|
|
150
|
+
/**
|
|
151
|
+
* Rebuild the token of a replayed media message: the original `WAMessage` while it is still
|
|
152
|
+
* in this process's message cache, otherwise one holding only the journaled media content.
|
|
153
|
+
* Expired CDN media is re-requested from the sender's phone on download, as for live media.
|
|
154
|
+
* @throws {ProviderError} on an invalid, unknown-version or tampered reference.
|
|
155
|
+
*/
|
|
156
|
+
rehydrateMediaRef(stored: SerializedMediaRef, ref: Omit<MediaRef, 'token'>): MediaRef;
|
|
157
|
+
markRead(refs: MessageRef[]): Promise<void>;
|
|
158
|
+
typing(chatId: ChatId, state?: 'typing' | 'recording' | 'paused'): Promise<void>;
|
|
159
|
+
deleteMessage(ref: MessageRef): Promise<void>;
|
|
160
|
+
private resolveJid;
|
|
161
|
+
/**
|
|
162
|
+
* Resolve a LID to its phone-number JID, or `undefined` when no mapping is
|
|
163
|
+
* known (e.g. a username-only contact). The mapping store answers with a
|
|
164
|
+
* device-qualified JID (`<user>:<device>@s.whatsapp.net`); it is normalised to
|
|
165
|
+
* the user JID so the cache, the identity store and outbound sends agree.
|
|
166
|
+
*/
|
|
167
|
+
getPNForLID(lid: string): Promise<string | undefined>;
|
|
168
|
+
private cacheLid;
|
|
169
|
+
private cacheLidMapping;
|
|
170
|
+
private rememberMessage;
|
|
171
|
+
private buildSocket;
|
|
172
|
+
/**
|
|
173
|
+
* Translate one batch of Baileys events. Every user/chat id emitted here goes
|
|
174
|
+
* through the same LID/PN canonicalisation as messages ({@link canonicalKey} /
|
|
175
|
+
* {@link IdentityStore.resolve}), whatever the event kind.
|
|
176
|
+
*/
|
|
177
|
+
private handleEvents;
|
|
178
|
+
private acceptsChat;
|
|
179
|
+
private onMessagesUpsert;
|
|
180
|
+
private onPollVote;
|
|
181
|
+
/**
|
|
182
|
+
* Track a poll creation message so its votes can be decrypted, persisting it
|
|
183
|
+
* when a {@link PollStore} is configured. A poll id already in the store keeps
|
|
184
|
+
* the stored poll: like the in-memory cache, a later poll reusing an id (ids
|
|
185
|
+
* are chosen by the sending client) never replaces the first one.
|
|
186
|
+
*/
|
|
187
|
+
private rememberPoll;
|
|
188
|
+
/**
|
|
189
|
+
* Load a poll from the {@link PollStore} into the in-memory cache. A
|
|
190
|
+
* malformed entry is deleted. Store failures are logged and read as a miss,
|
|
191
|
+
* unless `rethrow` is set.
|
|
192
|
+
*/
|
|
193
|
+
private loadPoll;
|
|
194
|
+
private readPoll;
|
|
195
|
+
/**
|
|
196
|
+
* Persist a poll (creation or new tally). Saves of one poll run one after
|
|
197
|
+
* another and each serialises the poll when it runs, so the last write always
|
|
198
|
+
* holds the latest tally. Failures are logged, never thrown.
|
|
199
|
+
*/
|
|
200
|
+
private savePoll;
|
|
201
|
+
private onReactions;
|
|
202
|
+
private onCalls;
|
|
203
|
+
private onGroupParticipants;
|
|
204
|
+
private onPresence;
|
|
205
|
+
private seedIdentity;
|
|
206
|
+
/**
|
|
207
|
+
* Look up the PN of bare-LID users a message quotes or mentions, so
|
|
208
|
+
* `quoted.senderId` and `mentions` canonicalise like the sender does.
|
|
209
|
+
*/
|
|
210
|
+
private seedQuotedIdentity;
|
|
211
|
+
/**
|
|
212
|
+
* Canonical id for a bare JID from a non-message event. `alt` is the twin the
|
|
213
|
+
* event carries (PN or LID); without a PN twin, a LID is first looked up in
|
|
214
|
+
* the signal mapping store, as for message keys.
|
|
215
|
+
*/
|
|
216
|
+
private canonicalId;
|
|
217
|
+
/** The bot's own canonical id and the JID forms (LID, PN) it signs with. */
|
|
218
|
+
private self;
|
|
219
|
+
/** Normalised JIDs plus their learned twins, deduplicated, in order. */
|
|
220
|
+
private jidCandidates;
|
|
221
|
+
/**
|
|
222
|
+
* The JID forms of a poll's creator per a message key: the bot (both forms)
|
|
223
|
+
* for its own polls, otherwise the group participant or the DM partner, with
|
|
224
|
+
* learned twins. Works for the poll's own key and for a vote's
|
|
225
|
+
* `pollCreationMessageKey` (which Baileys rewrites to our perspective:
|
|
226
|
+
* `fromMe` when the bot created it, the voter as participant when unset).
|
|
227
|
+
*/
|
|
228
|
+
private creatorForms;
|
|
229
|
+
/** Whether two JID-form lists name the same user or chat. */
|
|
230
|
+
private overlaps;
|
|
231
|
+
private mapReason;
|
|
232
|
+
private onConnectionUpdate;
|
|
233
|
+
private scheduleReconnect;
|
|
234
|
+
/** Drop a scheduled reconnect, so a later start() does not race a stale timer into a second socket. */
|
|
235
|
+
private cancelReconnect;
|
|
236
|
+
/**
|
|
237
|
+
* A `start()` invoked from a scheduled reconnect rejected BEFORE a socket was
|
|
238
|
+
* built (e.g. `fetchLatestBaileysVersion()` hit a network blip, or the auth
|
|
239
|
+
* state failed to load). No socket means no future `connection: 'close'` event
|
|
240
|
+
* will ever fire to drive the retry — so if we merely logged here the provider
|
|
241
|
+
* would stay dead permanently. We must reschedule the retry ourselves, with
|
|
242
|
+
* backoff, until the cap is reached.
|
|
243
|
+
*/
|
|
244
|
+
private onReconnectStartFailed;
|
|
245
|
+
private renderQr;
|
|
246
|
+
}
|