whalibmob 5.6.2 → 5.6.3

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 CHANGED
@@ -669,6 +669,29 @@ Run it once, type the code into the phone, and it is linked. Run it again and it
669
669
  |---|---|---|
670
670
  | `syncFullHistory` | `true` | Ask the phone for the full archive. `false` requests recent messages only and links noticeably faster. |
671
671
  | `browser` | `['Ubuntu', 'Chrome', '120.0.0.0']` | `[os, client, version]`. The second element picks the icon shown under Linked Devices: `Chrome`, `Firefox`, `Safari`, `Edge`, `Opera`, `Desktop`. |
672
+ | `version` | fetched live | Web client revision to announce, e.g. `[2, 3000, 1035194821]`. Set it to pin one. |
673
+ | `fetchVersion` | `true` | Set `false` to skip the live lookup and use the pinned fallback. |
674
+
675
+ #### The Announced Version
676
+
677
+ The web endpoint checks the client revision during the handshake and refuses an unrecognised one with `<failure reason="405">` — before any stanza is exchanged, and with nothing in the failure to say the version was the problem. The mobile endpoint is far more forgiving; this one is not.
678
+
679
+ whalibmob therefore reads the live revision from WhatsApp Web's own service worker on each `connectWeb()`, and falls back to a pinned value if that lookup fails. You should not have to think about it, but you can:
680
+
681
+ ```js
682
+ const { fetchWaWebVersion } = require('whalibmob')
683
+
684
+ const { version, isLatest } = await fetchWaWebVersion()
685
+ console.log(version, isLatest) // [2, 3000, 1035194821] true
686
+
687
+ // pin it yourself
688
+ await client.connectWeb(phone, { version: [2, 3000, 1035194821] })
689
+
690
+ // or skip the lookup entirely
691
+ await client.connectWeb(phone, { fetchVersion: false })
692
+ ```
693
+
694
+ If you ever see `405`, this is what it means. It is not a revoked session and there is nothing to re-pair — reconnect to pick up the current revision.
672
695
 
673
696
  `requestPairingCode(phone, customCode)`:
674
697
 
@@ -687,6 +710,7 @@ Every event from the SMS primary API fires here too — `message`, `receipt`, `p
687
710
  | `pair_device` | `{ refs }` | the QR path produced reference strings |
688
711
  | `history_sync` | `{ syncTypeName, chats, contacts, pushNames, merged }` | a chunk of history arrived |
689
712
  | `history_sync_error` | `{ err, notification }` | a chunk could not be fetched or decrypted |
713
+ | `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. |
690
714
 
691
715
  ### Reading What the Phone Sent
692
716
 
