whalibmob 5.29.6 → 5.32.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
@@ -194,6 +194,9 @@ npm install -g whalibmob
194
194
  - [One-time Pre-keys](#one-time-pre-keys)
195
195
  - [Where the Folder Comes From](#where-the-folder-comes-from)
196
196
  - [Working Out the Paths Yourself](#working-out-the-paths-yourself)
197
+ - [Where a Session Is Kept](#where-a-session-is-kept)
198
+ - [One Database Instead of 834 Files](#one-database-instead-of-834-files)
199
+ - [Moving a Session Between Backends](#moving-a-session-between-backends)
197
200
  - [Signal Store Utilities](#signal-store-utilities)
198
201
  - [makeCacheableSignalKeyStore](#makecacheablesignalkeystore)
199
202
  - [addTransactionCapability](#addtransactioncapability)
@@ -1845,6 +1848,9 @@ Both are optional and both keep working when omitted — you get an error naming
1845
1848
  > [!NOTE]
1846
1849
  > 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
1850
 
1851
+ > [!NOTE]
1852
+ > 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.
1853
+
1848
1854
  ### Registering as Android
1849
1855
 
1850
1856
  **There is nothing to do first.** Name the platform and register:
@@ -3061,6 +3067,171 @@ migrateSession(base, '919634847671')
3061
3067
  `SESSION_SUFFIXES` is every per-number file the library writes — the list to
3062
3068
  copy or delete against if you are moving an account by hand.
3063
3069
 
3070
+ ### Where a Session Is Kept
3071
+
3072
+ Everything above describes files, because files are what whalibmob writes and
3073
+ what it will go on writing unless you say otherwise. **Nothing in this section
3074
+ is something you have to do.** Leave it alone and sessions stay exactly where
3075
+ they have always been, in the JSON files named above.
3076
+
3077
+ What is new is that the place is now a choice. A **backend** is four
3078
+ synchronous operations over a flat key space:
3079
+
3080
+ ```js
3081
+ read(key) // the stored text, or null when the key was never written
3082
+ write(key, value) // put it there, replacing whatever was there before
3083
+ remove(key) // take it away; a key that is not there is not an error
3084
+ list(prefix) // every key present that starts with prefix
3085
+ ```
3086
+
3087
+ The keys are logical names rather than file names — `auth`, `signal`,
3088
+ `sender-key`, `tc-token`, `device-cache`, `lid-mapping`,
3089
+ `lid-reverse-mapping`, `history`, `messages`, `app-state`, `app-state-keys`,
3090
+ and `` `pre-key/${id}` `` for each of the 812 one-time pre-keys.
3091
+
3092
+ Three implementations ship with the package:
3093
+
3094
+ | Backend | Where the state goes | Needs |
3095
+ |---|---|---|
3096
+ | `FileBackend` | JSON files — what the library has always written | nothing; this is the default |
3097
+ | `SqliteBackend` | one database file | Node 22.5+, or `better-sqlite3` |
3098
+ | `MemoryBackend` | nowhere; gone when the process ends | nothing |
3099
+
3100
+ ```js
3101
+ const { FileBackend } = require('whalibmob')
3102
+
3103
+ const backend = new FileBackend({
3104
+ dir: '/home/you/.waSession/919634847671',
3105
+ phone: '919634847671'
3106
+ })
3107
+
3108
+ backend.write('auth', JSON.stringify(creds))
3109
+ backend.read('auth') // the text, or null
3110
+ backend.list() // ['auth', 'pre-key/1', 'signal', …]
3111
+ backend.list('pre-key/') // just the pre-keys
3112
+ backend.remove('pre-key/42')
3113
+ backend.fileFor('signal') // …/919634847671.signal.json
3114
+ ```
3115
+
3116
+ `FileBackend` writes the same names in the same places as every release before
3117
+ it, so a session written by whalibmob 5.30 opens through it untouched and one
3118
+ written through it opens in 5.30. The only change is that writes now go to a
3119
+ temporary file and are renamed into place, so a process that dies mid-write
3120
+ leaves the previous state intact instead of half a file.
3121
+
3122
+ > [!IMPORTANT]
3123
+ > **A number has two halves, and they are separate sessions.** The one
3124
+ > registered over the Mobile API and the companion linked over the Web API
3125
+ > never share state — in particular they have separate pre-key id spaces, and
3126
+ > mixing them hands out two different keys under one id and breaks decryption.
3127
+ > A backend covers **one** half, chosen by `web`:
3128
+ >
3129
+ > ```js
3130
+ > const mobile = new FileBackend({ dir, phone, web: false }) // default
3131
+ > const web = new FileBackend({ dir, phone, web: true })
3132
+ > ```
3133
+ >
3134
+ > Both take the same keys and keep entirely separate values.
3135
+
3136
+ > [!NOTE]
3137
+ > **The client does not accept a backend yet.** `new WhalibmobClient({ … })`
3138
+ > takes `sessionDir` and writes files, as it always has; the modules that hold
3139
+ > session state still do their own file I/O. The backends are usable on their
3140
+ > own — for reading, inspecting, copying or moving a session — and wiring them
3141
+ > through the client is the next step. Nothing here changes how a session is
3142
+ > created or connected today.
3143
+
3144
+ ### One Database Instead of 834 Files
3145
+
3146
+ A number's state is 22 named files plus a file for each of its 812 one-time
3147
+ pre-keys. On a laptop nobody notices. On a phone under Termux, on a container
3148
+ with a small inode budget, or with fifty numbers in one folder, it is 40 000
3149
+ files whose directory has to be read every time the pre-key pool is counted.
3150
+
3151
+ `SqliteBackend` is the same state as a handful of rows:
3152
+
3153
+ ```js
3154
+ const { SqliteBackend } = require('whalibmob')
3155
+
3156
+ const db = new SqliteBackend({
3157
+ path: '/home/you/.waSession/sessions.sqlite',
3158
+ phone: '919634847671'
3159
+ })
3160
+
3161
+ db.write('auth', JSON.stringify(creds))
3162
+ db.read('auth')
3163
+ db.list('pre-key/')
3164
+ db.driver // 'node:sqlite' or 'better-sqlite3'
3165
+ db.close() // let go of the file
3166
+ ```
3167
+
3168
+ **What it needs.** Node ships SQLite of its own from **22.5.0** as
3169
+ `node:sqlite`, and that is what this uses when it is there — nothing to
3170
+ install, nothing for node-gyp to fail at, and Termux stays a place whalibmob
3171
+ runs. On older Node it falls back to `better-sqlite3` if that is installed:
3172
+
3173
+ ```sh
3174
+ npm install better-sqlite3 # only on Node older than 22.5
3175
+ ```
3176
+
3177
+ Neither is a dependency of this package. On a runtime with neither, the
3178
+ constructor throws and says which of the two to reach for — and `FileBackend`
3179
+ goes on needing nothing at all.
3180
+
3181
+ One file holds as many numbers and halves as you like, while each backend sees
3182
+ only its own slice:
3183
+
3184
+ ```js
3185
+ const mobile = new SqliteBackend({ path: file, phone: '919634847671' })
3186
+ const web = new SqliteBackend({ path: file, phone: '919634847671', web: true })
3187
+ const other = new SqliteBackend({ path: file, phone: '40712345678' })
3188
+
3189
+ SqliteBackend.sessionsIn(file)
3190
+ // [ { phone: '40712345678', half: 'mobile', web: false },
3191
+ // { phone: '919634847671', half: 'mobile', web: false },
3192
+ // { phone: '919634847671', half: 'web', web: true } ]
3193
+ ```
3194
+
3195
+ The file handle is shared between the backends opened on it and closed once
3196
+ the last of them calls `close()`, so closing one does not pull the file out
3197
+ from under the others. Calling `close()` twice is harmless.
3198
+
3199
+ The database runs in WAL mode, so a client flushing its Signal store and
3200
+ another reading the pre-key pool do not block each other. Values are stored as
3201
+ blobs rather than text: a credential blob carrying a NUL byte is truncated by
3202
+ a text binding and the session then loads with keys that are wrong from that
3203
+ byte on, which is the worst way for a bug to present.
3204
+
3205
+ ### Moving a Session Between Backends
3206
+
3207
+ Nothing is removed and the source is never modified, so if the result is not
3208
+ what you wanted the old files are still there to go back to:
3209
+
3210
+ ```js
3211
+ const { FileBackend, SqliteBackend, copySession, compareSessions } = require('whalibmob')
3212
+
3213
+ const files = new FileBackend({ dir: '/home/you/.waSession/919634847671',
3214
+ phone: '919634847671' })
3215
+ const db = new SqliteBackend({ path: '/home/you/.waSession/sessions.sqlite',
3216
+ phone: '919634847671' })
3217
+
3218
+ const moved = copySession(files, db)
3219
+ // { copied: ['auth', 'pre-key/1', …], skipped: [], bytes: 1284 }
3220
+
3221
+ const check = compareSessions(files, db)
3222
+ // { ok: true, missing: [], differing: [], extra: [] }
3223
+
3224
+ db.close()
3225
+ ```
3226
+
3227
+ `compareSessions` reads both sides back rather than trusting that the copy
3228
+ said so — run it before deleting anything. A key the destination already holds
3229
+ is left alone, so an interrupted copy is safe to run again; pass
3230
+ `{ overwrite: true }` when you do mean to replace what is there.
3231
+
3232
+ Do both halves of a number separately — `web: false` and `web: true` are two
3233
+ sessions and a copy of one is not a copy of the other.
3234
+
3064
3235
  ## Signal Store Utilities
3065
3236
 
3066
3237
  `auth-utils` is a collection of optional helpers for power users who manage their own `SignalStore` instances directly (e.g. custom storage backends, multi-account servers).
package/index.d.ts CHANGED
@@ -1297,6 +1297,180 @@ export declare const SessionPaths: {
1297
1297
  SHARED_FILES: string[];
1298
1298
  };
1299
1299
 
1300
+ // ────────────────────────────────────────────────────────────────────────────
1301
+ // Session storage
1302
+ // ────────────────────────────────────────────────────────────────────────────
1303
+
1304
+ /**
1305
+ * A key under which a piece of session state is kept — one of the fixed names
1306
+ * (`'auth'`, `'signal'`, …) or `` `pre-key/${id}` `` for one one-time pre-key.
1307
+ */
1308
+ export type StorageKey = string;
1309
+
1310
+ /**
1311
+ * Where a session's state is kept.
1312
+ *
1313
+ * Four synchronous operations over a flat key space. `FileBackend` is the
1314
+ * implementation the library has always used and remains the default; anything
1315
+ * satisfying this interface can take its place.
1316
+ *
1317
+ * Synchronous on purpose: the Signal store flushes from `exit` and `SIGTERM`
1318
+ * handlers, where an awaited write does not land.
1319
+ */
1320
+ export interface StorageBackend {
1321
+ /** The stored text, or `null` when the key has never been written. */
1322
+ read(key: StorageKey): string | null;
1323
+ /** Store `value` under `key`, replacing whatever was there. */
1324
+ write(key: StorageKey, value: string): void;
1325
+ /** Remove `key`. A key that is not there is not an error. */
1326
+ remove(key: StorageKey): void;
1327
+ /** Every key present that starts with `prefix` (all of them when omitted). */
1328
+ list(prefix?: string): StorageKey[];
1329
+ }
1330
+
1331
+ /** Options for {@link FileBackend}. */
1332
+ export interface FileBackendOptions {
1333
+ /** The directory this session's files live in. */
1334
+ dir: string;
1335
+ /** The number. Non-digits are stripped. */
1336
+ phone: string;
1337
+ /** The companion (Web API) half rather than the mobile one. Default `false`. */
1338
+ web?: boolean;
1339
+ }
1340
+
1341
+ /**
1342
+ * The session on disk as JSON files — what whalibmob has always written, under
1343
+ * the same names, in the same place.
1344
+ */
1345
+ export declare class FileBackend implements StorageBackend {
1346
+ constructor(opts: FileBackendOptions);
1347
+ readonly dir: string;
1348
+ readonly phone: string;
1349
+ readonly web: boolean;
1350
+ /** The absolute path a key is kept at, whether or not it exists yet. */
1351
+ fileFor(key: StorageKey): string;
1352
+ read(key: StorageKey): string | null;
1353
+ write(key: StorageKey, value: string): void;
1354
+ remove(key: StorageKey): void;
1355
+ list(prefix?: string): StorageKey[];
1356
+ /** key → the part of the file name that follows the number. */
1357
+ static KEY_SUFFIX: Record<string, string>;
1358
+ /** What sits between the stem and a pre-key's id. */
1359
+ static PRE_KEY_INFIX: string;
1360
+ }
1361
+
1362
+ /** Options for {@link SqliteBackend}. */
1363
+ export interface SqliteBackendOptions {
1364
+ /** The database file. Created, with its directory, if missing. */
1365
+ path: string;
1366
+ /** The number. Non-digits are stripped. */
1367
+ phone: string;
1368
+ /** The companion (Web API) half rather than the mobile one. Default `false`. */
1369
+ web?: boolean;
1370
+ /** Demand one driver rather than taking whichever is available. */
1371
+ driver?: 'node' | 'better-sqlite3';
1372
+ }
1373
+
1374
+ /** One number and half of it, as held in a database file. */
1375
+ export interface SqliteSessionRow {
1376
+ phone: string;
1377
+ half: 'mobile' | 'web';
1378
+ web: boolean;
1379
+ }
1380
+
1381
+ /**
1382
+ * The session in one database instead of 834 files.
1383
+ *
1384
+ * Uses Node's built-in `node:sqlite` (22.5.0 and later) when it is there, and
1385
+ * `better-sqlite3` when it is not. Neither is a dependency of this package —
1386
+ * on a runtime with neither, the constructor throws saying what to install,
1387
+ * and {@link FileBackend} goes on needing nothing.
1388
+ *
1389
+ * One file holds any number of sessions; a backend addresses one number's one
1390
+ * half of it. The file handle is shared between the backends opened on it and
1391
+ * released when the last of them calls `close()`.
1392
+ */
1393
+ export declare class SqliteBackend implements StorageBackend {
1394
+ constructor(opts: SqliteBackendOptions);
1395
+ readonly path: string;
1396
+ readonly phone: string;
1397
+ readonly web: boolean;
1398
+ /** Which driver this backend actually opened the file with. */
1399
+ readonly driver: 'node:sqlite' | 'better-sqlite3';
1400
+ read(key: StorageKey): string | null;
1401
+ write(key: StorageKey, value: string): void;
1402
+ remove(key: StorageKey): void;
1403
+ list(prefix?: string): StorageKey[];
1404
+ /** Let go of the database; the file closes once nobody else holds it. */
1405
+ close(): void;
1406
+ /** Every number and half the database file holds. */
1407
+ static sessionsIn(file: string, opts?: { driver?: 'node' | 'better-sqlite3' }): SqliteSessionRow[];
1408
+ /** The schema version this release writes and understands. */
1409
+ static SCHEMA_VERSION: number;
1410
+ /** The table the state lives in. */
1411
+ static TABLE: string;
1412
+ }
1413
+
1414
+ /** What {@link copySession} did. */
1415
+ export interface CopySessionResult {
1416
+ /** Keys written to the destination. */
1417
+ copied: StorageKey[];
1418
+ /** Keys left alone — already present, or gone from the source mid-copy. */
1419
+ skipped: StorageKey[];
1420
+ /** How much was copied, in UTF-8 bytes. */
1421
+ bytes: number;
1422
+ }
1423
+
1424
+ /** What {@link compareSessions} found. */
1425
+ export interface CompareSessionsResult {
1426
+ /** True when both hold the same keys with the same values. */
1427
+ ok: boolean;
1428
+ /** Keys the first has and the second does not. */
1429
+ missing: StorageKey[];
1430
+ /** Keys both have, holding different values. */
1431
+ differing: StorageKey[];
1432
+ /** Keys the second has and the first does not. */
1433
+ extra: StorageKey[];
1434
+ }
1435
+
1436
+ /**
1437
+ * Copy every key from one backend to another. The source is not modified, and
1438
+ * a key the destination already holds is left alone unless `overwrite` is set —
1439
+ * so an interrupted copy is safe to run again.
1440
+ */
1441
+ export declare function copySession(
1442
+ from: StorageBackend,
1443
+ to: StorageBackend,
1444
+ opts?: { overwrite?: boolean }
1445
+ ): CopySessionResult;
1446
+
1447
+ /**
1448
+ * Check that two backends hold the same state, reading both sides rather than
1449
+ * trusting that a copy said so. Worth running before deleting the original.
1450
+ */
1451
+ export declare function compareSessions(
1452
+ a: StorageBackend,
1453
+ b: StorageBackend
1454
+ ): CompareSessionsResult;
1455
+
1456
+ /**
1457
+ * A session held only for the life of the process.
1458
+ *
1459
+ * Nothing survives the run: a number registered against this backend cannot be
1460
+ * recovered, because the keys that proved the registration are gone with it.
1461
+ */
1462
+ export declare class MemoryBackend implements StorageBackend {
1463
+ constructor();
1464
+ read(key: StorageKey): string | null;
1465
+ write(key: StorageKey, value: string): void;
1466
+ remove(key: StorageKey): void;
1467
+ list(prefix?: string): StorageKey[];
1468
+ /** How many keys are held. Not part of the contract. */
1469
+ readonly size: number;
1470
+ /** Drop everything. Not part of the contract. */
1471
+ clear(): void;
1472
+ }
1473
+
1300
1474
  // ────────────────────────────────────────────────────────────────────────────
1301
1475
  // Device
1302
1476
  // ────────────────────────────────────────────────────────────────────────────
@@ -1432,6 +1606,35 @@ export interface LibModule {
1432
1606
  [name: string]: any;
1433
1607
  }
1434
1608
 
1609
+ /**
1610
+ * `whalibmob/lib/store/Backend` — the storage contract itself: the key names a
1611
+ * backend has to honour, and the checks that go with them. The implementations
1612
+ * are {@link FileBackend} and {@link MemoryBackend}, declared above.
1613
+ */
1614
+ export declare const StoreBackend: {
1615
+ /** Every key that is a fixed name rather than one generated per record. */
1616
+ KEYS: readonly string[];
1617
+ /** The prefix the per-record pre-key keys are built on: `'pre-key/'`. */
1618
+ PRE_KEY_PREFIX: string;
1619
+ /** The key one pre-key id is stored under. Throws on a non-integer id. */
1620
+ preKeyKey(id: number): StorageKey;
1621
+ /** The id back out of a pre-key key, or `null` when the key is not one. */
1622
+ preKeyId(key: StorageKey): number | null;
1623
+ /** Whether a string is a key every backend must accept. */
1624
+ isValidKey(key: unknown): boolean;
1625
+ /** Throw unless `backend` implements the contract. Returns it when it does. */
1626
+ assertBackend<T>(backend: T, what?: string): T;
1627
+ };
1628
+
1629
+ /**
1630
+ * `whalibmob/lib/store/migrate` — moving a session from one backend to another
1631
+ * and checking that it landed. Both members are also exported flat, above.
1632
+ */
1633
+ export declare const StoreMigrate: {
1634
+ copySession: typeof copySession;
1635
+ compareSessions: typeof compareSessions;
1636
+ };
1637
+
1435
1638
  /**
1436
1639
  * `whalibmob/lib/MediaService` — the encryption, upload and download beneath
1437
1640
  * `client.downloadMedia()`. Everything it exports is typed.
package/index.js CHANGED
@@ -102,6 +102,16 @@ const {
102
102
 
103
103
  const { encodeWAM, BinaryInfo, WEB_EVENTS, WEB_GLOBALS } = WAM;
104
104
 
105
+ // Where a session's state goes. FileBackend is what the library has always
106
+ // done — JSON files, same names — and is the default; anything implementing
107
+ // the same four methods can take its place. See lib/store/Backend.js.
108
+ const StoreBackend = require('./lib/store/Backend');
109
+ const { FileBackend } = require('./lib/store/FileBackend');
110
+ const { MemoryBackend } = require('./lib/store/MemoryBackend');
111
+ const { SqliteBackend } = require('./lib/store/SqliteBackend');
112
+ const StoreMigrate = require('./lib/store/migrate');
113
+ const { copySession, compareSessions } = StoreMigrate;
114
+
105
115
  // ─── The namespaces ──────────────────────────────────────────────────────────
106
116
  //
107
117
  // Every module of lib/, whole, under a name of its own — see the note at the
@@ -229,6 +239,20 @@ module.exports = {
229
239
  webStoreFileFor,
230
240
  listSessions,
231
241
  migrateSession,
242
+ // Where a session's state is kept. FileBackend is the default and writes the
243
+ // JSON files whalibmob has always written; MemoryBackend keeps a session only
244
+ // for the life of the process. Both satisfy the contract in StoreBackend.
245
+ StoreBackend,
246
+ FileBackend,
247
+ MemoryBackend,
248
+ // One database instead of 834 files. Needs Node 22.5+ for its built-in
249
+ // node:sqlite, or better-sqlite3 installed; neither is a dependency, and
250
+ // FileBackend stays the default that needs nothing.
251
+ SqliteBackend,
252
+ // Moving a session from one backend to another, and checking that it landed.
253
+ StoreMigrate,
254
+ copySession,
255
+ compareSessions,
232
256
  // Device config — reads WA_OS / WA_DEVICE / WA_DEVICE_* from process.env
233
257
  getDeviceConfig,
234
258
  // Store helpers
@@ -1339,10 +1339,17 @@ function getAppPid(store) {
1339
1339
  return store._appPid;
1340
1340
  }
1341
1341
 
1342
- 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);
1343
1350
  const json = '{"attempts":' + (attempt || 1)
1344
1351
  + ',"app_campaign_download_source":"google-play|unknown"'
1345
- + ',"is_sim_absent":false}';
1352
+ + ',"is_sim_absent":' + (simAbsent ? 'true' : 'false') + '}';
1346
1353
  return encodeURIComponent(json);
1347
1354
  }
1348
1355
 
@@ -1383,7 +1390,7 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
1383
1390
  'prefer_sms_over_flash', wantsFlash ? 'false' : 'true',
1384
1391
  'simnum', '0',
1385
1392
  'airplane_mode_type', '0',
1386
- 'client_metrics', buildClientMetrics(attempt),
1393
+ 'client_metrics', buildClientMetrics(attempt, meta),
1387
1394
  'mistyped', '7',
1388
1395
  'advertising_id', store.advertisingId || '',
1389
1396
  'hasinrc', '1',
@@ -1641,6 +1648,45 @@ async function sendEncrypted(path, plaintext, store, waVersion) {
1641
1648
  return result;
1642
1649
  }
1643
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
+
1644
1690
  // ---------- Funnel telemetry ----------
1645
1691
  //
1646
1692
  // The native client reports every screen it moves through — the number entry,
@@ -2042,10 +2088,13 @@ async function checkNumberStatus(phoneNumber) {
2042
2088
  async function assertRegistrationKeys(store, waVersion) {
2043
2089
  // The session-start event the native client fires before it knows the number.
2044
2090
  await sendPrePnFunnelLog(store, waVersion, 'session_start', 'registration_session_start');
2091
+ // The number gets typed between those two events.
2092
+ await humanPause('enter_number');
2045
2093
  await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_attempt');
2046
2094
 
2047
2095
  for (let attempt = 0; attempt < 2; attempt++) {
2048
2096
  try {
2097
+ if (attempt > 0) await humanPause('retry_code');
2049
2098
  logDeviceIdentity('before /exist', store, waVersion);
2050
2099
  const result = await sendRequest('/exist', store, waVersion, false, null);
2051
2100
  // reason === 'incorrect' → keys not found → fresh
@@ -2140,6 +2189,8 @@ async function requestSmsCode(store, method, opts) {
2140
2189
  // 7. Unknown error first time → retry once
2141
2190
  let lastReason = null;
2142
2191
  let attemptNum = 1;
2192
+ // The confirmation sheet the app puts up before it will ask for a code.
2193
+ await humanPause('confirm_number');
2143
2194
  while (true) {
2144
2195
  // Rebuilt per attempt so client_metrics carries the current attempt count.
2145
2196
  const extra = getRequestVerificationCodeParameters(store, m, _regMeta, _device, attemptNum);
@@ -2204,6 +2255,9 @@ async function requestSmsCode(store, method, opts) {
2204
2255
  if (attemptNum > MAX_CODE_REQUEST_ATTEMPTS) {
2205
2256
  throw new Error(`Registration error (${m}): giving up after ${MAX_CODE_REQUEST_ATTEMPTS} attempts — raw: ${JSON.stringify(result)}`);
2206
2257
  }
2258
+
2259
+ // Nobody re-taps the button the instant the error lands.
2260
+ await humanPause('retry_code');
2207
2261
  }
2208
2262
  }
2209
2263
 
@@ -2214,6 +2268,8 @@ async function requestSmsCode(store, method, opts) {
2214
2268
  if (result && result._noRoutes && !autoFallbackDone && method !== 'email') {
2215
2269
  autoFallbackDone = true;
2216
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');
2217
2273
  result = await _tryMethod(fallbackMethod);
2218
2274
  }
2219
2275
 
@@ -2373,3 +2429,6 @@ module.exports._verify = {
2373
2429
  flashCodeFromCallerId, flashCodeLength, codeForSubmission,
2374
2430
  getRequestVerificationCodeParameters
2375
2431
  };
2432
+
2433
+ // Pacing internals, exposed for tests. Not part of the public API.
2434
+ module.exports._pacing = { pacingEnabled, humanPause, buildClientMetrics, PACING_RANGES_MS };