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 +16 -3
- package/cli.js +10 -0
- package/lib/PlayStore.js +64 -9
- package/lib/Registration.js +83 -4
- package/package.json +1 -1
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
|
|
1870
|
-
|
|
1871
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
};
|
package/lib/Registration.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|