package/cli.js CHANGED
@@ -572,7 +572,19 @@ function attachEvents(client) {
572
572
 
573
573
  client.on('auth_failure', (f) => {
574
574
  _rl && _rl.pause();
575
- fail('session revoked: ' + f.reason + ' — re-register with /reg code');
575
+ const how = client._mode === 'web'
576
+ ? 'link again with /pair'
577
+ : 're-register with /reg code';
578
+ fail('session revoked: ' + f.reason + ' — ' + how);
579
+ _rl && (_rl.resume(), _rl.prompt(true));
580
+ });
581
+
582
+ // The server declined the client, not the session. Nothing to re-register.
583
+ client.on('client_rejected', (r) => {
584
+ _rl && _rl.pause();
585
+ fail('rejected by WhatsApp (' + r.reason +
586
+ (r.location ? ', edge ' + r.location : '') + ')');
587
+ out(' ' + r.message);
576
588
  _rl && (_rl.resume(), _rl.prompt(true));
577
589
  });
578
590
 
@@ -812,7 +824,12 @@ async function handleLine(line) {
812
824
 
813
825
  case '/pair': {
814
826
  const ph = p[1] || _phone;
815
- if (!ph) { fail('usage: /pair <phone> [8-char-code]'); break; }
827
+ if (!ph) {
828
+ fail('usage: /pair <phone> [8-char-code]');
829
+ out(' the phone number is the account you want to link to, digits only');
830
+ out(' example: /pair 40756469325');
831
+ break;
832
+ }
816
833
  if (_client && _client.connected) { fail('already connected — /disconnect first'); break; }
817
834
  out('connecting...');
818
835
  await doConnectWeb(ph, { customCode: p[2] });
package/index.js CHANGED
@@ -28,6 +28,7 @@ const {
28
28
  buildCompanionFinishBundle
29
29
  } = require('./lib/PairingCode');
30
30
  const { configureSuccessfulPairing } = require('./lib/CompanionPairing');
31
+ const { fetchWaWebVersion } = require('./lib/WebVersion');
31
32
  const {
32
33
  encodeCompanionRegisterPayload,
33
34
  encodeCompanionLoginPayload
@@ -81,6 +82,7 @@ module.exports = {
81
82
  configureSuccessfulPairing,
82
83
  encodeCompanionRegisterPayload,
83
84
  encodeCompanionLoginPayload,
85
+ fetchWaWebVersion,
84
86
  // Signal / encryption internals
85
87
  SignalProtocol,
86
88
  SignalStore,
package/lib/Client.js CHANGED
@@ -198,6 +198,10 @@ class WhalibmobClient extends EventEmitter {
198
198
  // account as a companion, via pairing code.
199
199
  this._mode = 'mobile';
200
200
  this._webSessionFile = null;
201
+ // Set once the server has told us to stop trying: an account logout, or a
202
+ // client the server will not accept. Suppresses the reconnect loop.
203
+ this._fatal = false;
204
+ this._closing = false;
201
205
  this._pingTimer = null;
202
206
  this._keepTimer = null;
203
207
  this._connected = false;
@@ -267,6 +271,9 @@ class WhalibmobClient extends EventEmitter {
267
271
  async init(phoneNumber) {
268
272
  phoneNumber = String(phoneNumber).replace(/\D/g, '');
269
273
  this._phoneNumber = phoneNumber;
274
+ // A fresh attempt clears whatever made the last one fatal. Only the public
275
+ // entry points reset it — the reconnect loop must not clear its own guard.
276
+ this._fatal = false;
270
277
 
271
278
  const sessionFile = path.join(this._sessionDir, `${phoneNumber}.json`);
272
279
  this._store = loadStore(sessionFile);
@@ -354,6 +361,7 @@ class WhalibmobClient extends EventEmitter {
354
361
  phoneNumber = String(phoneNumber).replace(/\D/g, '');
355
362
  this._phoneNumber = phoneNumber;
356
363
  this._mode = 'web';
364
+ this._fatal = false;
357
365
 
358
366
  const { createNewWebStore, loadWebStore, saveWebStore, webSessionPath } =
359
367
  require('./WebStore');
@@ -363,6 +371,25 @@ class WhalibmobClient extends EventEmitter {
363
371
 
364
372
  if (opts.syncFullHistory !== undefined) this._store.syncFullHistory = !!opts.syncFullHistory;
365
373
  if (opts.browser) this._store.browser = opts.browser;
374
+
375
+ // Announce the revision the web client is actually on.
376
+ //
377
+ // The web endpoint rejects an unrecognised version during the handshake
378
+ // with <failure reason="405"> and says nothing about why, so a version
379
+ // pinned in source is a connection that stops working on WhatsApp's
380
+ // schedule rather than ours. Read the live one, keep the pinned value as
381
+ // the fallback, and let a caller override both.
382
+ if (opts.version) {
383
+ this._store.webVersion = opts.version;
384
+ } else if (opts.fetchVersion !== false) {
385
+ const { fetchWaWebVersion } = require('./WebVersion');
386
+ const res = await fetchWaWebVersion();
387
+ this._store.webVersion = res.version;
388
+ _whaDbg('[DBG] WEB_VERSION ' + res.version.join('.') +
389
+ (res.isLatest ? ' (live)' : ' (pinned fallback: ' +
390
+ (res.error && res.error.message) + ')'));
391
+ }
392
+
366
393
  saveWebStore(this._store, this._webSessionFile);
367
394
 
368
395
  // Signal state lives in its own files so a web link and an SMS
@@ -721,6 +748,12 @@ class WhalibmobClient extends EventEmitter {
721
748
  }
722
749
 
723
750
  disconnect() {
751
+ // Closing the socket makes it emit 'close', which lands in _onClose and
752
+ // schedules a reconnect. That is right for a dropped connection and wrong
753
+ // for a deliberate teardown — and it is what turned a single <failure> into
754
+ // an endless retry loop, since clearing _reconnecting was not enough to
755
+ // stop the handler that sets it again.
756
+ this._closing = true;
724
757
  this._reconnecting = false;
725
758
  this._reconnectTry = 0;
726
759
  this._stopTimers();
@@ -730,11 +763,16 @@ class WhalibmobClient extends EventEmitter {
730
763
  }
731
764
  this._connected = false;
732
765
  this.emit('close');
766
+ this._closing = false;
733
767
  }
734
768
 
735
769
  // ─── Reconnection ─────────────────────────────────────────────────────────
736
770
 
737
771
  _scheduleReconnect() {
772
+ // A fatal failure — the account logged us out, or the server refused the
773
+ // client outright — will fail identically every time. Retrying it only
774
+ // fills the terminal.
775
+ if (this._closing || this._fatal) return;
738
776
  if (this._reconnecting) return;
739
777
  this._reconnecting = true;
740
778
  const delay = RECONNECT_BACKOFF[Math.min(this._reconnectTry, RECONNECT_BACKOFF.length - 1)];
@@ -2068,11 +2106,44 @@ class WhalibmobClient extends EventEmitter {
2068
2106
 
2069
2107
  // ─── Auth failure (server forces logout during active session) ──────────────
2070
2108
 
2109
+ // <failure> ends the connection, but not every reason means the session died.
2110
+ //
2111
+ // 401, 403 and 419 are the ones that do: the account logged us out, or the
2112
+ // owner removed the device. Those are unrecoverable and reconnecting only
2113
+ // burns attempts.
2114
+ //
2115
+ // 405 is different, and reading it as "session revoked" sent people looking
2116
+ // in the wrong place. It means the server declined the client itself — on the
2117
+ // web endpoint, almost always because the announced version is one it does
2118
+ // not recognise. Nothing about the session is wrong; retrying with the same
2119
+ // version will fail identically, so say what it is instead.
2071
2120
  _handleFailure(node) {
2072
2121
  const attrs = (node && node.attrs) || {};
2073
- const reason = attrs.reason || attrs.location || 'unknown';
2074
- this.emit('auth_failure', { reason, node });
2122
+ const reason = String(attrs.reason || attrs.location || 'unknown');
2123
+ const loc = attrs.location ? String(attrs.location) : null;
2124
+
2125
+ if (reason === '405') {
2126
+ const detail = this._mode === 'web'
2127
+ ? 'WhatsApp refused this client (405). The web version we announced (' +
2128
+ ((this._store && this._store.webVersion) || []).join('.') +
2129
+ ') is not one the server accepts — it changes on their schedule. ' +
2130
+ 'Reconnect to pick up the current one, or pass { version: [2, 3000, N] } ' +
2131
+ 'to connectWeb().'
2132
+ : 'WhatsApp refused this client (405) — the announced app version is ' +
2133
+ 'not accepted. Update the version in the session store.';
2134
+ _whaDbg('[DBG] FAILURE 405 location=' + loc + ' — client refused, not a dead session');
2135
+ this._fatal = true;
2136
+ this._reconnecting = false;
2137
+ this.emit('client_rejected', { reason, location: loc, message: detail, node });
2138
+ this.emit('error', new Error(detail));
2139
+ this.disconnect();
2140
+ return;
2141
+ }
2142
+
2143
+ _whaDbg('[DBG] FAILURE reason=' + reason + ' location=' + loc);
2144
+ this.emit('auth_failure', { reason, location: loc, node });
2075
2145
  // The session is no longer valid — stop reconnecting and disconnect
2146
+ this._fatal = true;
2076
2147
  this._reconnecting = false;
2077
2148
  this.disconnect();
2078
2149
  }
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ // The web client's own revision number.
4
+ //
5
+ // The mobile endpoint tolerates a version that has drifted by months. The web
6
+ // endpoint does not: it checks the revision during the Noise handshake and
7
+ // refuses an unrecognised one with <failure reason="405"> before a single
8
+ // stanza has been exchanged. There is nothing in that failure to say the
9
+ // version was the problem, which makes a stale constant an expensive thing to
10
+ // carry.
11
+ //
12
+ // So read the live one. WhatsApp Web ships its service worker at
13
+ // /sw.js with the current revision embedded in it; that number, paired with
14
+ // the fixed 2.3000 prefix, is the version the browser announces.
15
+ //
16
+ // Everything here is best-effort — a failed fetch falls back to the pinned
17
+ // value rather than blocking a connection.
18
+
19
+ const https = require('https');
20
+ const { WEB_VERSION_FALLBACK } = require('./constants');
21
+
22
+ const SW_URL = 'https://web.whatsapp.com/sw.js';
23
+ const CACHE_MS = 60 * 60 * 1000; // an hour; the revision changes far slower
24
+
25
+ let _cached = null;
26
+ let _cachedAt = 0;
27
+
28
+ function fetchText(url, timeoutMs) {
29
+ return new Promise((resolve, reject) => {
30
+ const req = https.get(url, {
31
+ headers: {
32
+ // The minimum that gets past the anti-bot check on this path.
33
+ 'sec-fetch-site': 'none',
34
+ 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' +
35
+ '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
36
+ }
37
+ }, (res) => {
38
+ if (res.statusCode !== 200) {
39
+ res.resume();
40
+ return reject(new Error('sw.js returned HTTP ' + res.statusCode));
41
+ }
42
+ const chunks = [];
43
+ res.on('data', c => chunks.push(c));
44
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
45
+ res.on('error', reject);
46
+ });
47
+ req.on('error', reject);
48
+ req.setTimeout(timeoutMs || 10000, () => req.destroy(new Error('sw.js fetch timed out')));
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Resolve the version to announce.
54
+ *
55
+ * @param {object} [opts] { force } to bypass the hourly cache
56
+ * @returns {Promise<{version: number[], isLatest: boolean, error?: Error}>}
57
+ */
58
+ async function fetchWaWebVersion(opts) {
59
+ opts = opts || {};
60
+ if (!opts.force && _cached && (Date.now() - _cachedAt) < CACHE_MS) {
61
+ return { version: _cached.slice(), isLatest: true };
62
+ }
63
+
64
+ try {
65
+ const body = await fetchText(SW_URL, opts.timeoutMs);
66
+ const match = body.match(/\\?"client_revision\\?":\s*(\d+)/);
67
+ if (!match || !match[1]) throw new Error('client_revision not found in sw.js');
68
+
69
+ const version = [2, 3000, parseInt(match[1], 10)];
70
+ _cached = version;
71
+ _cachedAt = Date.now();
72
+ return { version: version.slice(), isLatest: true };
73
+ } catch (error) {
74
+ return { version: WEB_VERSION_FALLBACK.slice(), isLatest: false, error };
75
+ }
76
+ }
77
+
78
+ module.exports = { fetchWaWebVersion, WEB_VERSION_FALLBACK };
package/lib/constants.js CHANGED
@@ -18,10 +18,14 @@ const WEB_PROLOGUE = Buffer.from([0x57, 0x41, 0x06, 0x03]);
18
18
  const WEB_SOCKET_URL = 'wss://web.whatsapp.com/ws/chat';
19
19
  const WEB_ORIGIN = 'https://web.whatsapp.com';
20
20
 
21
- // Version the companion announces. Unlike the mobile version this is not tied
22
- // to an App Store build — it is the web client release, and the server is
23
- // tolerant of a slightly stale one.
24
- const WEB_VERSION_FALLBACK = [2, 3000, 1027934701];
21
+ // Version the companion announces.
22
+ //
23
+ // Not an App Store build like the mobile version — this is the web client's
24
+ // own revision, and the server is far less tolerant of it than the mobile
25
+ // endpoint is: an unrecognised revision is refused during the handshake with
26
+ // <failure reason="405">, before any stanza is exchanged. Keep it current, or
27
+ // let fetchWaWebVersion() read the live one.
28
+ const WEB_VERSION_FALLBACK = [2, 3000, 1035194821];
25
29
 
26
30
  // [os, browser] pair. Decides the DeviceProps.platformType we register with and
27
31
  // the name the account owner sees in the linked-devices list on their phone.
package/lib/webproto.js CHANGED
@@ -148,6 +148,9 @@ function encodeAppVersion(v) {
148
148
  ]);
149
149
  }
150
150
 
151
+ // Exactly the fields the web client sets, and no others. `manufacturer` is
152
+ // deliberately absent: the reference never populates it for a web UserAgent,
153
+ // and an extra field is a difference the server can notice.
151
154
  function encodeWebUserAgent(opts) {
152
155
  const version = opts.version || [2, 3000, 1];
153
156
  const browser = opts.browser || ['Ubuntu', 'Chrome', '120.0.0.0'];
@@ -158,7 +161,6 @@ function encodeWebUserAgent(opts) {
158
161
  stringField(3, '000'), // mcc
159
162
  stringField(4, '000'), // mnc
160
163
  stringField(5, '0.1'), // osVersion
161
- stringField(6, 'Desktop'), // manufacturer
162
164
  stringField(7, 'Desktop'), // device
163
165
  stringField(8, '0.1'), // osBuildNumber
164
166
  varintField(10, 0), // releaseChannel = RELEASE
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.6.2",
3
+ "version": "5.6.3",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto50",
6
6
  "main": "index.js",