whalibmob 5.14.5 → 5.14.7
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 +36 -3
- package/cli.js +48 -14
- package/lib/Client.js +242 -19
- package/lib/Registration.js +13 -0
- package/lib/Store.js +27 -2
- package/lib/auth-utils.js +3 -1
- package/lib/noise.js +8 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -294,6 +294,20 @@ wa registration --request-code 919634847671 --method voice
|
|
|
294
294
|
wa registration --request-code 919634847671 --method wa_old
|
|
295
295
|
```
|
|
296
296
|
|
|
297
|
+
**Set the account's display name while registering** with `--name`. This is the
|
|
298
|
+
name people who have *not* saved your number see next to it — in group
|
|
299
|
+
participant lists, in notifications, and beside your messages. Quote it if it
|
|
300
|
+
contains spaces:
|
|
301
|
+
|
|
302
|
+
```sh
|
|
303
|
+
wa registration --request-code 919634847671 --name "Ricardo Trade"
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The name is stored with the session and announced on every connection from then
|
|
307
|
+
on. Registration itself carries no name — the server learns it when the session
|
|
308
|
+
connects — so a number registered without `--name` has none until one is set.
|
|
309
|
+
It can be given at either registration step, and changed later with `/name`.
|
|
310
|
+
|
|
297
311
|
**Registering as Android** is the same command with the platform named. Nothing
|
|
298
312
|
else to prepare — see [what it does behind that one command](#registering-as-android).
|
|
299
313
|
|
|
@@ -1378,6 +1392,10 @@ wa> /reg code 919634847671
|
|
|
1378
1392
|
wa> /reg code 919634847671 voice
|
|
1379
1393
|
wa> /reg code 919634847671 wa_old
|
|
1380
1394
|
|
|
1395
|
+
# register with a display name — what people who have not saved
|
|
1396
|
+
# your number see. Quote it if it has spaces.
|
|
1397
|
+
wa> /reg code 919634847671 --name "Ricardo Trade"
|
|
1398
|
+
|
|
1381
1399
|
# confirm the code received
|
|
1382
1400
|
wa> /reg confirm 919634847671 123456
|
|
1383
1401
|
registered session saved to /home/user/.waSession/919634847671/919634847671.json
|
|
@@ -1542,8 +1560,8 @@ wa> /quit
|
|
|
1542
1560
|
| `/biz <phone\|jid>` | Query business profile of a WhatsApp Business account |
|
|
1543
1561
|
| **Registration** | |
|
|
1544
1562
|
| `/reg check <phone>` | Check if number has WhatsApp |
|
|
1545
|
-
| `/reg code <phone> [method]` | Request verification code |
|
|
1546
|
-
| `/reg confirm <phone> <code
|
|
1563
|
+
| `/reg code <phone> [method] [--name "Name"]` | Request verification code, optionally naming the account |
|
|
1564
|
+
| `/reg confirm <phone> <code> [--name "Name"]` | Complete registration |
|
|
1547
1565
|
| **Connection** | |
|
|
1548
1566
|
| `/connect <phone> [sms\|pair]` | Connect to WhatsApp — asks which method when unset |
|
|
1549
1567
|
| `/pair <phone> [code]` | Link to an existing account by 8-digit pairing code |
|
|
@@ -1578,12 +1596,27 @@ const sessFile = path.join(sessDir, phone + '.json')
|
|
|
1578
1596
|
|
|
1579
1597
|
fs.mkdirSync(sessDir, { recursive: true })
|
|
1580
1598
|
|
|
1581
|
-
|
|
1599
|
+
// `name` is the display name the account registers with — what people who have
|
|
1600
|
+
// not saved the number see. Omit it and the account has no name until one is
|
|
1601
|
+
// set later with client.changeName().
|
|
1602
|
+
const store = createNewStore(phone, { name: 'Ricardo Trade' })
|
|
1582
1603
|
saveStore(store, sessFile)
|
|
1583
1604
|
|
|
1584
1605
|
await requestSmsCode(store, 'sms') // 'sms' | 'voice' | 'wa_old'
|
|
1585
1606
|
```
|
|
1586
1607
|
|
|
1608
|
+
The name is written to the session, not sent to the registration endpoint — the
|
|
1609
|
+
server learns it from the first connection, and from every one after. It can
|
|
1610
|
+
also be passed as an option at either step, which is the way to name a session
|
|
1611
|
+
that already exists:
|
|
1612
|
+
|
|
1613
|
+
```js
|
|
1614
|
+
await requestSmsCode(store, 'sms', { name: 'Ricardo Trade' })
|
|
1615
|
+
await verifyCode(store, '123456', { name: 'Ricardo Trade' })
|
|
1616
|
+
```
|
|
1617
|
+
|
|
1618
|
+
Names are trimmed, collapsed to single spaces and capped at 25 characters.
|
|
1619
|
+
|
|
1587
1620
|
**Step 2 — verify the code**
|
|
1588
1621
|
|
|
1589
1622
|
```js
|
package/cli.js
CHANGED
|
@@ -1152,6 +1152,17 @@ function requireConn() {
|
|
|
1152
1152
|
|
|
1153
1153
|
// ─── tokenizer ────────────────────────────────────────────────────────────────
|
|
1154
1154
|
|
|
1155
|
+
// Pull `--flag value` out of a token list, in place, and hand back the value.
|
|
1156
|
+
// Removing it keeps every positional argument at the index the usage line
|
|
1157
|
+
// promises, whichever end of the command the flag was typed at.
|
|
1158
|
+
function takeFlag(toks, flag) {
|
|
1159
|
+
const i = toks.indexOf(flag);
|
|
1160
|
+
if (i === -1) return null;
|
|
1161
|
+
const value = (i + 1 < toks.length && !toks[i + 1].startsWith('--')) ? toks[i + 1] : null;
|
|
1162
|
+
toks.splice(i, value === null ? 1 : 2);
|
|
1163
|
+
return value;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1155
1166
|
function tokens(line) {
|
|
1156
1167
|
const s = String(line || '');
|
|
1157
1168
|
const t = []; let b = '';
|
|
@@ -2167,6 +2178,10 @@ async function handleLine(line) {
|
|
|
2167
2178
|
// ── registration ───────────────────────────────────────────────────────
|
|
2168
2179
|
|
|
2169
2180
|
case '/reg': {
|
|
2181
|
+
// --name is pulled out before anything reads a position, so the phone
|
|
2182
|
+
// and the code stay where the usage lines say they are no matter where
|
|
2183
|
+
// the flag was typed. A name with spaces needs quoting: --name "A B".
|
|
2184
|
+
const regName = takeFlag(p, '--name');
|
|
2170
2185
|
const sub = p[1] && p[1].toLowerCase();
|
|
2171
2186
|
|
|
2172
2187
|
if (sub === 'check') {
|
|
@@ -2182,7 +2197,12 @@ async function handleLine(line) {
|
|
|
2182
2197
|
const method = (p[3] || 'sms').toLowerCase();
|
|
2183
2198
|
// email method: /reg code <phone> email <address>
|
|
2184
2199
|
const emailAddr = method === 'email' ? (p[4] || '') : '';
|
|
2185
|
-
if (!ph) {
|
|
2200
|
+
if (!ph) {
|
|
2201
|
+
fail('usage: /reg code <phone> [sms|voice|wa_old|email <address>] [--name "Your Name"]');
|
|
2202
|
+
out(' --name sets the display name the account registers with — what people');
|
|
2203
|
+
out(' who have not saved your number see. It can be changed later with /name.');
|
|
2204
|
+
break;
|
|
2205
|
+
}
|
|
2186
2206
|
if (method === 'email' && !emailAddr) {
|
|
2187
2207
|
fail('email method requires an address — usage: /reg code <phone> email <address>');
|
|
2188
2208
|
break;
|
|
@@ -2191,7 +2211,7 @@ async function handleLine(line) {
|
|
|
2191
2211
|
const sessFile = storeFileFor(_sessDir, ph);
|
|
2192
2212
|
let store = loadStore(sessFile);
|
|
2193
2213
|
if (!store) {
|
|
2194
|
-
store = initAuthCreds(ph);
|
|
2214
|
+
store = initAuthCreds(ph, { name: regName });
|
|
2195
2215
|
saveStore(store, sessFile);
|
|
2196
2216
|
} else if (!store.codePending && !store.registered) {
|
|
2197
2217
|
// Only check /exist when keys were never used to request a code.
|
|
@@ -2200,14 +2220,16 @@ async function handleLine(line) {
|
|
|
2200
2220
|
const fresh = await assertRegistrationKeys(store, waVersion);
|
|
2201
2221
|
if (!fresh) {
|
|
2202
2222
|
out(' device keys already registered — generating new keys...');
|
|
2203
|
-
store = initAuthCreds(ph);
|
|
2223
|
+
store = initAuthCreds(ph, { name: regName });
|
|
2204
2224
|
saveStore(store, sessFile);
|
|
2205
2225
|
out(' new keys saved — proceed with code below');
|
|
2206
2226
|
}
|
|
2207
2227
|
}
|
|
2208
2228
|
const methodLabel = method === 'email' ? ('email → ' + emailAddr) : method;
|
|
2209
2229
|
out('requesting ' + methodLabel + ' code for +' + ph + '...');
|
|
2210
|
-
const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {},
|
|
2230
|
+
const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {},
|
|
2231
|
+
{ onProgress: out, name: regName });
|
|
2232
|
+
if (regName) out(' registering as "' + (store.name || regName) + '"');
|
|
2211
2233
|
const r = await requestSmsCode(store, method, codeOpts);
|
|
2212
2234
|
store.codePending = true;
|
|
2213
2235
|
saveStore(store, sessFile);
|
|
@@ -2218,11 +2240,12 @@ async function handleLine(line) {
|
|
|
2218
2240
|
else if (sub === 'confirm') {
|
|
2219
2241
|
const ph = normalizePhone(p[2]);
|
|
2220
2242
|
const code = p[3];
|
|
2221
|
-
if (!ph || !code) { fail('usage: /reg confirm <phone> <code>'); break; }
|
|
2243
|
+
if (!ph || !code) { fail('usage: /reg confirm <phone> <code> [--name "Your Name"]'); break; }
|
|
2222
2244
|
const file = storeFileFor(_sessDir, ph);
|
|
2223
|
-
const store = loadStore(file) || initAuthCreds(ph);
|
|
2245
|
+
const store = loadStore(file) || initAuthCreds(ph, { name: regName });
|
|
2224
2246
|
out('verifying...');
|
|
2225
|
-
const r = await verifyCode(store, code,
|
|
2247
|
+
const r = await verifyCode(store, code,
|
|
2248
|
+
Object.assign(registrationPrompts(), { onProgress: out, name: regName }));
|
|
2226
2249
|
if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
|
|
2227
2250
|
sessionDirFor(_sessDir, ph, { create: true });
|
|
2228
2251
|
const finalStore = r.store || store;
|
|
@@ -2239,6 +2262,11 @@ async function handleLine(line) {
|
|
|
2239
2262
|
out(' the session is saved under that number');
|
|
2240
2263
|
}
|
|
2241
2264
|
out('registered session saved to ' + savedFile);
|
|
2265
|
+
if (finalStore.name && finalStore.name !== 'User') {
|
|
2266
|
+
out(' name ' + finalStore.name + ' (announced on every connect)');
|
|
2267
|
+
} else {
|
|
2268
|
+
out(' name not set — run /name <text> after connecting');
|
|
2269
|
+
}
|
|
2242
2270
|
out('now run: /connect ' + savedPhone);
|
|
2243
2271
|
} else {
|
|
2244
2272
|
fail('verification failed ' + JSON.stringify(r));
|
|
@@ -2464,8 +2492,8 @@ usage:
|
|
|
2464
2492
|
wa connect <phone> connect and open interactive shell
|
|
2465
2493
|
wa pair <phone> [code] link to an existing account (8-digit code)
|
|
2466
2494
|
wa listen <phone> connect and listen (stay-alive)
|
|
2467
|
-
wa registration --request-code <phone>
|
|
2468
|
-
wa registration --register <phone> --code <code>
|
|
2495
|
+
wa registration --request-code <phone> [--name "Your Name"]
|
|
2496
|
+
wa registration --register <phone> --code <code> [--name "Your Name"]
|
|
2469
2497
|
wa registration --check <phone>
|
|
2470
2498
|
wa apk-material <base.apk> [split.apk ...] read the Android token material
|
|
2471
2499
|
wa apk-material --download fetch that APK from Google Play
|
|
@@ -2490,6 +2518,7 @@ debug:
|
|
|
2490
2518
|
|
|
2491
2519
|
--debug trace without asking (same as WA_DEBUG=1)
|
|
2492
2520
|
--no-debug, -q stay quiet without asking (same as WA_DEBUG=0)
|
|
2521
|
+
--name <text> display name to register with (registration commands only)
|
|
2493
2522
|
--trace-bytes also dump the raw encoded bytes of every stanza
|
|
2494
2523
|
|
|
2495
2524
|
after connecting, type /help for all available commands.
|
|
@@ -2821,10 +2850,11 @@ async function main() {
|
|
|
2821
2850
|
// the base directory stays there, a new one gets a directory of its own.
|
|
2822
2851
|
sessionDirFor(_sessDir, ph, { create: true });
|
|
2823
2852
|
const sessFile = storeFileFor(_sessDir, ph);
|
|
2853
|
+
const regName = typeof flags.name === 'string' ? flags.name : null;
|
|
2824
2854
|
let store = loadStore(sessFile);
|
|
2825
2855
|
if (!store) {
|
|
2826
2856
|
// Brand new — generate fresh keys, save immediately, no need to check /exist
|
|
2827
|
-
store = initAuthCreds(ph);
|
|
2857
|
+
store = initAuthCreds(ph, { name: regName });
|
|
2828
2858
|
saveStore(store, sessFile);
|
|
2829
2859
|
} else if (!store.codePending && !store.registered) {
|
|
2830
2860
|
// Existing store but code was never sent and not registered — check if
|
|
@@ -2835,7 +2865,7 @@ async function main() {
|
|
|
2835
2865
|
const fresh = await assertRegistrationKeys(store, waVersion);
|
|
2836
2866
|
if (!fresh) {
|
|
2837
2867
|
out(' device keys already registered — generating new keys...');
|
|
2838
|
-
store = initAuthCreds(ph);
|
|
2868
|
+
store = initAuthCreds(ph, { name: regName });
|
|
2839
2869
|
saveStore(store, sessFile);
|
|
2840
2870
|
}
|
|
2841
2871
|
}
|
|
@@ -2844,7 +2874,9 @@ async function main() {
|
|
|
2844
2874
|
const methodLabel = method === 'email' ? ('email → ' + emailAddr) : method;
|
|
2845
2875
|
out('requesting ' + methodLabel + ' code for +' + ph + '...');
|
|
2846
2876
|
try {
|
|
2847
|
-
const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {},
|
|
2877
|
+
const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {},
|
|
2878
|
+
{ onProgress: out, name: regName });
|
|
2879
|
+
if (regName) out(' registering as "' + (store.name || regName) + '"');
|
|
2848
2880
|
const r = await requestSmsCode(store, method, codeOpts);
|
|
2849
2881
|
store.codePending = true;
|
|
2850
2882
|
saveStore(store, sessFile);
|
|
@@ -2866,11 +2898,13 @@ async function main() {
|
|
|
2866
2898
|
const code = flags.code;
|
|
2867
2899
|
if (!ph) { fail('phone number required'); process.exit(1); }
|
|
2868
2900
|
if (!code) { fail('--code is required'); process.exit(1); }
|
|
2901
|
+
const regName = typeof flags.name === 'string' ? flags.name : null;
|
|
2869
2902
|
const file = storeFileFor(_sessDir, ph);
|
|
2870
|
-
const store = loadStore(file) || initAuthCreds(ph);
|
|
2903
|
+
const store = loadStore(file) || initAuthCreds(ph, { name: regName });
|
|
2871
2904
|
out('verifying code for +' + ph + '...');
|
|
2872
2905
|
try {
|
|
2873
|
-
const r = await verifyCode(store, code,
|
|
2906
|
+
const r = await verifyCode(store, code,
|
|
2907
|
+
Object.assign(registrationPrompts(), { onProgress: out, name: regName }));
|
|
2874
2908
|
if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
|
|
2875
2909
|
if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
|
|
2876
2910
|
const finalStore = r.store || store;
|
package/lib/Client.js
CHANGED
|
@@ -171,6 +171,17 @@ const PING_INTERVAL_MS = 25000;
|
|
|
171
171
|
const KEEPALIVE_INTERVAL = 20000;
|
|
172
172
|
const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 15000, 30000];
|
|
173
173
|
|
|
174
|
+
// When the server is down to this many one-time keys or fewer, send it more.
|
|
175
|
+
// The number is small on purpose: the server tells us when it is running low,
|
|
176
|
+
// and this is the floor for the case where that notice never arrives.
|
|
177
|
+
const PRE_KEY_SERVER_MIN = 5;
|
|
178
|
+
|
|
179
|
+
// How often to ask the server how many it has left. The drain is invisible from
|
|
180
|
+
// here — every bundle handed out is a key gone, and none of that reaches this
|
|
181
|
+
// side — so on a session that stays up for days this is the only thing that
|
|
182
|
+
// notices before the pool is empty and the account stops being reachable.
|
|
183
|
+
const PRE_KEY_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
|
|
184
|
+
|
|
174
185
|
// ─── Key helpers ──────────────────────────────────────────────────────────────
|
|
175
186
|
|
|
176
187
|
function intToBytes(n, byteLen) {
|
|
@@ -676,6 +687,19 @@ class WhalibmobClient extends EventEmitter {
|
|
|
676
687
|
saveWebStore(this._store, this._webSessionFile);
|
|
677
688
|
}
|
|
678
689
|
|
|
690
|
+
// Write the session back to disk, whichever kind it is. A change that only
|
|
691
|
+
// lives in memory is a change that is gone at the next start.
|
|
692
|
+
_persistStore() {
|
|
693
|
+
try {
|
|
694
|
+
if (this._mode === 'web') { this._saveWebStore(); return; }
|
|
695
|
+
if (!this._sessionDir || !this._store || !this._store.phoneNumber) return;
|
|
696
|
+
const { saveStore } = require('./Store');
|
|
697
|
+
saveStore(this._store, path.join(this._sessionDir, `${this._store.phoneNumber}.json`));
|
|
698
|
+
} catch (err) {
|
|
699
|
+
_whaDbg('[DBG] STORE persist failed: ' + (err && err.message));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
679
703
|
async _connectWebSocket() {
|
|
680
704
|
const { encodeCompanionRegisterPayload, encodeCompanionLoginPayload } =
|
|
681
705
|
require('./webproto');
|
|
@@ -1221,7 +1245,9 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1221
1245
|
this.setOnline(true);
|
|
1222
1246
|
|
|
1223
1247
|
this._requestMediaConnection();
|
|
1224
|
-
|
|
1248
|
+
// Ask what the server still has rather than uploading blind. It answers
|
|
1249
|
+
// with a number this side has no other way of learning.
|
|
1250
|
+
this._ensureServerPreKeys('login');
|
|
1225
1251
|
|
|
1226
1252
|
// Same telemetry ping the web client sends on every completed login.
|
|
1227
1253
|
this._sendUnifiedSession();
|
|
@@ -1279,6 +1305,26 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1279
1305
|
const attrs = node.attrs || {};
|
|
1280
1306
|
// Debug: log the full success node structure
|
|
1281
1307
|
_whaDbg('[DBG] SUCCESS node attrs=' + JSON.stringify(attrs));
|
|
1308
|
+
|
|
1309
|
+
// The one place the server ever tells us what it thinks this account is
|
|
1310
|
+
// called. Nothing else answers that question — there is no request that
|
|
1311
|
+
// reads a push name back — so this is both how the local name is kept
|
|
1312
|
+
// honest and the only way to find out whether a rename was accepted.
|
|
1313
|
+
if (attrs.display_name) {
|
|
1314
|
+
const serverName = String(attrs.display_name);
|
|
1315
|
+
if (this._store && this._store.name !== serverName) {
|
|
1316
|
+
_whaDbg('[DBG] SUCCESS display_name="' + serverName + '" — server disagrees with the ' +
|
|
1317
|
+
'stored name "' + this._store.name + '"; taking the server\'s');
|
|
1318
|
+
this._store.name = serverName;
|
|
1319
|
+
if (!this._store.pushName) this._store.pushName = serverName;
|
|
1320
|
+
this._persistStore();
|
|
1321
|
+
} else {
|
|
1322
|
+
_whaDbg('[DBG] SUCCESS display_name="' + serverName + '" — matches the stored name');
|
|
1323
|
+
}
|
|
1324
|
+
} else {
|
|
1325
|
+
_whaDbg('[DBG] SUCCESS carried no display_name — the server did not say what this ' +
|
|
1326
|
+
'account is called');
|
|
1327
|
+
}
|
|
1282
1328
|
if (Array.isArray(node.content)) {
|
|
1283
1329
|
for (const child of node.content) {
|
|
1284
1330
|
if (child && child.description) {
|
|
@@ -1467,9 +1513,23 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1467
1513
|
}
|
|
1468
1514
|
|
|
1469
1515
|
if (collections.size === 0) return;
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1516
|
+
|
|
1517
|
+
// The bit has to be cleared whatever came of the sync. A session that
|
|
1518
|
+
// cannot sync at all — no key has ever been shared with it, which is every
|
|
1519
|
+
// session that registered its own number — would otherwise leave it set
|
|
1520
|
+
// forever and be told about the same change on every single connection.
|
|
1521
|
+
const clearAll = () => {
|
|
1522
|
+
for (const type of dirtyTypes) {
|
|
1523
|
+
if (COLLECTION_MAP[type]) this._cleanDirtyBits(type);
|
|
1524
|
+
}
|
|
1525
|
+
};
|
|
1526
|
+
|
|
1527
|
+
this.syncAppState([...collections])
|
|
1528
|
+
.then(clearAll)
|
|
1529
|
+
.catch(err => {
|
|
1530
|
+
_whaDbg('[DBG] APP_STATE sync failed: ' + (err && err.message));
|
|
1531
|
+
clearAll();
|
|
1532
|
+
});
|
|
1473
1533
|
}
|
|
1474
1534
|
|
|
1475
1535
|
/**
|
|
@@ -1725,7 +1785,15 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1725
1785
|
}
|
|
1726
1786
|
case 'pushName': {
|
|
1727
1787
|
const name = (a.pushNameSetting && a.pushNameSetting.name) || null;
|
|
1728
|
-
|
|
1788
|
+
// Both fields, and to disk. Only `pushName` was set here, and it is
|
|
1789
|
+
// the one the store does not carry — so a rename made on another
|
|
1790
|
+
// device took effect until the process restarted, and never reached
|
|
1791
|
+
// the handshake, which announces `name`.
|
|
1792
|
+
if (name && this._store) {
|
|
1793
|
+
this._store.name = name;
|
|
1794
|
+
this._store.pushName = name;
|
|
1795
|
+
this._persistStore();
|
|
1796
|
+
}
|
|
1729
1797
|
this.emit('push_name_update', { name, remote: true });
|
|
1730
1798
|
break;
|
|
1731
1799
|
}
|
|
@@ -1879,11 +1947,20 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1879
1947
|
}, [new BinaryNode('ping', {}, null)]));
|
|
1880
1948
|
}
|
|
1881
1949
|
}, KEEPALIVE_INTERVAL);
|
|
1950
|
+
|
|
1951
|
+
// The server is supposed to say when it is running out of one-time keys,
|
|
1952
|
+
// and mostly does. This is for when it does not: a session that stays up
|
|
1953
|
+
// long enough can be drained without a word, and the first sign of it is
|
|
1954
|
+
// that nobody can reach the account any more.
|
|
1955
|
+
this._preKeyTimer = setInterval(() => {
|
|
1956
|
+
if (this._socket && this._connected) this._ensureServerPreKeys('periodic');
|
|
1957
|
+
}, PRE_KEY_CHECK_INTERVAL);
|
|
1882
1958
|
}
|
|
1883
1959
|
|
|
1884
1960
|
_stopTimers() {
|
|
1885
|
-
if (this._pingTimer)
|
|
1886
|
-
if (this._keepTimer)
|
|
1961
|
+
if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
|
|
1962
|
+
if (this._keepTimer) { clearInterval(this._keepTimer); this._keepTimer = null; }
|
|
1963
|
+
if (this._preKeyTimer) { clearInterval(this._preKeyTimer); this._preKeyTimer = null; }
|
|
1887
1964
|
}
|
|
1888
1965
|
|
|
1889
1966
|
// ─── Node dispatch ────────────────────────────────────────────────────────
|
|
@@ -2964,15 +3041,28 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2964
3041
|
// whole dispatch down with it, so every notification behind it in the
|
|
2965
3042
|
// offline queue was dropped too.
|
|
2966
3043
|
const fromJid = String(attrs.from || '');
|
|
2967
|
-
const
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
3044
|
+
const fromUser = fromJid.split('@')[0].split(':')[0];
|
|
3045
|
+
// The user segment of a LID is not a phone number, and usync only takes
|
|
3046
|
+
// phone numbers — asking it about a LID comes back "invalid" and the
|
|
3047
|
+
// device list is never actually refreshed. Translate first when we know
|
|
3048
|
+
// the mapping, and when we do not, invalidate the cache and stop there
|
|
3049
|
+
// rather than spending an IQ on a question that cannot be answered.
|
|
3050
|
+
const isLid = fromJid.endsWith('@lid');
|
|
3051
|
+
const mappedPn = isLid && this._lidToPn ? this._lidToPn.get(fromUser) : null;
|
|
3052
|
+
const fromPhone = isLid ? mappedPn : fromUser;
|
|
3053
|
+
if (fromUser && this._devMgr) {
|
|
3054
|
+
this._devMgr._dcDel(isLid ? [fromUser, 'lid:' + fromUser] : [fromUser]);
|
|
3055
|
+
if (mappedPn) this._devMgr._dcDel([mappedPn]);
|
|
3056
|
+
_whaDbg('[DBG] NOTIF_DEVICES invalidated cache for ' + fromUser +
|
|
3057
|
+
(isLid ? (mappedPn ? ' (lid → ' + mappedPn + ')' : ' (lid, no phone known)') : ''));
|
|
2971
3058
|
// Also process any inline <devices> list the server included
|
|
2972
3059
|
const devicesNode = findChildDeep(node, 'devices');
|
|
2973
3060
|
if (devicesNode) this._processDeviceUpdate(devicesNode);
|
|
2974
|
-
// Background re-usync so the cache is warm before the next send
|
|
2975
|
-
|
|
3061
|
+
// Background re-usync so the cache is warm before the next send. Only
|
|
3062
|
+
// when there is a phone number to ask about — the cache is already
|
|
3063
|
+
// cleared, so a LID with no known number simply gets looked up the next
|
|
3064
|
+
// time something sends to it.
|
|
3065
|
+
if (this._signal && fromPhone) {
|
|
2976
3066
|
setImmediate(() => {
|
|
2977
3067
|
if (!this._connected || !this._devMgr) return;
|
|
2978
3068
|
this._devMgr.bulkEnsureSessions([fromPhone], this._signal, false)
|
|
@@ -3279,15 +3369,90 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3279
3369
|
]));
|
|
3280
3370
|
}
|
|
3281
3371
|
|
|
3282
|
-
|
|
3372
|
+
// How many one-time keys the server is still holding for us.
|
|
3373
|
+
//
|
|
3374
|
+
// This is the number that matters and the one we could not see. The local
|
|
3375
|
+
// store only loses a key when a pkmsg using it is actually decrypted, while
|
|
3376
|
+
// the server hands one out to everybody who asks for a bundle — including the
|
|
3377
|
+
// ones who then never write. So the two drift apart, and the local count can
|
|
3378
|
+
// sit at its full 800 while the server has none left. With none left nobody
|
|
3379
|
+
// can open a session with this account at all: the messages are not delayed,
|
|
3380
|
+
// they are never sent, which is why nothing arrives and nothing is queued.
|
|
3381
|
+
//
|
|
3382
|
+
// The two client families ask differently, so each is asked its own way. A
|
|
3383
|
+
// companion asks for a count and is told a number. A primary asks for the
|
|
3384
|
+
// digest and is given the ids it holds, which is the same answer with the
|
|
3385
|
+
// arithmetic left to us.
|
|
3386
|
+
async _queryServerPreKeyCount() {
|
|
3387
|
+
if (!this._socket || !this._connected) return null;
|
|
3388
|
+
|
|
3389
|
+
const web = this._mode === 'web';
|
|
3390
|
+
const resp = await this._sendIq(new BinaryNode('iq', {
|
|
3391
|
+
id: this._genMsgId(),
|
|
3392
|
+
to: 's.whatsapp.net',
|
|
3393
|
+
type: 'get',
|
|
3394
|
+
xmlns: 'encrypt'
|
|
3395
|
+
}, [new BinaryNode(web ? 'count' : 'digest', {}, null)]));
|
|
3396
|
+
|
|
3397
|
+
if (!resp) return null; // timed out
|
|
3398
|
+
if (resp.attrs && resp.attrs.type === 'error') return null;
|
|
3399
|
+
|
|
3400
|
+
if (web) {
|
|
3401
|
+
const countNode = findChild(resp, 'count');
|
|
3402
|
+
const value = countNode && countNode.attrs && countNode.attrs.value;
|
|
3403
|
+
const n = value === undefined ? NaN : Number(value);
|
|
3404
|
+
return Number.isFinite(n) ? n : null;
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
// <digest><list><...one child per key we still have there...></list></digest>
|
|
3408
|
+
const digest = findChild(resp, 'digest');
|
|
3409
|
+
const list = digest && findChild(digest, 'list');
|
|
3410
|
+
if (!list) return null;
|
|
3411
|
+
return Array.isArray(list.content) ? list.content.length : 0;
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
// Top the server back up when it is running out. Never throws: a session that
|
|
3415
|
+
// cannot ask is left exactly as it was rather than losing its connection over
|
|
3416
|
+
// a bookkeeping query.
|
|
3417
|
+
async _ensureServerPreKeys(reason) {
|
|
3283
3418
|
if (!this._signal) return;
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3419
|
+
try {
|
|
3420
|
+
const serverCount = await this._queryServerPreKeyCount();
|
|
3421
|
+
if (serverCount === null) {
|
|
3422
|
+
// No usable answer. Fall back to the old behaviour — an upload we may
|
|
3423
|
+
// not have needed costs nothing next to an account nobody can reach.
|
|
3424
|
+
_whaDbg('[DBG] PREKEY server count unavailable (' + reason + ') — uploading anyway');
|
|
3425
|
+
this._replenishLocalPreKeys();
|
|
3426
|
+
this._uploadPreKeys();
|
|
3427
|
+
return;
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
_whaDbg('[DBG] PREKEY server holds ' + serverCount + ' (' + reason + ')');
|
|
3431
|
+
if (serverCount > PRE_KEY_SERVER_MIN) return;
|
|
3432
|
+
|
|
3433
|
+
_whaDbg('[DBG] PREKEY server low (' + serverCount + ' <= ' + PRE_KEY_SERVER_MIN +
|
|
3434
|
+
') — uploading');
|
|
3435
|
+
this._replenishLocalPreKeys();
|
|
3436
|
+
this._uploadPreKeys();
|
|
3437
|
+
} catch (err) {
|
|
3438
|
+
_whaDbg('[DBG] PREKEY check failed (' + reason + '): ' + (err && err.message));
|
|
3288
3439
|
}
|
|
3289
3440
|
}
|
|
3290
3441
|
|
|
3442
|
+
// Make sure there is something to upload before uploading.
|
|
3443
|
+
_replenishLocalPreKeys() {
|
|
3444
|
+
if (!this._signal) return;
|
|
3445
|
+
if (this._signal.preKeyCount() < 20) this._signal.replenishPreKeys();
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
_checkAndReplenishPreKeys() {
|
|
3449
|
+
if (!this._signal) return;
|
|
3450
|
+
// The server said it is running low, so ask it how low and act on that
|
|
3451
|
+
// rather than on the local count, which does not move when the server
|
|
3452
|
+
// hands a key out.
|
|
3453
|
+
this._ensureServerPreKeys('server asked');
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3291
3456
|
// ─── IQ helper (send + await response) ────────────────────────────────────
|
|
3292
3457
|
|
|
3293
3458
|
_sendIq(node) {
|
|
@@ -4546,13 +4711,71 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4546
4711
|
|
|
4547
4712
|
// Change display name
|
|
4548
4713
|
// Sends presence node with new name — immediate, no IQ needed.
|
|
4714
|
+
// The push name lives in two places, and only one of them survives.
|
|
4715
|
+
//
|
|
4716
|
+
// The presence node announces it to whoever is looking right now — it is how
|
|
4717
|
+
// a contact who has not saved the number sees a name at all. But it says
|
|
4718
|
+
// nothing lasting: the server treats it as the state of this connection, so a
|
|
4719
|
+
// name sent only that way is gone by the next login, and the account keeps
|
|
4720
|
+
// whatever the profile actually holds.
|
|
4721
|
+
//
|
|
4722
|
+
// What persists is an app state write, filed under 'setting_pushName' in the
|
|
4723
|
+
// critical_block collection. That is the record the phone and every other
|
|
4724
|
+
// linked device read, and the one the profile is served from.
|
|
4725
|
+
//
|
|
4726
|
+
// Both are sent: the presence so the change shows immediately, the patch so
|
|
4727
|
+
// it is still there tomorrow.
|
|
4728
|
+
//
|
|
4729
|
+
// Returns a promise for the patch that resolves true when it was written and
|
|
4730
|
+
// false when app state was not writable (no sync key shared yet) or the
|
|
4731
|
+
// server refused it. It never rejects, so a caller that ignores the return —
|
|
4732
|
+
// as every caller did when this only sent presence — cannot end up with an
|
|
4733
|
+
// unhandled rejection. The connection check still throws synchronously.
|
|
4549
4734
|
changeName(name) {
|
|
4550
4735
|
if (!this._socket || !this._connected) throw new Error('Not connected');
|
|
4736
|
+
// A presence node carrying only a name is a name republish: it says what
|
|
4737
|
+
// this account is called without also describing whether it is online.
|
|
4738
|
+
// Carrying a type turns it into an availability update that happens to
|
|
4739
|
+
// mention a name, which is a different thing to ask for and is not what a
|
|
4740
|
+
// rename is. The bare form goes first, on its own.
|
|
4741
|
+
this._socket.sendNode(new BinaryNode('presence', { name }, null));
|
|
4742
|
+
|
|
4743
|
+
// And the same name again on an availability node, unconditionally. The
|
|
4744
|
+
// bare form above is what a browser client sends, but the implementation
|
|
4745
|
+
// with the longest production record only ever sends this one, and ties the
|
|
4746
|
+
// server learning your name to it: announce it at least once after
|
|
4747
|
+
// connecting or other people see a placeholder instead. Making it
|
|
4748
|
+
// conditional on already being marked available would leave a session that
|
|
4749
|
+
// renames before going online with only the form that may be ignored.
|
|
4551
4750
|
this._socket.sendNode(new BinaryNode('presence', {
|
|
4552
4751
|
name,
|
|
4553
4752
|
type: 'available'
|
|
4554
4753
|
}, null));
|
|
4555
|
-
|
|
4754
|
+
|
|
4755
|
+
// `name` is the field the handshake reads when it announces this account,
|
|
4756
|
+
// and it is the one the session file carries. Writing only `pushName` left
|
|
4757
|
+
// the store's `name` at its default, so every login announced that default
|
|
4758
|
+
// again and undid the change — the account went on being called "User" no
|
|
4759
|
+
// matter how often the presence went out. Both are set: `name` because the
|
|
4760
|
+
// handshake and the file are keyed off it, `pushName` because the presence
|
|
4761
|
+
// helper reads that one first.
|
|
4762
|
+
if (this._store) {
|
|
4763
|
+
this._store.name = name;
|
|
4764
|
+
this._store.pushName = name;
|
|
4765
|
+
this._persistStore();
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
return this._appStatePatchOrFallback(AppMutations.pushNamePatch(name))
|
|
4769
|
+
.then(synced => {
|
|
4770
|
+
_whaDbg(synced
|
|
4771
|
+
? '[DBG] PUSHNAME persisted in app state: ' + name
|
|
4772
|
+
: '[DBG] PUSHNAME announced but NOT persisted — no app state sync key yet');
|
|
4773
|
+
return synced;
|
|
4774
|
+
})
|
|
4775
|
+
.catch(err => {
|
|
4776
|
+
_whaDbg('[DBG] PUSHNAME app state write failed: ' + (err && err.message));
|
|
4777
|
+
return false;
|
|
4778
|
+
});
|
|
4556
4779
|
}
|
|
4557
4780
|
|
|
4558
4781
|
// Change bio/about text
|
package/lib/Registration.js
CHANGED
|
@@ -1653,9 +1653,21 @@ async function assertRegistrationKeys(store, waVersion) {
|
|
|
1653
1653
|
// bites when the server keeps answering with a different reason each time.
|
|
1654
1654
|
const MAX_CODE_REQUEST_ATTEMPTS = 5;
|
|
1655
1655
|
|
|
1656
|
+
// The display name a registration is carrying, if the caller gave one. It is
|
|
1657
|
+
// never sent to the registration endpoints — those take no name, and adding one
|
|
1658
|
+
// would be inventing a field. It is written to the session, and the handshake
|
|
1659
|
+
// on the first connection is what announces it.
|
|
1660
|
+
function applyRegistrationName(store, opts) {
|
|
1661
|
+
if (!store || !opts || opts.name === undefined || opts.name === null) return;
|
|
1662
|
+
const { normalizePushName } = require('./Store');
|
|
1663
|
+
const clean = normalizePushName(opts.name);
|
|
1664
|
+
if (clean) store.name = clean;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1656
1667
|
async function requestSmsCode(store, method, opts) {
|
|
1657
1668
|
method = method || 'sms';
|
|
1658
1669
|
opts = opts || {};
|
|
1670
|
+
applyRegistrationName(store, opts);
|
|
1659
1671
|
// Before the version is read, since on Android the version comes out of the
|
|
1660
1672
|
// APK this fetches.
|
|
1661
1673
|
const _device = deviceForRegistration(store, opts);
|
|
@@ -1792,6 +1804,7 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1792
1804
|
|
|
1793
1805
|
async function verifyCode(store, code, opts) {
|
|
1794
1806
|
opts = opts || {};
|
|
1807
|
+
applyRegistrationName(store, opts);
|
|
1795
1808
|
// Normally already there from the code request, but a confirmation can be run
|
|
1796
1809
|
// from a fresh shell that never made one.
|
|
1797
1810
|
const _device = deviceForRegistration(store, opts);
|
package/lib/Store.js
CHANGED
|
@@ -49,7 +49,30 @@ function sign(privKey32, message) {
|
|
|
49
49
|
// Store creation
|
|
50
50
|
// ─────────────────────────────────────────────────────────
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
// What is safe to announce as a display name. The server takes the value
|
|
53
|
+
// verbatim, so a stray newline or a run of spaces goes out as typed; and 'User'
|
|
54
|
+
// is the placeholder this file writes when no name was given, so accepting it
|
|
55
|
+
// back as a real name would make it indistinguishable from having none.
|
|
56
|
+
const MAX_PUSH_NAME_LENGTH = 25;
|
|
57
|
+
|
|
58
|
+
function normalizePushName(name) {
|
|
59
|
+
if (name === null || name === undefined) return null;
|
|
60
|
+
const clean = String(name).replace(/[\r\n\t]+/g, ' ').trim().replace(/\s{2,}/g, ' ');
|
|
61
|
+
if (!clean || clean === 'User') return null;
|
|
62
|
+
return clean.length > MAX_PUSH_NAME_LENGTH ? clean.slice(0, MAX_PUSH_NAME_LENGTH).trim() : clean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} phoneNumber digits, no +
|
|
67
|
+
* @param {object} [opts]
|
|
68
|
+
* name the display name this account announces to people who have not saved
|
|
69
|
+
* the number. Setting it here is what makes the first connection carry
|
|
70
|
+
* it: the handshake reads this field, and it only announces a name that
|
|
71
|
+
* is not the placeholder below. Left unset, the account stays nameless
|
|
72
|
+
* until something calls changeName().
|
|
73
|
+
*/
|
|
74
|
+
function createNewStore(phoneNumber, opts) {
|
|
75
|
+
opts = opts || {};
|
|
53
76
|
const noiseKeyPair = generateKeyPair();
|
|
54
77
|
const identityKeyPair = generateKeyPair();
|
|
55
78
|
const signedPreKeyPair = generateKeyPair();
|
|
@@ -89,7 +112,7 @@ function createNewStore(phoneNumber) {
|
|
|
89
112
|
backupToken,
|
|
90
113
|
registered: false,
|
|
91
114
|
codePending: false, // true after /code request, cleared on successful /register
|
|
92
|
-
name: 'User',
|
|
115
|
+
name: normalizePushName(opts.name) || 'User',
|
|
93
116
|
version,
|
|
94
117
|
device,
|
|
95
118
|
// ADVSignedDeviceIdentity bytes received from server <success> node.
|
|
@@ -307,6 +330,8 @@ function fromSixParts(sixParts) {
|
|
|
307
330
|
|
|
308
331
|
module.exports = {
|
|
309
332
|
createNewStore,
|
|
333
|
+
normalizePushName,
|
|
334
|
+
MAX_PUSH_NAME_LENGTH,
|
|
310
335
|
saveStore,
|
|
311
336
|
loadStore,
|
|
312
337
|
toSixParts,
|
package/lib/auth-utils.js
CHANGED
|
@@ -666,7 +666,9 @@ function initAuthCreds(phoneNumber, options) {
|
|
|
666
666
|
options = options || {};
|
|
667
667
|
|
|
668
668
|
// The library's own key generation — curve25519, with the right prefixes.
|
|
669
|
-
|
|
669
|
+
// options.name, when given, is the display name the account registers with:
|
|
670
|
+
// it is stored here and announced by the first handshake.
|
|
671
|
+
const store = createNewStore(phoneNumber, { name: options.name });
|
|
670
672
|
|
|
671
673
|
// ── Extra credential fields ──────────────────────────────────────────────────
|
|
672
674
|
// Not required internally, but commonly expected by application code.
|
package/lib/noise.js
CHANGED
|
@@ -546,7 +546,14 @@ class NoiseSocket extends EventEmitter {
|
|
|
546
546
|
const payload = encodeClientPayload({
|
|
547
547
|
username: BigInt(this.store.phoneNumber),
|
|
548
548
|
passive: false,
|
|
549
|
-
|
|
549
|
+
// A session starts out named "User" because the store needs something
|
|
550
|
+
// there, not because the account is called that. Announcing it says the
|
|
551
|
+
// account IS called "User", and the handshake runs on every connect, so
|
|
552
|
+
// a name set while online was overwritten by the placeholder the moment
|
|
553
|
+
// the connection came back. Send nothing until there is a real name —
|
|
554
|
+
// the companion payload has always guarded this the same way.
|
|
555
|
+
pushName: (this.store.registered && this.store.name &&
|
|
556
|
+
this.store.name !== 'User') ? this.store.name : null,
|
|
550
557
|
shortConnect: _reconnect,
|
|
551
558
|
// ConnectType is a closed set — 0 CELLULAR_UNKNOWN, 1 WIFI_UNKNOWN,
|
|
552
559
|
// and 100-112 for the named cellular radios. There is no 3, which is
|