whalibmob 5.12.15 → 5.12.17

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/.env.example CHANGED
@@ -52,11 +52,13 @@
52
52
 
53
53
  # ─── WhatsApp version ────────────────────────────────────────────────────────
54
54
  # Pin the WhatsApp client version. When set, the live version lookup is skipped
55
- # entirely. Leave unset to fetch the current version automatically (recommended).
55
+ # entirely, and this is what is announced on connect in place of the version
56
+ # stored in the session. Leave unset to fetch the current version automatically.
56
57
  #
57
- # On Android this is normally not needed either way: the version is taken from
58
- # the APK the token material came from, since the token is signed over that
59
- # build. Setting this still overrides it.
58
+ # On Android this is normally not needed: the version comes from the APK the
59
+ # token material was read from. On iOS it is looked up on the App Store, and a
60
+ # lookup that fails leaves a built-in fallback version in the session — which
61
+ # the server refuses at connect with a 405. That is what this is for.
60
62
  # WA_VERSION=2.24.10.75
61
63
 
62
64
  # Override the static registration token. iOS only — Android has no static token
package/README.md CHANGED
@@ -254,6 +254,8 @@ npm install -g whalibmob
254
254
  - [Android Profiles](#android-profiles)
255
255
  - [Custom Device Fields](#custom-device-fields)
256
256
  - [Version & Token Overrides](#version--token-overrides)
257
+ - [When the server answers 405 on connect](#when-the-server-answers-405-on-connect)
258
+ - [Finding out what a 405 objects to](#finding-out-what-a-405-objects-to)
257
259
 
258
260
  ---
259
261
 
@@ -291,14 +293,36 @@ wa registration --request-code 919634847671 --method wa_old
291
293
  ```
292
294
 
293
295
  **Registering as Android** is the same command with the platform named. Nothing
294
- else to prepare — see [what it does behind that one command](#registering-as-android):
296
+ else to prepare — see [what it does behind that one command](#registering-as-android).
297
+
298
+ Linux, macOS, Termux:
295
299
 
296
300
  ```sh
297
301
  WA_OS=android WA_DEVICE=samsung-s24-ultra wa registration --request-code 919634847671 --debug
298
302
  ```
299
303
 
300
- Set `WA_OS=android` in a `.env` file instead and the plain command is enough.
301
- `--debug` prints every request and reply, which is worth having the first time.
304
+ Windows, Command Prompt — `set` on its own line, because `VAR=value` in front of
305
+ a command is Unix syntax and Windows refuses it:
306
+
307
+ ```bat
308
+ set WA_OS=android
309
+ set WA_DEVICE=samsung-s24-ultra
310
+ wa registration --request-code 919634847671 --debug
311
+ ```
312
+
313
+ Windows, PowerShell:
314
+
315
+ ```powershell
316
+ $env:WA_OS = "android"
317
+ $env:WA_DEVICE = "samsung-s24-ultra"
318
+ wa registration --request-code 919634847671 --debug
319
+ ```
320
+
321
+ A `.env` file in the directory you run from works the same everywhere and saves
322
+ repeating it — see [Device Quick Start](#device-quick-start). `--debug` prints
323
+ every request and reply, which is worth having the first time: its first line
324
+ names the platform that actually went out, so you can see whether the variables
325
+ arrived.
302
326
 
303
327
  The variables only matter for the command that *creates* the session. What a
304
328
  session registered as is written into it, so the confirmation step and every
@@ -376,6 +400,14 @@ Use a custom session directory with `--session`:
376
400
  wa connect 919634847671 --session /data/my-sessions
377
401
  ```
378
402
 
403
+ > [!IMPORTANT]
404
+ > **If this is refused with `405`, do not re-register the number.** The account
405
+ > is fine; the server declined the version the connect announced. Check for a
406
+ > `WA_VERSION` in your shell or in a `.env` file in the directory you ran the
407
+ > command from — it overrides the version the session registered with, and a
408
+ > stale one left there refuses every connect from that directory. See
409
+ > [When the server answers 405 on connect](#when-the-server-answers-405-on-connect).
410
+
379
411
  ### CLI Pairing Code
380
412
 
381
413
  If the number is already in use on a phone, or the verification SMS never arrives, link to the existing account instead. When it is not obvious which way you mean, `wa connect` asks:
@@ -2140,7 +2172,7 @@ Every event from the SMS primary API fires here too — `message`, `receipt`, `p
2140
2172
  | `pair_device` | `{ refs }` | the QR path produced reference strings |
2141
2173
  | `history_sync` | `{ syncTypeName, chats, contacts, pushNames, merged }` | a chunk of history arrived |
2142
2174
  | `history_sync_error` | `{ err, notification }` | a chunk could not be fetched or decrypted |
2143
- | `client_rejected` | `{ reason, location, message }` | the server refused the client itself, not the session — `405` means the announced version is not accepted. Distinct from `auth_failure`, and there is nothing to re-pair. |
2175
+ | `client_rejected` | `{ reason, location, message }` | the server refused the client itself, not the session — `405` means the announced version is not accepted. Fires whether the refusal arrives during the handshake or once the stream is open, and the client stops retrying either way. Distinct from `auth_failure`, and there is nothing to re-pair. |
2144
2176
 
2145
2177
  ### Reading What the Phone Sent
2146
2178
 
@@ -4308,12 +4340,56 @@ Copy `.env.example` to `.env` in your project root and set the variables you nee
4308
4340
 
4309
4341
  ### Device Quick Start
4310
4342
 
4311
- Emulate an Android Pixel 8 Pro:
4343
+ Emulate an Android Pixel 8 Pro. **The syntax for setting a variable differs per
4344
+ shell**, and getting it wrong is the most common reason a device profile appears
4345
+ to be ignored:
4346
+
4347
+ **Linux, macOS, Termux** — set them for the one command:
4312
4348
 
4313
4349
  ```sh
4314
4350
  WA_OS=android WA_DEVICE=pixel_8_pro node your-app.js
4351
+ WA_OS=android WA_DEVICE=pixel_8_pro wa registration --request-code 919634847671
4315
4352
  ```
4316
4353
 
4354
+ **Windows, Command Prompt** — `set` first, one per line. `VAR=value` in front of
4355
+ a command is Unix syntax and Windows answers it with
4356
+ `'WA_OS' is not recognized as an internal or external command`:
4357
+
4358
+ ```bat
4359
+ set WA_OS=android
4360
+ set WA_DEVICE=pixel_8_pro
4361
+ wa registration --request-code 919634847671
4362
+ ```
4363
+
4364
+ Keep them on separate lines. Chaining with `&&` puts the space before the `&&`
4365
+ inside the value.
4366
+
4367
+ **Windows, PowerShell**:
4368
+
4369
+ ```powershell
4370
+ $env:WA_OS = "android"
4371
+ $env:WA_DEVICE = "pixel_8_pro"
4372
+ wa registration --request-code 919634847671
4373
+ ```
4374
+
4375
+ **Anywhere, and the one worth preferring** — a `.env` file in the directory you
4376
+ run from, which behaves identically on every platform:
4377
+
4378
+ ```dotenv
4379
+ WA_OS=android
4380
+ WA_DEVICE=pixel_8_pro
4381
+ ```
4382
+
4383
+ ```sh
4384
+ wa registration --request-code 919634847671
4385
+ ```
4386
+
4387
+ The CLI reads `.env` from the **current directory**, not from where whalibmob is
4388
+ installed, so `cd` to the directory holding it before running. Whichever way you
4389
+ choose, the first line of `--debug` output tells you whether it took:
4390
+ `User-Agent: WhatsApp/… Android/14 Device/Google-Pixel 8 Pro`. An `iOS/…` there
4391
+ means the variables never arrived.
4392
+
4317
4393
  Or put the variables in a `.env` file. When using the **CLI** (`wa` command) the file is loaded automatically. When using the **library directly**, load it before `require('whalibmob')`:
4318
4394
 
4319
4395
  ```js
@@ -4400,8 +4476,137 @@ These variables override individual fields on top of the selected profile:
4400
4476
 
4401
4477
  | Variable | Description |
4402
4478
  |---|---|
4403
- | `WA_VERSION` | Pin the WhatsApp version (e.g. `2.24.13.80`). Skips the live store fetch. |
4404
- | `WA_STATIC_TOKEN` | Override the static token used in registration token computation. |
4479
+ | `WA_VERSION` | Pin the WhatsApp version (e.g. `2.24.13.80`). Skips the live store fetch, and is announced on connect **in place of the version stored in the session**. The CLI also reads it from a `.env` file in the working directory, so one left there is announced by every connect from that directory — which is how a working session starts being refused with [405](#when-the-server-answers-405-on-connect). Pin it deliberately, unset it when done. |
4480
+ | `WA_STATIC_TOKEN` | Override the static token used in registration token computation. iOS only — Android has no static token. |
4481
+
4482
+ ### When the server answers 405 on connect
4483
+
4484
+ ```
4485
+ WhatsApp auth failure 405 — client outdated. The server refused the version
4486
+ this connect announced, which was 2.24.10.75. That value came from WA_VERSION
4487
+ in the environment — the CLI also reads it out of a .env file in the directory
4488
+ it runs from — while the session itself holds 2.26.29.73.
4489
+ ```
4490
+
4491
+ **The session is fine and the number is still registered.** 405 is the server
4492
+ declining the *client*, not the account: the version being announced is not one
4493
+ it accepts. Registering the number again is the one move that cannot help — the
4494
+ same version would go out and be refused identically, at the cost of a real
4495
+ phone number and a code request.
4496
+
4497
+ Connecting announces exactly one version, and there are only two places it can
4498
+ come from:
4499
+
4500
+ | order | where the announced version comes from |
4501
+ |---|---|
4502
+ | 1 | `WA_VERSION`, from the shell **or from a `.env` file in the directory the command runs in** |
4503
+ | 2 | the version stored in the session file — what the number was registered with |
4504
+
4505
+ Almost every 405 is the first line winning when nobody meant it to.
4506
+
4507
+ #### Check `WA_VERSION` before anything else
4508
+
4509
+ The CLI loads `.env` from the working directory before it does anything else, so
4510
+ a `WA_VERSION` left in that file is announced by *every* connect started from
4511
+ that directory — in place of the version the session registered with, which the
4512
+ server would have accepted. Nothing about the session changes, so the failure
4513
+ looks like a dead account and is not one.
4514
+
4515
+ ```sh
4516
+ grep -i wa_version .env ~/.env
4517
+ env | grep WA_VERSION
4518
+ ```
4519
+
4520
+ Remove or comment the line, then connect again. Since 5.12.17 you do not have to
4521
+ go looking: the CLI says so before it connects,
4522
+
4523
+ ```
4524
+ warning: WA_VERSION=2.24.10.75 is pinned (shell or .env in /home/you) —
4525
+ connecting announces it instead of the version stored in the session.
4526
+ ```
4527
+
4528
+ and a 405 names the version that went out and which of the two places it came
4529
+ from, because that decides the remedy — unset the override, or pin a newer one.
4530
+
4531
+ Pin `WA_VERSION` deliberately and temporarily, to force one specific build:
4532
+
4533
+ ```sh
4534
+ WA_VERSION=2.26.30.3 wa connect 919634847671
4535
+ ```
4536
+
4537
+ Leaving it in `.env` means every session on that machine announces it until the
4538
+ day it goes stale, wherever those sessions came from.
4539
+
4540
+ #### Keeping the session's own version current
4541
+
4542
+ With no override, the session announces the version it was registered with, and
4543
+ each platform learns that differently:
4544
+
4545
+ | | how the version is found | what happens when that fails |
4546
+ |---|---|---|
4547
+ | iOS | looked up on the App Store | falls back to a version compiled into the library, which goes stale |
4548
+ | Android | read from the APK the registration token material came from | `wa apk-material --download` fetches the current one |
4549
+
4550
+ So a genuinely stale session version is mostly an iOS story: a lookup that times
4551
+ out or is rate limited leaves an old fallback in the session, and nothing says so
4552
+ until the handshake is refused. On Android the version travels with the APK, and
4553
+ refreshing the material refreshes the version with it:
4554
+
4555
+ ```sh
4556
+ wa apk-material --download
4557
+ ```
4558
+
4559
+ #### If every Android session is refused, whatever the version
4560
+
4561
+ Then it is the library, not the version — update it. Until 5.12.15 the Android
4562
+ device profiles announced platform `3`, which is BlackBerry, a client WhatsApp
4563
+ stopped building in 2017. The server validates the announced app version
4564
+ *against the platform it was announced with*, so a current Android build arrived
4565
+ looking like an impossible BlackBerry one, and nothing in the failure named the
4566
+ platform. iOS announced `1` and was never affected.
4567
+
4568
+ Sessions written before the fix repair themselves the next time they are loaded
4569
+ — the platform is derived from the profile's `os` rather than trusted from the
4570
+ file — so nothing has to be registered again.
4571
+
4572
+ 5.12.16 also brought the rest of the Android handshake in line with what a
4573
+ native Android client announces: no carrier (`mcc` and `mnc` are `000`), `en`
4574
+ and `US` as the locale, no `osBuildNumber`, the model rather than the model id
4575
+ as the device name, an uppercase `phoneId`, and `shortConnect`, `connectType`,
4576
+ `connectReason` and `connectAttemptCount` fixed at the values a real client
4577
+ sends on every connect — `connectType` had been sending `3`, which is not in the
4578
+ enum at all. iOS announces the real carrier and locale, which it has always been
4579
+ accepted with, and is unchanged.
4580
+
4581
+ ### Finding out what a 405 objects to
4582
+
4583
+ When the version is right and the connect is still refused, stop guessing:
4584
+ `tools/diagnose-405.js` runs the login once per payload variation and prints
4585
+ what the server answered each time.
4586
+
4587
+ ```sh
4588
+ node tools/diagnose-405.js 5568936182750
4589
+
4590
+ # installed globally:
4591
+ node $(npm root -g)/whalibmob/tools/diagnose-405.js 5568936182750
4592
+ ```
4593
+
4594
+ ```
4595
+ reference shape, as-is ok — LOGIN ACCEPTED
4596
+ + the real carrier (mcc/mnc) ok — LOGIN ACCEPTED
4597
+ + the real locale 405 {"reason":"405"}
4598
+ ```
4599
+
4600
+ `405` means that row was refused, `401` means the client was accepted and only
4601
+ the credentials failed, `ok` means the login went through — so the first row
4602
+ that stops saying `ok` names the field the server objected to. Every row uses
4603
+ the session already on disk: nothing is registered, no code is requested, and
4604
+ the session is never written to. `--dry-run` prints the payload sizes without
4605
+ opening a socket.
4606
+
4607
+ If *every* row is accepted while `wa connect` is refused, the payload is not the
4608
+ problem and the difference is in the environment the CLI reads and the tool does
4609
+ not — which is `WA_VERSION`, and the top of this section.
4405
4610
 
4406
4611
  ## License
4407
4612
 
package/cli.js CHANGED
@@ -957,6 +957,17 @@ async function doConnectWeb(phone, opts) {
957
957
 
958
958
  async function doConnect(phone) {
959
959
  phone = normalizePhone(phone);
960
+
961
+ // A WA_VERSION left in .env is announced by every connect from this
962
+ // directory, in place of the version the session was registered with, and a
963
+ // stale one is refused with a 405 that says nothing about where the value
964
+ // came from. Say it out loud before connecting rather than after failing.
965
+ if (process.env.WA_VERSION) {
966
+ warn('WA_VERSION=' + process.env.WA_VERSION + ' is pinned (shell or .env in ' +
967
+ process.cwd() + ') — connecting announces it instead of the version ' +
968
+ 'stored in the session. Unset it to use the session\'s own.');
969
+ }
970
+
960
971
  const client = new WhalibmobClient({ sessionDir: _sessDir });
961
972
  attachEvents(client);
962
973
 
package/lib/Client.js CHANGED
@@ -705,6 +705,26 @@ class WhalibmobClient extends EventEmitter {
705
705
  * Anything the handshake marked as a spent session stops the loop here.
706
706
  */
707
707
  _onSocketError(err) {
708
+ // The server declined the client, not the session. The next attempt
709
+ // announces exactly the same thing and is refused exactly the same way, so
710
+ // this stops here instead of retrying once a second forever. _handleFailure
711
+ // already reads a 405 this way, but it only runs once a stream is open — a
712
+ // 405 during the handshake never reached it, which is why a refused client
713
+ // reconnected in a loop.
714
+ if (err && String(err.code) === '405') {
715
+ _whaDbg('[DBG] HANDSHAKE_REJECTED code=405 — client refused, not a dead session');
716
+ this._fatal = true;
717
+ this._reconnecting = false;
718
+ this.emit('client_rejected', {
719
+ reason: '405',
720
+ location: null,
721
+ message: err.message,
722
+ node: null
723
+ });
724
+ this.emit('error', err);
725
+ return;
726
+ }
727
+
708
728
  if (err && err.loggedOut) {
709
729
  _whaDbg('[DBG] HANDSHAKE_REJECTED code=' + err.code + ' — not retrying');
710
730
  this._fatal = true;
@@ -3,7 +3,8 @@
3
3
  const {
4
4
  IOS_DEVICE,
5
5
  IOS_DEVICE_PROFILES,
6
- ANDROID_DEVICE_PROFILES
6
+ ANDROID_DEVICE_PROFILES,
7
+ PLATFORM
7
8
  } = require('./constants');
8
9
 
9
10
  // Pre-build normalised lookup tables so that user-supplied profile keys in any
@@ -49,7 +50,7 @@ function getDeviceConfig() {
49
50
 
50
51
  return {
51
52
  os: 'android',
52
- platform: 3,
53
+ platform: PLATFORM.ANDROID,
53
54
  model: process.env.WA_DEVICE_MODEL || 'Samsung Galaxy S24 Ultra',
54
55
  manufacturer: process.env.WA_DEVICE_MANUFACTURER || 'Samsung',
55
56
  osVersion: process.env.WA_DEVICE_OS_VERSION || '14',
@@ -68,7 +69,7 @@ function getDeviceConfig() {
68
69
 
69
70
  return {
70
71
  os: 'ios',
71
- platform: 1,
72
+ platform: PLATFORM.IOS,
72
73
  model: process.env.WA_DEVICE_MODEL || IOS_DEVICE.model,
73
74
  manufacturer: process.env.WA_DEVICE_MANUFACTURER || IOS_DEVICE.manufacturer,
74
75
  osVersion: process.env.WA_DEVICE_OS_VERSION || IOS_DEVICE.osVersion,
@@ -11,7 +11,7 @@ const { v4: uuidv4 } = require('uuid');
11
11
  const { getDeviceConfig } = require('./DeviceConfig');
12
12
  const AndroidApk = require('./AndroidApk');
13
13
  const attestation = require('./Attestation');
14
- const { dbg: _whaDbg } = require('./logger');
14
+ const { dbg: _whaDbg, warn: _whaWarn } = require('./logger');
15
15
 
16
16
  // ---------- Request envelope ----------
17
17
  //
@@ -499,12 +499,25 @@ async function fetchAndroidVersion() {
499
499
  {
500
500
  headers: {
501
501
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
502
- 'Accept-Language': 'en-US,en;q=0.9'
502
+ 'Accept': 'text/html,application/xhtml+xml',
503
+ 'Accept-Language': 'en-US,en;q=0.9',
504
+ // Only what every axios build decompresses. A default that asks for
505
+ // brotli and then gets bytes back undecoded is a page that parses to
506
+ // nothing.
507
+ 'Accept-Encoding': 'gzip, deflate'
503
508
  },
509
+ responseType: 'text',
510
+ decompress: true,
504
511
  timeout: 12000
505
512
  }
506
513
  );
507
- const html = String(resp.data);
514
+ // Buffer when the response was compressed and axios handed the bytes back
515
+ // as-is. String() on a Buffer of gzip is a page no regex will ever match,
516
+ // which is how this came to fall through to a two-year-old fallback without
517
+ // saying a word.
518
+ const html = Buffer.isBuffer(resp.data)
519
+ ? resp.data.toString('utf8')
520
+ : String(resp.data);
508
521
 
509
522
  // Primary: first quoted 4-part version string matching WhatsApp's "2.x.x.x" scheme.
510
523
  // In Play Store JSON the current stable version appears first, before beta/history entries.
@@ -520,10 +533,41 @@ async function fetchAndroidVersion() {
520
533
  _cachedAndroidVersion = secondary[1];
521
534
  return secondary[1];
522
535
  }
523
- } catch (_) {}
536
+
537
+ // Last resort: the highest 4-part version anywhere on the page. Play has
538
+ // rearranged this page before, and a version read from the wrong element
539
+ // still beats announcing one from two years ago.
540
+ const all = html.match(/2\.\d+\.\d+\.\d+/g);
541
+ if (all && all.length) {
542
+ const newest = all.sort(compareVersions).pop();
543
+ _whaDbg('[DBG] ANDROID_VERSION scraped from an unrecognised page layout: ' + newest);
544
+ _cachedAndroidVersion = newest;
545
+ return newest;
546
+ }
547
+
548
+ _whaWarn('could not read the current WhatsApp version off the Play Store page — ' +
549
+ 'falling back to ' + ANDROID_VERSION_FALLBACK + '. If the server refuses the ' +
550
+ 'client with 405, set WA_VERSION to a current version.');
551
+ return ANDROID_VERSION_FALLBACK;
552
+ } catch (err) {
553
+ _whaWarn('could not reach the Play Store to read the current WhatsApp version (' +
554
+ err.message + ') — falling back to ' + ANDROID_VERSION_FALLBACK + '.');
555
+ }
524
556
  return ANDROID_VERSION_FALLBACK;
525
557
  }
526
558
 
559
+ // Numeric, part by part: '2.26.9.75' is older than '2.26.29.73', which a
560
+ // lexicographic sort gets backwards.
561
+ function compareVersions(a, b) {
562
+ const pa = String(a).split('.').map(Number);
563
+ const pb = String(b).split('.').map(Number);
564
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
565
+ const d = (pa[i] || 0) - (pb[i] || 0);
566
+ if (d) return d;
567
+ }
568
+ return 0;
569
+ }
570
+
527
571
  // Return the appropriate WhatsApp version for the active device.
528
572
  // If WA_VERSION is set in the environment, that value is always used.
529
573
  //
@@ -575,6 +619,39 @@ function tryLoadAndroidMaterial() {
575
619
  try { return loadAndroidMaterial(); } catch (_) { return null; }
576
620
  }
577
621
 
622
+ /**
623
+ * Which platform a registration speaks.
624
+ *
625
+ * Once a session has an account behind it, or has a code outstanding, its
626
+ * platform is settled: the account was created as that, the token and the
627
+ * User-Agent have to keep saying so, and re-reading WA_OS could only break it.
628
+ * A confirmation run from a shell without the variables must not flip to iOS
629
+ * halfway through.
630
+ *
631
+ * Before that, a session holds nothing but keys. Re-running the code request is
632
+ * how you start it over, and the environment is how you say what to start it
633
+ * over as — so the environment decides, and this stops being consulted the
634
+ * moment a code goes out.
635
+ *
636
+ * When the two disagree on a session that is settled, that is said out loud.
637
+ * Silently ignoring WA_OS is how you end up watching an Android registration go
638
+ * out under an iOS User-Agent and not knowing why.
639
+ */
640
+ function deviceForRegistration(store, opts) {
641
+ const env = getDeviceConfig();
642
+ const stored = store && store.device;
643
+ const settled = !!(store && (store.registered || store.codePending));
644
+ if (!settled || !stored) return env;
645
+
646
+ if (stored.os !== env.os) {
647
+ const say = (opts && opts.onProgress) || (m => _whaDbg('[DBG] REG ' + m));
648
+ say('note: this session was started as ' + stored.os + ', so it stays ' + stored.os +
649
+ ' — WA_OS=' + env.os + ' does not apply to a number that is already part-way ' +
650
+ 'through registering. Delete the session file to start it over as ' + env.os + '.');
651
+ }
652
+ return stored;
653
+ }
654
+
578
655
  /**
579
656
  * Have the Android token material ready, fetching the APK if it is not.
580
657
  *
@@ -1333,11 +1410,7 @@ async function requestSmsCode(store, method, opts) {
1333
1410
  opts = opts || {};
1334
1411
  // Before the version is read, since on Android the version comes out of the
1335
1412
  // APK this fetches.
1336
- // What a session registered as is settled when it is created, so it is the
1337
- // store that decides — not whatever WA_OS the shell happens to carry now. A
1338
- // confirmation run from a shell without the variables would otherwise sign an
1339
- // Android registration with an iOS token.
1340
- const _device = store.device || getDeviceConfig();
1413
+ const _device = deviceForRegistration(store, opts);
1341
1414
  await ensureAndroidMaterial(opts, _device);
1342
1415
  const waVersion = await fetchWaVersion(_device);
1343
1416
  store.version = waVersion;
@@ -1459,11 +1532,7 @@ async function verifyCode(store, code, opts) {
1459
1532
  opts = opts || {};
1460
1533
  // Normally already there from the code request, but a confirmation can be run
1461
1534
  // from a fresh shell that never made one.
1462
- // What a session registered as is settled when it is created, so it is the
1463
- // store that decides — not whatever WA_OS the shell happens to carry now. A
1464
- // confirmation run from a shell without the variables would otherwise sign an
1465
- // Android registration with an iOS token.
1466
- const _device = store.device || getDeviceConfig();
1535
+ const _device = deviceForRegistration(store, opts);
1467
1536
  await ensureAndroidMaterial(opts, _device);
1468
1537
  const waVersion = await fetchWaVersion(_device);
1469
1538
  store.version = waVersion;
@@ -1559,7 +1628,7 @@ module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyC
1559
1628
  module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
1560
1629
 
1561
1630
  // Token computation, exposed for tests. Not part of the public API.
1562
- module.exports._token = { computeToken, androidMaterialPath, registrationHeaders, ensureAndroidMaterial };
1631
+ module.exports._token = { computeToken, androidMaterialPath, registrationHeaders, ensureAndroidMaterial, deviceForRegistration };
1563
1632
 
1564
1633
  // Challenge / two-factor internals, exposed for tests. Not part of the public API.
1565
1634
  module.exports._verify = {
package/lib/Store.js CHANGED
@@ -1,13 +1,14 @@
1
1
  'use strict';
2
2
 
3
- const { warn: _whaWarn } = require('./logger');
3
+ const { warn: _whaWarn, dbg: _whaDbg } = require('./logger');
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const crypto = require('crypto');
8
8
  const curveJs = require('curve25519-js');
9
9
  const { v4: uuidv4 } = require('uuid');
10
- const { IOS_DEVICE, IOS_VERSION_FALLBACK, ANDROID_VERSION_FALLBACK } = require('./constants');
10
+ const { IOS_DEVICE, IOS_VERSION_FALLBACK, ANDROID_VERSION_FALLBACK,
11
+ platformForOs } = require('./constants');
11
12
  const { getDeviceConfig } = require('./DeviceConfig');
12
13
 
13
14
  // ─────────────────────────────────────────────────────────
@@ -101,6 +102,25 @@ function createNewStore(phoneNumber) {
101
102
  // Serialisation / deserialisation
102
103
  // ─────────────────────────────────────────────────────────
103
104
 
105
+ // The device profile as it should be announced, whatever a session file happens
106
+ // to hold.
107
+ //
108
+ // `platform` is the number that goes into the handshake, and Android sessions
109
+ // written before it was corrected carry 3 — BlackBerry — which the server
110
+ // refuses with 405 on every connect. `os` is the part that was ever chosen
111
+ // deliberately, so it decides, and a stale number is repaired the moment the
112
+ // session is read. Nothing has to be registered again over it.
113
+ function normaliseDevice(device) {
114
+ const merged = Object.assign({}, IOS_DEVICE, device || null);
115
+ const expected = platformForOs(merged.os);
116
+ if (merged.platform !== expected) {
117
+ _whaDbg('[DBG] DEVICE_PLATFORM_FIXED os=' + merged.os +
118
+ ' ' + merged.platform + ' → ' + expected);
119
+ merged.platform = expected;
120
+ }
121
+ return merged;
122
+ }
123
+
104
124
  function storeToJson(store) {
105
125
  if (!store) {
106
126
  throw new Error('storeToJson: store is undefined or null');
@@ -112,7 +132,7 @@ function storeToJson(store) {
112
132
 
113
133
  const name = store.name || 'User';
114
134
  const version = store.version || IOS_VERSION_FALLBACK;
115
- const device = store.device ? Object.assign({}, IOS_DEVICE, store.device) : IOS_DEVICE;
135
+ const device = normaliseDevice(store.device);
116
136
 
117
137
  const advIdentity = store.advIdentity
118
138
  ? store.advIdentity.toString('base64')
@@ -156,7 +176,7 @@ function storeFromJson(obj) {
156
176
 
157
177
  const name = obj.name || 'User';
158
178
  const version = obj.version || IOS_VERSION_FALLBACK;
159
- const device = obj.device ? Object.assign({}, IOS_DEVICE, obj.device) : IOS_DEVICE;
179
+ const device = normaliseDevice(obj.device);
160
180
 
161
181
  return {
162
182
  phoneNumber: obj.phoneNumber,
package/lib/constants.js CHANGED
@@ -42,17 +42,54 @@ const IOS_STATIC_TOKEN = '0a1mLfGUIBVrMKF1RdvLI5lkRBvof6vn0fD2QRSM';
42
42
  const IOS_BUSINESS_STATIC_TOKEN = 'USUDuDYDeQhY4RF2fCSp5m3F6kJ1M2J8wS7bbNA2';
43
43
 
44
44
  // WhatsApp version fallbacks — used when live fetch fails.
45
+ //
46
+ // These are announced to the server, which refuses a build it does not
47
+ // recognise, so a fallback that has gone stale is a connection that fails for a
48
+ // reason nothing in the error names. Keep them within a few releases of what
49
+ // the stores are actually serving; the live lookup is what normally decides.
45
50
  const IOS_VERSION_FALLBACK = '2.26.9.75';
46
- const ANDROID_VERSION_FALLBACK = '2.24.13.80';
51
+ const ANDROID_VERSION_FALLBACK = '2.26.29.73';
47
52
 
48
53
  // Safari UA for the iTunes lookup only.
49
54
  const IOS_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1';
50
55
 
56
+ // ─── ClientPayload.UserAgent.Platform ────────────────────────────────────────
57
+ //
58
+ // The enum the handshake announces the client with, straight off the schema:
59
+ //
60
+ // ANDROID = 0 IOS = 1 WINDOWS_PHONE = 2 BLACKBERRY = 3 ... WEB = 14
61
+ //
62
+ // The server validates the announced app version *against this platform*, so a
63
+ // wrong value here does not read as a wrong platform — it reads as an
64
+ // impossible version and comes back as <failure reason="405"> ("client
65
+ // outdated") before a single stanza is exchanged. Android sat on 3 (BlackBerry,
66
+ // a client WhatsApp stopped building in 2017) and every Android session was
67
+ // refused on connect with a version that was two weeks old, while iOS on 1 was
68
+ // correct and never saw a 405. Nothing in the failure says which field was
69
+ // wrong, which is what made it worth this many words.
70
+ //
71
+ // Anything that needs a platform number takes it from here.
72
+ const PLATFORM = {
73
+ ANDROID: 0,
74
+ IOS: 1,
75
+ WEB: 14
76
+ };
77
+
78
+ // The platform an `os` string announces itself as. Unknown values fall back to
79
+ // iOS, which is what a session with no device profile has always been.
80
+ function platformForOs(os) {
81
+ return String(os).toLowerCase() === 'android' ? PLATFORM.ANDROID : PLATFORM.IOS;
82
+ }
83
+
84
+ // A note on the `deviceModelType` numbers in the profiles below: nothing reads
85
+ // them. The proto's deviceModelType (field 16) is a string and is filled from
86
+ // `modelId`; the proto's deviceType (field 15) is the DeviceType enum, which is
87
+ // PHONE for every profile here. The numbers are left as they were found.
88
+
51
89
  // Default iOS device (iPhone 15 Pro, iOS 17.4.1).
52
- // platform 1 = iOS in WhatsApp's ClientPayload.UserAgent proto enum.
53
90
  const IOS_DEVICE = {
54
91
  os: 'ios',
55
- platform: 1,
92
+ platform: PLATFORM.IOS,
56
93
  model: 'iPhone 15 Pro',
57
94
  manufacturer: 'Apple',
58
95
  osVersion: '17.4.1',
@@ -65,37 +102,37 @@ const IOS_DEVICE = {
65
102
  // Set WA_OS=ios WA_DEVICE=<key> in .env to select one.
66
103
  const IOS_DEVICE_PROFILES = {
67
104
  'iphone15pro': {
68
- os: 'ios', platform: 1,
105
+ os: 'ios', platform: PLATFORM.IOS,
69
106
  model: 'iPhone 15 Pro', manufacturer: 'Apple',
70
107
  osVersion: '17.4.1', osBuildNumber: '21E236',
71
108
  modelId: 'iPhone16,1', deviceModelType: 2
72
109
  },
73
110
  'iphone15': {
74
- os: 'ios', platform: 1,
111
+ os: 'ios', platform: PLATFORM.IOS,
75
112
  model: 'iPhone 15', manufacturer: 'Apple',
76
113
  osVersion: '17.4.1', osBuildNumber: '21E236',
77
114
  modelId: 'iPhone15,4', deviceModelType: 2
78
115
  },
79
116
  'iphone14': {
80
- os: 'ios', platform: 1,
117
+ os: 'ios', platform: PLATFORM.IOS,
81
118
  model: 'iPhone 14', manufacturer: 'Apple',
82
119
  osVersion: '17.4.1', osBuildNumber: '21E236',
83
120
  modelId: 'iPhone14,2', deviceModelType: 2
84
121
  },
85
122
  'iphone14pro': {
86
- os: 'ios', platform: 1,
123
+ os: 'ios', platform: PLATFORM.IOS,
87
124
  model: 'iPhone 14 Pro', manufacturer: 'Apple',
88
125
  osVersion: '17.4.1', osBuildNumber: '21E236',
89
126
  modelId: 'iPhone15,2', deviceModelType: 2
90
127
  },
91
128
  'iphone13': {
92
- os: 'ios', platform: 1,
129
+ os: 'ios', platform: PLATFORM.IOS,
93
130
  model: 'iPhone 13', manufacturer: 'Apple',
94
131
  osVersion: '16.7.8', osBuildNumber: '20H343',
95
132
  modelId: 'iPhone14,5', deviceModelType: 2
96
133
  },
97
134
  'iphone12': {
98
- os: 'ios', platform: 1,
135
+ os: 'ios', platform: PLATFORM.IOS,
99
136
  model: 'iPhone 12', manufacturer: 'Apple',
100
137
  osVersion: '15.8.3', osBuildNumber: '19H384',
101
138
  modelId: 'iPhone13,2', deviceModelType: 1
@@ -104,94 +141,93 @@ const IOS_DEVICE_PROFILES = {
104
141
 
105
142
  // ─── Android device profiles ───────────────────────────────────────────────
106
143
  // Set WA_OS=android WA_DEVICE=<key> in .env to select one.
107
- // platform 3 = ANDROID in WhatsApp's ClientPayload.UserAgent proto enum.
108
144
  const ANDROID_DEVICE_PROFILES = {
109
145
  'samsung-s24-ultra': {
110
- os: 'android', platform: 3,
146
+ os: 'android', platform: PLATFORM.ANDROID,
111
147
  model: 'Samsung Galaxy S24 Ultra', manufacturer: 'Samsung',
112
148
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
113
149
  modelId: 'SM-S928B', deviceModelType: 2
114
150
  },
115
151
  'samsung-s24': {
116
- os: 'android', platform: 3,
152
+ os: 'android', platform: PLATFORM.ANDROID,
117
153
  model: 'Samsung Galaxy S24', manufacturer: 'Samsung',
118
154
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
119
155
  modelId: 'SM-S921B', deviceModelType: 2
120
156
  },
121
157
  'samsung-s23': {
122
- os: 'android', platform: 3,
158
+ os: 'android', platform: PLATFORM.ANDROID,
123
159
  model: 'Samsung Galaxy S23', manufacturer: 'Samsung',
124
160
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
125
161
  modelId: 'SM-S911B', deviceModelType: 2
126
162
  },
127
163
  'samsung-s23-ultra': {
128
- os: 'android', platform: 3,
164
+ os: 'android', platform: PLATFORM.ANDROID,
129
165
  model: 'Samsung Galaxy S23 Ultra', manufacturer: 'Samsung',
130
166
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
131
167
  modelId: 'SM-S918B', deviceModelType: 2
132
168
  },
133
169
  'samsung-a55': {
134
- os: 'android', platform: 3,
170
+ os: 'android', platform: PLATFORM.ANDROID,
135
171
  model: 'Samsung Galaxy A55', manufacturer: 'Samsung',
136
172
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
137
173
  modelId: 'SM-A556B', deviceModelType: 1
138
174
  },
139
175
  'pixel8pro': {
140
- os: 'android', platform: 3,
176
+ os: 'android', platform: PLATFORM.ANDROID,
141
177
  model: 'Pixel 8 Pro', manufacturer: 'Google',
142
178
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
143
179
  modelId: 'Pixel 8 Pro', deviceModelType: 2
144
180
  },
145
181
  'pixel8': {
146
- os: 'android', platform: 3,
182
+ os: 'android', platform: PLATFORM.ANDROID,
147
183
  model: 'Pixel 8', manufacturer: 'Google',
148
184
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
149
185
  modelId: 'Pixel 8', deviceModelType: 2
150
186
  },
151
187
  'pixel7': {
152
- os: 'android', platform: 3,
188
+ os: 'android', platform: PLATFORM.ANDROID,
153
189
  model: 'Pixel 7', manufacturer: 'Google',
154
190
  osVersion: '14', osBuildNumber: 'TP1A.221005.002',
155
191
  modelId: 'Pixel 7', deviceModelType: 2
156
192
  },
157
193
  'pixel7a': {
158
- os: 'android', platform: 3,
194
+ os: 'android', platform: PLATFORM.ANDROID,
159
195
  model: 'Pixel 7a', manufacturer: 'Google',
160
196
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
161
197
  modelId: 'Pixel 7a', deviceModelType: 1
162
198
  },
163
199
  'xiaomi14': {
164
- os: 'android', platform: 3,
200
+ os: 'android', platform: PLATFORM.ANDROID,
165
201
  model: 'Xiaomi 14', manufacturer: 'Xiaomi',
166
202
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
167
203
  modelId: '23127PN0CG', deviceModelType: 2
168
204
  },
169
205
  'xiaomi13': {
170
- os: 'android', platform: 3,
206
+ os: 'android', platform: PLATFORM.ANDROID,
171
207
  model: 'Xiaomi 13', manufacturer: 'Xiaomi',
172
208
  osVersion: '13', osBuildNumber: 'TQ3A.230901.001',
173
209
  modelId: '2211133G', deviceModelType: 2
174
210
  },
175
211
  'oneplus12': {
176
- os: 'android', platform: 3,
212
+ os: 'android', platform: PLATFORM.ANDROID,
177
213
  model: 'OnePlus 12', manufacturer: 'OnePlus',
178
214
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
179
215
  modelId: 'CPH2573', deviceModelType: 2
180
216
  },
181
217
  'oneplus11': {
182
- os: 'android', platform: 3,
218
+ os: 'android', platform: PLATFORM.ANDROID,
183
219
  model: 'OnePlus 11', manufacturer: 'OnePlus',
184
220
  osVersion: '13', osBuildNumber: 'TQ3A.230901.001',
185
221
  modelId: 'CPH2449', deviceModelType: 2
186
222
  },
187
223
  'oppo-find-x7': {
188
- os: 'android', platform: 3,
224
+ os: 'android', platform: PLATFORM.ANDROID,
189
225
  model: 'OPPO Find X7', manufacturer: 'OPPO',
190
226
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
191
227
  modelId: 'CPH2599', deviceModelType: 2
192
228
  },
193
229
  'realme-gt5': {
194
- os: 'android', platform: 3,
230
+ os: 'android', platform: PLATFORM.ANDROID,
195
231
  model: 'realme GT 5 Pro', manufacturer: 'realme',
196
232
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
197
233
  modelId: 'RMX3888', deviceModelType: 2
@@ -235,6 +271,8 @@ module.exports = {
235
271
  IOS_VERSION_FALLBACK,
236
272
  ANDROID_VERSION_FALLBACK,
237
273
  IOS_USER_AGENT,
274
+ PLATFORM,
275
+ platformForOs,
238
276
  IOS_DEVICE,
239
277
  IOS_DEVICE_PROFILES,
240
278
  ANDROID_DEVICE_PROFILES,
package/lib/noise.js CHANGED
@@ -6,9 +6,11 @@ const EventEmitter = require('events');
6
6
  const curveJs = require('curve25519-js');
7
7
  const { sha256 } = require('@noble/hashes/sha256');
8
8
  const { hkdf } = require('@noble/hashes/hkdf');
9
- const { MOBILE_PROLOGUE, WEB_PROLOGUE, WHATSAPP_HOST, WHATSAPP_PORT } = require('./constants');
9
+ const { MOBILE_PROLOGUE, WEB_PROLOGUE, WHATSAPP_HOST, WHATSAPP_PORT,
10
+ platformForOs } = require('./constants');
10
11
  const { encodeHandshakeClientHello, encodeHandshakeClientFinish,
11
12
  decodeServerHello, encodeClientPayload } = require('./proto');
13
+ const { dbg: _whaDbg } = require('./logger');
12
14
  const { parsePhone, getCountryMeta } = require('./Registration');
13
15
 
14
16
  // ─── CertChain validator ────────
@@ -66,6 +68,16 @@ function validateServerCert(payloadBuf) {
66
68
  }
67
69
  }
68
70
 
71
+ // ─── ClientPayload enum values ───────────────────────────────────────────────
72
+ //
73
+ // ClientPayload.ConnectType, ClientPayload.ConnectReason and
74
+ // ClientPayload.UserAgent.DeviceType. Named here because a bare number in the
75
+ // payload is exactly how this file came to announce a connect type that is not
76
+ // in the enum at all.
77
+ const CONNECT_TYPE_WIFI = 1; // WIFI_UNKNOWN
78
+ const CONNECT_REASON_USER_ACTIVATED = 1;
79
+ const DEVICE_TYPE_PHONE = 0;
80
+
69
81
  // ─── Curve25519 DH helpers ───────────────────────────────────────────────────
70
82
 
71
83
  function stripKeyPrefix(pub) {
@@ -362,32 +374,79 @@ class NoiseSocket extends EventEmitter {
362
374
  // Auto-derive MCC/MNC and locale from the registered phone number so the
363
375
  // ClientPayload userAgent is never sent with the telltale '000'/'000' values.
364
376
  const _phoneMeta = getCountryMeta(parsePhone(this.store.phoneNumber).cc);
377
+ const _device = this.store.device || {};
378
+ const _isAndroid = String(_device.os).toLowerCase() === 'android';
379
+ const _version = process.env.WA_VERSION || this.store.version;
380
+
381
+ this._announcedVersion = _version;
382
+ this._versionFromEnv = !!process.env.WA_VERSION;
383
+ this._sessionVersion = this.store.version;
384
+ _whaDbg('[DBG] ANNOUNCING version=' + _version +
385
+ (this._versionFromEnv ? ' (from WA_VERSION, session holds ' +
386
+ this.store.version + ')' : ' (from the session)') +
387
+ ' platform=' + platformForOs(_device.os));
365
388
 
366
389
  const payload = encodeClientPayload({
367
390
  username: BigInt(this.store.phoneNumber),
368
391
  passive: false,
369
392
  pushName: this.store.registered ? (this.store.name || null) : null,
370
- shortConnect: (this.store.connectAttemptCount || 0) > 0,
371
- connectType: (this.store.connectAttemptCount || 0) > 0 ? 3 : 1,
372
- connectReason: 1,
373
- connectAttemptCount: (this.store.connectAttemptCount || 0),
393
+ // The next four are constants in the reference client: it announces
394
+ // shortConnect, WIFI_UNKNOWN, USER_ACTIVATED and attempt 0 on every
395
+ // connect, retries included. ConnectType is a closed set — 0
396
+ // CELLULAR_UNKNOWN, 1 WIFI_UNKNOWN, 100-112 for the named cellular
397
+ // radios — and a reconnect used to send 3, which is not in it.
398
+ shortConnect: true,
399
+ connectType: CONNECT_TYPE_WIFI,
400
+ connectReason: CONNECT_REASON_USER_ACTIVATED,
401
+ connectAttemptCount: 0,
374
402
  device: 0,
375
403
  oc: false,
376
404
  userAgent: {
377
- platform: (this.store.device && this.store.device.platform) || 1,
378
- version: this.store.version,
379
- mcc: _phoneMeta.mcc,
380
- mnc: _phoneMeta.mnc,
381
- osVersion: this.store.device.osVersion,
382
- manufacturer: this.store.device.manufacturer,
383
- device: this.store.device.model,
384
- osBuildNumber: this.store.device.osBuildNumber,
405
+ // Which client this says it is. The server checks the announced
406
+ // version against *this* platform, so a wrong number here comes back
407
+ // as 405 "client outdated" and never mentions the platform at all —
408
+ // see PLATFORM in constants.js. Derived from `os` rather than read
409
+ // out of the session, so a session file written before this was
410
+ // corrected cannot keep announcing BlackBerry. ANDROID is 0, so the
411
+ // old `|| 1` fallback would also have quietly turned every Android
412
+ // session into an iOS one.
413
+ platform: platformForOs(_device.os),
414
+ // The version the session was registered with, unless WA_VERSION says
415
+ // otherwise. Without that override there is no way out of a 405: the
416
+ // version lives in the session file, the server refuses it, and the
417
+ // only remaining move is to register the number again — which is the
418
+ // one thing that should never be the answer to a client-side problem.
419
+ //
420
+ // The override is also a way *into* a 405, and a quiet one: the CLI
421
+ // reads .env before anything else, so a WA_VERSION left in that file
422
+ // is announced by every connect from that directory while the session
423
+ // file still holds a version the server would have accepted. Both are
424
+ // remembered here so the failure can say which one went out.
425
+ version: _version,
426
+ // Android announces no carrier and no locale of its own: the
427
+ // reference client sends 000/000 and en/US on this platform and is
428
+ // accepted, and this is the half that was being refused. iOS is
429
+ // accepted as it is and keeps sending the real values.
430
+ mcc: _isAndroid ? '000' : _phoneMeta.mcc,
431
+ mnc: _isAndroid ? '000' : _phoneMeta.mnc,
432
+ osVersion: _device.osVersion,
433
+ manufacturer: _device.manufacturer,
434
+ device: _device.model,
435
+ // Not sent on Android by the reference client, which describes the
436
+ // handset with manufacturer and model alone. iOS does send it.
437
+ osBuildNumber: _isAndroid ? null : _device.osBuildNumber,
438
+ // Uppercase on both platforms, even though the Android registration
439
+ // sends the same id lowercase — that asymmetry is the reference
440
+ // client's, not an oversight.
385
441
  phoneId: this.store.fdid.toUpperCase(),
386
442
  releaseChannel: 0,
387
- localeLanguage: _phoneMeta.lg,
388
- localeCountry: _phoneMeta.lc,
389
- deviceType: 0,
390
- deviceModelType: this.store.device.modelId
443
+ localeLanguage: _isAndroid ? 'en' : _phoneMeta.lg,
444
+ localeCountry: _isAndroid ? 'US' : _phoneMeta.lc,
445
+ // DeviceType enum (field 15): PHONE. Every profile shipped here is a
446
+ // phone — the numeric `deviceModelType` on the profiles is unrelated
447
+ // to this and is read by nothing.
448
+ deviceType: DEVICE_TYPE_PHONE,
449
+ deviceModelType: _device.modelId
391
450
  }
392
451
  });
393
452
 
@@ -463,8 +522,20 @@ class NoiseSocket extends EventEmitter {
463
522
  '402': { wipe: false, text: 'temporarily banned.' },
464
523
  '403': { wipe: true, text: "the account's primary device is gone " +
465
524
  '— it is no longer a multi-device account.' },
466
- '405': { wipe: true, text: 'client outdated — the announced ' +
467
- 'version is not one the server accepts.' },
525
+ // Not a spent session, however much it looks like one. The server
526
+ // declined the *client*: the version we announced is not one it
527
+ // accepts. The registration behind the session is untouched, and
528
+ // telling people to register again over this costs them a real
529
+ // phone number for nothing — the same version would be announced
530
+ // and refused identically. _handleFailure already reads it this way
531
+ // once a session is up; this is the same code arriving during the
532
+ // handshake, and it was the one path still calling it revoked.
533
+ // Filled in below, where the version that actually went out is
534
+ // known. Telling everyone to set WA_VERSION was wrong exactly when
535
+ // it mattered most: a stale WA_VERSION is itself a way to get a 405,
536
+ // and the advice then read as "fix it by doing more of the thing
537
+ // that broke it".
538
+ '405': { wipe: false, text: 'client outdated.' },
468
539
  '406': { wipe: true, text: 'banned.' },
469
540
  '409': { wipe: true, text: 'bad user agent — the client identified ' +
470
541
  'itself in a way the server rejects.' },
@@ -475,8 +546,32 @@ class NoiseSocket extends EventEmitter {
475
546
  '503': { wipe: false, text: 'service unavailable — worth retrying.' }
476
547
  };
477
548
  const info = FAILURE[reason];
549
+ let detail = info ? info.text : '';
550
+
551
+ // A refused client is a refused *version*, so name the one that went
552
+ // out and say where it came from. Which of the two it was decides the
553
+ // whole remedy, and the failure itself says neither.
554
+ // The companion path builds its own payload and never sets these, so
555
+ // it gets the short form rather than a sentence about undefined.
556
+ if (reason === '405' && !this._announcedVersion) {
557
+ detail += ' The server refused the version this connect announced.';
558
+ } else if (reason === '405') {
559
+ detail += ' The server refused the version this connect announced, ' +
560
+ 'which was ' + this._announcedVersion + '.';
561
+ detail += this._versionFromEnv
562
+ ? ' That value came from WA_VERSION in the environment — the CLI ' +
563
+ 'also reads it out of a .env file in the directory it runs from ' +
564
+ '— while the session itself holds ' + this._sessionVersion +
565
+ '. Unset WA_VERSION to announce the session\'s own version again.'
566
+ : ' It came from the session file. Announce a current one with ' +
567
+ 'WA_VERSION, or on Android re-read it from the store with ' +
568
+ '`wa apk-material --download`.';
569
+ detail += ' Nothing about the session is wrong and there is nothing ' +
570
+ 'to re-register.';
571
+ }
572
+
478
573
  const err = new Error('WhatsApp auth failure ' + reason +
479
- (info ? ' — ' + info.text : ''));
574
+ (detail ? ' — ' + detail : ''));
480
575
  err.code = reason;
481
576
  // True when the session is spent and the caller should register or
482
577
  // link again rather than retry.
package/lib/webproto.js CHANGED
@@ -137,8 +137,11 @@ function encodeDeviceProps(opts) {
137
137
  // device id, no phone id. That absence is part of what marks the connection as
138
138
  // a companion rather than a primary.
139
139
 
140
- const PLATFORM_WEB = 14;
141
- const PLATFORM_ANDROID = 0;
140
+ // From the one table that holds them, so the mobile and web halves can never
141
+ // drift apart on what a platform number means again.
142
+ const { PLATFORM } = require('./constants');
143
+ const PLATFORM_WEB = PLATFORM.WEB;
144
+ const PLATFORM_ANDROID = PLATFORM.ANDROID;
142
145
 
143
146
  function encodeAppVersion(v) {
144
147
  return Buffer.concat([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.12.15",
3
+ "version": "5.12.17",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",
@@ -11,6 +11,7 @@
11
11
  "index.js",
12
12
  "cli.js",
13
13
  "lib/**/*.js",
14
+ "tools/**/*.js",
14
15
  "README.md",
15
16
  "LICENSE",
16
17
  ".env.example"
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // Asks the server which part of the login it objects to.
5
+ //
6
+ // node tools/diagnose-405.js <phone> [--session <dir>]
7
+ //
8
+ // A 405 says "client outdated" and names nothing else, so the only way to find
9
+ // out what it actually dislikes is to vary one thing at a time and read the
10
+ // answer. Each row below is one login attempt with the session already on disk:
11
+ // nothing is registered, no code is requested, and the session is never
12
+ // written to. The verdict per row is what the server replied.
13
+ //
14
+ // 405 the client was refused — that row's combination is not accepted
15
+ // 401 the client was accepted and only the credentials were rejected
16
+ // ok the login succeeded
17
+ //
18
+ // Read it by comparing rows: the first row that stops saying 405 names the
19
+ // field that was the problem.
20
+
21
+ const path = require('path');
22
+ const os = require('os');
23
+
24
+ const LIB = path.join(__dirname, '..', 'lib');
25
+ const { loadStore } = require(path.join(LIB, 'Store.js'));
26
+ const { encodeClientPayload } = require(path.join(LIB, 'proto.js'));
27
+ const { NoiseSocket } = require(path.join(LIB, 'noise.js'));
28
+ const { parsePhone, getCountryMeta } = require(path.join(LIB, 'Registration.js'));
29
+ const { platformForOs, PLATFORM } = require(path.join(LIB, 'constants.js'));
30
+
31
+ // Keep the whole <failure> node, not just the reason the library keeps.
32
+ const BinaryNode = require(path.join(LIB, 'BinaryNode.js'));
33
+ const decodeNode = BinaryNode.decodeNode;
34
+ let lastNode = null;
35
+ BinaryNode.decodeNode = function () {
36
+ lastNode = decodeNode.apply(this, arguments);
37
+ return lastNode;
38
+ };
39
+
40
+ const args = process.argv.slice(2);
41
+ const phone = String(args[0] || '').replace(/\D/g, '');
42
+ const sessArg = args.indexOf('--session');
43
+ const sessDir = sessArg !== -1 ? args[sessArg + 1] : path.join(os.homedir(), '.waSession');
44
+
45
+ if (!phone) {
46
+ console.error('usage: node tools/diagnose-405.js <phone> [--session <dir>]');
47
+ process.exit(2);
48
+ }
49
+
50
+ const store = loadStore(path.join(sessDir, phone + '.json'));
51
+ if (!store) {
52
+ console.error('no session for ' + phone + ' in ' + sessDir);
53
+ process.exit(2);
54
+ }
55
+
56
+ const meta = getCountryMeta(parsePhone(store.phoneNumber).cc);
57
+ const device = store.device || {};
58
+ const isAndroid = String(device.os).toLowerCase() === 'android';
59
+
60
+ // What the reference client announces on Android, and the knobs that differ
61
+ // between it and what this library used to send.
62
+ function payloadFor(over) {
63
+ over = over || {};
64
+ const ua = {
65
+ platform: over.platform !== undefined ? over.platform : platformForOs(device.os),
66
+ version: over.version || store.version,
67
+ mcc: over.realCarrier ? meta.mcc : '000',
68
+ mnc: over.realCarrier ? meta.mnc : '000',
69
+ osVersion: device.osVersion,
70
+ manufacturer: device.manufacturer,
71
+ device: over.useModelId ? device.modelId : device.model,
72
+ osBuildNumber: over.withBuild ? device.osBuildNumber : null,
73
+ phoneId: over.lowerPhoneId ? store.fdid.toLowerCase() : store.fdid.toUpperCase(),
74
+ releaseChannel: 0,
75
+ localeLanguage: over.realLocale ? meta.lg : 'en',
76
+ localeCountry: over.realLocale ? meta.lc : 'US',
77
+ deviceType: 0,
78
+ deviceModelType: device.modelId
79
+ };
80
+ return encodeClientPayload({
81
+ username: BigInt(store.phoneNumber),
82
+ passive: false,
83
+ pushName: store.registered ? (store.name || null) : null,
84
+ shortConnect: true,
85
+ connectType: 1,
86
+ connectReason: 1,
87
+ connectAttemptCount: 0,
88
+ device: 0,
89
+ oc: false,
90
+ userAgent: ua
91
+ });
92
+ }
93
+
94
+ function attempt(over) {
95
+ return new Promise(resolve => {
96
+ lastNode = null;
97
+ const sock = new NoiseSocket(store, { buildPayload: () => payloadFor(over) });
98
+ let settled = false;
99
+ const finish = (verdict) => {
100
+ if (settled) return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ try { sock.close(); } catch (_) {}
104
+ const attrs = lastNode && lastNode.description === 'failure' && lastNode.attrs;
105
+ resolve(verdict + (attrs ? ' ' + JSON.stringify(attrs) : ''));
106
+ };
107
+ const timer = setTimeout(() => finish('timeout'), 30000);
108
+ sock.on('open', () => finish('ok — LOGIN ACCEPTED'));
109
+ sock.on('error', e => finish(e.code ? String(e.code) : e.message));
110
+ sock.connect().catch(() => {});
111
+ });
112
+ }
113
+
114
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
115
+
116
+ // One variable at a time, starting from the reference client's exact shape.
117
+ const ROWS = [
118
+ ['reference client, as-is', {}],
119
+ [' + the real carrier (mcc/mnc)', { realCarrier: true }],
120
+ [' + the real locale', { realLocale: true }],
121
+ [' + osBuildNumber', { withBuild: true }],
122
+ [' + model id as the device', { useModelId: true }],
123
+ [' + lowercase phoneId', { lowerPhoneId: true }],
124
+ ['everything the old code sent', { realCarrier: true, realLocale: true, withBuild: true }],
125
+ ['announced as iOS', { platform: PLATFORM.IOS }]
126
+ ];
127
+
128
+ // --dry-run builds every row's payload and prints its size without opening a
129
+ // socket, so the tool itself can be checked without talking to the server.
130
+ if (args.includes('--dry-run')) {
131
+ console.log('session +' + store.phoneNumber + ' (dry run — nothing is sent)\n');
132
+ for (const [label, over] of ROWS) {
133
+ console.log(label.padEnd(34) + payloadFor(over).length + ' bytes');
134
+ }
135
+ process.exit(0);
136
+ }
137
+
138
+ (async () => {
139
+ console.log('session +' + store.phoneNumber);
140
+ console.log('platform ' + (isAndroid ? 'android' : 'ios') + ' (' + platformForOs(device.os) + ')');
141
+ console.log('version ' + store.version);
142
+ console.log('device ' + device.manufacturer + ' / ' + device.model + ' / ' + device.modelId);
143
+ console.log('build ' + (device.osBuildNumber || '—'));
144
+ console.log('');
145
+ for (const [label, over] of ROWS) {
146
+ process.stdout.write(label.padEnd(34));
147
+ const verdict = await attempt(over);
148
+ console.log(verdict);
149
+ await sleep(4000);
150
+ }
151
+ console.log('\nthe first row that is not 405 names what the server objected to.');
152
+ process.exit(0);
153
+ })();