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 +171 -0
- package/index.d.ts +203 -0
- package/index.js +24 -0
- package/lib/Registration.js +62 -3
- package/lib/store/Backend.js +143 -0
- package/lib/store/FileBackend.js +178 -0
- package/lib/store/MemoryBackend.js +73 -0
- package/lib/store/SqliteBackend.js +306 -0
- package/lib/store/migrate.js +82 -0
- package/llms.txt +167 -0
- package/package.json +2 -1
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
|
package/lib/Registration.js
CHANGED
|
@@ -1339,10 +1339,17 @@ function getAppPid(store) {
|
|
|
1339
1339
|
return store._appPid;
|
|
1340
1340
|
}
|
|
1341
1341
|
|
|
1342
|
-
|
|
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 };
|