whalibmob 5.29.5 → 5.30.0

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
@@ -1845,6 +1845,9 @@ Both are optional and both keep working when omitted — you get an error naming
1845
1845
  > [!NOTE]
1846
1846
  > Registration reports the screens it passes through to WhatsApp's `/client_log`, the way the phone clients do — a client that registers in total silence does something no real installation does. It is fire-and-forget and every failure is swallowed, so it can never take a registration down. Set `WA_FUNNEL_LOG=0` to send none of it.
1847
1847
 
1848
+ > [!NOTE]
1849
+ > Those events carry timestamps, and registration waits between them the way a person would: a few seconds to type the number in, a moment on the confirmation sheet, longer before asking again after a refusal. Without the waits the whole funnel leaves inside one millisecond, which no handset does. It adds a handful of seconds to a registration. Set `WA_REG_PACING=0` to remove them.
1850
+
1848
1851
  ### Registering as Android
1849
1852
 
1850
1853
  **There is nothing to do first.** Name the platform and register:
@@ -1866,9 +1869,19 @@ requesting sms code for +919634847671...
1866
1869
  status sent
1867
1870
  ```
1868
1871
 
1869
- It happens once. Every later registration reads the file. `WA_NO_APK_DOWNLOAD=1`
1870
- turns the fetch off if you would rather it never pulled a hundred megabytes
1871
- unasked, and `wa apk-material` below does the same job by hand.
1872
+ It happens once. Every later registration reads the file — from the session
1873
+ directory you are using, wherever that is. `WA_NO_APK_DOWNLOAD=1` turns the
1874
+ fetch off if you would rather it never pulled a hundred megabytes unasked, and
1875
+ `wa apk-material` below does the same job by hand.
1876
+
1877
+ > [!NOTE]
1878
+ > The download goes through the Aurora OSS token dispenser, a free third-party
1879
+ > service that mints an anonymous Play account. It refuses with **HTTP 403**
1880
+ > when it is rate limiting and **5xx** when it is having a bad minute, so the
1881
+ > request is retried a few times with a growing wait before giving up.
1882
+ > `WA_PLAY_RETRY_DELAY_MS` sets that wait (default `2000`, doubling each time).
1883
+ > If it still refuses, nothing is wrong with your setup — pass an APK path to
1884
+ > `wa apk-material` instead, which is the same thing without the middleman.
1872
1885
 
1873
1886
  The rest of this section is what happens behind that one command, and how to
1874
1887
  drive each part yourself.
package/cli.js CHANGED
@@ -2833,6 +2833,16 @@ async function main() {
2833
2833
  // first question land in the wrong prompt.
2834
2834
  _sessDir = await askSessionDir(cmd, flags.session);
2835
2835
 
2836
+ // One source of truth for the rest of the run. The library resolves the files
2837
+ // that belong to the installation rather than to a number — the Android token
2838
+ // material above all — through SessionPaths.defaultBaseDir(), which reads
2839
+ // WA_SESSION_DIR. The CLI, though, can arrive at its directory three other
2840
+ // ways: --session, the folder remembered from the setup prompt, or the home
2841
+ // default. Without publishing the answer here, `wa apk-material` wrote into
2842
+ // the resolved directory while registration went looking in ~/.waSession and
2843
+ // reported no material at all.
2844
+ process.env.WA_SESSION_DIR = _sessDir;
2845
+
2836
2846
  await askDebugMode(cmd);
2837
2847
  await askDonation(cmd);
2838
2848
 
package/lib/PlayStore.js CHANGED
@@ -158,19 +158,68 @@ function _request(url, opts) {
158
158
 
159
159
  // ─── Auth ─────────────────────────────────────────────────────────────────────
160
160
 
161
+ // How often the dispenser is asked before giving up, and how long between tries.
162
+ //
163
+ // The dispenser is a free service shared by everyone who uses it, and the way it
164
+ // sheds load is to refuse — 403 when it is rate limiting, 5xx when it is having
165
+ // a bad minute. Both are usually over in seconds, so one refusal is not an
166
+ // answer worth reporting to the caller. The waits grow so a dispenser that is
167
+ // genuinely busy is not hammered: roughly 2s, then 4s, then 8s.
168
+ const DISPENSER_ATTEMPTS = 4;
169
+ const DISPENSER_BASE_DELAY = 2000;
170
+
171
+ // The wait, overridable. Someone behind a dispenser that is having a long day
172
+ // may want to wait longer between tries, and the test suite wants not to wait
173
+ // at all. 0 is valid and means retry immediately.
174
+ function _dispenserDelay() {
175
+ const n = Number(process.env.WA_PLAY_RETRY_DELAY_MS);
176
+ return Number.isFinite(n) && n >= 0 ? n : DISPENSER_BASE_DELAY;
177
+ }
178
+
179
+ // Worth asking again, or not. A refusal that says "too many, come back" is; a
180
+ // 400 saying the request itself is malformed never will be, and retrying it
181
+ // only makes the caller wait three times as long for the same error.
182
+ function _dispenserRetryable(status) {
183
+ return status === 403 || status === 408 || status === 429 || status >= 500;
184
+ }
185
+
186
+ const _sleep = (ms) => new Promise(r => setTimeout(r, ms));
187
+
161
188
  async function fetchAnonymousAuth() {
162
189
  const body = JSON.stringify(DEVICE_PROFILE);
163
- const res = await _request(AURORA_DISPENSER_URL, {
164
- method: 'POST',
165
- headers: {
166
- 'Content-Type': 'application/json',
167
- 'User-Agent': AURORA_USER_AGENT,
168
- 'Content-Length': Buffer.byteLength(body)
169
- },
170
- body
171
- });
190
+ const headers = {
191
+ 'Content-Type': 'application/json',
192
+ 'User-Agent': AURORA_USER_AGENT,
193
+ 'Content-Length': Buffer.byteLength(body)
194
+ };
195
+
196
+ let res = null;
197
+ let lastErr = null;
198
+ for (let attempt = 1; attempt <= DISPENSER_ATTEMPTS; attempt++) {
199
+ try {
200
+ res = await _request(AURORA_DISPENSER_URL, { method: 'POST', headers, body });
201
+ lastErr = null;
202
+ if (res.status === 200) break;
203
+ if (!_dispenserRetryable(res.status) || attempt === DISPENSER_ATTEMPTS) break;
204
+ _whaDbg('[DBG] PLAY dispenser HTTP ' + res.status + ' — attempt ' +
205
+ attempt + '/' + DISPENSER_ATTEMPTS + ', retrying');
206
+ } catch (e) {
207
+ // A dropped connection or a timeout is the same kind of transient as a
208
+ // 5xx, so it is retried on the same schedule rather than thrown at once.
209
+ lastErr = e;
210
+ res = null;
211
+ if (attempt === DISPENSER_ATTEMPTS) break;
212
+ _whaDbg('[DBG] PLAY dispenser ' + (e && e.message) + ' — attempt ' +
213
+ attempt + '/' + DISPENSER_ATTEMPTS + ', retrying');
214
+ }
215
+ await _sleep(_dispenserDelay() * Math.pow(2, attempt - 1));
216
+ }
217
+
218
+ if (lastErr) throw lastErr;
219
+
172
220
  if (res.status !== 200) {
173
221
  throw new Error('the Aurora token dispenser answered HTTP ' + res.status +
222
+ ' after ' + DISPENSER_ATTEMPTS + ' attempts' +
174
223
  ' — it is a free third-party service and this is what it does when it is ' +
175
224
  'down or rate limiting. Nothing is wrong with your setup; either try again ' +
176
225
  'later or pass an APK path instead.');
@@ -384,3 +433,9 @@ module.exports = {
384
433
  AURORA_DISPENSER_URL,
385
434
  PERSONAL_PACKAGE
386
435
  };
436
+
437
+ // Dispenser retry internals, exposed for tests. Not part of the public API.
438
+ module.exports._retry = {
439
+ dispenserRetryable: _dispenserRetryable,
440
+ DISPENSER_ATTEMPTS
441
+ };
@@ -957,7 +957,27 @@ function androidMaterialPath(device) {
957
957
  const name = (device && device.business)
958
958
  ? 'android-apk-material-business.json'
959
959
  : 'android-apk-material.json';
960
- return path.join(os.homedir(), '.waSession', name);
960
+
961
+ // The session directory, wherever it actually is — not a hardcoded
962
+ // ~/.waSession. `wa apk-material` writes the file into the directory the CLI
963
+ // resolved (--session, WA_SESSION_DIR, or the one remembered from the setup
964
+ // prompt), while this read it from the home default and reported "no
965
+ // material" for anything else. Anyone who moved their sessions had to set
966
+ // WA_ANDROID_APK_MATERIAL by hand to make registration find a file that was
967
+ // already there. SessionPaths.defaultBaseDir() is the same resolver the
968
+ // writer goes through, and SHARED_FILES there already names both of these as
969
+ // belonging to the base directory.
970
+ const { defaultBaseDir } = require('./SessionPaths');
971
+ const primary = path.join(defaultBaseDir(), name);
972
+
973
+ // Material extracted before the directory could be moved still sits in
974
+ // ~/.waSession. If the configured directory has none but the old default
975
+ // does, read that rather than claiming there is no material at all.
976
+ if (!fs.existsSync(primary)) {
977
+ const legacy = path.join(os.homedir(), '.waSession', name);
978
+ if (legacy !== primary && fs.existsSync(legacy)) return legacy;
979
+ }
980
+ return primary;
961
981
  }
962
982
 
963
983
  const _androidMaterial = {};
@@ -1319,10 +1339,17 @@ function getAppPid(store) {
1319
1339
  return store._appPid;
1320
1340
  }
1321
1341
 
1322
- function buildClientMetrics(attempt) {
1342
+ // is_sim_absent used to be the constant false while the same request could send
1343
+ // sim_mcc/sim_mnc as 000/000 — which is precisely what a handset reports when
1344
+ // there is no SIM in it. One request then said both "a SIM is present" and "no
1345
+ // operator", which no real handset ever says. Reading it off the operator the
1346
+ // request actually declares keeps the two halves telling one story.
1347
+ function buildClientMetrics(attempt, meta) {
1348
+ const mcc = meta && meta.mcc != null ? String(meta.mcc).trim() : '';
1349
+ const simAbsent = !/^\d+$/.test(mcc) || /^0+$/.test(mcc);
1323
1350
  const json = '{"attempts":' + (attempt || 1)
1324
1351
  + ',"app_campaign_download_source":"google-play|unknown"'
1325
- + ',"is_sim_absent":false}';
1352
+ + ',"is_sim_absent":' + (simAbsent ? 'true' : 'false') + '}';
1326
1353
  return encodeURIComponent(json);
1327
1354
  }
1328
1355
 
@@ -1363,7 +1390,7 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
1363
1390
  'prefer_sms_over_flash', wantsFlash ? 'false' : 'true',
1364
1391
  'simnum', '0',
1365
1392
  'airplane_mode_type', '0',
1366
- 'client_metrics', buildClientMetrics(attempt),
1393
+ 'client_metrics', buildClientMetrics(attempt, meta),
1367
1394
  'mistyped', '7',
1368
1395
  'advertising_id', store.advertisingId || '',
1369
1396
  'hasinrc', '1',
@@ -1621,6 +1648,45 @@ async function sendEncrypted(path, plaintext, store, waVersion) {
1621
1648
  return result;
1622
1649
  }
1623
1650
 
1651
+ // ---------- Pacing ----------
1652
+ //
1653
+ // The funnel events say a person walked through the screens. The timestamps on
1654
+ // them said otherwise: session_start, the number lookup and the code request
1655
+ // left within a few milliseconds of each other, because nothing in between was
1656
+ // waiting on a person. Nobody opens WhatsApp and has a phone number typed,
1657
+ // checked and submitted inside one millisecond, and that gap is visible to the
1658
+ // server on every event it receives.
1659
+ //
1660
+ // So the waits a person actually causes are put back. Each one is a range
1661
+ // rather than a number — a fixed delay is its own signature — and each is
1662
+ // named after the thing being waited for:
1663
+ //
1664
+ // enter_number typing the number in before the lookup fires
1665
+ // confirm_number the "is this your number?" sheet, and tapping through it
1666
+ // retry_code after a refused request, before asking again
1667
+ // switch_method picking a different delivery method out of the list
1668
+ //
1669
+ // Set WA_REG_PACING=0 to drop all of it, for anything that wants the request
1670
+ // shapes without the waiting.
1671
+ const PACING_RANGES_MS = {
1672
+ enter_number: [1800, 5200],
1673
+ confirm_number: [1200, 3600],
1674
+ retry_code: [2800, 7500],
1675
+ switch_method: [2000, 5000]
1676
+ };
1677
+
1678
+ function pacingEnabled() {
1679
+ return process.env.WA_REG_PACING !== '0';
1680
+ }
1681
+
1682
+ function humanPause(kind) {
1683
+ const range = PACING_RANGES_MS[kind];
1684
+ if (!range || !pacingEnabled()) return Promise.resolve();
1685
+ const ms = range[0] + Math.floor(Math.random() * (range[1] - range[0] + 1));
1686
+ _whaDbg('[DBG] REG pacing ' + kind + ' ' + ms + 'ms');
1687
+ return new Promise((resolve) => setTimeout(resolve, ms));
1688
+ }
1689
+
1624
1690
  // ---------- Funnel telemetry ----------
1625
1691
  //
1626
1692
  // The native client reports every screen it moves through — the number entry,
@@ -2022,10 +2088,13 @@ async function checkNumberStatus(phoneNumber) {
2022
2088
  async function assertRegistrationKeys(store, waVersion) {
2023
2089
  // The session-start event the native client fires before it knows the number.
2024
2090
  await sendPrePnFunnelLog(store, waVersion, 'session_start', 'registration_session_start');
2091
+ // The number gets typed between those two events.
2092
+ await humanPause('enter_number');
2025
2093
  await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_attempt');
2026
2094
 
2027
2095
  for (let attempt = 0; attempt < 2; attempt++) {
2028
2096
  try {
2097
+ if (attempt > 0) await humanPause('retry_code');
2029
2098
  logDeviceIdentity('before /exist', store, waVersion);
2030
2099
  const result = await sendRequest('/exist', store, waVersion, false, null);
2031
2100
  // reason === 'incorrect' → keys not found → fresh
@@ -2120,6 +2189,8 @@ async function requestSmsCode(store, method, opts) {
2120
2189
  // 7. Unknown error first time → retry once
2121
2190
  let lastReason = null;
2122
2191
  let attemptNum = 1;
2192
+ // The confirmation sheet the app puts up before it will ask for a code.
2193
+ await humanPause('confirm_number');
2123
2194
  while (true) {
2124
2195
  // Rebuilt per attempt so client_metrics carries the current attempt count.
2125
2196
  const extra = getRequestVerificationCodeParameters(store, m, _regMeta, _device, attemptNum);
@@ -2184,6 +2255,9 @@ async function requestSmsCode(store, method, opts) {
2184
2255
  if (attemptNum > MAX_CODE_REQUEST_ATTEMPTS) {
2185
2256
  throw new Error(`Registration error (${m}): giving up after ${MAX_CODE_REQUEST_ATTEMPTS} attempts — raw: ${JSON.stringify(result)}`);
2186
2257
  }
2258
+
2259
+ // Nobody re-taps the button the instant the error lands.
2260
+ await humanPause('retry_code');
2187
2261
  }
2188
2262
  }
2189
2263
 
@@ -2194,6 +2268,8 @@ async function requestSmsCode(store, method, opts) {
2194
2268
  if (result && result._noRoutes && !autoFallbackDone && method !== 'email') {
2195
2269
  autoFallbackDone = true;
2196
2270
  process.stderr.write(`[REG] ${method} returned no_routes — auto-trying ${fallbackMethod}\n`);
2271
+ // Reading the refusal and picking another method off the list.
2272
+ await humanPause('switch_method');
2197
2273
  result = await _tryMethod(fallbackMethod);
2198
2274
  }
2199
2275
 
@@ -2353,3 +2429,6 @@ module.exports._verify = {
2353
2429
  flashCodeFromCallerId, flashCodeLength, codeForSubmission,
2354
2430
  getRequestVerificationCodeParameters
2355
2431
  };
2432
+
2433
+ // Pacing internals, exposed for tests. Not part of the public API.
2434
+ module.exports._pacing = { pacingEnabled, humanPause, buildClientMetrics, PACING_RANGES_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.29.5",
3
+ "version": "5.30.0",
4
4
  "description": "Node.js library for WhatsApp — register a number over SMS, or link as a companion by QR. Signal E2E encryption, media, groups, channels.",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",