whalibmob 5.19.4 → 5.21.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/README.md +125 -9
- package/cli.js +157 -1
- package/index.js +4 -0
- package/lib/Client.js +75 -3
- package/lib/QrPairing.js +60 -0
- package/lib/fcm-mcs.js +360 -0
- package/lib/fcm.js +40 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ If you want News about whalibmob enter this whalibmob channel: https://t.me/+sHN
|
|
|
28
28
|
|
|
29
29
|
- whalibmob does not require a browser, Selenium, or any other external runtime — it communicates directly with WhatsApp using a **TCP socket** and the **Noise Protocol** handshake.
|
|
30
30
|
- The library operates as a real **iOS mobile device**, using the Mobile API endpoint, which behaves differently from the Web API.
|
|
31
|
-
- It **also speaks WhatsApp Web over a WebSocket**. When a number cannot receive an SMS, or is already in use on a phone, whalibmob can link itself to that existing account with an **8-character pairing code** and run as one of its linked devices — with full message history and the account's address book. See [Linking to an Existing Account](#linking-to-an-existing-account-pairing-code). The API is identical in both modes.
|
|
31
|
+
- It **also speaks WhatsApp Web over a WebSocket**. When a number cannot receive an SMS, or is already in use on a phone, whalibmob can link itself to that existing account with an **8-character pairing code** and run as one of its linked devices — with full message history and the account's address book. See [Linking to an Existing Account](#linking-to-an-existing-account-pairing-code-or-qr). The API is identical in both modes.
|
|
32
32
|
- Signal Protocol encryption is **fully inlined** in pure JavaScript — no native binaries, no node-gyp, runs anywhere Node.js runs.
|
|
33
33
|
|
|
34
34
|
## Install
|
|
@@ -130,7 +130,7 @@ npm install -g whalibmob
|
|
|
130
130
|
- [Device Attestation with Frida (optional)](#device-attestation-with-frida-optional)
|
|
131
131
|
- [Connect](#connect)
|
|
132
132
|
- [Client Options](#client-options)
|
|
133
|
-
- [Linking to an Existing Account (Pairing Code)](#linking-to-an-existing-account-pairing-code)
|
|
133
|
+
- [Linking to an Existing Account (Pairing Code or QR)](#linking-to-an-existing-account-pairing-code-or-qr)
|
|
134
134
|
- [Requesting a Pairing Code](#requesting-a-pairing-code)
|
|
135
135
|
- [Reconnecting a Linked Session](#reconnecting-a-linked-session)
|
|
136
136
|
- [Choosing Your Own Code](#choosing-your-own-code)
|
|
@@ -1477,6 +1477,9 @@ wa> /connect 919634847671 pair
|
|
|
1477
1477
|
# link to an existing account by 8-digit pairing code
|
|
1478
1478
|
wa> /pair 919634847671
|
|
1479
1479
|
|
|
1480
|
+
# or link by scanning a QR drawn in the terminal
|
|
1481
|
+
wa> /qrcode 919634847671
|
|
1482
|
+
|
|
1480
1483
|
# with a code you chose yourself (exactly 8 characters)
|
|
1481
1484
|
wa> /pair 919634847671 MYCODE12
|
|
1482
1485
|
|
|
@@ -1602,6 +1605,7 @@ wa> /quit
|
|
|
1602
1605
|
| **Connection** | |
|
|
1603
1606
|
| `/connect <phone> [sms\|pair]` | Connect to WhatsApp — picks the method from the session files when unset |
|
|
1604
1607
|
| `/pair <phone> [code]` | Link to an existing account by 8-digit pairing code |
|
|
1608
|
+
| `/qrcode <phone>` | Link to an existing account by scanning a QR drawn in the terminal |
|
|
1605
1609
|
| `/disconnect` | Disconnect current session |
|
|
1606
1610
|
| `/reconnect` | Force reconnection |
|
|
1607
1611
|
| `/session` | Show session info |
|
|
@@ -1668,6 +1672,27 @@ if (result.status === 'ok') {
|
|
|
1668
1672
|
}
|
|
1669
1673
|
```
|
|
1670
1674
|
|
|
1675
|
+
**Optional — let the code arrive by itself.** The two steps above are the whole flow, and nothing about them changes if you do nothing else. But because registration now sends a Firebase push token (see [The Push Token](#the-push-token)), WhatsApp *may* also deliver the six-digit code as a silent push. Open a listener for it before requesting the code, and the code can come back with nothing typed:
|
|
1676
|
+
|
|
1677
|
+
```js
|
|
1678
|
+
const { receivePushCode } = require('whalibmob')
|
|
1679
|
+
|
|
1680
|
+
// open the listener FIRST, so the push has somewhere to land
|
|
1681
|
+
const codePromise = receivePushCode(store, store.device, { timeoutMs: 180000 })
|
|
1682
|
+
|
|
1683
|
+
await requestSmsCode(store, 'sms') // any method; the push is a copy of the code
|
|
1684
|
+
const code = await codePromise // the six digits, or null if no push came
|
|
1685
|
+
|
|
1686
|
+
if (code) {
|
|
1687
|
+
const result = await verifyCode(store, code)
|
|
1688
|
+
if (result.status === 'ok') saveStore(result.store, sessFile)
|
|
1689
|
+
} else {
|
|
1690
|
+
// no push this time — read the code the ordinary way and call verifyCode(store, code)
|
|
1691
|
+
}
|
|
1692
|
+
```
|
|
1693
|
+
|
|
1694
|
+
This is purely additive: `receivePushCode` resolves `null` on a timeout or any failure, so the plain `requestSmsCode` / `verifyCode` path always stands behind it. Whether WhatsApp sends the silent push is the server's decision — see [Receiving the code over push](#receiving-the-code-over-push-without-typing-it) for what governs it. From the CLI the same thing is one command, `/reg push <phone>`.
|
|
1695
|
+
|
|
1671
1696
|
**When the code is not the end of it.** The server can answer a submitted code with a CAPTCHA, or with a demand for the account's two-step verification PIN. Neither is something the library can work out on its own, so both come back to you through optional handlers:
|
|
1672
1697
|
|
|
1673
1698
|
```js
|
|
@@ -2032,11 +2057,16 @@ The defaults cover an ordinary sender. Raise `sentCacheSize` if you push message
|
|
|
2032
2057
|
|
|
2033
2058
|
If you see it, the cache is smaller than your in-flight window. An entry holds the encoded message, not the media it points at, so entries are small and raising the bound costs little memory.
|
|
2034
2059
|
|
|
2035
|
-
## Linking to an Existing Account (Pairing Code)
|
|
2060
|
+
## Linking to an Existing Account (Pairing Code or QR)
|
|
2036
2061
|
|
|
2037
2062
|
Registering a number over SMS makes whalibmob that number's **own device**. Sometimes that is not what you want — the number is already in use on a phone, or the verification SMS never arrives. For those cases whalibmob can instead connect over a **WebSocket** and link itself to an account that already exists, exactly the way the WhatsApp Web and desktop clients do.
|
|
2038
2063
|
|
|
2039
|
-
|
|
2064
|
+
There are two ways to link, and they reach the same place:
|
|
2065
|
+
|
|
2066
|
+
- **Pairing code** — whalibmob gives you an 8-character code, the owner types it into their phone.
|
|
2067
|
+
- **QR code** — whalibmob draws a QR, the owner scans it with the phone's camera.
|
|
2068
|
+
|
|
2069
|
+
Both link the number as a companion device, both leave the whole library working the same afterwards — same client, same methods, same events. The only difference is what the owner does: type a code, or scan a square.
|
|
2040
2070
|
|
|
2041
2071
|
> [!IMPORTANT]
|
|
2042
2072
|
> The two modes are independent. SMS registration is unchanged and still the default; nothing about it is affected by linking. A single number can even have both a registered session and a linked session — they are stored in separate files and never share state.
|
|
@@ -2075,7 +2105,45 @@ On the phone that owns the number:
|
|
|
2075
2105
|
|
|
2076
2106
|
**WhatsApp → Settings → Linked Devices → Link a device → Link with phone number instead**, then type the code.
|
|
2077
2107
|
|
|
2078
|
-
|
|
2108
|
+
### Linking by QR Code
|
|
2109
|
+
|
|
2110
|
+
The QR path is the scan-to-connect alternative. You **do not** request a pairing code — you just connect and listen for the `qr` event. Because no code is asked for, the server volunteers a QR instead, and whalibmob turns it into a ready-to-render string:
|
|
2111
|
+
|
|
2112
|
+
```js
|
|
2113
|
+
const { WhalibmobClient } = require('whalibmob')
|
|
2114
|
+
const qrcode = require('qrcode-terminal') // npm install qrcode-terminal
|
|
2115
|
+
const path = require('path')
|
|
2116
|
+
|
|
2117
|
+
const client = new WhalibmobClient({
|
|
2118
|
+
sessionDir: path.join(process.env.HOME, '.waSession')
|
|
2119
|
+
})
|
|
2120
|
+
|
|
2121
|
+
// a fresh QR string arrives here, and again each time the previous one expires
|
|
2122
|
+
client.on('qr', ({ qr, remaining }) => {
|
|
2123
|
+
qrcode.generate(qr, { small: true }) // draw it in the terminal
|
|
2124
|
+
console.log('scan it — refreshes on its own,', remaining, 'left before it expires')
|
|
2125
|
+
})
|
|
2126
|
+
|
|
2127
|
+
client.on('qr_timeout', () => console.log('QR set expired — reconnect for a fresh one'))
|
|
2128
|
+
|
|
2129
|
+
client.on('paired', (p) => console.log('scanned — linked as', p.jid))
|
|
2130
|
+
client.on('connected', () => console.log('ready'))
|
|
2131
|
+
|
|
2132
|
+
// connect as a companion — and DO NOT request a pairing code
|
|
2133
|
+
await client.connectWeb('919634847671', { syncFullHistory: true })
|
|
2134
|
+
```
|
|
2135
|
+
|
|
2136
|
+
On the phone that owns the number:
|
|
2137
|
+
|
|
2138
|
+
**WhatsApp → Settings → Linked Devices → Link a device**, then point the camera at the QR.
|
|
2139
|
+
|
|
2140
|
+
The `qr` string is what a WhatsApp camera reads: `ref,noise,identity,advSecret,platformId` — a one-time ref plus this client's public keys, comma-joined, the same fields WhatsApp Web itself renders. It rotates on its own (about a minute for the first, then shorter) until the account is scanned or the refs run out. `qrcode-terminal` is optional — without it you get the raw string on `qr` and can render it however you like (a PNG, a web page, an image in a chat). To emit the QR as a `wa.me/settings/linked_devices#…` link instead of the bare fields, pass `{ qrWrapUrl: true }` to `connectWeb`.
|
|
2141
|
+
|
|
2142
|
+
Everything after the scan is identical to the pairing-code path: `paired`, a stream restart, then `connected`.
|
|
2143
|
+
|
|
2144
|
+
### After the link — same for both paths
|
|
2145
|
+
|
|
2146
|
+
A few seconds after the code is accepted (or the QR is scanned) you will see `paired`, the server restarts the stream, and `connected` fires on the new connection. From that point on everything else in this document applies unchanged:
|
|
2079
2147
|
|
|
2080
2148
|
```js
|
|
2081
2149
|
await client.sendText('919876543210@s.whatsapp.net', 'sent from a linked device')
|
|
@@ -2119,9 +2187,11 @@ await client.connectWeb(phone, {
|
|
|
2119
2187
|
| Event | Fires when |
|
|
2120
2188
|
|---|---|
|
|
2121
2189
|
| `pairing_code` | a code has been requested — `{ code, phoneNumber }` |
|
|
2122
|
-
| `
|
|
2190
|
+
| `qr` | a QR is ready to render — `{ qr, ref, ttlMs, remaining }`; fires again on each rotation |
|
|
2191
|
+
| `qr_timeout` | the QR refs ran out — reconnect for a fresh set |
|
|
2192
|
+
| `paired` | the owner accepted the code or scanned the QR — `{ jid, lid, deviceIndex, platform }` |
|
|
2123
2193
|
| `restart_required` | the server is restarting the stream after pairing (normal; the reconnect is automatic) |
|
|
2124
|
-
| `pair_device` | the QR
|
|
2194
|
+
| `pair_device` | the raw QR reference strings, before they are turned into `qr` — `{ refs }` |
|
|
2125
2195
|
| `history_sync` | a chunk of history arrived from the phone |
|
|
2126
2196
|
|
|
2127
2197
|
### Getting the History and the Address Book
|
|
@@ -2491,9 +2561,21 @@ If that is refused too, the number has to go through the real app once, on a pho
|
|
|
2491
2561
|
|
|
2492
2562
|
## The Push Token
|
|
2493
2563
|
|
|
2494
|
-
Every WhatsApp on a real phone holds a Firebase push token. It is the address Google uses to wake the app
|
|
2564
|
+
Every WhatsApp on a real phone holds a Firebase push token. It is the address Google uses to wake the app, and no install exists without one — so a registration that ships no `push_token` describes a WhatsApp that cannot be notified, which is a device that does not exist.
|
|
2565
|
+
|
|
2566
|
+
The token does two distinct jobs, and it is easy to conflate them:
|
|
2567
|
+
|
|
2568
|
+
1. **It makes the registration look real — always.** Every `/code` request now carries the token, whichever delivery method you ask for. The server sees an install that can be reached, not a headless client. This is the reason the token matters, and it applies to `sms`, `voice`, `wa_old` — all of them.
|
|
2569
|
+
2. **It can carry the code itself — sometimes.** Because you handed WhatsApp a direct line, WhatsApp *may* also push the six-digit code silently down it, so the app fills the code in on its own. This is why the code auto-completes on a real phone before you have read the SMS. It happens *alongside* the method you chose, not instead of it.
|
|
2495
2570
|
|
|
2496
|
-
|
|
2571
|
+
The delivery method and the push are not alternatives. You still choose how a human receives the code (`sms`, `voice`, `wa_old`); the push, when it comes, is a second silent copy of that same code sent straight to the app.
|
|
2572
|
+
|
|
2573
|
+
```
|
|
2574
|
+
request a code ─┬─ method you chose → reaches a human (SMS, a call, the existing WhatsApp)
|
|
2575
|
+
└─ push_token line → reaches the app (silent, auto-filled — if WhatsApp sends it)
|
|
2576
|
+
```
|
|
2577
|
+
|
|
2578
|
+
Registration fetches a real token and sends it, in three plain HTTPS calls to Google:
|
|
2497
2579
|
|
|
2498
2580
|
| Step | Endpoint | Yields |
|
|
2499
2581
|
|---|---|---|
|
|
@@ -2509,6 +2591,40 @@ It runs once per number. The Firebase identity is cached on the session, because
|
|
|
2509
2591
|
|
|
2510
2592
|
Turn it off with `WA_FCM_PUSH=0`.
|
|
2511
2593
|
|
|
2594
|
+
### Receiving the code over push, without typing it
|
|
2595
|
+
|
|
2596
|
+
This is the receiving end of job 2 above. When WhatsApp sends the code as a silent push, something has to be listening on the Firebase line to catch it — the same long-lived connection every Android phone keeps open to Google. `receivePushCode(store, device)` opens it: a TLS stream to `mtalk.google.com:5228` speaking the MCS protocol, logged in with the Firebase identity, resolving with the code the moment a push carrying it arrives.
|
|
2597
|
+
|
|
2598
|
+
The order matters. Open the listener **first**, so the line is live before the code is requested; then request the code by whatever method; then await it.
|
|
2599
|
+
|
|
2600
|
+
```js
|
|
2601
|
+
const { receivePushCode, requestSmsCode, verifyCode } = require('whalibmob')
|
|
2602
|
+
|
|
2603
|
+
// 1. open the listener first, so the push has somewhere to land
|
|
2604
|
+
const codePromise = receivePushCode(store, store.device, { timeoutMs: 180000 })
|
|
2605
|
+
|
|
2606
|
+
// 2. request the code — any method. The push, if it comes, is a copy of it.
|
|
2607
|
+
await requestSmsCode(store, 'sms') // or 'voice', 'wa_old', …
|
|
2608
|
+
|
|
2609
|
+
// 3. if the push arrives, the code is here with nothing typed
|
|
2610
|
+
const code = await codePromise
|
|
2611
|
+
if (code) await verifyCode(store, code)
|
|
2612
|
+
else { /* no push — read the code from SMS / the existing WhatsApp, then verifyCode */ }
|
|
2613
|
+
```
|
|
2614
|
+
|
|
2615
|
+
From the CLI the whole sequence is one command:
|
|
2616
|
+
|
|
2617
|
+
```
|
|
2618
|
+
/reg push <phone> [sms|voice]
|
|
2619
|
+
```
|
|
2620
|
+
|
|
2621
|
+
It opens the listener, waits until it is logged in, requests the code, and confirms automatically if the push arrives — falling back to `/reg confirm <phone> <code>` when it does not.
|
|
2622
|
+
|
|
2623
|
+
The connection carries a heartbeat and remembers the message ids it has seen, so a reconnect does not re-read a delivered code, the way the native client does. It resolves `null` on timeout, a refused login, or any failure — at which point you simply read the code the ordinary way and verify it. Like the token, it routes through the configured SOCKS proxy.
|
|
2624
|
+
|
|
2625
|
+
> [!IMPORTANT]
|
|
2626
|
+
> Receiving the push is not the same as making WhatsApp send it. Whether WhatsApp pushes the code for a given request is the server's decision, and on a client shipping empty attestation it will often send the code only by the method you asked for (SMS, `wa_old`, a call) and no silent push. This listener catches the push correctly **when one is sent**; it cannot force that channel, and it never replaces the chosen method — it runs beside it. With a valid Play Integrity attestation in the request (`WA_FRIDA_HOST`), the server is more likely to include the silent push.
|
|
2627
|
+
|
|
2512
2628
|
## Routing Traffic Through a Proxy
|
|
2513
2629
|
|
|
2514
2630
|
Two reasons to want this. Registration is the part of the protocol most likely to be refused from a datacenter IP, so a residential exit helps with security blocks and undeterminable number statuses. And on a network that cannot reach WhatsApp at all, nothing works without one.
|
package/cli.js
CHANGED
|
@@ -663,11 +663,13 @@ const HELP = `
|
|
|
663
663
|
/reg check <phone> check if number has WhatsApp
|
|
664
664
|
/reg code <phone> [sms|voice|wa_old] request verification code
|
|
665
665
|
/reg code <phone> email <address> request code via email
|
|
666
|
+
/reg push <phone> [sms|voice] request code and receive it over Firebase push
|
|
666
667
|
/reg confirm <phone> <code> complete registration
|
|
667
668
|
|
|
668
669
|
Connection
|
|
669
670
|
/connect <phone> [sms|pair] connect to WhatsApp (asks which if unset)
|
|
670
671
|
/pair <phone> [code] link to an existing account by 8-digit code
|
|
672
|
+
/qrcode <phone> link to an existing account by scanning a QR
|
|
671
673
|
/fixnumber re-file a session under the number WhatsApp uses
|
|
672
674
|
/disconnect disconnect
|
|
673
675
|
/reconnect force reconnection
|
|
@@ -1091,6 +1093,75 @@ async function doConnectWeb(phone, opts) {
|
|
|
1091
1093
|
}
|
|
1092
1094
|
}
|
|
1093
1095
|
|
|
1096
|
+
// Link by QR instead of a pairing code. Same companion connection, but nothing
|
|
1097
|
+
// asks for a code — the server volunteers a pair-device, the client turns each
|
|
1098
|
+
// ref into a QR, and this draws it in the terminal for the phone to scan.
|
|
1099
|
+
async function doConnectWebQr(phone) {
|
|
1100
|
+
phone = normalizePhone(phone);
|
|
1101
|
+
const client = new WhalibmobClient({ sessionDir: _sessDir });
|
|
1102
|
+
attachEvents(client);
|
|
1103
|
+
|
|
1104
|
+
let renderQr = null;
|
|
1105
|
+
try {
|
|
1106
|
+
const qrcode = require('qrcode-terminal');
|
|
1107
|
+
renderQr = (text) => qrcode.generate(text, { small: true }, (art) => out('\n' + art));
|
|
1108
|
+
} catch (_) {
|
|
1109
|
+
out(' (qrcode-terminal is not installed — printing the raw QR string instead)');
|
|
1110
|
+
out(' install it for a scannable image: npm install qrcode-terminal');
|
|
1111
|
+
renderQr = (text) => { out(''); out(text); out(''); };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
client.on('qr', ({ qr, remaining }) => {
|
|
1115
|
+
out('');
|
|
1116
|
+
hr();
|
|
1117
|
+
out(' scan this from the phone that owns +' + phone + ':');
|
|
1118
|
+
out(' WhatsApp → Settings → Linked Devices → Link a device');
|
|
1119
|
+
hr();
|
|
1120
|
+
renderQr(qr);
|
|
1121
|
+
out(' the code refreshes on its own' +
|
|
1122
|
+
(remaining ? ' (' + remaining + ' more before it expires)' : '') + '; waiting...');
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
client.on('qr_timeout', () => {
|
|
1126
|
+
out(' the QR set expired — run /qrcode ' + phone + ' again for a fresh one');
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
client.once('paired', (pp) => {
|
|
1130
|
+
out('');
|
|
1131
|
+
out(' scanned — linked as ' + pp.jid + (pp.lid ? ' (' + pp.lid + ')' : ''));
|
|
1132
|
+
out(' device slot ' + pp.deviceIndex + (pp.platform ? ' · primary is ' + pp.platform : ''));
|
|
1133
|
+
out(' finishing handshake...');
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
client.on('history_sync', (r) => {
|
|
1137
|
+
out(' history ' + r.syncTypeName +
|
|
1138
|
+
' chats=' + r.chats.length + ' contacts=' + r.contacts.length);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
client.once('connected', () => {
|
|
1142
|
+
_client = client;
|
|
1143
|
+
_phone = phone;
|
|
1144
|
+
out('connected as +' + phone + ' (web / companion)');
|
|
1145
|
+
_rl.setPrompt('wa +' + phone + '> ');
|
|
1146
|
+
_rl.prompt();
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
const alreadyLinked = hasWebSession(phone);
|
|
1150
|
+
try {
|
|
1151
|
+
// connectWeb WITHOUT requestPairingCode — that is what makes the server
|
|
1152
|
+
// offer the QR pair-device instead of waiting on a code.
|
|
1153
|
+
await client.connectWeb(phone, { syncFullHistory: true });
|
|
1154
|
+
if (alreadyLinked) out('session already linked — reconnecting');
|
|
1155
|
+
|
|
1156
|
+
const keepAlive = setInterval(() => {}, 10000);
|
|
1157
|
+
client.once('connected', () => clearInterval(keepAlive));
|
|
1158
|
+
client.once('auth_failure', () => clearInterval(keepAlive));
|
|
1159
|
+
} catch (e) {
|
|
1160
|
+
fail(e.message);
|
|
1161
|
+
notConnected();
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1094
1165
|
async function doConnect(phone) {
|
|
1095
1166
|
phone = normalizePhone(phone);
|
|
1096
1167
|
|
|
@@ -1323,6 +1394,23 @@ async function handleLine(line) {
|
|
|
1323
1394
|
break;
|
|
1324
1395
|
}
|
|
1325
1396
|
|
|
1397
|
+
case '/qrcode':
|
|
1398
|
+
case '/qr': {
|
|
1399
|
+
const ph = p[1] || _phone;
|
|
1400
|
+
if (!ph) {
|
|
1401
|
+
fail('usage: /qrcode <phone>');
|
|
1402
|
+
out(' links the number as a companion by QR instead of a pairing code');
|
|
1403
|
+
out(' a QR is drawn in the terminal — scan it from the phone that owns');
|
|
1404
|
+
out(' the number: WhatsApp → Linked Devices → Link a device');
|
|
1405
|
+
out(' example: /qrcode 40756469325');
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1408
|
+
if (_client && _client.connected) { fail('already connected — /disconnect first'); break; }
|
|
1409
|
+
out('connecting...');
|
|
1410
|
+
await doConnectWebQr(ph);
|
|
1411
|
+
break;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1326
1414
|
case '/disconnect':
|
|
1327
1415
|
if (_client) { _client.disconnect(); _client = null; _phone = null; }
|
|
1328
1416
|
_rl.setPrompt('wa> ');
|
|
@@ -2329,8 +2417,76 @@ async function handleLine(line) {
|
|
|
2329
2417
|
fail('verification failed ' + JSON.stringify(r));
|
|
2330
2418
|
}
|
|
2331
2419
|
}
|
|
2420
|
+
else if (sub === 'push') {
|
|
2421
|
+
// Full push flow: open the MCS listener, request the code, and wait
|
|
2422
|
+
// for it to arrive over Firebase instead of by SMS. Falls back to the
|
|
2423
|
+
// ordinary code path automatically when no push comes.
|
|
2424
|
+
const ph = normalizePhone(p[2]);
|
|
2425
|
+
if (!ph) {
|
|
2426
|
+
fail('usage: /reg push <phone> [sms|voice] [--name "Your Name"]');
|
|
2427
|
+
out(' opens the Firebase push listener, requests a code, and waits for');
|
|
2428
|
+
out(' it to arrive over push. If it does, registration is confirmed');
|
|
2429
|
+
out(' automatically. If no push comes, request the code normally with');
|
|
2430
|
+
out(' /reg code and confirm it with /reg confirm.');
|
|
2431
|
+
break;
|
|
2432
|
+
}
|
|
2433
|
+
const method = (p[3] && !p[3].startsWith('--')) ? p[3] : 'sms';
|
|
2434
|
+
const { receivePushCode } = require('./lib/fcm');
|
|
2435
|
+
|
|
2436
|
+
sessionDirFor(_sessDir, ph, { create: true });
|
|
2437
|
+
const sessFile = storeFileFor(_sessDir, ph);
|
|
2438
|
+
let store = loadStore(sessFile);
|
|
2439
|
+
if (!store) { store = initAuthCreds(ph, { name: regName }); saveStore(store, sessFile); }
|
|
2440
|
+
if (!store.device) store.device = getDeviceConfig();
|
|
2441
|
+
|
|
2442
|
+
out('opening Firebase push listener (this can take a moment)...');
|
|
2443
|
+
// Open the listener first so the push has somewhere to land. onReady
|
|
2444
|
+
// fires once MCS is logged in — only then is it safe to ask for the
|
|
2445
|
+
// code.
|
|
2446
|
+
let ready = false;
|
|
2447
|
+
const codePromise = receivePushCode(store, store.device, {
|
|
2448
|
+
timeoutMs: 180000,
|
|
2449
|
+
onReady: () => { ready = true; out(' push listener ready — requesting code'); }
|
|
2450
|
+
});
|
|
2451
|
+
|
|
2452
|
+
// Give the listener a few seconds to log in before requesting. If it
|
|
2453
|
+
// has not, request anyway — SMS still works, and the push may yet come.
|
|
2454
|
+
const waitReady = async () => {
|
|
2455
|
+
for (let i = 0; i < 40 && !ready; i++) await new Promise(r => setTimeout(r, 250));
|
|
2456
|
+
};
|
|
2457
|
+
await waitReady();
|
|
2458
|
+
if (!ready) out(' listener not ready yet — requesting code anyway (SMS fallback stands)');
|
|
2459
|
+
|
|
2460
|
+
out('requesting ' + method + ' code for +' + ph + '...');
|
|
2461
|
+
const r = await requestSmsCode(store, method, { onProgress: out, name: regName });
|
|
2462
|
+
store.codePending = true;
|
|
2463
|
+
saveStore(store, sessFile);
|
|
2464
|
+
out(' status ' + (r && r.status));
|
|
2465
|
+
|
|
2466
|
+
out('waiting for the code over push (up to 3 min; Ctrl-C to stop and use /reg confirm)...');
|
|
2467
|
+
const code = await codePromise;
|
|
2468
|
+
if (!code) {
|
|
2469
|
+
out('no code arrived over push — WhatsApp likely sent it by SMS.');
|
|
2470
|
+
out(' read the SMS and run: /reg confirm ' + ph + ' <code>');
|
|
2471
|
+
saveStore(store, sessFile);
|
|
2472
|
+
break;
|
|
2473
|
+
}
|
|
2474
|
+
out('code received over push: ' + code + ' — confirming...');
|
|
2475
|
+
const v = await verifyCode(store, code,
|
|
2476
|
+
Object.assign(registrationPrompts(), { onProgress: out, name: regName }));
|
|
2477
|
+
if (v && (v.status === 'ok' || v.status === 'sent' || v.status === 'verified')) {
|
|
2478
|
+
const finalStore = v.store || store;
|
|
2479
|
+
finalStore.registered = true; finalStore.codePending = false;
|
|
2480
|
+
const savedPhone = String(finalStore.phoneNumber || ph);
|
|
2481
|
+
saveStore(finalStore, storeFileFor(_sessDir, savedPhone));
|
|
2482
|
+
out('registered via push session saved');
|
|
2483
|
+
out('now run: /connect ' + savedPhone);
|
|
2484
|
+
} else {
|
|
2485
|
+
fail('verification failed ' + JSON.stringify(v));
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2332
2488
|
else {
|
|
2333
|
-
fail('usage: /reg check|code|confirm ...');
|
|
2489
|
+
fail('usage: /reg check|code|push|confirm ...');
|
|
2334
2490
|
}
|
|
2335
2491
|
break;
|
|
2336
2492
|
}
|
package/index.js
CHANGED
|
@@ -61,6 +61,10 @@ module.exports = {
|
|
|
61
61
|
checkIfRegistered,
|
|
62
62
|
requestSmsCode,
|
|
63
63
|
verifyCode,
|
|
64
|
+
// Receive the verification code as a silent Firebase push, without an SMS —
|
|
65
|
+
// opens the MCS listener the native client keeps to Google. See "Receiving
|
|
66
|
+
// the code over push" in the README.
|
|
67
|
+
receivePushCode: (store, device, opts) => require('./lib/fcm').receivePushCode(store, device, opts),
|
|
64
68
|
assertRegistrationKeys,
|
|
65
69
|
// Version fetch — use fetchWaVersion for device-aware (iOS or Android) fetching.
|
|
66
70
|
// fetchIosVersion is kept for backward compatibility.
|
package/lib/Client.js
CHANGED
|
@@ -840,6 +840,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
840
840
|
|
|
841
841
|
if (opts.syncFullHistory !== undefined) this._store.syncFullHistory = !!opts.syncFullHistory;
|
|
842
842
|
if (opts.browser) this._store.browser = opts.browser;
|
|
843
|
+
// Emit the QR as a wa.me/settings/linked_devices# link rather than the bare
|
|
844
|
+
// comma-joined fields. Off by default — the bare form is what WhatsApp Web
|
|
845
|
+
// itself renders and the most widely scannable.
|
|
846
|
+
if (opts.qrWrapUrl) this._qrWrapUrl = true;
|
|
843
847
|
|
|
844
848
|
// Announce the revision the web client is actually on.
|
|
845
849
|
//
|
|
@@ -1177,6 +1181,8 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1177
1181
|
* that is when history starts arriving.
|
|
1178
1182
|
*/
|
|
1179
1183
|
_handlePairSuccess(node) {
|
|
1184
|
+
// The phone accepted a scan (or a code) — no more QRs to show.
|
|
1185
|
+
this._stopQrRotation();
|
|
1180
1186
|
const { configureSuccessfulPairing } = require('./CompanionPairing');
|
|
1181
1187
|
try {
|
|
1182
1188
|
const result = configureSuccessfulPairing(node, {
|
|
@@ -1222,9 +1228,15 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1222
1228
|
/**
|
|
1223
1229
|
* <iq><pair-device> — the QR path.
|
|
1224
1230
|
*
|
|
1225
|
-
*
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
1231
|
+
* The server volunteers this to an unpaired companion that has not asked for
|
|
1232
|
+
* a pairing code. It carries a list of one-time refs; each pairs with our
|
|
1233
|
+
* keys to form one QR string. The node has to be acknowledged or the server
|
|
1234
|
+
* keeps resending it.
|
|
1235
|
+
*
|
|
1236
|
+
* The refs are surfaced raw on `pair_device` for anything that wants them,
|
|
1237
|
+
* and turned into ready-to-render QR strings on `qr` — one now, then a fresh
|
|
1238
|
+
* one each time the current ref expires, until the list runs out. Scanning
|
|
1239
|
+
* any of them links the device.
|
|
1228
1240
|
*/
|
|
1229
1241
|
_handlePairDevice(node) {
|
|
1230
1242
|
const id = node.attrs && node.attrs.id;
|
|
@@ -1240,6 +1252,65 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1240
1252
|
.map(c => (Buffer.isBuffer(c.content) ? c.content.toString('utf8') : String(c.content)))
|
|
1241
1253
|
: [];
|
|
1242
1254
|
this.emit('pair_device', { refs });
|
|
1255
|
+
|
|
1256
|
+
this._startQrRotation(refs);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Turn the server's refs into QR strings and emit them one at a time.
|
|
1260
|
+
//
|
|
1261
|
+
// A ref is single-use and short-lived, so the first is shown at once and each
|
|
1262
|
+
// later one only when the current has aged out — mirroring the phone-facing
|
|
1263
|
+
// client, which shows a QR for ~60s and then rotates. When the refs run out
|
|
1264
|
+
// the account has to reconnect for a fresh set, which is surfaced as
|
|
1265
|
+
// `qr_timeout` rather than left hanging.
|
|
1266
|
+
_startQrRotation(refs) {
|
|
1267
|
+
this._stopQrRotation();
|
|
1268
|
+
if (!Array.isArray(refs) || refs.length === 0) return;
|
|
1269
|
+
|
|
1270
|
+
const { buildQrData } = require('./QrPairing');
|
|
1271
|
+
const crypto = require('crypto');
|
|
1272
|
+
|
|
1273
|
+
// The adv secret rides inside the QR and is what the phone signs the
|
|
1274
|
+
// account proof with; pair-success verifies against it. The pairing-code
|
|
1275
|
+
// path derives its own, but the QR path has none until here, so mint a
|
|
1276
|
+
// random 32 bytes once and keep it for the whole attempt.
|
|
1277
|
+
if (!this._store.advSecretKey) {
|
|
1278
|
+
this._store.advSecretKey = crypto.randomBytes(32).toString('base64');
|
|
1279
|
+
this._saveWebStore();
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const { WEB_BROWSER } = require('./constants');
|
|
1283
|
+
const noiseB64 = stripKeyPrefix(this._store.noiseKeyPair.public).toString('base64');
|
|
1284
|
+
const identityB64 = stripKeyPrefix(this._store.identityKeyPair.public).toString('base64');
|
|
1285
|
+
const advB64 = this._store.advSecretKey;
|
|
1286
|
+
const browser = this._store.browser || WEB_BROWSER;
|
|
1287
|
+
const wrap = !!this._qrWrapUrl;
|
|
1288
|
+
|
|
1289
|
+
const queue = refs.slice();
|
|
1290
|
+
let first = true;
|
|
1291
|
+
|
|
1292
|
+
const showNext = () => {
|
|
1293
|
+
const ref = queue.shift();
|
|
1294
|
+
if (!ref) {
|
|
1295
|
+
_whaDbg('[DBG] QR refs exhausted — reconnect for a fresh set');
|
|
1296
|
+
this._stopQrRotation();
|
|
1297
|
+
this.emit('qr_timeout');
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const qr = buildQrData(ref, noiseB64, identityB64, advB64, browser, { wrap });
|
|
1301
|
+
// 60s for the first, 20s for each after — the reference client's cadence.
|
|
1302
|
+
const ttlMs = first ? 60000 : 20000;
|
|
1303
|
+
first = false;
|
|
1304
|
+
_whaDbg('[DBG] QR emitted (ref …' + ref.slice(-6) + ', ' + queue.length + ' left)');
|
|
1305
|
+
this.emit('qr', { qr, ref, ttlMs, remaining: queue.length });
|
|
1306
|
+
this._qrTimer = setTimeout(showNext, ttlMs);
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
showNext();
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
_stopQrRotation() {
|
|
1313
|
+
if (this._qrTimer) { clearTimeout(this._qrTimer); this._qrTimer = null; }
|
|
1243
1314
|
}
|
|
1244
1315
|
|
|
1245
1316
|
/**
|
|
@@ -1325,6 +1396,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1325
1396
|
if (!(opts && opts.reconnect)) this._closing = true;
|
|
1326
1397
|
this._reconnecting = false;
|
|
1327
1398
|
this._reconnectTry = 0;
|
|
1399
|
+
this._stopQrRotation();
|
|
1328
1400
|
this._stopTimers();
|
|
1329
1401
|
if (this._socket) {
|
|
1330
1402
|
try { this._socket.close(); } catch (_) {}
|
package/lib/QrPairing.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// QR-code companion login, the scan-to-connect alternative to the pairing code.
|
|
4
|
+
//
|
|
5
|
+
// Both paths link the same way — as a companion device on the web transport —
|
|
6
|
+
// and diverge only in how the primary phone is told to trust this client:
|
|
7
|
+
//
|
|
8
|
+
// pairing code the client asks for an 8-character code, the user types it
|
|
9
|
+
// into WhatsApp → Linked devices → Link with phone number
|
|
10
|
+
// QR code the server volunteers a set of refs, the client renders one
|
|
11
|
+
// as a QR, the user points the phone's camera at it
|
|
12
|
+
//
|
|
13
|
+
// The QR is a short string the WhatsApp camera reads. It carries everything the
|
|
14
|
+
// phone needs to authorise this device: a one-time ref, this client's public
|
|
15
|
+
// noise and identity keys, and the adv secret it will sign the account proof
|
|
16
|
+
// with. The phone does the linking; the client only has to present the string.
|
|
17
|
+
//
|
|
18
|
+
// The wire format is the one the native web client and every maintained web
|
|
19
|
+
// library produce:
|
|
20
|
+
//
|
|
21
|
+
// ref,<noise pub b64>,<identity pub b64>,<adv secret b64>,<platform id>
|
|
22
|
+
//
|
|
23
|
+
// The platform id is the companion "web client type": Chrome is 1, Edge 2,
|
|
24
|
+
// Firefox 3, and so on. WhatsApp Web itself renders exactly these comma-joined
|
|
25
|
+
// fields, so that is the default; some clients wrap them in a
|
|
26
|
+
// wa.me/settings/linked_devices# link, which is offered as an option.
|
|
27
|
+
|
|
28
|
+
// Companion web-client type, keyed by browser name. Matches the ids the phone
|
|
29
|
+
// expects; anything unknown is "other web client".
|
|
30
|
+
const COMPANION_WEB_CLIENT = {
|
|
31
|
+
CHROME: 1, EDGE: 2, FIREFOX: 3, IE: 4, OPERA: 5, SAFARI: 6,
|
|
32
|
+
ELECTRON: 7, UWP: 8, OTHER: 9
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// A [os, browserName] description → the numeric platform id in the QR.
|
|
36
|
+
function companionPlatformId(browser) {
|
|
37
|
+
const os = browser && browser[0];
|
|
38
|
+
const name = String((browser && browser[1]) || 'Chrome');
|
|
39
|
+
if (name === 'Desktop') return os === 'Windows' ? COMPANION_WEB_CLIENT.UWP : COMPANION_WEB_CLIENT.ELECTRON;
|
|
40
|
+
const key = name.toUpperCase();
|
|
41
|
+
return COMPANION_WEB_CLIENT[key] !== undefined ? COMPANION_WEB_CLIENT[key] : COMPANION_WEB_CLIENT.OTHER;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the string a WhatsApp camera scans to link this device.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} ref one ref from the server's pair-device node
|
|
48
|
+
* @param {string} noisePubB64 base64 of the 32-byte noise public key
|
|
49
|
+
* @param {string} identityPubB64 base64 of the 32-byte signed-identity public key
|
|
50
|
+
* @param {string} advB64 base64 of the 32-byte adv secret
|
|
51
|
+
* @param {Array} browser [os, browserName, version]
|
|
52
|
+
* @param {object} [opts] { wrap } — prefix the wa.me linked-devices URL
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function buildQrData(ref, noisePubB64, identityPubB64, advB64, browser, opts) {
|
|
56
|
+
const fields = [ref, noisePubB64, identityPubB64, advB64, String(companionPlatformId(browser))].join(',');
|
|
57
|
+
return (opts && opts.wrap) ? 'https://wa.me/settings/linked_devices#' + fields : fields;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { buildQrData, companionPlatformId, COMPANION_WEB_CLIENT };
|
package/lib/fcm-mcs.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCS — the long-lived Firebase connection that carries WhatsApp's verification
|
|
4
|
+
// code as a silent push, so a number can be registered without an SMS.
|
|
5
|
+
//
|
|
6
|
+
// The push token from lib/fcm.js is the address; this is the mailbox. WhatsApp
|
|
7
|
+
// sends the six-digit code as a data message over Firebase Cloud Messaging, and
|
|
8
|
+
// the only way to receive it is to hold a connection open to Google's MCS
|
|
9
|
+
// endpoint and speak its binary protocol — the same one every Android phone
|
|
10
|
+
// keeps running in the background.
|
|
11
|
+
//
|
|
12
|
+
// The wire format, tag for tag, is WhatsApp's own client's:
|
|
13
|
+
//
|
|
14
|
+
// connect TLS to mtalk.google.com:5228
|
|
15
|
+
// → write one version byte (41), then a LoginRequest frame
|
|
16
|
+
// ← read one version byte, then a LoginResponse frame
|
|
17
|
+
// then frames both ways: [tag:1][length:varint][protobuf payload]
|
|
18
|
+
//
|
|
19
|
+
// tags: 0 heartbeat-ping 1 heartbeat-ack 2 login-request
|
|
20
|
+
// 3 login-response 4 close 7 iq 8 data-message
|
|
21
|
+
//
|
|
22
|
+
// The code rides on a data-message whose appData carries a
|
|
23
|
+
// key="registration_code". Everything else — heartbeats, iqs, acks — is
|
|
24
|
+
// bookkeeping that keeps the stream alive long enough to receive it.
|
|
25
|
+
//
|
|
26
|
+
// The codec is hand-written for the handful of messages involved and driven off
|
|
27
|
+
// the event loop; the bytes on the wire are the ones the native Android client
|
|
28
|
+
// produces.
|
|
29
|
+
//
|
|
30
|
+
// Nothing here is reachable without a push token, and the whole path is
|
|
31
|
+
// optional: registration falls back to SMS whenever any of it fails.
|
|
32
|
+
|
|
33
|
+
const tls = require('tls');
|
|
34
|
+
const { dbg: _whaDbg } = require('./logger');
|
|
35
|
+
|
|
36
|
+
const MCS_HOST = 'mtalk.google.com';
|
|
37
|
+
const MCS_PORT = 5228;
|
|
38
|
+
const MCS_VERSION = 41;
|
|
39
|
+
|
|
40
|
+
const TAG_HEARTBEAT_PING = 0;
|
|
41
|
+
const TAG_HEARTBEAT_ACK = 1;
|
|
42
|
+
const TAG_LOGIN_REQUEST = 2;
|
|
43
|
+
const TAG_LOGIN_RESPONSE = 3;
|
|
44
|
+
const TAG_CLOSE = 4;
|
|
45
|
+
const TAG_IQ_STANZA = 7;
|
|
46
|
+
const TAG_DATA_MESSAGE = 8;
|
|
47
|
+
|
|
48
|
+
// Ten minutes, matching the native client. Google closes an idle stream, and a
|
|
49
|
+
// ping well inside its window keeps it open without chattering.
|
|
50
|
+
const HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
// The last N persistent ids are echoed on the next login so the server does not
|
|
53
|
+
// redeliver messages already seen. A short buffer is enough for a code fetch.
|
|
54
|
+
const PERSISTENT_ID_BUFFER = 50;
|
|
55
|
+
|
|
56
|
+
// The appData key WhatsApp files the verification code under.
|
|
57
|
+
const PUSH_CODE_APP_DATA_KEY = 'registration_code';
|
|
58
|
+
|
|
59
|
+
// ─── minimal protobuf codec ──────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function encodeVarint(value) {
|
|
62
|
+
let v = typeof value === 'bigint' ? value : BigInt(Math.trunc(value));
|
|
63
|
+
if (v < 0n) v += 1n << 64n;
|
|
64
|
+
const out = [];
|
|
65
|
+
do {
|
|
66
|
+
let b = Number(v & 0x7fn);
|
|
67
|
+
v >>= 7n;
|
|
68
|
+
if (v > 0n) b |= 0x80;
|
|
69
|
+
out.push(b);
|
|
70
|
+
} while (v > 0n);
|
|
71
|
+
return Buffer.from(out);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const pbTag = (field, wire) => encodeVarint((field << 3) | wire);
|
|
75
|
+
const pbInt = (field, n) => Buffer.concat([pbTag(field, 0), encodeVarint(n)]);
|
|
76
|
+
function pbStr(field, s) {
|
|
77
|
+
const b = Buffer.from(String(s), 'utf8');
|
|
78
|
+
return Buffer.concat([pbTag(field, 2), encodeVarint(b.length), b]);
|
|
79
|
+
}
|
|
80
|
+
function pbMsg(field, buf) {
|
|
81
|
+
return Buffer.concat([pbTag(field, 2), encodeVarint(buf.length), buf]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Read a protobuf message into { field: value | [values] }. Length-delimited
|
|
85
|
+
// fields come back as Buffers, varints as BigInt, so a 64-bit id never loses
|
|
86
|
+
// precision. Repeated fields collect into arrays.
|
|
87
|
+
function pbDecode(buf) {
|
|
88
|
+
const out = {};
|
|
89
|
+
let i = 0;
|
|
90
|
+
const put = (f, v) => {
|
|
91
|
+
if (out[f] === undefined) out[f] = v;
|
|
92
|
+
else if (Array.isArray(out[f])) out[f].push(v);
|
|
93
|
+
else out[f] = [out[f], v];
|
|
94
|
+
};
|
|
95
|
+
const varint = () => {
|
|
96
|
+
let r = 0n, s = 0n;
|
|
97
|
+
while (i < buf.length) { const b = buf[i++]; r |= BigInt(b & 0x7f) << s; s += 7n; if (!(b & 0x80)) break; }
|
|
98
|
+
return r;
|
|
99
|
+
};
|
|
100
|
+
while (i < buf.length) {
|
|
101
|
+
const key = Number(varint()), field = key >> 3, wire = key & 7;
|
|
102
|
+
if (wire === 0) put(field, varint());
|
|
103
|
+
else if (wire === 2) { const n = Number(varint()); put(field, buf.slice(i, i + n)); i += n; }
|
|
104
|
+
else if (wire === 1) { i += 8; }
|
|
105
|
+
else if (wire === 5) { i += 4; }
|
|
106
|
+
else break;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── message builders ────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
function buildLoginRequest(session) {
|
|
114
|
+
const androidId = String(session.androidId);
|
|
115
|
+
const setting = Buffer.concat([pbStr(1, 'new_vc'), pbStr(2, '1')]);
|
|
116
|
+
|
|
117
|
+
const parts = [
|
|
118
|
+
pbStr(1, 'android-30'), // id
|
|
119
|
+
pbStr(2, 'mcs.android.com'), // domain
|
|
120
|
+
pbStr(3, androidId), // user
|
|
121
|
+
pbStr(4, androidId), // resource
|
|
122
|
+
pbStr(5, String(session.securityToken)), // authToken
|
|
123
|
+
pbStr(6, 'android-' + BigInt(androidId).toString(16)), // deviceId
|
|
124
|
+
pbMsg(8, setting) // settings[0]
|
|
125
|
+
];
|
|
126
|
+
for (const pid of (session.persistentIds || [])) parts.push(pbStr(10, pid));
|
|
127
|
+
// adaptiveHeartbeat (12) is false → omitted. Then:
|
|
128
|
+
parts.push(pbInt(14, 1)); // useRmq2 = true
|
|
129
|
+
parts.push(pbInt(16, 2)); // authService = 2
|
|
130
|
+
parts.push(pbInt(17, 1)); // networkType = 1
|
|
131
|
+
return Buffer.concat(parts);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const buildHeartbeatAck = streamId => pbInt(2, streamId); // lastStreamIdReceived
|
|
135
|
+
const buildHeartbeatPing = streamId => pbInt(2, streamId);
|
|
136
|
+
|
|
137
|
+
function frame(tag, payload) {
|
|
138
|
+
const len = encodeVarint(payload.length);
|
|
139
|
+
return Buffer.concat([Buffer.from([tag]), len, payload]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── connection ──────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Open MCS, log in, and resolve with the verification code the moment it
|
|
146
|
+
* arrives — or null on timeout or any failure. Never throws.
|
|
147
|
+
*
|
|
148
|
+
* The connection must be established before the /code request is sent, or the
|
|
149
|
+
* push can arrive with nowhere to land; callers open this first and request the
|
|
150
|
+
* code once `onReady` has fired.
|
|
151
|
+
*
|
|
152
|
+
* @param {object} session { androidId, securityToken, persistentIds? } from lib/fcm
|
|
153
|
+
* @param {object} [opts] { timeoutMs=180000, onReady, signal }
|
|
154
|
+
* @returns {Promise<string|null>}
|
|
155
|
+
*/
|
|
156
|
+
function receivePushCode(session, opts) {
|
|
157
|
+
opts = opts || {};
|
|
158
|
+
const timeoutMs = opts.timeoutMs > 0 ? opts.timeoutMs : 180000;
|
|
159
|
+
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
if (!session || !session.androidId || !session.securityToken) {
|
|
162
|
+
_whaDbg('[DBG] MCS no android id — cannot receive push code');
|
|
163
|
+
return resolve(null);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let settled = false;
|
|
167
|
+
let socket = null;
|
|
168
|
+
let heartbeat = null;
|
|
169
|
+
let deadline = null;
|
|
170
|
+
let rx = Buffer.alloc(0);
|
|
171
|
+
let sawVersion = false;
|
|
172
|
+
let loggedIn = false;
|
|
173
|
+
let streamId = 0;
|
|
174
|
+
const persistentIds = Array.isArray(session.persistentIds) ? session.persistentIds : [];
|
|
175
|
+
|
|
176
|
+
const finish = (code) => {
|
|
177
|
+
if (settled) return;
|
|
178
|
+
settled = true;
|
|
179
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
180
|
+
if (deadline) clearTimeout(deadline);
|
|
181
|
+
try { if (socket) socket.destroy(); } catch (_) {}
|
|
182
|
+
resolve(code);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
deadline = setTimeout(() => {
|
|
186
|
+
if (!settled) { _whaDbg('[DBG] MCS timed out after ' + timeoutMs + 'ms'); finish(null); }
|
|
187
|
+
}, timeoutMs);
|
|
188
|
+
|
|
189
|
+
if (opts.signal) {
|
|
190
|
+
if (opts.signal.aborted) return finish(null);
|
|
191
|
+
opts.signal.addEventListener('abort', () => finish(null), { once: true });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const write = (tag, payload) => {
|
|
195
|
+
try { socket.write(frame(tag, payload)); } catch (e) {
|
|
196
|
+
_whaDbg('[DBG] MCS write failed: ' + (e && e.message)); finish(null);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Route MCS through the SOCKS agent's dialer when one is set, so a proxied
|
|
201
|
+
// process reaches Google here too. Loaded lazily to avoid a cycle.
|
|
202
|
+
const { socksProxyUrl, socksConnect } = require('./socks');
|
|
203
|
+
const proxyUrl = socksProxyUrl();
|
|
204
|
+
|
|
205
|
+
const onRaw = (rawSocket) => {
|
|
206
|
+
socket = tls.connect({
|
|
207
|
+
socket: rawSocket || undefined,
|
|
208
|
+
host: MCS_HOST,
|
|
209
|
+
port: MCS_PORT,
|
|
210
|
+
servername: MCS_HOST
|
|
211
|
+
}, () => {
|
|
212
|
+
// version byte first, then the login frame
|
|
213
|
+
try {
|
|
214
|
+
socket.write(Buffer.from([MCS_VERSION]));
|
|
215
|
+
socket.write(frame(TAG_LOGIN_REQUEST, buildLoginRequest({
|
|
216
|
+
androidId: session.androidId, securityToken: session.securityToken, persistentIds
|
|
217
|
+
})));
|
|
218
|
+
_whaDbg('[DBG] MCS connected, login sent');
|
|
219
|
+
} catch (e) { _whaDbg('[DBG] MCS login write failed: ' + (e && e.message)); finish(null); }
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
socket.setNoDelay(true);
|
|
223
|
+
socket.on('data', onData);
|
|
224
|
+
socket.on('error', (e) => { _whaDbg('[DBG] MCS socket error: ' + (e && e.message)); finish(null); });
|
|
225
|
+
socket.on('close', () => { if (!settled) { _whaDbg('[DBG] MCS closed'); finish(null); } });
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
if (proxyUrl) {
|
|
229
|
+
_whaDbg('[DBG] MCS dialing via SOCKS proxy');
|
|
230
|
+
socksConnect(proxyUrl, MCS_HOST, MCS_PORT)
|
|
231
|
+
.then(onRaw)
|
|
232
|
+
.catch(e => { _whaDbg('[DBG] MCS proxy dial failed: ' + (e && e.message)); finish(null); });
|
|
233
|
+
} else {
|
|
234
|
+
onRaw(null);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function onData(chunk) {
|
|
238
|
+
rx = Buffer.concat([rx, chunk]);
|
|
239
|
+
|
|
240
|
+
// The server's first byte is its protocol version, once, before any frame.
|
|
241
|
+
if (!sawVersion) {
|
|
242
|
+
if (rx.length < 1) return;
|
|
243
|
+
_whaDbg('[DBG] MCS server version=' + rx[0]);
|
|
244
|
+
rx = rx.slice(1);
|
|
245
|
+
sawVersion = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Drain whole frames. A varint length that runs past the buffer means the
|
|
249
|
+
// rest is still in flight — wait for more.
|
|
250
|
+
for (;;) {
|
|
251
|
+
if (rx.length < 1) return;
|
|
252
|
+
const tag = rx[0];
|
|
253
|
+
|
|
254
|
+
let len = 0, shift = 0n, p = 1, complete = false;
|
|
255
|
+
for (; p < rx.length && p <= 10; p++) {
|
|
256
|
+
const b = rx[p];
|
|
257
|
+
len |= (b & 0x7f) << Number(shift);
|
|
258
|
+
shift += 7n;
|
|
259
|
+
if (!(b & 0x80)) { p++; complete = true; break; }
|
|
260
|
+
}
|
|
261
|
+
if (!complete) return; // length varint not fully here yet
|
|
262
|
+
if (rx.length < p + len) return; // payload not fully here yet
|
|
263
|
+
|
|
264
|
+
const payload = rx.slice(p, p + len);
|
|
265
|
+
rx = rx.slice(p + len);
|
|
266
|
+
handleFrame(tag, payload);
|
|
267
|
+
if (settled) return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function handleFrame(tag, payload) {
|
|
272
|
+
if (!loggedIn) {
|
|
273
|
+
if (tag !== TAG_LOGIN_RESPONSE) {
|
|
274
|
+
_whaDbg('[DBG] MCS expected login response, got tag=' + tag); return finish(null);
|
|
275
|
+
}
|
|
276
|
+
const resp = pbDecode(payload);
|
|
277
|
+
if (resp[3]) { // error info present
|
|
278
|
+
const err = pbDecode(resp[3]);
|
|
279
|
+
_whaDbg('[DBG] MCS login refused code=' + (err[1] && err[1].toString()));
|
|
280
|
+
return finish(null);
|
|
281
|
+
}
|
|
282
|
+
loggedIn = true;
|
|
283
|
+
streamId = 1;
|
|
284
|
+
_whaDbg('[DBG] MCS logged in — waiting for push code');
|
|
285
|
+
heartbeat = setInterval(() => write(TAG_HEARTBEAT_PING, buildHeartbeatPing(streamId)),
|
|
286
|
+
HEARTBEAT_INTERVAL_MS);
|
|
287
|
+
if (typeof opts.onReady === 'function') { try { opts.onReady(); } catch (_) {} }
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
streamId++;
|
|
292
|
+
|
|
293
|
+
switch (tag) {
|
|
294
|
+
case TAG_DATA_MESSAGE: {
|
|
295
|
+
const code = extractCode(payload, persistentIds);
|
|
296
|
+
if (code) { _whaDbg('[DBG] MCS push code received'); return finish(code); }
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case TAG_HEARTBEAT_PING:
|
|
300
|
+
write(TAG_HEARTBEAT_ACK, buildHeartbeatAck(streamId));
|
|
301
|
+
break;
|
|
302
|
+
case TAG_HEARTBEAT_ACK:
|
|
303
|
+
case TAG_IQ_STANZA:
|
|
304
|
+
break;
|
|
305
|
+
case TAG_CLOSE:
|
|
306
|
+
_whaDbg('[DBG] MCS server requested close'); return finish(null);
|
|
307
|
+
default:
|
|
308
|
+
_whaDbg('[DBG] MCS unknown tag ' + tag + ' (' + payload.length + 'b)');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Pull the verification code out of a data-message stanza, and remember its
|
|
315
|
+
// persistent id so a reconnect does not ask for it again.
|
|
316
|
+
//
|
|
317
|
+
// field 9 persistentId (string)
|
|
318
|
+
// field 7 appData, repeated { 1: key, 2: value }
|
|
319
|
+
function extractCode(payload, persistentIds) {
|
|
320
|
+
const stanza = pbDecode(payload);
|
|
321
|
+
|
|
322
|
+
const pid = stanza[9];
|
|
323
|
+
if (pid && pid.length) {
|
|
324
|
+
persistentIds.push(pid.toString('utf8'));
|
|
325
|
+
if (persistentIds.length > PERSISTENT_ID_BUFFER) {
|
|
326
|
+
persistentIds.splice(0, persistentIds.length - PERSISTENT_ID_BUFFER);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let appData = stanza[7];
|
|
331
|
+
if (!appData) return null;
|
|
332
|
+
if (!Array.isArray(appData)) appData = [appData];
|
|
333
|
+
|
|
334
|
+
for (const entry of appData) {
|
|
335
|
+
const kv = pbDecode(entry);
|
|
336
|
+
const key = kv[1] ? kv[1].toString('utf8') : '';
|
|
337
|
+
if (key === PUSH_CODE_APP_DATA_KEY) {
|
|
338
|
+
return kv[2] ? kv[2].toString('utf8') : null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = {
|
|
345
|
+
receivePushCode,
|
|
346
|
+
MCS_HOST,
|
|
347
|
+
MCS_PORT,
|
|
348
|
+
MCS_VERSION,
|
|
349
|
+
PUSH_CODE_APP_DATA_KEY,
|
|
350
|
+
// exported for tests
|
|
351
|
+
buildLoginRequest,
|
|
352
|
+
frame,
|
|
353
|
+
pbDecode,
|
|
354
|
+
extractCode,
|
|
355
|
+
encodeVarint,
|
|
356
|
+
TAG_LOGIN_RESPONSE,
|
|
357
|
+
TAG_DATA_MESSAGE,
|
|
358
|
+
TAG_HEARTBEAT_PING,
|
|
359
|
+
TAG_HEARTBEAT_ACK
|
|
360
|
+
};
|
package/lib/fcm.js
CHANGED
|
@@ -512,8 +512,48 @@ async function getPushToken(store, device) {
|
|
|
512
512
|
}
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Open the MCS listener that receives the verification code as a push, using
|
|
517
|
+
* the Firebase identity already established for this account.
|
|
518
|
+
*
|
|
519
|
+
* The token has to exist first — getPushToken establishes the android id and
|
|
520
|
+
* security token MCS logs in with — so this calls it when the store has none.
|
|
521
|
+
* The returned promise resolves with the code when it arrives, or null on any
|
|
522
|
+
* failure, so the caller can fall back to SMS.
|
|
523
|
+
*
|
|
524
|
+
* @param {object} store the account store; the Firebase session is on store.fcm
|
|
525
|
+
* @param {object} device device config
|
|
526
|
+
* @param {object} [opts] { timeoutMs, onReady, signal }
|
|
527
|
+
* @returns {Promise<string|null>}
|
|
528
|
+
*/
|
|
529
|
+
async function receivePushCode(store, device, opts) {
|
|
530
|
+
if (!enabled() || !store) return null;
|
|
531
|
+
|
|
532
|
+
// Make sure we hold an android id / security token before opening MCS.
|
|
533
|
+
if (!store.fcm || !store.fcm.androidId || !store.fcm.securityToken) {
|
|
534
|
+
await getPushToken(store, device);
|
|
535
|
+
}
|
|
536
|
+
const s = store.fcm;
|
|
537
|
+
if (!s || !s.androidId || !s.securityToken) {
|
|
538
|
+
_whaDbg('[DBG] FCM no identity for MCS — cannot receive push code');
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const { receivePushCode: mcsReceive } = require('./fcm-mcs');
|
|
543
|
+
const session = {
|
|
544
|
+
androidId: s.androidId,
|
|
545
|
+
securityToken: s.securityToken,
|
|
546
|
+
persistentIds: Array.isArray(s.persistentIds) ? s.persistentIds : []
|
|
547
|
+
};
|
|
548
|
+
const code = await mcsReceive(session, opts);
|
|
549
|
+
// Persist the persistent-id buffer so a later listen does not re-see messages.
|
|
550
|
+
store.fcm = Object.assign({}, store.fcm, { persistentIds: session.persistentIds });
|
|
551
|
+
return code;
|
|
552
|
+
}
|
|
553
|
+
|
|
515
554
|
module.exports = {
|
|
516
555
|
getPushToken,
|
|
556
|
+
receivePushCode,
|
|
517
557
|
enabled,
|
|
518
558
|
FCM_CONFIG,
|
|
519
559
|
// exported for tests
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whalibmob",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.21.0",
|
|
4
4
|
"description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
|
|
5
5
|
"author": "Kunboruto20",
|
|
6
6
|
"main": "index.js",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"optionalDependencies": {
|
|
55
55
|
"jimp": "^1.6.0",
|
|
56
|
-
"socks": "^2.8.9"
|
|
56
|
+
"socks": "^2.8.9",
|
|
57
|
+
"qrcode-terminal": "^0.12.0"
|
|
57
58
|
}
|
|
58
59
|
}
|