whalibmob 5.6.4 → 5.6.9

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
@@ -61,11 +61,23 @@
61
61
 
62
62
 
63
63
  # ─── Proxy ───────────────────────────────────────────────────────────────────
64
- # Route registration traffic through a SOCKS5 proxy, given as host:port.
65
- # TOR_PROXY takes precedence over SOCKS_PROXY when both are set.
66
- # Default Tor SOCKS port is 9050.
64
+ # Route registration traffic through a SOCKS proxy. Requires the `socks`
65
+ # package: npm install socks
66
+ #
67
+ # A bare host:port is assumed to be socks5. Add a scheme to be explicit, and
68
+ # credentials if the proxy needs them. Percent-encode any @ or : in a password.
69
+ #
70
+ # TOR_PROXY takes precedence over SOCKS_PROXY when both are set.
71
+ # The default Tor SOCKS port is 9050; the SOCKS default is 1080.
72
+ #
67
73
  # TOR_PROXY=127.0.0.1:9050
68
- # SOCKS_PROXY=127.0.0.1:1080
74
+ # TOR_PROXY=socks5://127.0.0.1:9050
75
+ # SOCKS_PROXY=socks5://user:pass@proxy.example.com:1080
76
+ # SOCKS_PROXY=socks5h://proxy.example.com:1080
77
+ # SOCKS_PROXY=socks4://proxy.example.com:1080
78
+ #
79
+ # Only if `socks` lives somewhere require() cannot find it:
80
+ # WA_SOCKS_LIB=/absolute/path/to/socks/build/index.js
69
81
 
70
82
 
71
83
  # ─── Frida attestation bridge ────────────────────────────────────────────────
