whalibmob 5.19.0 → 5.19.2

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
@@ -124,8 +124,10 @@
124
124
 
125
125
 
126
126
  # ─── Proxy ───────────────────────────────────────────────────────────────────
127
- # Route registration traffic through a SOCKS proxy. Requires the `socks`
128
- # package: npm install socks
127
+ # Route every outbound connection through a SOCKS proxy: the message socket
128
+ # (mobile TCP and web WSS alike), registration, the announced-version lookups,
129
+ # the APK material download and media uploads. Requires the `socks` package:
130
+ # npm install socks
129
131
  #
130
132
  # A bare host:port is assumed to be socks5. Add a scheme to be explicit, and
131
133
  # credentials if the proxy needs them. Percent-encode any @ or : in a password.
package/README.md CHANGED
@@ -152,7 +152,7 @@ npm install -g whalibmob
152
152
  - [Two Sessions on One Number](#two-sessions-on-one-number)
153
153
  - [The Number WhatsApp Files Your Account Under](#the-number-whatsapp-files-your-account-under)
154
154
  - [When Registration Is Refused for Consent](#when-registration-is-refused-for-consent)
155
- - [Routing Registration Through a Proxy](#routing-registration-through-a-proxy)
155
+ - [Routing Traffic Through a Proxy](#routing-traffic-through-a-proxy)
156
156
  - [Saving & Restoring Sessions](#saving--restoring-sessions)
157
157
  - [Signal Store Utilities](#signal-store-utilities)
158
158
  - [makeCacheableSignalKeyStore](#makecacheablesignalkeystore)
@@ -2007,10 +2007,11 @@ Every option is optional; `sessionDir` is the only one most senders ever set.
2007
2007
  | `sessionDir` | `~/.waSession` | The authentication folder. Each number gets its own subfolder inside it — see [Saving & Restoring Sessions](#saving--restoring-sessions). |
2008
2008
  | `autoFixNumber` | `true` | Re-file the session automatically when the server reports the account under a different number. Set `false` to be told instead of fixed — see [The Number WhatsApp Files Your Account Under](#the-number-whatsapp-files-your-account-under). |
2009
2009
  | `autoRead` | `true` | Send read receipts for incoming messages. `false` leaves them unread. |
2010
- | `refreshVersion` | `true` | **iOS sessions only.** Check the App Store for the current build on every `init()` and write it into the session file. Set `false` to keep announcing whatever the session registered with — see [Keeping the Announced Version Current](#keeping-the-announced-version-current). Android is unaffected either way. |
2010
+ | `refreshVersion` | `true` | Check the platform's store for the current build on every connect and reconnect, and write it into the session file. Both iOS and Android. Set `false` to keep announcing whatever the session registered with — see [Keeping the Announced Version Current](#keeping-the-announced-version-current). |
2011
2011
  | `pino` | off | Debug logging. `true` turns it on at `debug` level; an object is passed to `pino` as-is. |
2012
2012
  | `sentCacheSize` | `2000` | How many sent messages keep their plaintext so a retry receipt naming them can be answered. |
2013
2013
  | `maxRetryResends` | `5` | How many times one message may be re-sent in answer to retry receipts before the client gives up. |
2014
+ | `tcTokenPresendTimeoutMs` | `5000` | How long the first message to a new contact waits for its trusted-contact token before going out without one — see [tcToken — Error 463 Defense](#tctoken--error-463-defense). `0` sends immediately and lets the token arrive for the next message. |
2014
2015
 
2015
2016
  ```js
2016
2017
  const client = new WhalibmobClient({
@@ -2487,9 +2488,9 @@ If that is refused too, the number has to go through the real app once, on a pho
2487
2488
  > [!NOTE]
2488
2489
  > The `login` field in that reply is worth reading. Brazilian mobiles gained a ninth digit that WhatsApp never adopted, so `+5571976034186` is filed as `+557176034186`. whalibmob adopts the server's form automatically on a successful registration and saves the session under it — the digit difference is not itself the failure.
2489
2490
 
2490
- ## Routing Registration Through a Proxy
2491
+ ## Routing Traffic Through a Proxy
2491
2492
 
2492
- 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.
2493
+ Two reasons to want this. Registration is the part of the protocol most likely to be refused from a datacenter IP, so a residential exit helps with security blocks and undeterminable number statuses. And on a network that cannot reach WhatsApp at all, nothing works without one.
2493
2494
 
2494
2495
  Install the optional dependency and set one environment variable:
2495
2496
 
@@ -2516,10 +2517,30 @@ process.env.SOCKS_PROXY = 'socks5://user:pass@proxy.example.com:1080'
2516
2517
  await requestSmsCode(phone, store)
2517
2518
  ```
2518
2519
 
2519
- > [!NOTE]
2520
- > 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.
2520
+ ### What goes through it
2521
+
2522
+ Everything the library sends out:
2523
+
2524
+ | Connection | Destination |
2525
+ |---|---|
2526
+ | Message socket, mobile | raw TCP to WhatsApp |
2527
+ | Message socket, web | `wss://web.whatsapp.com/ws/chat` |
2528
+ | Registration | `v.whatsapp.net` |
2529
+ | Announced version, iOS | App Store lookup |
2530
+ | Announced version, Android | Play Store listing |
2531
+ | Announced version, web | `web.whatsapp.com/sw.js` |
2532
+ | APK material for registration | Play Store download |
2533
+ | Media upload | WhatsApp's media hosts |
2534
+
2535
+ Node's `http`/`https` modules ignore proxy environment variables entirely — a proxy has to be handed in as an agent, per call site. Only the registration POST used to do that, which produced a confusing failure: `curl -x socks5://…` reached WhatsApp while the library timed out on the same machine, falling back to a pinned version with only a debug line to show for it:
2536
+
2537
+ ```
2538
+ [DBG] WEB_VERSION 2.3000.1035194821 (pinned fallback: sw.js fetch timed out)
2539
+ ```
2540
+
2541
+ All eight paths now share one agent.
2521
2542
 
2522
- 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.
2543
+ If `socks` is not installed, or a proxy is unreachable, you get a message saying so rather than a silent failure. A proxy URL that cannot be parsed leaves the connection direct rather than throwing — a version lookup with a pinned fallback should not take a process down over its proxy. In the rare case the package lives somewhere `require()` cannot find it, `WA_SOCKS_LIB` takes an absolute path to it.
2523
2544
 
2524
2545
  ## Saving & Restoring Sessions
2525
2546
 
@@ -3278,8 +3299,9 @@ whalibmob implements the full lifecycle to prevent this:
3278
3299
  | Step | What the library does automatically |
3279
3300
  |---|---|
3280
3301
  | **History seed** | On every history sync chunk, `tcToken` bytes are extracted from each conversation in the protobuf and loaded into `TcTokenStore` in memory. The first send after reconnect already has a valid token ready — no 463 risk on cold start. |
3302
+ | **First contact** | The very first DM to a contact has no token on file yet. Before the stanza is built, the library issues one and waits for it, so that message carries a `<tctoken>` like every later one instead of counting as an anonymous reach-out. The wait is bounded by `tcTokenPresendTimeoutMs` (default 5 s) — past it the message goes out regardless and the token lands in time for the next one. |
3281
3303
  | **Attach on send** | Before dispatching any DM, `MessageSender` looks up the token for the recipient JID, checks it has not expired (28-day rolling window), and pushes a `<tctoken>` child node into the message stanza. |
3282
- | **Proactive issuance** | After each successful DM send, the library fires a `<iq type='set' xmlns='privacy'>` requesting a fresh token for that JID from the server — once per 7-day bucket, deduplicated in-flight. |
3304
+ | **Renewal** | After a successful DM send, the library fires a `<iq type='set' xmlns='privacy'>` requesting a fresh token for that JID from the server — once per 7-day bucket, deduplicated in-flight. This one is fire-and-forget: the token being attached right now is still valid, so nothing waits for it. |
3283
3305
  | **Incoming notification** | When a contact starts a new conversation, WhatsApp pushes a `<notification type='privacy_token'>`. The library catches it in `_handlePrivacyTokenNotification` and stores the token immediately. |
3284
3306
  | **Identity change re-issue** | When decrypting a `pkmsg` (new Signal session from peer), the library calls `_reissueTcTokenAfterIdentityChange` to re-issue the token for the new session. |
3285
3307
  | **Error 463 recovery** | If a send fails with error 463, the library issues a fresh token, waits for the server response, and automatically retries the same message with the new token attached. |
package/lib/Client.js CHANGED
@@ -79,6 +79,12 @@ const MEX_QUERY_REACHOUT_TIMELOCK = '23983697327930364';
79
79
  // server takes a longer one, answers <iq type="result"/> and keeps the old text.
80
80
  const ABOUT_MAX_LENGTH = 139;
81
81
 
82
+ // How long the first message to a contact waits for its trusted-contact token.
83
+ // One round-trip to the server, no more: past this the message goes out without
84
+ // a token rather than sitting in the queue, and the issuance finishes in the
85
+ // background for the next send.
86
+ const TC_TOKEN_PRESEND_TIMEOUT_MS = 5000;
87
+
82
88
  // The names privacy settings actually go by on the wire, and the friendly
83
89
  // aliases this library has always accepted for them. The wire names are the
84
90
  // right-hand column.
@@ -464,6 +470,15 @@ class WhalibmobClient extends EventEmitter {
464
470
  this._tcTokenStore = null; // TcTokenStore — loaded in init()
465
471
  this._inFlightTcTokenIssuance = new Set(); // dedupe concurrent proactive issuePrivacyTokens per JID
466
472
  this._inFlight463Recoveries = new Set(); // dedupe concurrent 463-triggered token issuances per JID (separate from proactive)
473
+ // jid → in-flight pre-send issuance promise. A Set would only say that one
474
+ // is running; concurrent first sends to the same contact have to be able to
475
+ // wait for the same answer, so the promise itself is what gets shared.
476
+ this._tcTokenIssuanceWaiters = new Map();
477
+ // How long a first send will wait for its token before going out without
478
+ // one. _sendIq gives up after 15s, which is far too long to hold a message.
479
+ this._tcTokenPresendTimeoutMs =
480
+ Number(opts.tcTokenPresendTimeoutMs) >= 0
481
+ ? Number(opts.tcTokenPresendTimeoutMs) : TC_TOKEN_PRESEND_TIMEOUT_MS;
467
482
  // Last known account restriction, or null until one is fetched or pushed.
468
483
  this._reachoutTimelock = null;
469
484
  this._reachoutTimelockInFlight = null;
@@ -4765,6 +4780,97 @@ class WhalibmobClient extends EventEmitter {
4765
4780
  return this._sendIq(node);
4766
4781
  }
4767
4782
 
4783
+ /**
4784
+ * Put a trusted-contact token on file *before* the first message to a contact
4785
+ * is built, so that message carries a `<tctoken>` like every later one.
4786
+ *
4787
+ * The token never came from the contact. `_issuePrivacyTokens` is an IQ to
4788
+ * s.whatsapp.net and the server answers with the bytes, so nothing here waits
4789
+ * on the other side replying, having us in their address book, or a
4790
+ * conversation existing at all.
4791
+ *
4792
+ * What was missing was only the ordering. Issuance ran after
4793
+ * `_dispatchAndAck` and fire-and-forget, so the first message to any new
4794
+ * contact went out with no token and the server counted it as an anonymous
4795
+ * reach-out. At bulk that is one such event per number, which is exactly the
4796
+ * fuel for error 463. The official app issues on opening the chat — before
4797
+ * anything is typed — so `issue → send` is the faithful order, not `send →
4798
+ * issue`.
4799
+ *
4800
+ * Three cases return without waiting, and each matters:
4801
+ *
4802
+ * - a live token is already on file. Renewal is due eventually, but the
4803
+ * post-send path does that without holding a message up.
4804
+ * - `shouldIssue` is false. We already asked inside this 7-day bucket and
4805
+ * the server gave us nothing; asking again now would put a round-trip in
4806
+ * front of every send for nothing.
4807
+ * - the JID is the PSA account or a bot, which cannot timelock us.
4808
+ *
4809
+ * @param {string} tcJid storage key, already through _resolveTcTokenJid
4810
+ * @param {string} routingToJid the JID the message is addressed to
4811
+ * @returns {Promise<boolean>} whether a usable token is on file now
4812
+ */
4813
+ async ensureTcTokenBeforeSend(tcJid, routingToJid) {
4814
+ const store = this._tcTokenStore;
4815
+ if (!store) return false;
4816
+
4817
+ const { tcTokenExpired, isRegularTcTokenUser } = require('./messages/TcTokenStore');
4818
+ if (!isRegularTcTokenUser(tcJid)) return false;
4819
+
4820
+ // Never ourselves. A note-to-self is not a reach-out, and holding it up for
4821
+ // a token nobody will check is the one stall this change could introduce.
4822
+ const bare = String(tcJid).split('@')[0].split(':')[0];
4823
+ const ownPhone = this._store && this._store.phoneNumber
4824
+ ? String(this._store.phoneNumber) : null;
4825
+ const ownLidUsr = this._myLid ? String(this._myLid).split('@')[0].split(':')[0] : null;
4826
+ if ((ownPhone && bare === ownPhone) || (ownLidUsr && bare === ownLidUsr)) return false;
4827
+
4828
+ const live = entry =>
4829
+ !!(entry && entry.token && entry.token.length && !tcTokenExpired(entry.timestamp));
4830
+
4831
+ if (live(store.get(tcJid))) return true;
4832
+ if (!store.shouldIssue(tcJid)) return false;
4833
+
4834
+ // A second send to the same contact must wait on the first one's answer
4835
+ // rather than issue a competing IQ for the same JID.
4836
+ let pending = this._tcTokenIssuanceWaiters.get(tcJid);
4837
+ if (!pending) {
4838
+ const issueTs = Math.floor(Date.now() / 1000);
4839
+ const issueJid = this._resolveTcTokenIssuanceJid(routingToJid);
4840
+ _whaDbg('[DBG] TCTOKEN_PRESEND issuing for ' + tcJid + ' (as ' + issueJid + ')');
4841
+ pending = this._issuePrivacyTokens(issueJid, issueTs)
4842
+ .then(result => { this._storeTcTokenFromIqResult(result, tcJid, issueTs); })
4843
+ .catch(err => {
4844
+ _whaDbg('[DBG] TCTOKEN_PRESEND_ERR jid=' + tcJid + ' err=' + (err && err.message));
4845
+ })
4846
+ .finally(() => { this._tcTokenIssuanceWaiters.delete(tcJid); });
4847
+ this._tcTokenIssuanceWaiters.set(tcJid, pending);
4848
+ }
4849
+
4850
+ // Bounded wait. A send is never failed or delayed indefinitely over a token:
4851
+ // past the deadline it goes out without one, the issuance carries on in the
4852
+ // background, and the next message to this contact picks the token up.
4853
+ const limit = this._tcTokenPresendTimeoutMs;
4854
+ if (limit <= 0) return false;
4855
+
4856
+ let timer = null;
4857
+ const deadline = new Promise(resolve => {
4858
+ timer = setTimeout(() => resolve(false), limit);
4859
+ });
4860
+ const finished = await Promise.race([pending.then(() => true), deadline]);
4861
+ if (timer) clearTimeout(timer);
4862
+
4863
+ if (!finished) {
4864
+ _whaDbg('[DBG] TCTOKEN_PRESEND timed out after ' + limit + 'ms jid=' + tcJid +
4865
+ ' — sending without a token');
4866
+ return false;
4867
+ }
4868
+
4869
+ const ok = live(store.get(tcJid));
4870
+ _whaDbg('[DBG] TCTOKEN_PRESEND ' + (ok ? 'ready' : 'no bytes returned') + ' jid=' + tcJid);
4871
+ return ok;
4872
+ }
4873
+
4768
4874
  /**
4769
4875
  * Parse the IQ result from `_issuePrivacyTokens` and persist the token.
4770
4876
  *
@@ -195,11 +195,17 @@ function _tryUpload(host, mediaPath, token, auth, encrypted, web) {
195
195
  const hostname = typeof host === 'string' ? host : host.hostname;
196
196
  const urlPath = `/${mediaPath}/${token}?auth=${auth}&token=${token}`;
197
197
 
198
+ const { proxyAgent } = require('./socks');
199
+ const agent = proxyAgent();
198
200
  const options = {
199
201
  hostname,
200
202
  port: 443,
201
203
  path: urlPath,
202
204
  method: 'POST',
205
+ // The media hosts are as unreachable as the rest of WhatsApp on a network
206
+ // that needs a proxy. Uploading direct while the socket is proxied leaves
207
+ // a session that connects and then cannot send a photo.
208
+ agent: agent || undefined,
203
209
  headers: Object.assign({
204
210
  'Content-Type': 'application/octet-stream',
205
211
  'Content-Length': encrypted.length,
@@ -265,9 +271,15 @@ const MAX_REDIRECTS = 5;
265
271
 
266
272
  function _httpGet(url, opts, depth) {
267
273
  return new Promise((resolve, reject) => {
268
- const lib = url.startsWith('https') ? https : http;
274
+ const isTls = url.startsWith('https');
275
+ const lib = isTls ? https : http;
269
276
  const web = !!(opts && opts.web);
270
- lib.get(url, { headers: mediaHeaders(web) }, (res) => {
277
+ // The agent tunnels TLS, so it belongs on https only. A plain-http media
278
+ // URL is rare enough that leaving it direct is better than handing an
279
+ // https.Agent to the http module and getting an obscure failure.
280
+ const { proxyAgent } = require('./socks');
281
+ const agent = isTls ? proxyAgent() : null;
282
+ lib.get(url, { agent: agent || undefined, headers: mediaHeaders(web) }, (res) => {
271
283
  const status = res.statusCode || 0;
272
284
  if (status === 301 || status === 302) {
273
285
  res.resume(); // drop the redirect body
package/lib/PlayStore.js CHANGED
@@ -124,12 +124,18 @@ function _request(url, opts) {
124
124
  opts = opts || {};
125
125
  return new Promise((resolve, reject) => {
126
126
  const u = new URL(url);
127
+ // The APK material download is a Google endpoint, blocked on the same
128
+ // networks that block WhatsApp. Registration would otherwise fall back to
129
+ // stale certificate material with no way to refresh it.
130
+ const { proxyAgent } = require('./socks');
131
+ const agent = proxyAgent();
127
132
  const req = https.request({
128
133
  hostname: u.hostname,
129
134
  port: u.port || 443,
130
135
  path: u.pathname + u.search,
131
136
  method: opts.method || 'GET',
132
137
  headers: opts.headers || {},
138
+ agent: agent || undefined,
133
139
  timeout: REQUEST_TIMEOUT
134
140
  }, (res) => {
135
141
  // The CDN answers a signed URL with a redirect more often than not.
@@ -56,108 +56,23 @@ function registrationHeaders(device, waVersion) {
56
56
 
57
57
  // ---------- SOCKS4 / SOCKS5 / Tor support ----------
58
58
  //
59
- // Set SOCKS_PROXY (or TOR_PROXY) to route registration traffic through a proxy:
60
- //
61
- // socks5://127.0.0.1:9050 Tor
62
- // socks5://user:pass@host:1080 authenticated residential proxy
63
- // socks5h://host:1080 resolve the destination at the proxy
64
- // socks4://host:1080 older proxies
65
- //
66
- // Requires the `socks` package: npm install socks
67
-
68
- const SOCKS_SCHEMES = {
69
- 'socks:': 5,
70
- 'socks5:': 5,
71
- 'socks5h:': 5,
72
- 'socks4:': 4,
73
- 'socks4a:': 4
74
- };
75
-
76
- // Resolve the library the way every other dependency is resolved.
77
- //
78
- // This used to be a hardcoded absolute path into one particular machine's
79
- // global npm directory. It resolved there and nowhere else, so proxy support
80
- // was dead for everybody who installed whalibmob normally — the require threw
81
- // MODULE_NOT_FOUND the moment a proxy was configured. WA_SOCKS_LIB stays as an
82
- // escape hatch for unusual layouts, but it is an override now, not the only
83
- // way in.
84
- function loadSocks() {
85
- const override = process.env.WA_SOCKS_LIB;
86
- if (override) {
87
- try { return require(override); }
88
- catch (err) {
89
- throw new Error('WA_SOCKS_LIB points at ' + override +
90
- ' but it could not be loaded: ' + err.message);
91
- }
92
- }
93
- try {
94
- return require('socks');
95
- } catch (_) {
96
- throw new Error(
97
- "A SOCKS proxy is configured but the 'socks' package is not installed. " +
98
- 'Run: npm install socks'
99
- );
100
- }
101
- }
102
-
103
- /**
104
- * Parse a proxy URL into what SocksClient expects.
105
- *
106
- * Accepts the schemes above, with or without credentials, and defaults the
107
- * port to 1080 the way every SOCKS client does.
108
- */
109
- function parseSocksProxy(proxyUrl) {
110
- let raw = String(proxyUrl || '').trim();
111
- if (!raw) throw new Error('No SOCKS proxy configured');
112
-
113
- // A bare host:port is what .env.example has always shown and what most people
114
- // type. Without a scheme `new URL` reads "127.0.0.1:" as the protocol and the
115
- // rest as the path, so it has to be filled in before parsing.
116
- if (!/^[a-z0-9+.-]+:\/\//i.test(raw)) raw = 'socks5://' + raw;
117
-
118
- let url;
119
- try { url = new URL(raw); }
120
- catch (_) { throw new Error('Invalid SOCKS proxy URL: ' + proxyUrl); }
121
-
122
- const type = SOCKS_SCHEMES[url.protocol];
123
- if (!type) {
124
- throw new Error('Unsupported proxy scheme "' + url.protocol + '" — use ' +
125
- Object.keys(SOCKS_SCHEMES).map(s => s.replace(':', '')).join(', '));
126
- }
127
- if (!url.hostname) throw new Error('SOCKS proxy URL has no host: ' + proxyUrl);
128
-
129
- const proxy = {
130
- host: url.hostname,
131
- port: parseInt(url.port, 10) || 1080,
132
- type
133
- };
134
- // Credentials may be percent-encoded in a URL; a password with an @ or a :
135
- // in it is common enough on paid proxies to be worth decoding.
136
- if (url.username) proxy.userId = decodeURIComponent(url.username);
137
- if (url.password) proxy.password = decodeURIComponent(url.password);
138
- return proxy;
139
- }
140
-
141
- // TOR_PROXY wins when both are set — the precedence .env.example has always
142
- // documented.
143
- function socksProxyUrl() {
144
- return process.env.TOR_PROXY || process.env.SOCKS_PROXY || '';
145
- }
59
+ // The parsing, the dialer and the HTTPS agent all live in ./socks now, because
60
+ // registration is no longer the only thing that needs them: the version
61
+ // lookups below, the web sw.js probe, the WhatsApp Web socket and the mobile
62
+ // TCP socket route through the same proxy. They are re-exported at the bottom
63
+ // of this file so existing importers keep working.
64
+ const {
65
+ parseSocksProxy, socksProxyUrl, socksConnect, proxyAgent
66
+ } = require('./socks');
146
67
 
147
68
  async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader, device) {
148
- const proxy = parseSocksProxy(proxyUrl);
149
69
  const dHost = 'v.whatsapp.net';
150
70
  const dPort = 443;
151
71
 
152
- const { SocksClient } = loadSocks();
153
- const { socket: rawSocket } = await SocksClient.createConnection({
154
- proxy,
155
- command: 'connect',
156
- destination: { host: dHost, port: dPort },
157
- // Without this a proxy that accepts the TCP connection and then says
158
- // nothing leaves the whole registration hanging with no error.
159
- timeout: 20000
160
- });
72
+ // socksConnect carries the same 20s handshake timeout this used to set
73
+ // inline: without one, a proxy that accepts the TCP connection and then says
74
+ // nothing leaves the whole registration hanging with no error.
75
+ const rawSocket = await socksConnect(proxyUrl, dHost, dPort);
161
76
 
162
77
  const tlsSocket = tls.connect({
163
78
  socket: rawSocket,
@@ -671,9 +586,10 @@ async function fetchIosVersion(business) {
671
586
  if (hit) return hit;
672
587
  const bundleId = business ? IOS_BUSINESS_BUNDLE_ID : IOS_BUNDLE_ID;
673
588
  return new Promise((resolve) => {
589
+ const agent = proxyAgent();
674
590
  const req = https.get(
675
591
  'https://itunes.apple.com/lookup?bundleId=' + bundleId,
676
- { headers: { 'User-Agent': IOS_USER_AGENT } },
592
+ { agent: agent || undefined, headers: { 'User-Agent': IOS_USER_AGENT } },
677
593
  (res) => {
678
594
  const chunks = [];
679
595
  res.on('data', d => chunks.push(d));
@@ -705,6 +621,10 @@ async function fetchAndroidVersion(business) {
705
621
  const packageName = business ? WHATSAPP_BUSINESS_PACKAGE : WHATSAPP_PACKAGE;
706
622
  try {
707
623
  const axios = require('axios');
624
+ // Unlike https.get, axios reads HTTP_PROXY/HTTPS_PROXY on its own. That
625
+ // stays untouched when no SOCKS proxy is configured; `proxy: false` is set
626
+ // only alongside our own agent, so the two never fight over the socket.
627
+ const socksAgent = proxyAgent();
708
628
  const resp = await axios.get(
709
629
  'https://play.google.com/store/apps/details?id=' + packageName + '&hl=en&gl=us',
710
630
  {
@@ -719,7 +639,11 @@ async function fetchAndroidVersion(business) {
719
639
  },
720
640
  responseType: 'text',
721
641
  decompress: true,
722
- timeout: 12000
642
+ timeout: 12000,
643
+ // axios takes an agent under a different key than https.get does, but
644
+ // drives it identically. Omitted, this lookup ignores SOCKS_PROXY and
645
+ // silently falls back to a pinned version on a blocked network.
646
+ ...(socksAgent ? { httpsAgent: socksAgent, proxy: false } : {})
723
647
  }
724
648
  );
725
649
  // Buffer when the response was compressed and axios handed the bytes back
@@ -38,8 +38,15 @@ class WebSocketStream extends EventEmitter {
38
38
 
39
39
  connect() {
40
40
  const WebSocket = loadWs();
41
+ // ws drives an agent exactly the way https.request does, so the same SOCKS
42
+ // agent that carries the version probe carries the socket. Without it the
43
+ // upgrade request goes out directly while everything else in the process
44
+ // is proxied, which fails in the one situation a proxy is set for.
45
+ const { proxyAgent } = require('./socks');
46
+ const agent = proxyAgent();
41
47
  this._ws = new WebSocket(this._url, {
42
48
  origin: this._origin,
49
+ agent: agent || undefined,
43
50
  headers: {
44
51
  'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' +
45
52
  '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
package/lib/WebVersion.js CHANGED
@@ -18,6 +18,7 @@
18
18
 
19
19
  const https = require('https');
20
20
  const { WEB_VERSION_FALLBACK } = require('./constants');
21
+ const { proxyAgent } = require('./socks');
21
22
 
22
23
  const SW_URL = 'https://web.whatsapp.com/sw.js';
23
24
  const CACHE_MS = 60 * 60 * 1000; // an hour; the revision changes far slower
@@ -27,7 +28,13 @@ let _cachedAt = 0;
27
28
 
28
29
  function fetchText(url, timeoutMs) {
29
30
  return new Promise((resolve, reject) => {
31
+ // Without this the request ignores SOCKS_PROXY and goes out directly —
32
+ // which on a network that cannot reach web.whatsapp.com means a ten-second
33
+ // stall and a fall back to the pinned revision, while curl through the very
34
+ // same proxy answers instantly.
35
+ const agent = proxyAgent();
30
36
  const req = https.get(url, {
37
+ agent: agent || undefined,
31
38
  headers: {
32
39
  // The minimum that gets past the anti-bot check on this path.
33
40
  'sec-fetch-site': 'none',
@@ -1097,6 +1097,28 @@ class MessageSender {
1097
1097
  // filed when it arrived. Looking it up by the phone JID — which is what
1098
1098
  // this did — never found one, so nothing was ever attached.
1099
1099
  const tcJid = this._client._resolveTcTokenJid(routingToJid);
1100
+
1101
+ // A protocol message — a revoke, an ephemeral-timer change — is bookkeeping
1102
+ // rather than a reach-out, so it never triggers an issuance. An existing
1103
+ // token still rides along on it, which is what the apps do.
1104
+ const isProtocolMsg = !!(options && options._protocol);
1105
+
1106
+ // First message to this contact: fetch the token now, before the stanza is
1107
+ // built, so it goes out on this message instead of on the next one.
1108
+ //
1109
+ // The device enumeration above has already run, so the LID is known by the
1110
+ // time we get here and the issuance and the storage agree on the contact.
1111
+ // Doing this any earlier would issue against the phone JID while the token
1112
+ // got filed under the LID, which is the split that used to leave live
1113
+ // tokens unused.
1114
+ //
1115
+ // Returns without waiting when a live token is already on file, so this
1116
+ // costs one round-trip per contact rather than one per message. It never
1117
+ // throws and never fails the send.
1118
+ if (tcStore && !isProtocolMsg) {
1119
+ await this._client.ensureTcTokenBeforeSend(tcJid, routingToJid);
1120
+ }
1121
+
1100
1122
  if (tcStore) {
1101
1123
  const tcEntry = tcStore.get(tcJid);
1102
1124
  if (tcEntry && tcEntry.token && tcEntry.token.length) {
@@ -1153,11 +1175,16 @@ class MessageSender {
1153
1175
  }
1154
1176
  }
1155
1177
 
1156
- // ─── Fire-and-forget: issue privacy token to contact ─────────────────────
1157
- // After each DM send we ask WhatsApp to issue us a trusted-contact token
1158
- // for this JID (one issuance per 7-day bucket is enough). The token is
1159
- // stored and attached on future sends so the server does not count them
1160
- // as anonymous "reaching out" events (which would trigger error 463).
1178
+ // ─── Fire-and-forget: renew the privacy token ────────────────────────────
1179
+ // The first message to a contact now gets its token before the stanza is
1180
+ // built, up above, so what is left here is renewal: a contact whose token
1181
+ // is still live but whose 7-day bucket has rolled over. That case must not
1182
+ // hold a message up — the token being attached right now is perfectly
1183
+ // valid — so it stays fire-and-forget.
1184
+ //
1185
+ // The gate below is what keeps the two from firing twice for one contact.
1186
+ // A successful pre-send issuance saves senderTimestamp, which puts
1187
+ // shouldIssue in the current bucket and makes this a no-op.
1161
1188
  //
1162
1189
  // Two targets never get one. WhatsApp's own announcement account
1163
1190
  // (0@s.whatsapp.net) and the bot numbers, including Meta AI, are not
@@ -1166,7 +1193,6 @@ class MessageSender {
1166
1193
  // too — they are bookkeeping, not a reach-out. Attaching an existing token
1167
1194
  // to them is still correct
1168
1195
  // and still happens above — this only governs asking for a new one.
1169
- const isProtocolMsg = !!(options && options._protocol);
1170
1196
  if (tcStore && isRegularTcTokenUser(tcJid) && !isProtocolMsg &&
1171
1197
  tcStore.shouldIssue(tcJid) &&
1172
1198
  !this._client._inFlightTcTokenIssuance.has(tcJid)) {
package/lib/noise.js CHANGED
@@ -370,15 +370,8 @@ class NoiseSocket extends EventEmitter {
370
370
  this._connectResolve = resolve;
371
371
  this._connectReject = reject;
372
372
 
373
- if (this.transport === 'web') {
374
- const { WebSocketStream } = require('./WebSocketStream');
375
- this.socket = new WebSocketStream().connect();
376
- } else {
377
- this.socket = net.createConnection({ host: WHATSAPP_HOST, port: WHATSAPP_PORT });
378
- }
379
- // OS-level TCP keepalive: detects silently-dead NAT connections that never send FIN/RST
380
- this.socket.setKeepAlive(true, 15000);
381
- // Connect-phase watchdog: abort if TCP handshake takes >20s
373
+ // Armed before the socket exists so it covers a SOCKS handshake that
374
+ // never completes, not just a TCP one.
382
375
  this._connectTimeoutTimer = setTimeout(() => {
383
376
  if (this._connectReject) {
384
377
  const err = new Error('WA TCP connect timeout (20s)');
@@ -386,16 +379,55 @@ class NoiseSocket extends EventEmitter {
386
379
  this._connectResolve = null;
387
380
  this._connectReject = null;
388
381
  }
389
- try { this.socket.destroy(); } catch (_) {}
382
+ try { if (this.socket) this.socket.destroy(); } catch (_) {}
390
383
  }, 20000);
391
- this.socket.on('connect', () => {
392
- clearTimeout(this._connectTimeoutTimer);
393
- this._connectTimeoutTimer = null;
394
- this._onTcpConnect();
395
- });
396
- this.socket.on('data', (data) => this._onData(data));
397
- this.socket.on('error', (err) => this._onTcpError(err));
398
- this.socket.on('close', () => this._onTcpClose());
384
+
385
+ // `alreadyConnected` is the SOCKS case: the proxy hands back a socket
386
+ // whose handshake is finished, so no 'connect' event will ever fire on
387
+ // it and waiting for one would stall until the watchdog fires.
388
+ const attach = (sock, alreadyConnected) => {
389
+ this.socket = sock;
390
+ // OS-level TCP keepalive: detects silently-dead NAT connections that
391
+ // never send FIN/RST.
392
+ sock.setKeepAlive(true, 15000);
393
+
394
+ sock.on('data', (data) => this._onData(data));
395
+ sock.on('error', (err) => this._onTcpError(err));
396
+ sock.on('close', () => this._onTcpClose());
397
+
398
+ const onUp = () => {
399
+ clearTimeout(this._connectTimeoutTimer);
400
+ this._connectTimeoutTimer = null;
401
+ this._onTcpConnect();
402
+ };
403
+ if (alreadyConnected) onUp();
404
+ else sock.on('connect', onUp);
405
+ };
406
+
407
+ if (this.transport === 'web') {
408
+ const { WebSocketStream } = require('./WebSocketStream');
409
+ attach(new WebSocketStream().connect(), false);
410
+ return;
411
+ }
412
+
413
+ // Mobile is a raw TCP socket, and net.createConnection has no notion of
414
+ // a proxy. Dial through SOCKS when one is configured so the mobile
415
+ // transport is reachable from the same networks the web one is.
416
+ const { socksProxyUrl, socksConnect } = require('./socks');
417
+ const proxyUrl = socksProxyUrl();
418
+ if (!proxyUrl) {
419
+ attach(net.createConnection({ host: WHATSAPP_HOST, port: WHATSAPP_PORT }), false);
420
+ return;
421
+ }
422
+
423
+ _whaDbg('[DBG] TCP via SOCKS proxy → ' + WHATSAPP_HOST + ':' + WHATSAPP_PORT);
424
+ socksConnect(proxyUrl, WHATSAPP_HOST, WHATSAPP_PORT)
425
+ .then(sock => attach(sock, true))
426
+ .catch(err => {
427
+ clearTimeout(this._connectTimeoutTimer);
428
+ this._connectTimeoutTimer = null;
429
+ this._onTcpError(err);
430
+ });
399
431
  });
400
432
  }
401
433
 
package/lib/socks.js ADDED
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ // SOCKS4 / SOCKS5 / Tor support for every outbound connection the library makes.
4
+ //
5
+ // Set SOCKS_PROXY (or TOR_PROXY) and everything goes through it:
6
+ //
7
+ // socks5://127.0.0.1:9050 Tor
8
+ // socks5://user:pass@host:1080 authenticated residential proxy
9
+ // socks5h://host:1080 resolve the destination at the proxy
10
+ // socks4://host:1080 older proxies
11
+ //
12
+ // Requires the `socks` package, which ships as an optional dependency.
13
+ //
14
+ // Why this module exists at all: the proxy used to be honoured by exactly one
15
+ // call site, the registration POST. Everything else — the App Store and Play
16
+ // Store version lookups, the web client's sw.js revision probe, the WhatsApp
17
+ // Web socket and the mobile TCP socket — went out through `https.get`,
18
+ // `axios` and `net.createConnection`, none of which look at proxy environment
19
+ // variables. Node has no built-in proxy support; an agent has to be handed in.
20
+ //
21
+ // The visible symptom was a connection that "worked" in curl and timed out in
22
+ // the library:
23
+ //
24
+ // [DBG] WEB_VERSION 2.3000.1035194821 (pinned fallback: sw.js fetch timed out)
25
+ //
26
+ // curl honours SOCKS_PROXY, https.get does not. Same machine, same proxy,
27
+ // opposite results.
28
+
29
+ const https = require('https');
30
+ const tls = require('tls');
31
+
32
+ const SOCKS_SCHEMES = {
33
+ 'socks:': 5,
34
+ 'socks5:': 5,
35
+ 'socks5h:': 5,
36
+ 'socks4:': 4,
37
+ 'socks4a:': 4
38
+ };
39
+
40
+ // How long a SOCKS handshake may take before it is treated as dead. A proxy
41
+ // that accepts the TCP connection and then says nothing would otherwise hang
42
+ // whatever asked for the connection.
43
+ const SOCKS_TIMEOUT_MS = 20000;
44
+
45
+ // Resolve the library the way every other dependency is resolved.
46
+ //
47
+ // WA_SOCKS_LIB stays as an escape hatch for unusual layouts, but it is an
48
+ // override, not the only way in.
49
+ function loadSocks() {
50
+ const override = process.env.WA_SOCKS_LIB;
51
+ if (override) {
52
+ try { return require(override); }
53
+ catch (err) {
54
+ throw new Error('WA_SOCKS_LIB points at ' + override +
55
+ ' but it could not be loaded: ' + err.message);
56
+ }
57
+ }
58
+ try {
59
+ return require('socks');
60
+ } catch (_) {
61
+ throw new Error(
62
+ "A SOCKS proxy is configured but the 'socks' package is not installed. " +
63
+ 'Run: npm install socks'
64
+ );
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Parse a proxy URL into what SocksClient expects.
70
+ *
71
+ * Accepts the schemes above, with or without credentials, and defaults the
72
+ * port to 1080 the way every SOCKS client does.
73
+ */
74
+ function parseSocksProxy(proxyUrl) {
75
+ let raw = String(proxyUrl || '').trim();
76
+ if (!raw) throw new Error('No SOCKS proxy configured');
77
+
78
+ // A bare host:port is what .env.example has always shown and what most people
79
+ // type. Without a scheme `new URL` reads "127.0.0.1:" as the protocol and the
80
+ // rest as the path, so it has to be filled in before parsing.
81
+ if (!/^[a-z0-9+.-]+:\/\//i.test(raw)) raw = 'socks5://' + raw;
82
+
83
+ let url;
84
+ try { url = new URL(raw); }
85
+ catch (_) { throw new Error('Invalid SOCKS proxy URL: ' + proxyUrl); }
86
+
87
+ const type = SOCKS_SCHEMES[url.protocol];
88
+ if (!type) {
89
+ throw new Error('Unsupported proxy scheme "' + url.protocol + '" — use ' +
90
+ Object.keys(SOCKS_SCHEMES).map(s => s.replace(':', '')).join(', '));
91
+ }
92
+ if (!url.hostname) throw new Error('SOCKS proxy URL has no host: ' + proxyUrl);
93
+
94
+ const proxy = {
95
+ host: url.hostname,
96
+ port: parseInt(url.port, 10) || 1080,
97
+ type
98
+ };
99
+ // Credentials may be percent-encoded in a URL; a password with an @ or a :
100
+ // in it is common enough on paid proxies to be worth decoding.
101
+ if (url.username) proxy.userId = decodeURIComponent(url.username);
102
+ if (url.password) proxy.password = decodeURIComponent(url.password);
103
+ return proxy;
104
+ }
105
+
106
+ // TOR_PROXY wins when both are set — the precedence .env.example has always
107
+ // documented. Read from the environment on every call rather than cached at
108
+ // require time, so a process that sets it late still gets it.
109
+ function socksProxyUrl() {
110
+ return process.env.TOR_PROXY || process.env.SOCKS_PROXY || '';
111
+ }
112
+
113
+ /** Whether a proxy is configured at all. Cheap, and never throws. */
114
+ function hasProxy() {
115
+ return !!socksProxyUrl();
116
+ }
117
+
118
+ /**
119
+ * Open a raw TCP connection to host:port through the configured proxy.
120
+ *
121
+ * Resolves with a connected socket — the SOCKS handshake is already done, so
122
+ * no 'connect' event will fire on it. Callers that key off 'connect' have to
123
+ * account for that.
124
+ */
125
+ async function socksConnect(proxyUrl, host, port, timeoutMs) {
126
+ const proxy = parseSocksProxy(proxyUrl);
127
+ const { SocksClient } = loadSocks();
128
+ const { socket } = await SocksClient.createConnection({
129
+ proxy,
130
+ command: 'connect',
131
+ destination: { host, port },
132
+ timeout: timeoutMs > 0 ? timeoutMs : SOCKS_TIMEOUT_MS
133
+ });
134
+ return socket;
135
+ }
136
+
137
+ /**
138
+ * An https.Agent that tunnels through SOCKS.
139
+ *
140
+ * `https.get`, `axios` and `ws` all accept an agent and all drive it the same
141
+ * way, so one of these covers every HTTPS and WSS call site in the library.
142
+ *
143
+ * The base agent's createConnection calls tls.connect on a fresh TCP socket;
144
+ * this one gets the TCP socket from the proxy first and layers TLS on top of
145
+ * it. Everything else — pooling, keep-alive, timeouts — is inherited.
146
+ */
147
+ class SocksHttpsAgent extends https.Agent {
148
+ constructor(proxyUrl, opts) {
149
+ super(opts);
150
+ this._proxyUrl = proxyUrl;
151
+ // Fail fast on a malformed URL, at construction rather than on first use,
152
+ // so a typo surfaces as a clear error instead of a stalled request.
153
+ this._proxy = parseSocksProxy(proxyUrl);
154
+ }
155
+
156
+ createConnection(options, callback) {
157
+ const { SocksClient } = loadSocks();
158
+ const host = options.host || options.hostname;
159
+ const port = Number(options.port) || 443;
160
+
161
+ SocksClient.createConnection({
162
+ proxy: this._proxy,
163
+ command: 'connect',
164
+ destination: { host, port },
165
+ timeout: SOCKS_TIMEOUT_MS
166
+ }).then(({ socket }) => {
167
+ // servername has to be set explicitly: the TLS socket is built on a
168
+ // socket that was never given a hostname, so without it SNI goes out
169
+ // empty and WhatsApp's edge answers with the wrong certificate.
170
+ const tlsSocket = tls.connect(Object.assign({}, options, {
171
+ socket,
172
+ host,
173
+ port,
174
+ servername: options.servername || (typeof host === 'string' ? host : undefined)
175
+ }));
176
+ callback(null, tlsSocket);
177
+ }).catch(err => callback(err));
178
+ }
179
+ }
180
+
181
+ // One agent per proxy URL. Agents pool sockets, so building a fresh one per
182
+ // request would throw the pool away every time.
183
+ const _agentCache = new Map();
184
+
185
+ /**
186
+ * The agent for a given proxy URL, or null when none is configured.
187
+ *
188
+ * Never throws: a proxy that cannot be parsed, or a missing `socks` package,
189
+ * returns null and leaves the caller on a direct connection. A version lookup
190
+ * that has a pinned fallback must not take the process down over its proxy.
191
+ */
192
+ function socksAgent(proxyUrl) {
193
+ const url = proxyUrl || socksProxyUrl();
194
+ if (!url) return null;
195
+ if (_agentCache.has(url)) return _agentCache.get(url);
196
+
197
+ let agent = null;
198
+ try {
199
+ agent = new SocksHttpsAgent(url, { keepAlive: false });
200
+ } catch (_) {
201
+ agent = null;
202
+ }
203
+ _agentCache.set(url, agent);
204
+ return agent;
205
+ }
206
+
207
+ /** The agent for the environment's proxy, or null. */
208
+ function proxyAgent() {
209
+ return socksAgent(socksProxyUrl());
210
+ }
211
+
212
+ /** Drop cached agents. For tests, and for a process that swaps proxies. */
213
+ function clearAgentCache() {
214
+ for (const agent of _agentCache.values()) {
215
+ if (agent && typeof agent.destroy === 'function') {
216
+ try { agent.destroy(); } catch (_) {}
217
+ }
218
+ }
219
+ _agentCache.clear();
220
+ }
221
+
222
+ module.exports = {
223
+ SOCKS_SCHEMES,
224
+ SOCKS_TIMEOUT_MS,
225
+ loadSocks,
226
+ parseSocksProxy,
227
+ socksProxyUrl,
228
+ hasProxy,
229
+ socksConnect,
230
+ socksAgent,
231
+ proxyAgent,
232
+ clearAgentCache,
233
+ SocksHttpsAgent
234
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.19.0",
3
+ "version": "5.19.2",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",