package/README.md CHANGED
@@ -150,6 +150,7 @@ npm install -g whalibmob
150
150
  - [Handling Reconnects](#handling-reconnects)
151
151
  - [Knowing Which Mode You Are In](#knowing-which-mode-you-are-in)
152
152
  - [Two Sessions on One Number](#two-sessions-on-one-number)
153
+ - [Routing Registration Through a Proxy](#routing-registration-through-a-proxy)
153
154
  - [Saving & Restoring Sessions](#saving--restoring-sessions)
154
155
  - [Signal Store Utilities](#signal-store-utilities)
155
156
  - [makeCacheableSignalKeyStore](#makecacheablesignalkeystore)
@@ -806,6 +807,40 @@ await client.connectWeb(PHONE) // web / companion, over WebSocket
806
807
 
807
808
  Use two `WhalibmobClient` instances if you want both at once.
808
809
 
810
+ ## Routing Registration Through a Proxy
811
+
812
+ Registration is the part of the protocol most likely to be refused from a datacenter IP. If you are seeing security blocks or an undeterminable number status, route it through a SOCKS proxy — Tor, or a residential provider.
813
+
814
+ Install the optional dependency and set one environment variable:
815
+
816
+ ```sh
817
+ npm install socks
818
+ ```
819
+
820
+ ```sh
821
+ # Tor
822
+ export TOR_PROXY=socks5://127.0.0.1:9050
823
+
824
+ # a residential proxy that needs credentials
825
+ export SOCKS_PROXY=socks5://user:pass@proxy.example.com:1080
826
+ ```
827
+
828
+ A bare `host:port` is assumed to be SOCKS5, so `TOR_PROXY=127.0.0.1:9050` works too. `socks5`, `socks5h`, `socks4` and `socks4a` are all accepted, and `TOR_PROXY` wins if both variables are set.
829
+
830
+ If the password contains an `@` or a `:`, percent-encode it — `p@ss:word` becomes `p%40ss%3Aword`. whalibmob decodes it before handing it to the proxy.
831
+
832
+ ```js
833
+ // or from code, before you call any registration function
834
+ process.env.SOCKS_PROXY = 'socks5://user:pass@proxy.example.com:1080'
835
+
836
+ await requestSmsCode(phone, store)
837
+ ```
838
+
839
+ > [!NOTE]
840
+ > This covers the registration traffic to `v.whatsapp.net`. The message socket and media transfers are not proxied — if you need those behind a proxy as well, open an issue describing the setup.
841
+
842
+ If `socks` is not installed, or a proxy is unreachable, you get a message saying so rather than a silent failure. In the rare case the package lives somewhere `require()` cannot find it, `WA_SOCKS_LIB` takes an absolute path to it.
843
+
809
844
  ## Saving & Restoring Sessions
810
845
 
811
846
  Sessions are automatically persisted to disk as JSON files under the `sessionDir` you provide. The file is named `<phone>.json`. On the next `client.init()` call the session is restored and no re-registration is needed.
package/cli.js CHANGED
@@ -576,6 +576,24 @@ function attachEvents(client) {
576
576
  ? 'link again with /pair'
577
577
  : 're-register with /reg code';
578
578
  fail('session revoked: ' + f.reason + ' — ' + how);
579
+ _rl && _rl.resume();
580
+ notConnected();
581
+ });
582
+
583
+ // Someone else opened a session for this number. Reconnecting would fight
584
+ // them for it, so the client stands down and says so.
585
+ client.on('connection_replaced', (r) => {
586
+ _rl && _rl.pause();
587
+ fail('connection replaced');
588
+ out(' ' + r.message);
589
+ _rl && _rl.resume();
590
+ notConnected();
591
+ });
592
+
593
+ // A stanza whose shape we mishandled. Not fatal — the connection kept going.
594
+ client.on('node_error', (e) => {
595
+ _rl && _rl.pause();
596
+ fail('could not handle a <' + e.tag + '>: ' + (e.err && e.err.message));
579
597
  _rl && (_rl.resume(), _rl.prompt(true));
580
598
  });
581
599
 
@@ -585,7 +603,8 @@ function attachEvents(client) {
585
603
  fail('rejected by WhatsApp (' + r.reason +
586
604
  (r.location ? ', edge ' + r.location : '') + ')');
587
605
  out(' ' + r.message);
588
- _rl && (_rl.resume(), _rl.prompt(true));
606
+ _rl && _rl.resume();
607
+ notConnected();
589
608
  });
590
609
 
591
610
  client.on('error', (e) => {
@@ -715,7 +734,7 @@ async function doConnectWeb(phone, opts) {
715
734
  client.once('auth_failure', () => clearInterval(keepAlive));
716
735
  } catch (e) {
717
736
  fail(e.message);
718
- _rl.prompt();
737
+ notConnected();
719
738
  }
720
739
  }
721
740
 
@@ -735,7 +754,7 @@ async function doConnect(phone) {
735
754
  client.once('auth_failure', () => {
736
755
  fail('auth failed — session revoked');
737
756
  out('use /reg code ' + phone + ' to re-register');
738
- _rl.prompt();
757
+ notConnected();
739
758
  });
740
759
 
741
760
  try {
@@ -745,11 +764,48 @@ async function doConnect(phone) {
745
764
  client.once('auth_failure', () => clearInterval(keepAlive));
746
765
  } catch (e) {
747
766
  fail(e.message);
748
- out('register first with: /reg code ' + phone);
749
- _rl.prompt();
767
+ if (/No (primary )?session for/i.test(e.message)) {
768
+ if (hasWebSession(phone)) {
769
+ out(' this number is linked as a companion — use: /pair ' + phone);
770
+ } else {
771
+ out(' register it: /reg code ' + phone + ' then /reg confirm ' + phone + ' <code>');
772
+ out(' or link it to an account already on a phone: /pair ' + phone);
773
+ }
774
+ } else if (e.code === '401' || /auth failure 401/i.test(e.message)) {
775
+ // 401 says the credentials were refused; it does not say whether the
776
+ // number itself is still registered. Ask, so the next step is obvious.
777
+ out(' checking whether the number is still registered...');
778
+ try {
779
+ const probe = await client.checkSessionAlive();
780
+ if (probe.alive) {
781
+ out(' the number IS still registered, but this device was logged out.');
782
+ out(' register it again: /reg code ' + phone);
783
+ } else if (probe.error) {
784
+ out(' could not reach the registration endpoint: ' + probe.error);
785
+ } else {
786
+ out(' the registration is gone server-side (' + (probe.status || 'no status') + ').');
787
+ out(' register it again: /reg code ' + phone);
788
+ }
789
+ } catch (probeErr) {
790
+ out(' status check failed: ' + probeErr.message);
791
+ }
792
+ }
793
+ notConnected();
750
794
  }
751
795
  }
752
796
 
797
+ // Put the shell back in its disconnected state.
798
+ //
799
+ // The prompt is the only standing indication of whether there is a session, so
800
+ // it has to be right after a failure: a prompt reading "wa +<number>>" under an
801
+ // error saying there is no session for that number is the shell contradicting
802
+ // itself.
803
+ function notConnected() {
804
+ _client = null;
805
+ _phone = null;
806
+ if (_rl) { _rl.setPrompt('wa> '); _rl.prompt(); }
807
+ }
808
+
753
809
  // ─── guard ────────────────────────────────────────────────────────────────────
754
810
 
755
811
  function requireConn() {
@@ -2039,7 +2095,11 @@ async function main() {
2039
2095
  const method = flags.pair || forced === 'pair' || forced === 'pairing' ? 'pairing'
2040
2096
  : flags.sms || forced === 'sms' ? 'sms'
2041
2097
  : await askLoginMethod(phone);
2042
- openShell('wa +' + phone + '> ');
2098
+ // Plain `wa>` until the connection actually opens. Naming the number in the
2099
+ // prompt before that says "connected as this number" while the line above
2100
+ // it says there is no session, which is the opposite of what happened —
2101
+ // doConnect sets the real prompt from the 'connected' event.
2102
+ openShell();
2043
2103
  out('connecting to +' + phone + '...');
2044
2104
  if (method === 'pairing') await doConnectWeb(phone);
2045
2105
  else await doConnect(phone);
@@ -2053,7 +2113,7 @@ async function main() {
2053
2113
  if (!phone) { fail('phone number required'); process.exit(1); }
2054
2114
  const custom = (sub ? pos[0] : pos[1]) ||
2055
2115
  (typeof flags.code === 'string' ? flags.code : undefined);
2056
- openShell('wa +' + phone + '> ');
2116
+ openShell();
2057
2117
  out('linking +' + phone + ' to an existing WhatsApp account...');
2058
2118
  await doConnectWeb(phone, { customCode: custom });
2059
2119
  return;
package/lib/Client.js CHANGED
@@ -4,6 +4,7 @@ const { dbg: _whaDbg, configureLogger: _whaConfigLogger } = require('./logger');
4
4
 
5
5
  const EventEmitter = require('events');
6
6
  const path = require('path');
7
+ const fs = require('fs');
7
8
  const crypto = require('crypto');
8
9
  const { NoiseSocket } = require('./noise');
9
10
  const { MessageSender, generateMessageId, makeJid, buildOrGetAdvIdentity } = require('./messages/MessageSender');
@@ -278,7 +279,19 @@ class WhalibmobClient extends EventEmitter {
278
279
  const sessionFile = path.join(this._sessionDir, `${phoneNumber}.json`);
279
280
  this._store = loadStore(sessionFile);
280
281
  if (!this._store) {
281
- throw new Error(`No session for ${phoneNumber}. Run 'wa registration -R <code>' first.`);
282
+ // Say what is missing, and say it accurately. The old text pointed at
283
+ // 'wa registration -R', a flag this CLI has never accepted, so anyone who
284
+ // followed it got a second error instead of a registered number. A
285
+ // library has no business teaching CLI syntax either — the caller knows
286
+ // which interface it is; this only has to name the state.
287
+ const hasWeb = fs.existsSync(path.join(this._sessionDir, `${phoneNumber}.web.json`));
288
+ throw new Error(
289
+ hasWeb
290
+ ? `No primary session for ${phoneNumber}, but it is linked as a ` +
291
+ 'companion device — use connectWeb() rather than init().'
292
+ : `No session for ${phoneNumber} — register the number first, or ` +
293
+ 'link it to an existing account with connectWeb().'
294
+ );
282
295
  }
283
296
 
284
297
  const signalFile = path.join(this._sessionDir, `${phoneNumber}.signal.json`);
@@ -766,6 +779,28 @@ class WhalibmobClient extends EventEmitter {
766
779
  this._closing = false;
767
780
  }
768
781
 
782
+ /**
783
+ * Ask the server whether this session's registration is still alive.
784
+ *
785
+ * The read-only /exist probe, run with the credentials already on disk. It
786
+ * answers the question a 401 leaves open: whether the number is still
787
+ * registered and only this device was cut loose, or whether the registration
788
+ * is gone entirely. Sends nothing to the phone and requests no code, so it
789
+ * is safe to run on a failure.
790
+ *
791
+ * @returns {Promise<{alive: boolean, status: string|null, raw: object|null, error?: string}>}
792
+ */
793
+ async checkSessionAlive() {
794
+ if (!this._store) throw new Error('No session loaded');
795
+ try {
796
+ const res = await checkIfRegistered(this._store);
797
+ const status = (res && (res.status || res.reason)) || null;
798
+ return { alive: status === 'ok', status, raw: res };
799
+ } catch (err) {
800
+ return { alive: false, status: null, raw: null, error: err.message };
801
+ }
802
+ }
803
+
769
804
  // ─── Reconnection ─────────────────────────────────────────────────────────
770
805
 
771
806
  _scheduleReconnect() {
@@ -984,6 +1019,29 @@ class WhalibmobClient extends EventEmitter {
984
1019
  // We do NOT decrypt the patches — that requires the full LTHASH machinery.
985
1020
  // We DO update our version counters from the server response so that we
986
1021
  // never re-request the same snapshot twice.
1022
+ /**
1023
+ * Acknowledge a dirty bit so the server stops announcing it.
1024
+ *
1025
+ * `<ib><dirty type="..."/></ib>` is the server saying something changed while
1026
+ * we were away. Whatever the client does about it, the bit has to be cleared
1027
+ * or the same announcement arrives on every connection.
1028
+ *
1029
+ * @param {string} type the dirty type, e.g. 'groups'
1030
+ * @param {number|string} [fromTs] the timestamp the announcement carried
1031
+ */
1032
+ _cleanDirtyBits(type, fromTs) {
1033
+ if (!this._socket || !this._connected) return;
1034
+ const attrs = { type: String(type) };
1035
+ if (fromTs) attrs.timestamp = String(fromTs);
1036
+ _whaDbg('[DBG] CLEAN_DIRTY type=' + type + (fromTs ? ' t=' + fromTs : ''));
1037
+ this._socket.sendNode(new BinaryNode('iq', {
1038
+ id: this._genMsgId(),
1039
+ to: 's.whatsapp.net',
1040
+ type: 'set',
1041
+ xmlns: 'urn:xmpp:whatsapp:dirty'
1042
+ }, [new BinaryNode('clean', attrs, null)]));
1043
+ }
1044
+
987
1045
  _sendAppStateSyncForTypes(dirtyTypes) {
988
1046
  if (!this._socket || !this._connected) return;
989
1047
 
@@ -1002,7 +1060,11 @@ class WhalibmobClient extends EventEmitter {
1002
1060
  for (const type of dirtyTypes) {
1003
1061
  const mapped = COLLECTION_MAP[type];
1004
1062
  if (!mapped) {
1005
- _whaDbg('[DBG] ignoring unknown dirty type: ' + type);
1063
+ // "groups" is not an app-state collection, but it is still a dirty bit
1064
+ // the server expects to be cleared. Ignoring it meant the server
1065
+ // re-announced it on every single connection, forever.
1066
+ _whaDbg('[DBG] DIRTY type=' + type + ' — not an app-state collection, clearing the bit');
1067
+ this._cleanDirtyBits(type);
1006
1068
  continue;
1007
1069
  }
1008
1070
  for (const c of mapped) collections.add(c);
@@ -1099,18 +1161,31 @@ class WhalibmobClient extends EventEmitter {
1099
1161
  try { _whaDbg('[DBG] IQ_ERROR content=' + JSON.stringify(node.content)); } catch(_) {}
1100
1162
  }
1101
1163
 
1102
- if (tag === 'iq') this._handleIq(node);
1103
- else if (tag === 'message') this._handleMessage(node);
1104
- else if (tag === 'receipt') this._handleReceipt(node);
1105
- else if (tag === 'ack') this._handleAck(node);
1106
- else if (tag === 'notification') this._handleNotification(node);
1107
- else if (tag === 'presence') this._handlePresence(node);
1108
- else if (tag === 'call') this._handleCall(node);
1109
- else if (tag === 'ib') this._handleIb(node);
1110
- else if (tag === 'success') this.emit('session_refresh', { node }); // late success (re-auth)
1111
- else if (tag === 'failure') this._handleFailure(node);
1112
- else if (tag === 'stream:error') this._handleStreamError(node);
1113
- else this.emit('node', node);
1164
+ // One malformed stanza must not take the connection down with it.
1165
+ //
1166
+ // A throw here propagates out through the socket's frame reader, which
1167
+ // reports it as a socket error — so a single unhandled shape in one
1168
+ // notification looked like the transport failing, and every stanza queued
1169
+ // behind it in the same offline batch was lost. Contain it: log the stanza
1170
+ // that did it, tell the caller, carry on reading.
1171
+ try {
1172
+ if (tag === 'iq') this._handleIq(node);
1173
+ else if (tag === 'message') this._handleMessage(node);
1174
+ else if (tag === 'receipt') this._handleReceipt(node);
1175
+ else if (tag === 'ack') this._handleAck(node);
1176
+ else if (tag === 'notification') this._handleNotification(node);
1177
+ else if (tag === 'presence') this._handlePresence(node);
1178
+ else if (tag === 'call') this._handleCall(node);
1179
+ else if (tag === 'ib') this._handleIb(node);
1180
+ else if (tag === 'success') this.emit('session_refresh', { node }); // late success (re-auth)
1181
+ else if (tag === 'failure') this._handleFailure(node);
1182
+ else if (tag === 'stream:error') this._handleStreamError(node);
1183
+ else this.emit('node', node);
1184
+ } catch (err) {
1185
+ _whaDbg('[DBG] NODE_HANDLER_ERR tag=' + tag + ' err=' + (err && err.message) +
1186
+ ' attrs=' + JSON.stringify(node.attrs || {}));
1187
+ this.emit('node_error', { tag, node, err });
1188
+ }
1114
1189
  }
1115
1190
 
1116
1191
  // A stream error ends the connection; the reconnect in _onClose picks it up.
@@ -1122,16 +1197,55 @@ class WhalibmobClient extends EventEmitter {
1122
1197
  _handleStreamError(node) {
1123
1198
  const attrs = (node && node.attrs) || {};
1124
1199
  const code = attrs.code ? String(attrs.code) : null;
1125
- const reason = attrs.reason || code || 'unknown';
1126
1200
 
1127
1201
  if (code === '515') {
1128
1202
  _whaDbg('[DBG] STREAM_ERROR 515 — restart required after pairing, reconnecting');
1129
- this.emit('restart_required', { reason });
1203
+ this.emit('restart_required', { reason: '515' });
1130
1204
  return;
1131
1205
  }
1132
1206
 
1133
- _whaDbg('[DBG] STREAM_ERROR code=' + code + ' reason=' + reason);
1134
- this.emit('stream_error', { reason, code });
1207
+ // Most stream errors carry no code at all — the reason is a child element,
1208
+ // and for <conflict> the type attribute on it says which kind. Reading only
1209
+ // the attributes reported every one of these as "code=null reason=unknown",
1210
+ // which is the same message for a session someone else took over and a
1211
+ // session that was deleted.
1212
+ const child = Array.isArray(node.content) && node.content[0] ? node.content[0] : null;
1213
+ const childTag = child && child.description ? child.description : null;
1214
+ const conflictType = childTag === 'conflict' && child.attrs
1215
+ ? String(child.attrs.type || '') : null;
1216
+
1217
+ if (conflictType === 'replaced') {
1218
+ // Another client opened a session for this account and the server handed
1219
+ // it over. Reconnecting immediately just takes it back, and the two ends
1220
+ // trade the session until one of them is stopped — so this one stands
1221
+ // down and says why.
1222
+ _whaDbg('[DBG] STREAM_ERROR conflict/replaced — another client took the session');
1223
+ this._fatal = true;
1224
+ this.emit('connection_replaced', {
1225
+ message: 'Another client connected with this number and took over the ' +
1226
+ 'session. Reconnecting would take it back and the two would ' +
1227
+ 'keep displacing each other, so this connection has stopped. ' +
1228
+ 'Close the other client, then connect again.'
1229
+ });
1230
+ this.disconnect();
1231
+ return;
1232
+ }
1233
+
1234
+ if (conflictType === 'device_removed') {
1235
+ // The companion was unlinked from the phone. Same meaning as a 401.
1236
+ _whaDbg('[DBG] STREAM_ERROR conflict/device_removed — unlinked');
1237
+ this._fatal = true;
1238
+ this.emit('auth_failure', {
1239
+ reason: '401', location: null, loggedOut: true, node
1240
+ });
1241
+ this.disconnect();
1242
+ return;
1243
+ }
1244
+
1245
+ const reason = attrs.reason || conflictType || childTag || code || 'unknown';
1246
+ _whaDbg('[DBG] STREAM_ERROR code=' + code + ' child=' + childTag +
1247
+ ' conflict=' + conflictType + ' reason=' + reason);
1248
+ this.emit('stream_error', { reason, code, conflictType, childTag, node });
1135
1249
  }
1136
1250
 
1137
1251
  // ─── IQ handling ──────────────────────────────────────────────────────────
@@ -1921,7 +2035,13 @@ class WhalibmobClient extends EventEmitter {
1921
2035
  // Server push: a contact's linked device list changed (they linked or
1922
2036
  // unlinked a tablet, desktop, etc.). Invalidate that phone's cache entry
1923
2037
  // so the next send triggers a fresh usync IQ and reaches all their devices.
1924
- const fromJid = attrs.from || '';
2038
+ // BinaryNode decodes an AD-encoded JID into an object, not a string, so
2039
+ // this has to be stringified before it can be split. It was not, and a
2040
+ // <notification type="devices"> from a LID-addressed contact threw
2041
+ // "fromJid.split is not a function" out of the node handler — taking the
2042
+ // whole dispatch down with it, so every notification behind it in the
2043
+ // offline queue was dropped too.
2044
+ const fromJid = String(attrs.from || '');
1925
2045
  const fromPhone = fromJid.split('@')[0].split(':')[0];
1926
2046
  if (fromPhone && this._devMgr) {
1927
2047
  this._devMgr._dcDel([fromPhone]);
@@ -1945,7 +2065,7 @@ class WhalibmobClient extends EventEmitter {
1945
2065
  // Server push: a contact re-registered WhatsApp (new identity key / new phone).
1946
2066
  // Their old Signal sessions are no longer valid — clear them and the device
1947
2067
  // cache so the next send builds a fresh pkmsg session from scratch.
1948
- const fromJid = attrs.from || '';
2068
+ const fromJid = String(attrs.from || '');
1949
2069
  const fromPhone = fromJid.split('@')[0].split(':')[0];
1950
2070
  if (fromPhone && this._devMgr) {
1951
2071
  this._devMgr._dcDel([fromPhone]);
@@ -2140,10 +2260,17 @@ class WhalibmobClient extends EventEmitter {
2140
2260
  return;
2141
2261
  }
2142
2262
 
2143
- _whaDbg('[DBG] FAILURE reason=' + reason + ' location=' + loc);
2144
- this.emit('auth_failure', { reason, location: loc, node });
2145
- // The session is no longer valid — stop reconnecting and disconnect
2146
- this._fatal = true;
2263
+ // Not every failure kills the session. whatsmeow's IsLoggedOut() marks the
2264
+ // ones that do — 401, 403, 405, 406, 409 — and treats the rest as worth
2265
+ // another attempt. A 500 or a 503 is the server having a bad minute; giving
2266
+ // up on the session for that would be throwing away a working registration.
2267
+ const LOGGED_OUT = ['401', '403', '405', '406', '409'];
2268
+ const spent = LOGGED_OUT.includes(reason);
2269
+
2270
+ _whaDbg('[DBG] FAILURE reason=' + reason + ' location=' + loc +
2271
+ ' loggedOut=' + spent);
2272
+ this.emit('auth_failure', { reason, location: loc, loggedOut: spent, node });
2273
+ this._fatal = spent;
2147
2274
  this._reconnecting = false;
2148
2275
  this.disconnect();
2149
2276
  }
@@ -7,23 +7,109 @@ const curveJs = require('curve25519-js');
7
7
  const { getDeviceConfig } = require('./DeviceConfig');
8
8
  const attestation = require('./Attestation');
9
9
 
10
- // ---------- SOCKS5 / Tor support ----------
11
- // Set TOR_PROXY=socks5://127.0.0.1:9050 (or any socks5 host) to route all
12
- // WhatsApp registration traffic through Tor / a residential proxy.
13
- const SOCKS_LIB = '/home/runner/workspace/.config/npm/node_global/lib/node_modules/socks/build/index.js';
10
+ // ---------- SOCKS4 / SOCKS5 / Tor support ----------
11
+ //
12
+ // Set SOCKS_PROXY (or TOR_PROXY) to route registration traffic through a proxy:
13
+ //
14
+ // socks5://127.0.0.1:9050 Tor
15
+ // socks5://user:pass@host:1080 authenticated residential proxy
16
+ // socks5h://host:1080 resolve the destination at the proxy
17
+ // socks4://host:1080 older proxies
18
+ //
19
+ // Requires the `socks` package: npm install socks
20
+
21
+ const SOCKS_SCHEMES = {
22
+ 'socks:': 5,
23
+ 'socks5:': 5,
24
+ 'socks5h:': 5,
25
+ 'socks4:': 4,
26
+ 'socks4a:': 4
27
+ };
28
+
29
+ // Resolve the library the way every other dependency is resolved.
30
+ //
31
+ // This used to be a hardcoded absolute path into one particular machine's
32
+ // global npm directory. It resolved there and nowhere else, so proxy support
33
+ // was dead for everybody who installed whalibmob normally — the require threw
34
+ // MODULE_NOT_FOUND the moment a proxy was configured. WA_SOCKS_LIB stays as an
35
+ // escape hatch for unusual layouts, but it is an override now, not the only
36
+ // way in.
37
+ function loadSocks() {
38
+ const override = process.env.WA_SOCKS_LIB;
39
+ if (override) {
40
+ try { return require(override); }
41
+ catch (err) {
42
+ throw new Error('WA_SOCKS_LIB points at ' + override +
43
+ ' but it could not be loaded: ' + err.message);
44
+ }
45
+ }
46
+ try {
47
+ return require('socks');
48
+ } catch (_) {
49
+ throw new Error(
50
+ "A SOCKS proxy is configured but the 'socks' package is not installed. " +
51
+ 'Run: npm install socks'
52
+ );
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Parse a proxy URL into what SocksClient expects.
58
+ *
59
+ * Accepts the schemes above, with or without credentials, and defaults the
60
+ * port to 1080 the way every SOCKS client does.
61
+ */
62
+ function parseSocksProxy(proxyUrl) {
63
+ let raw = String(proxyUrl || '').trim();
64
+ if (!raw) throw new Error('No SOCKS proxy configured');
65
+
66
+ // A bare host:port is what .env.example has always shown and what most people
67
+ // type. Without a scheme `new URL` reads "127.0.0.1:" as the protocol and the
68
+ // rest as the path, so it has to be filled in before parsing.
69
+ if (!/^[a-z0-9+.-]+:\/\//i.test(raw)) raw = 'socks5://' + raw;
70
+
71
+ let url;
72
+ try { url = new URL(raw); }
73
+ catch (_) { throw new Error('Invalid SOCKS proxy URL: ' + proxyUrl); }
74
+
75
+ const type = SOCKS_SCHEMES[url.protocol];
76
+ if (!type) {
77
+ throw new Error('Unsupported proxy scheme "' + url.protocol + '" — use ' +
78
+ Object.keys(SOCKS_SCHEMES).map(s => s.replace(':', '')).join(', '));
79
+ }
80
+ if (!url.hostname) throw new Error('SOCKS proxy URL has no host: ' + proxyUrl);
81
+
82
+ const proxy = {
83
+ host: url.hostname,
84
+ port: parseInt(url.port, 10) || 1080,
85
+ type
86
+ };
87
+ // Credentials may be percent-encoded in a URL; a password with an @ or a :
88
+ // in it is common enough on paid proxies to be worth decoding.
89
+ if (url.username) proxy.userId = decodeURIComponent(url.username);
90
+ if (url.password) proxy.password = decodeURIComponent(url.password);
91
+ return proxy;
92
+ }
93
+
94
+ // TOR_PROXY wins when both are set — the precedence .env.example has always
95
+ // documented.
96
+ function socksProxyUrl() {
97
+ return process.env.TOR_PROXY || process.env.SOCKS_PROXY || '';
98
+ }
14
99
 
15
100
  async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
16
- const url = new URL(proxyUrl);
17
- const pHost = url.hostname;
18
- const pPort = parseInt(url.port) || 1080;
19
- const dHost = 'v.whatsapp.net';
20
- const dPort = 443;
101
+ const proxy = parseSocksProxy(proxyUrl);
102
+ const dHost = 'v.whatsapp.net';
103
+ const dPort = 443;
21
104
 
22
- const { SocksClient } = require(SOCKS_LIB);
105
+ const { SocksClient } = loadSocks();
23
106
  const { socket: rawSocket } = await SocksClient.createConnection({
24
- proxy: { host: pHost, port: pPort, type: 5 },
107
+ proxy,
25
108
  command: 'connect',
26
- destination: { host: dHost, port: dPort }
109
+ destination: { host: dHost, port: dPort },
110
+ // Without this a proxy that accepts the TCP connection and then says
111
+ // nothing leaves the whole registration hanging with no error.
112
+ timeout: 20000
27
113
  });
28
114
 
29
115
  const tlsSocket = tls.connect({
@@ -50,16 +136,25 @@ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
50
136
  `Content-Length: ${Buffer.byteLength(body)}`
51
137
  ];
52
138
  if (authHeader) reqLines.push(`Authorization: ${authHeader}`);
53
- reqLines.push(`Connection: keep-alive`, '', body);
139
+ // close, not keep-alive: the response is read by waiting for the server to
140
+ // end the stream. Asking it to keep the connection open means that never
141
+ // happens and the request hangs until something else times out.
142
+ reqLines.push(`Connection: close`, '', body);
54
143
  const req = reqLines.join('\r\n');
55
144
 
56
145
  tlsSocket.write(req);
57
146
 
58
147
  const chunks = [];
59
148
  await new Promise((res, rej) => {
60
- tlsSocket.on('data', d => chunks.push(d));
61
- tlsSocket.on('end', res);
62
- tlsSocket.on('error', rej);
149
+ const timer = setTimeout(() => {
150
+ tlsSocket.destroy();
151
+ rej(new Error('SOCKS proxy timed out reading ' + path));
152
+ }, 30000);
153
+ const finish = (fn, arg) => { clearTimeout(timer); fn(arg); };
154
+ tlsSocket.on('data', d => chunks.push(d));
155
+ tlsSocket.on('end', () => finish(res));
156
+ tlsSocket.on('close', () => finish(res));
157
+ tlsSocket.on('error', e => finish(rej, e));
63
158
  });
64
159
 
65
160
  const raw = Buffer.concat(chunks);
@@ -510,7 +605,7 @@ function encryptPayload(plaintext) {
510
605
  // ---------- HTTP ----------
511
606
 
512
607
  function httpPost(path, body, waVersion, authHeader) {
513
- const proxy = process.env.TOR_PROXY || process.env.SOCKS_PROXY || '';
608
+ const proxy = socksProxyUrl();
514
609
  if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader);
515
610
 
516
611
  return new Promise((resolve, reject) => {
@@ -841,4 +936,4 @@ async function verifyCode(store, code) {
841
936
  throw new Error(`Verification failed: ${reason || JSON.stringify(result)}`);
842
937
  }
843
938
 
844
- module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, parsePhone, getCountryMeta, assertRegistrationKeys };
939
+ module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
package/lib/noise.js CHANGED
@@ -444,9 +444,44 @@ class NoiseSocket extends EventEmitter {
444
444
 
445
445
  if (tag === 'failure') {
446
446
  const attrs = (node && node.attrs) || {};
447
- const reason = attrs.reason || attrs.location || '401';
448
- const err = new Error('WhatsApp auth failure: ' + reason);
447
+ const reason = String(attrs.reason || attrs.location || '401');
448
+ // Say what the code means. "auth failure: 401" sends people looking
449
+ // for a bug in the client; the credentials were rejected by the
450
+ // server, which is a different thing and has different remedies.
451
+ //
452
+ // The names and numbers are whatsmeow's ConnectFailureReason, which
453
+ // is the only place they are written down — WhatsApp publishes none
454
+ // of this. Codes marked "wipe" are the ones its IsLoggedOut() covers:
455
+ // the session is finished and retrying with it is pointless.
456
+ const FAILURE = {
457
+ '400': { wipe: false, text: 'generic failure.' },
458
+ '401': { wipe: true, text: 'logged out. The session was unpaired ' +
459
+ 'server-side — nothing in the session file can revive it. ' +
460
+ 'For a linked device this means it was removed under ' +
461
+ 'Linked Devices; for a registered number it means the ' +
462
+ 'registration was taken over, usually by verifying the ' +
463
+ 'same number on the official app.' },
464
+ '402': { wipe: false, text: 'temporarily banned.' },
465
+ '403': { wipe: true, text: "the account's primary device is gone " +
466
+ '— it is no longer a multi-device account.' },
467
+ '405': { wipe: true, text: 'client outdated — the announced ' +
468
+ 'version is not one the server accepts.' },
469
+ '406': { wipe: true, text: 'banned.' },
470
+ '409': { wipe: true, text: 'bad user agent — the client identified ' +
471
+ 'itself in a way the server rejects.' },
472
+ '413': { wipe: false, text: 'auth token expired.' },
473
+ '414': { wipe: false, text: 'auth token invalid.' },
474
+ '415': { wipe: false, text: 'not found.' },
475
+ '500': { wipe: false, text: 'server error — worth retrying.' },
476
+ '503': { wipe: false, text: 'service unavailable — worth retrying.' }
477
+ };
478
+ const info = FAILURE[reason];
479
+ const err = new Error('WhatsApp auth failure ' + reason +
480
+ (info ? ' — ' + info.text : ''));
449
481
  err.code = reason;
482
+ // True when the session is spent and the caller should register or
483
+ // link again rather than retry.
484
+ err.loggedOut = !!(info && info.wipe);
450
485
  if (this._connectReject) {
451
486
  this._connectReject(err);
452
487
  this._connectResolve = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.6.4",
3
+ "version": "5.6.9",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto50",
6
6
  "main": "index.js",
@@ -51,6 +51,7 @@
51
51
  "ws": "^8.21.1"
52
52
  },
53
53
  "optionalDependencies": {
54
- "jimp": "^1.6.0"
54
+ "jimp": "^1.6.0",
55
+ "socks": "^2.8.9"
55
56
  }
56
57
  }