whalibmob 5.13.1 → 5.14.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 +108 -5
- package/cli.js +155 -19
- package/index.js +10 -0
- package/lib/Client.js +34 -8
- package/lib/Registration.js +78 -29
- package/lib/SessionPaths.js +218 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -395,12 +395,16 @@ wa +919634847671>
|
|
|
395
395
|
> [!TIP]
|
|
396
396
|
> **The shell never exits on its own.** It stays open until you type `/quit` or press Ctrl+C. This is true for every command — registration, connection, sending messages — everything.
|
|
397
397
|
|
|
398
|
-
Use a
|
|
398
|
+
Use a different authentication folder with `--session`:
|
|
399
399
|
|
|
400
400
|
```sh
|
|
401
401
|
wa connect 919634847671 --session /data/my-sessions
|
|
402
402
|
```
|
|
403
403
|
|
|
404
|
+
The CLI asks what to call it once, on a first run, and remembers the answer in
|
|
405
|
+
`~/.whalibmob.json`. Each number gets its own folder inside — see
|
|
406
|
+
[Saving & Restoring Sessions](#saving--restoring-sessions).
|
|
407
|
+
|
|
404
408
|
> [!IMPORTANT]
|
|
405
409
|
> **If this is refused with `405`, do not re-register the number.** The account
|
|
406
410
|
> is fine; the server declined the version the connect announced. Check for a
|
|
@@ -2414,19 +2418,110 @@ If `socks` is not installed, or a proxy is unreachable, you get a message saying
|
|
|
2414
2418
|
|
|
2415
2419
|
## Saving & Restoring Sessions
|
|
2416
2420
|
|
|
2417
|
-
Sessions are
|
|
2421
|
+
Sessions are persisted to disk under the `sessionDir` you provide — the
|
|
2422
|
+
authentication folder. Pass any path you like; nothing is hardcoded.
|
|
2418
2423
|
|
|
2419
2424
|
```js
|
|
2420
2425
|
const client = new WhalibmobClient({
|
|
2421
|
-
sessionDir: path.join(process.env.HOME, '
|
|
2426
|
+
sessionDir: path.join(process.env.HOME, 'whalibmob_auth')
|
|
2422
2427
|
})
|
|
2423
2428
|
|
|
2424
2429
|
// no need to register again — just connect
|
|
2425
2430
|
await client.init('919634847671')
|
|
2426
2431
|
```
|
|
2427
2432
|
|
|
2433
|
+
**Each number gets a folder of its own inside it**, holding every file that
|
|
2434
|
+
number owns:
|
|
2435
|
+
|
|
2436
|
+
```
|
|
2437
|
+
whalibmob_auth/
|
|
2438
|
+
├── android-apk-material.json ← shared by every Android registration
|
|
2439
|
+
├── android-apk-material-business.json
|
|
2440
|
+
├── 919634847671/
|
|
2441
|
+
│ ├── 919634847671.json ← the store: keys, device, version
|
|
2442
|
+
│ ├── 919634847671.signal.json ← Signal sessions
|
|
2443
|
+
│ ├── 919634847671.sk.json ← sender keys
|
|
2444
|
+
│ ├── 919634847671.tctoken.json
|
|
2445
|
+
│ ├── 919634847671.device-cache.json
|
|
2446
|
+
│ ├── 919634847671.lid-mapping.json
|
|
2447
|
+
│ ├── 919634847671.lid-reverse-mapping.json
|
|
2448
|
+
│ ├── 919634847671.appState.json
|
|
2449
|
+
│ ├── 919634847671.appStateKeys.json
|
|
2450
|
+
│ ├── 919634847671.history.json
|
|
2451
|
+
│ └── 919634847671.messages.json
|
|
2452
|
+
└── 5568936182750/
|
|
2453
|
+
└── …
|
|
2454
|
+
```
|
|
2455
|
+
|
|
2456
|
+
A companion link for the same number lives beside it as `<phone>.web.json` and
|
|
2457
|
+
friends, in the same folder.
|
|
2458
|
+
|
|
2459
|
+
One account is then one directory: copy it to move a number to another machine,
|
|
2460
|
+
delete it to be rid of one, archive it to keep it. Nothing has to be picked out
|
|
2461
|
+
of a pile by prefix.
|
|
2462
|
+
|
|
2428
2463
|
> [!NOTE]
|
|
2429
|
-
>
|
|
2464
|
+
> **Sessions written by earlier versions keep working where they are.** The
|
|
2465
|
+
> layout is decided per number: a number whose files sit loose in the base
|
|
2466
|
+
> directory is left exactly as it is, and only new numbers get a folder. Nothing
|
|
2467
|
+
> is moved unless you ask — `wa migrate-sessions` does that, one number or all
|
|
2468
|
+
> of them, and re-running it is safe.
|
|
2469
|
+
|
|
2470
|
+
### Where the folder comes from
|
|
2471
|
+
|
|
2472
|
+
| order | source |
|
|
2473
|
+
|---|---|
|
|
2474
|
+
| 1 | `sessionDir` passed to `WhalibmobClient` (library), or `--session <dir>` (CLI) |
|
|
2475
|
+
| 2 | `WA_SESSION_DIR` in the environment |
|
|
2476
|
+
| 3 | the answer remembered in `~/.whalibmob.json`, which the CLI asks for once on a first run |
|
|
2477
|
+
| 4 | `~/.waSession` |
|
|
2478
|
+
|
|
2479
|
+
The CLI asks only when none of the above has decided it and the default folder
|
|
2480
|
+
is empty, so an existing installation is never asked to rename anything, and a
|
|
2481
|
+
non-interactive run — a script, a cron — never blocks on the question.
|
|
2482
|
+
|
|
2483
|
+
```
|
|
2484
|
+
where should sessions be kept? each number gets its own folder inside.
|
|
2485
|
+
a bare name goes under your home directory; enter for /home/you/.waSession
|
|
2486
|
+
authentication folder: whalibmob_auth
|
|
2487
|
+
sessions will be kept in /home/you/whalibmob_auth
|
|
2488
|
+
```
|
|
2489
|
+
|
|
2490
|
+
A bare name is created under your home directory; an absolute path or one
|
|
2491
|
+
starting with `~` is taken as given.
|
|
2492
|
+
|
|
2493
|
+
### Working out the paths yourself
|
|
2494
|
+
|
|
2495
|
+
`lib/SessionPaths` is the same resolver the library and the CLI use, so a
|
|
2496
|
+
caller that wants to read or move a session's files does not have to guess the
|
|
2497
|
+
layout:
|
|
2498
|
+
|
|
2499
|
+
```js
|
|
2500
|
+
// exported from the package itself, or from lib/SessionPaths directly
|
|
2501
|
+
const {
|
|
2502
|
+
defaultBaseDir, sessionDirFor, storeFileFor, webStoreFileFor,
|
|
2503
|
+
listSessions, migrateSession, SessionPaths
|
|
2504
|
+
} = require('whalibmob')
|
|
2505
|
+
|
|
2506
|
+
const { isLegacyLayout, SESSION_SUFFIXES } = SessionPaths
|
|
2507
|
+
|
|
2508
|
+
const base = path.join(process.env.HOME, 'whalibmob_auth')
|
|
2509
|
+
|
|
2510
|
+
sessionDirFor(base, '919634847671') // …/whalibmob_auth/919634847671
|
|
2511
|
+
sessionDirFor(base, '919634847671', { create: true }) // and makes it
|
|
2512
|
+
storeFileFor(base, '919634847671') // …/919634847671/919634847671.json
|
|
2513
|
+
webStoreFileFor(base, '919634847671') // …/919634847671/919634847671.web.json
|
|
2514
|
+
|
|
2515
|
+
listSessions(base)
|
|
2516
|
+
// [ { phone, dir, storeFile, webStoreFile, legacy, hasMobile, hasWeb }, … ]
|
|
2517
|
+
// covers both layouts, so it is what to iterate over
|
|
2518
|
+
|
|
2519
|
+
migrateSession(base, '919634847671')
|
|
2520
|
+
// { phone, from, to, moved: [...], skipped: [...] } moves one number into its folder
|
|
2521
|
+
```
|
|
2522
|
+
|
|
2523
|
+
`SESSION_SUFFIXES` is every per-number file the library writes — the list to
|
|
2524
|
+
copy or delete against if you are moving an account by hand.
|
|
2430
2525
|
|
|
2431
2526
|
## Signal Store Utilities
|
|
2432
2527
|
|
|
@@ -4611,7 +4706,15 @@ wa refresh-version 5568936182750 # then write its version into the session
|
|
|
4611
4706
|
```
|
|
4612
4707
|
|
|
4613
4708
|
It touches `version` and nothing else — the keys, the device profile and the
|
|
4614
|
-
registration are left exactly as they were
|
|
4709
|
+
registration are left exactly as they were — and it never moves a session
|
|
4710
|
+
backwards. The store it reads from can be behind what a session already holds,
|
|
4711
|
+
since Play serves the build matching the device profile it was asked with rather
|
|
4712
|
+
than the newest on the listing, and announcing an older version is the one
|
|
4713
|
+
outcome that makes a 405 more likely:
|
|
4714
|
+
|
|
4715
|
+
```
|
|
4716
|
+
+40756218532 android 2.26.30.3 (kept — newer than the 2.26.29.73 available)
|
|
4717
|
+
``` `--all` does every session in the
|
|
4615
4718
|
session directory, which is what belongs in a monthly cron for a bot that is
|
|
4616
4719
|
meant to stay up:
|
|
4617
4720
|
|
package/cli.js
CHANGED
|
@@ -60,6 +60,8 @@ const {
|
|
|
60
60
|
} = require('./lib/Client');
|
|
61
61
|
|
|
62
62
|
const { assertMeId, initAuthCreds } = require('./lib/auth-utils');
|
|
63
|
+
const { defaultBaseDir, sessionDirFor, storeFileFor, webStoreFileFor,
|
|
64
|
+
listSessions, migrateSession, isLegacyLayout } = require('./lib/SessionPaths');
|
|
63
65
|
|
|
64
66
|
// ─── Wire trace implementation ────────────────────────────────────────────────
|
|
65
67
|
// Everything below is CLI-only instrumentation; the library is untouched.
|
|
@@ -389,8 +391,88 @@ function printParticipantResults(verb, results) {
|
|
|
389
391
|
|
|
390
392
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
391
393
|
|
|
394
|
+
// Where the authentication folder is remembered between runs, so the question
|
|
395
|
+
// below is asked once rather than every time.
|
|
396
|
+
function configPath() {
|
|
397
|
+
return path.join(os.homedir(), '.whalibmob.json');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function readConfig() {
|
|
401
|
+
try { return JSON.parse(fs.readFileSync(configPath(), 'utf8')) || {}; }
|
|
402
|
+
catch (_) { return {}; }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function writeConfig(patch) {
|
|
406
|
+
const merged = Object.assign(readConfig(), patch);
|
|
407
|
+
try { fs.writeFileSync(configPath(), JSON.stringify(merged, null, 2)); } catch (_) {}
|
|
408
|
+
return merged;
|
|
409
|
+
}
|
|
410
|
+
|
|
392
411
|
function defaultSessionDir() {
|
|
393
|
-
return
|
|
412
|
+
return readConfig().sessionDir || defaultBaseDir();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// A folder name typed by a person, turned into somewhere to write.
|
|
416
|
+
//
|
|
417
|
+
// "" or blank null, and the caller falls back to the default
|
|
418
|
+
// whalibmob_auth under the home directory — what a bare name means
|
|
419
|
+
// ~/anything under the home directory
|
|
420
|
+
// /data/sessions taken as given
|
|
421
|
+
// project/auth relative to where the command is being run, as a shell
|
|
422
|
+
// would read it
|
|
423
|
+
//
|
|
424
|
+
// Returning null rather than a path for a blank answer keeps the "just press
|
|
425
|
+
// enter" case in one place: the caller owns what the default is.
|
|
426
|
+
function resolveAuthFolder(name) {
|
|
427
|
+
const raw = String(name || '').trim();
|
|
428
|
+
if (!raw) return null;
|
|
429
|
+
if (raw.startsWith('~')) return path.join(os.homedir(), raw.slice(1).replace(/^[\/\\]/, ''));
|
|
430
|
+
if (path.isAbsolute(raw)) return raw;
|
|
431
|
+
if (/[\/\\]/.test(raw)) return path.resolve(raw);
|
|
432
|
+
return path.join(os.homedir(), raw);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Ask what to call the authentication folder, once, and remember the answer.
|
|
436
|
+
//
|
|
437
|
+
// Skipped entirely when --session or WA_SESSION_DIR already say where it is,
|
|
438
|
+
// when a previous run answered, when the default folder already holds sessions
|
|
439
|
+
// (an existing installation is not asked to rename anything), and when stdin is
|
|
440
|
+
// not a terminal, so scripts and cron never block on it.
|
|
441
|
+
function askSessionDir(cmd, explicit) {
|
|
442
|
+
const OFFLINE = ['version', '--version', '-v', 'help', '--help', '-h'];
|
|
443
|
+
if (explicit) return Promise.resolve(explicit);
|
|
444
|
+
if (cmd && OFFLINE.includes(cmd)) return Promise.resolve(defaultSessionDir());
|
|
445
|
+
if (process.env.WA_SESSION_DIR) return Promise.resolve(process.env.WA_SESSION_DIR);
|
|
446
|
+
|
|
447
|
+
const remembered = readConfig().sessionDir;
|
|
448
|
+
if (remembered) return Promise.resolve(remembered);
|
|
449
|
+
|
|
450
|
+
const fallback = defaultBaseDir();
|
|
451
|
+
// An installation that already has sessions keeps them where they are.
|
|
452
|
+
if (listSessions(fallback).length) return Promise.resolve(fallback);
|
|
453
|
+
if (!process.stdin.isTTY) return Promise.resolve(fallback);
|
|
454
|
+
|
|
455
|
+
return new Promise(resolve => {
|
|
456
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
457
|
+
out('');
|
|
458
|
+
out(' where should sessions be kept? each number gets its own folder inside.');
|
|
459
|
+
out(' a bare name goes under your home directory; enter for ' + fallback);
|
|
460
|
+
rl.question(' authentication folder: ', (answer) => {
|
|
461
|
+
rl.close();
|
|
462
|
+
const dir = resolveAuthFolder(answer) || fallback;
|
|
463
|
+
try {
|
|
464
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
465
|
+
writeConfig({ sessionDir: dir });
|
|
466
|
+
out(' sessions will be kept in ' + dir);
|
|
467
|
+
out('');
|
|
468
|
+
} catch (e) {
|
|
469
|
+
warn('could not create ' + dir + ' (' + e.message + ') — using ' + fallback);
|
|
470
|
+
resolve(fallback);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
resolve(dir);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
394
476
|
}
|
|
395
477
|
|
|
396
478
|
function normalizePhone(s) {
|
|
@@ -817,11 +899,11 @@ function openShell(prompt) {
|
|
|
817
899
|
// already on disk that one wins, and a non-interactive stdin never blocks on a
|
|
818
900
|
// question nobody is there to answer.
|
|
819
901
|
function hasMobileSession(phone) {
|
|
820
|
-
return fs.existsSync(
|
|
902
|
+
return fs.existsSync(storeFileFor(_sessDir, phone));
|
|
821
903
|
}
|
|
822
904
|
|
|
823
905
|
function hasWebSession(phone) {
|
|
824
|
-
const f =
|
|
906
|
+
const f = webStoreFileFor(_sessDir, phone);
|
|
825
907
|
if (!fs.existsSync(f)) return false;
|
|
826
908
|
try {
|
|
827
909
|
const j = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
@@ -2076,8 +2158,8 @@ async function handleLine(line) {
|
|
|
2076
2158
|
fail('email method requires an address — usage: /reg code <phone> email <address>');
|
|
2077
2159
|
break;
|
|
2078
2160
|
}
|
|
2079
|
-
|
|
2080
|
-
const sessFile =
|
|
2161
|
+
sessionDirFor(_sessDir, ph, { create: true });
|
|
2162
|
+
const sessFile = storeFileFor(_sessDir, ph);
|
|
2081
2163
|
let store = loadStore(sessFile);
|
|
2082
2164
|
if (!store) {
|
|
2083
2165
|
store = initAuthCreds(ph);
|
|
@@ -2108,19 +2190,19 @@ async function handleLine(line) {
|
|
|
2108
2190
|
const ph = normalizePhone(p[2]);
|
|
2109
2191
|
const code = p[3];
|
|
2110
2192
|
if (!ph || !code) { fail('usage: /reg confirm <phone> <code>'); break; }
|
|
2111
|
-
const file =
|
|
2193
|
+
const file = storeFileFor(_sessDir, ph);
|
|
2112
2194
|
const store = loadStore(file) || initAuthCreds(ph);
|
|
2113
2195
|
out('verifying...');
|
|
2114
2196
|
const r = await verifyCode(store, code, Object.assign(registrationPrompts(), { onProgress: out }));
|
|
2115
2197
|
if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
|
|
2116
|
-
|
|
2198
|
+
sessionDirFor(_sessDir, ph, { create: true });
|
|
2117
2199
|
const finalStore = r.store || store;
|
|
2118
2200
|
finalStore.registered = true;
|
|
2119
2201
|
finalStore.codePending = false;
|
|
2120
2202
|
// Save under the number WhatsApp filed the account as, not the one
|
|
2121
2203
|
// that was typed — they differ often enough to matter.
|
|
2122
2204
|
const savedPhone = String(finalStore.phoneNumber || ph);
|
|
2123
|
-
const savedFile =
|
|
2205
|
+
const savedFile = storeFileFor(_sessDir, savedPhone);
|
|
2124
2206
|
saveStore(finalStore, savedFile);
|
|
2125
2207
|
if (r.canonicalPhoneNumber) {
|
|
2126
2208
|
out('note: WhatsApp knows this account as +' + r.canonicalPhoneNumber +
|
|
@@ -2359,10 +2441,11 @@ usage:
|
|
|
2359
2441
|
wa apk-material <base.apk> [split.apk ...] read the Android token material
|
|
2360
2442
|
wa apk-material --download fetch that APK from Google Play
|
|
2361
2443
|
wa refresh-version <phone> update the version a session announces
|
|
2444
|
+
wa migrate-sessions [phone] move old flat sessions into folders
|
|
2362
2445
|
wa version
|
|
2363
2446
|
|
|
2364
2447
|
options:
|
|
2365
|
-
--session <dir>
|
|
2448
|
+
--session <dir> authentication folder (default: remembered, else ~/.waSession)
|
|
2366
2449
|
--out <file> where apk-material writes (default: <session dir>/android-apk-material.json)
|
|
2367
2450
|
--sms connect by registering this number over SMS
|
|
2368
2451
|
--pair connect by linking to an existing account (8-digit code)
|
|
@@ -2395,7 +2478,11 @@ function announceTrace() {
|
|
|
2395
2478
|
function askDebugMode(cmd) {
|
|
2396
2479
|
if (TRACE_FORCE_ON) { enableWireTrace(); announceTrace(); return Promise.resolve(); }
|
|
2397
2480
|
if (TRACE_FORCE_OFF) return Promise.resolve();
|
|
2398
|
-
|
|
2481
|
+
// Commands that never open a WhatsApp connection: there is no wire for a
|
|
2482
|
+
// trace to show, and stopping a maintenance command to ask is what makes
|
|
2483
|
+
// `wa apk-material --download && wa refresh-version --all` prompt twice.
|
|
2484
|
+
const OFFLINE = ['version', '--version', '-v', 'help', '--help', '-h',
|
|
2485
|
+
'apk-material', 'refresh-version', 'migrate-sessions'];
|
|
2399
2486
|
if (cmd && OFFLINE.includes(cmd)) return Promise.resolve();
|
|
2400
2487
|
if (!process.stdin.isTTY) return Promise.resolve();
|
|
2401
2488
|
|
|
@@ -2417,7 +2504,11 @@ function askDebugMode(cmd) {
|
|
|
2417
2504
|
// WA_NO_DONATE=1 all skip it in silence, so nothing about it can get in the way
|
|
2418
2505
|
// of a script or a real command.
|
|
2419
2506
|
function askDonation(cmd) {
|
|
2420
|
-
|
|
2507
|
+
// Commands that never open a WhatsApp connection: there is no wire for a
|
|
2508
|
+
// trace to show, and stopping a maintenance command to ask is what makes
|
|
2509
|
+
// `wa apk-material --download && wa refresh-version --all` prompt twice.
|
|
2510
|
+
const OFFLINE = ['version', '--version', '-v', 'help', '--help', '-h',
|
|
2511
|
+
'apk-material', 'refresh-version', 'migrate-sessions'];
|
|
2421
2512
|
if (cmd && OFFLINE.includes(cmd)) return Promise.resolve();
|
|
2422
2513
|
if (!process.stdin.isTTY) return Promise.resolve();
|
|
2423
2514
|
if (process.env.WA_NO_DONATE === '1') return Promise.resolve();
|
|
@@ -2447,11 +2538,14 @@ function askDonation(cmd) {
|
|
|
2447
2538
|
async function main() {
|
|
2448
2539
|
const { cmd, sub, flags, pos } = parseArgs(process.argv);
|
|
2449
2540
|
|
|
2541
|
+
// The authentication folder first: it decides where everything this run
|
|
2542
|
+
// touches lives, and asking it after the other two made the answer to the
|
|
2543
|
+
// first question land in the wrong prompt.
|
|
2544
|
+
_sessDir = await askSessionDir(cmd, flags.session);
|
|
2545
|
+
|
|
2450
2546
|
await askDebugMode(cmd);
|
|
2451
2547
|
await askDonation(cmd);
|
|
2452
2548
|
|
|
2453
|
-
_sessDir = flags.session || defaultSessionDir();
|
|
2454
|
-
|
|
2455
2549
|
// `--business` is the flag form of WA_BUSINESS, mapped onto the environment
|
|
2456
2550
|
// before anything reads a device profile. The device config is env-driven, so
|
|
2457
2551
|
// this is the whole of it: the profile, the token material, the version
|
|
@@ -2475,6 +2569,42 @@ async function main() {
|
|
|
2475
2569
|
return;
|
|
2476
2570
|
}
|
|
2477
2571
|
|
|
2572
|
+
// Move sessions out of the old flat layout into a folder each.
|
|
2573
|
+
//
|
|
2574
|
+
// Nothing forces this: a number whose files sit loose in the base directory
|
|
2575
|
+
// keeps working exactly where it is. This is for anyone who wants the tidier
|
|
2576
|
+
// shape for what they already have.
|
|
2577
|
+
if (cmd === 'migrate-sessions') {
|
|
2578
|
+
const one = normalizePhone(sub || '');
|
|
2579
|
+
const all = listSessions(_sessDir);
|
|
2580
|
+
const legacy = all.filter(x => x.legacy && (!one || x.phone === one));
|
|
2581
|
+
|
|
2582
|
+
if (!legacy.length) {
|
|
2583
|
+
out(one
|
|
2584
|
+
? 'nothing to move for +' + one + ' — it is already in a folder of its own'
|
|
2585
|
+
: 'nothing to move — every session in ' + _sessDir + ' already has its own folder');
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
out('moving ' + legacy.length + ' session(s) into folders under ' + _sessDir);
|
|
2590
|
+
out('');
|
|
2591
|
+
let files = 0, skipped = 0;
|
|
2592
|
+
for (const entry of legacy) {
|
|
2593
|
+
try {
|
|
2594
|
+
const r = migrateSession(_sessDir, entry.phone);
|
|
2595
|
+
files += r.moved.length;
|
|
2596
|
+
skipped += r.skipped.length;
|
|
2597
|
+
out(' +' + entry.phone.padEnd(18) + r.moved.length + ' file(s)' +
|
|
2598
|
+
(r.skipped.length ? ' ' + r.skipped.length + ' left (already there)' : ''));
|
|
2599
|
+
} catch (e) {
|
|
2600
|
+
fail('+' + entry.phone + ': ' + e.message);
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
out('');
|
|
2604
|
+
out(' ' + files + ' file(s) moved' + (skipped ? ', ' + skipped + ' skipped' : ''));
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2478
2608
|
// Bring a session's stored version up to date.
|
|
2479
2609
|
//
|
|
2480
2610
|
// A session announces the version it registered with, forever — nothing else
|
|
@@ -2494,13 +2624,13 @@ async function main() {
|
|
|
2494
2624
|
let files;
|
|
2495
2625
|
if (flags.all) {
|
|
2496
2626
|
try {
|
|
2497
|
-
files =
|
|
2498
|
-
.filter(
|
|
2499
|
-
.map(
|
|
2627
|
+
files = listSessions(_sessDir)
|
|
2628
|
+
.filter(x => x.hasMobile)
|
|
2629
|
+
.map(x => x.storeFile);
|
|
2500
2630
|
} catch (_) { files = []; }
|
|
2501
2631
|
if (!files.length) { fail('no sessions in ' + _sessDir); process.exit(1); }
|
|
2502
2632
|
} else {
|
|
2503
|
-
files = [
|
|
2633
|
+
files = [storeFileFor(_sessDir, one)];
|
|
2504
2634
|
}
|
|
2505
2635
|
|
|
2506
2636
|
if (process.env.WA_VERSION) {
|
|
@@ -2508,7 +2638,7 @@ async function main() {
|
|
|
2508
2638
|
'that instead of what this command writes. Unset it for the refresh to take effect.');
|
|
2509
2639
|
}
|
|
2510
2640
|
|
|
2511
|
-
let changed = 0, failed = 0;
|
|
2641
|
+
let changed = 0, failed = 0, kept = 0;
|
|
2512
2642
|
const sources = new Set();
|
|
2513
2643
|
for (const file of files) {
|
|
2514
2644
|
try {
|
|
@@ -2518,6 +2648,10 @@ async function main() {
|
|
|
2518
2648
|
changed++;
|
|
2519
2649
|
sources.add(r.source);
|
|
2520
2650
|
out(who.padEnd(30) + r.before + ' → ' + r.after);
|
|
2651
|
+
} else if (r.keptNewer) {
|
|
2652
|
+
kept++;
|
|
2653
|
+
out(who.padEnd(30) + r.before + ' (kept — newer than the ' +
|
|
2654
|
+
r.candidate + ' available)');
|
|
2521
2655
|
} else {
|
|
2522
2656
|
out(who.padEnd(30) + r.after + ' (already current)');
|
|
2523
2657
|
}
|
|
@@ -2528,7 +2662,9 @@ async function main() {
|
|
|
2528
2662
|
}
|
|
2529
2663
|
|
|
2530
2664
|
out('');
|
|
2531
|
-
out(' ' + changed + ' session(s) updated' +
|
|
2665
|
+
out(' ' + changed + ' session(s) updated' +
|
|
2666
|
+
(kept ? ', ' + kept + ' left alone (already ahead)' : '') +
|
|
2667
|
+
(failed ? ', ' + failed + ' failed' : ''));
|
|
2532
2668
|
if (changed) {
|
|
2533
2669
|
out(' read from ' + [...sources].join(', ') + '.');
|
|
2534
2670
|
out(' reconnect for it to be announced.');
|
package/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { WhalibmobClient, checkNumberStatus, fetchIosVersion, fetchWaVersion, assertRegistrationKeys } = require('./lib/Client');
|
|
4
4
|
const { getDeviceConfig } = require('./lib/DeviceConfig');
|
|
5
5
|
const { fetchAndroidVersion, currentVersionFor, refreshSessionVersion } = require('./lib/Registration');
|
|
6
|
+
const SessionPaths = require('./lib/SessionPaths');
|
|
6
7
|
const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts, storeToJson, storeFromJson } = require('./lib/Store');
|
|
7
8
|
const { checkIfRegistered, requestSmsCode, verifyCode } = require('./lib/Registration');
|
|
8
9
|
const { SignalProtocol } = require('./lib/signal/SignalProtocol');
|
|
@@ -69,6 +70,15 @@ module.exports = {
|
|
|
69
70
|
// session that registered long enough ago to have gone stale.
|
|
70
71
|
currentVersionFor,
|
|
71
72
|
refreshSessionVersion,
|
|
73
|
+
// Where a session's files live: one folder per number inside the
|
|
74
|
+
// authentication folder. The same resolver the CLI uses.
|
|
75
|
+
SessionPaths,
|
|
76
|
+
defaultBaseDir: SessionPaths.defaultBaseDir,
|
|
77
|
+
sessionDirFor: SessionPaths.sessionDirFor,
|
|
78
|
+
storeFileFor: SessionPaths.storeFileFor,
|
|
79
|
+
webStoreFileFor: SessionPaths.webStoreFileFor,
|
|
80
|
+
listSessions: SessionPaths.listSessions,
|
|
81
|
+
migrateSession: SessionPaths.migrateSession,
|
|
72
82
|
// Device config — reads WA_OS / WA_DEVICE / WA_DEVICE_* from process.env
|
|
73
83
|
getDeviceConfig,
|
|
74
84
|
// Store helpers
|
package/lib/Client.js
CHANGED
|
@@ -9,6 +9,8 @@ const crypto = require('crypto');
|
|
|
9
9
|
const { NoiseSocket } = require('./noise');
|
|
10
10
|
const { MessageSender, generateMessageId, makeJid, buildOrGetAdvIdentity } = require('./messages/MessageSender');
|
|
11
11
|
const { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, assertRegistrationKeys, fetchIosVersion, fetchWaVersion } = require('./Registration');
|
|
12
|
+
const { defaultBaseDir, sessionDirFor, isLegacyLayout,
|
|
13
|
+
SESSION_SUFFIXES } = require('./SessionPaths');
|
|
12
14
|
const { getDeviceConfig } = require('./DeviceConfig');
|
|
13
15
|
const { prepareProfilePicture, probeImageSize, canDecodeImage } = require('./MediaThumbnail');
|
|
14
16
|
const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = require('./Store');
|
|
@@ -320,7 +322,13 @@ class WhalibmobClient extends EventEmitter {
|
|
|
320
322
|
this._sender = null;
|
|
321
323
|
this._signal = null;
|
|
322
324
|
this._devMgr = null;
|
|
323
|
-
|
|
325
|
+
// The authentication folder the caller chose. `_sessionDir` below is the
|
|
326
|
+
// directory this one number's files live in, which is a directory inside
|
|
327
|
+
// it — see SessionPaths. Both are kept: the base is what listing and
|
|
328
|
+
// renaming work against, the per-number one is what every file path is
|
|
329
|
+
// built from.
|
|
330
|
+
this._baseDir = opts.sessionDir || defaultBaseDir();
|
|
331
|
+
this._sessionDir = this._baseDir;
|
|
324
332
|
// 'mobile' — registered over SMS as the account's primary device (default,
|
|
325
333
|
// and what every existing caller gets). 'web' — linked to an existing
|
|
326
334
|
// account as a companion, via pairing code.
|
|
@@ -410,6 +418,8 @@ class WhalibmobClient extends EventEmitter {
|
|
|
410
418
|
// entry points reset it — the reconnect loop must not clear its own guard.
|
|
411
419
|
this._fatal = false;
|
|
412
420
|
|
|
421
|
+
this._sessionDir = sessionDirFor(this._baseDir, phoneNumber);
|
|
422
|
+
|
|
413
423
|
const sessionFile = path.join(this._sessionDir, `${phoneNumber}.json`);
|
|
414
424
|
this._store = loadStore(sessionFile);
|
|
415
425
|
if (!this._store) {
|
|
@@ -588,6 +598,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
588
598
|
const { createNewWebStore, loadWebStore, saveWebStore, webSessionPath } =
|
|
589
599
|
require('./WebStore');
|
|
590
600
|
|
|
601
|
+
this._sessionDir = sessionDirFor(this._baseDir, phoneNumber, { create: true });
|
|
591
602
|
this._webSessionFile = webSessionPath(this._sessionDir, phoneNumber);
|
|
592
603
|
this._store = loadWebStore(this._webSessionFile) || createNewWebStore(phoneNumber);
|
|
593
604
|
|
|
@@ -1095,14 +1106,24 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1095
1106
|
if (wasConnected) this.disconnect();
|
|
1096
1107
|
|
|
1097
1108
|
// Every per-session file shares the phone number as its stem.
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1109
|
+
// Both layouts: the files are renamed where they sit, and when they sit in
|
|
1110
|
+
// a directory named after the old number that directory is renamed too —
|
|
1111
|
+
// otherwise +<canonical> would live in a folder called +<current> and the
|
|
1112
|
+
// next lookup would not find it.
|
|
1113
|
+
const fromDir = this._sessionDir;
|
|
1114
|
+
const toDir = isLegacyLayout(this._baseDir, current)
|
|
1115
|
+
? this._baseDir
|
|
1116
|
+
: path.join(this._baseDir, canonical);
|
|
1117
|
+
|
|
1118
|
+
if (toDir !== fromDir && fs.existsSync(toDir)) {
|
|
1119
|
+
throw new Error('Cannot rename session: ' + toDir +
|
|
1120
|
+
' already exists. Move it aside first.');
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1102
1123
|
const moved = [];
|
|
1103
|
-
for (const suffix of
|
|
1104
|
-
const from = path.join(
|
|
1105
|
-
const to = path.join(
|
|
1124
|
+
for (const suffix of SESSION_SUFFIXES) {
|
|
1125
|
+
const from = path.join(fromDir, current + suffix);
|
|
1126
|
+
const to = path.join(fromDir, canonical + suffix);
|
|
1106
1127
|
if (!fs.existsSync(from)) continue;
|
|
1107
1128
|
if (fs.existsSync(to)) {
|
|
1108
1129
|
throw new Error('Cannot rename session: ' + canonical + suffix +
|
|
@@ -1113,6 +1134,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1113
1134
|
}
|
|
1114
1135
|
if (!moved.length) throw new Error('No session files found for ' + current);
|
|
1115
1136
|
|
|
1137
|
+
if (toDir !== fromDir) {
|
|
1138
|
+
fs.renameSync(fromDir, toDir);
|
|
1139
|
+
this._sessionDir = toDir;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1116
1142
|
// The number is inside the store as well as in the filename.
|
|
1117
1143
|
const sessionFile = path.join(this._sessionDir, canonical + '.json');
|
|
1118
1144
|
const store = loadStore(sessionFile);
|
package/lib/Registration.js
CHANGED
|
@@ -648,11 +648,21 @@ async function refreshSessionVersion(sessionFile, opts) {
|
|
|
648
648
|
const device = store.device || getDeviceConfig();
|
|
649
649
|
const before = store.version;
|
|
650
650
|
|
|
651
|
-
const
|
|
651
|
+
const explicit = opts.version != null;
|
|
652
|
+
const resolved = explicit
|
|
652
653
|
? { version: String(opts.version), source: 'the value you passed' }
|
|
653
654
|
: await currentVersionFor(device);
|
|
654
655
|
|
|
655
|
-
|
|
656
|
+
// Never go backwards on a lookup. The store the version is read from can be
|
|
657
|
+
// behind what a session already holds — Play serves the build that matches
|
|
658
|
+
// the device profile it was asked with, which is not always the newest one on
|
|
659
|
+
// the listing — and moving a session to an older version is the one outcome
|
|
660
|
+
// that makes a 405 more likely rather than less. A version named on the
|
|
661
|
+
// command line is a decision, so that still applies either way.
|
|
662
|
+
const olderThanStored = !explicit && !!before &&
|
|
663
|
+
compareVersions(resolved.version, before) < 0;
|
|
664
|
+
|
|
665
|
+
const changed = !olderThanStored && resolved.version !== before;
|
|
656
666
|
if (changed) {
|
|
657
667
|
store.version = resolved.version;
|
|
658
668
|
saveStore(store, sessionFile);
|
|
@@ -661,8 +671,10 @@ async function refreshSessionVersion(sessionFile, opts) {
|
|
|
661
671
|
return {
|
|
662
672
|
phoneNumber: store.phoneNumber,
|
|
663
673
|
before,
|
|
664
|
-
after: resolved.version,
|
|
674
|
+
after: changed ? resolved.version : before,
|
|
675
|
+
candidate: resolved.version,
|
|
665
676
|
changed,
|
|
677
|
+
keptNewer: olderThanStored,
|
|
666
678
|
source: resolved.source,
|
|
667
679
|
os: device.os,
|
|
668
680
|
business: !!device.business
|
|
@@ -947,6 +959,37 @@ function getAccessSessionId(store) {
|
|
|
947
959
|
// Cobalt's "000" placeholder), and advertising_id / backup_token come from the
|
|
948
960
|
// store.
|
|
949
961
|
|
|
962
|
+
// How long the server said to wait, in seconds, or null when it did not say.
|
|
963
|
+
//
|
|
964
|
+
// The wait comes back in a field named after the delivery method, and there are
|
|
965
|
+
// seven of them. Reading only `sms_wait` meant a rate-limited voice or wa_old
|
|
966
|
+
// request reported no wait at all — which is the one number that matters in a
|
|
967
|
+
// `too_recent`, because without it the only advice left is "wait a few minutes"
|
|
968
|
+
// and people re-run the command instead, spending attempts and extending the
|
|
969
|
+
// very cooldown they are trying to get out of.
|
|
970
|
+
//
|
|
971
|
+
// The method's own field wins; the longest of the others is the fallback, since
|
|
972
|
+
// the server sometimes answers about a method other than the one asked for.
|
|
973
|
+
const WAIT_FIELDS = ['sms_wait', 'voice_wait', 'wa_old_wait', 'flash_wait',
|
|
974
|
+
'email_otp_wait', 'send_sms_wait', 'silent_auth_wait'];
|
|
975
|
+
|
|
976
|
+
function waitHint(response, method) {
|
|
977
|
+
if (!response) return null;
|
|
978
|
+
|
|
979
|
+
const preferred = Number(response[method + '_wait']);
|
|
980
|
+
if (preferred > 0) return preferred;
|
|
981
|
+
|
|
982
|
+
let max = null;
|
|
983
|
+
for (const key of WAIT_FIELDS) {
|
|
984
|
+
const v = Number(response[key]);
|
|
985
|
+
if (v > 0 && (max === null || v > max)) max = v;
|
|
986
|
+
}
|
|
987
|
+
if (max !== null) return max;
|
|
988
|
+
|
|
989
|
+
const retry = Number(response.retry_after);
|
|
990
|
+
return retry > 0 ? retry : null;
|
|
991
|
+
}
|
|
992
|
+
|
|
950
993
|
function buildClientMetrics(attempt) {
|
|
951
994
|
const json = '{"attempts":' + (attempt || 1)
|
|
952
995
|
+ ',"app_campaign_download_source":"google-play|unknown"'
|
|
@@ -1607,27 +1650,19 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1607
1650
|
store.version = waVersion;
|
|
1608
1651
|
store.device = _device;
|
|
1609
1652
|
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
const extra = [
|
|
1619
|
-
...getRequestVerificationCodeParameters(store, 'email', _regMeta, _device, 1),
|
|
1620
|
-
'email', emailAddr
|
|
1621
|
-
];
|
|
1622
|
-
const result = await sendRequest('/code', store, waVersion, true, extra);
|
|
1623
|
-
const status = result.status;
|
|
1624
|
-
if (status === 'ok' || status === 'sent') return result;
|
|
1625
|
-
const reason = result.reason || status || '';
|
|
1626
|
-
throw new Error(`Registration email error: ${reason} — raw: ${JSON.stringify(result)}`);
|
|
1653
|
+
// The email OTP carries the address alongside the usual parameters. It goes
|
|
1654
|
+
// through the same request path as every other method rather than around it:
|
|
1655
|
+
// it used to have its own branch, which meant a rate-limited email request
|
|
1656
|
+
// reported no wait, a block screen was reported as a raw reason, and none of
|
|
1657
|
+
// the funnel events the other methods emit were sent.
|
|
1658
|
+
const emailAddr = method === 'email' ? (opts.email || '') : '';
|
|
1659
|
+
if (method === 'email' && !emailAddr) {
|
|
1660
|
+
throw new Error('requestSmsCode: email method requires opts.email address');
|
|
1627
1661
|
}
|
|
1628
1662
|
|
|
1629
1663
|
// Auto-fallback: if the primary method gets no_routes, try the alternate once.
|
|
1630
|
-
// sms → wa_old, wa_old → sms, voice → sms
|
|
1664
|
+
// sms → wa_old, wa_old → sms, voice → sms. Never for email: falling back
|
|
1665
|
+
// would send an SMS to somebody who asked for an email.
|
|
1631
1666
|
const fallbackMethod = method === 'wa_old' ? 'sms' : (method === 'sms' ? 'wa_old' : 'sms');
|
|
1632
1667
|
let autoFallbackDone = false;
|
|
1633
1668
|
|
|
@@ -1649,6 +1684,7 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1649
1684
|
while (true) {
|
|
1650
1685
|
// Rebuilt per attempt so client_metrics carries the current attempt count.
|
|
1651
1686
|
const extra = getRequestVerificationCodeParameters(store, m, _regMeta, _device, attemptNum);
|
|
1687
|
+
if (m === 'email') extra.push('email', emailAddr);
|
|
1652
1688
|
|
|
1653
1689
|
// The screen a later event belongs to is named after the method asked
|
|
1654
1690
|
// for here, so record it before the request rather than after.
|
|
@@ -1669,7 +1705,7 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1669
1705
|
// isTooRecent() — throw immediately, no point retrying
|
|
1670
1706
|
if (/too_recent|too_many|too_many_guesses|too_many_all_methods/i.test(reason) ||
|
|
1671
1707
|
/too_recent|too_many|too_many_guesses|too_many_all_methods/i.test(status)) {
|
|
1672
|
-
const waitSec = result
|
|
1708
|
+
const waitSec = waitHint(result, m);
|
|
1673
1709
|
const waitMsg = waitSec ? ` (wait ${Math.ceil(waitSec / 60)} min, ${waitSec}s)` : '';
|
|
1674
1710
|
throw new Error(`code already sent recently — check your phone or wait a few minutes (${reason})${waitMsg}`);
|
|
1675
1711
|
}
|
|
@@ -1700,19 +1736,32 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1700
1736
|
let result = await _tryMethod(method);
|
|
1701
1737
|
|
|
1702
1738
|
// Auto-fallback on no_routes
|
|
1703
|
-
if (result && result._noRoutes && !autoFallbackDone) {
|
|
1739
|
+
if (result && result._noRoutes && !autoFallbackDone && method !== 'email') {
|
|
1704
1740
|
autoFallbackDone = true;
|
|
1705
1741
|
process.stderr.write(`[REG] ${method} returned no_routes — auto-trying ${fallbackMethod}\n`);
|
|
1706
1742
|
result = await _tryMethod(fallbackMethod);
|
|
1707
1743
|
}
|
|
1708
1744
|
|
|
1709
|
-
// Final no_routes — give up
|
|
1745
|
+
// Final no_routes — give up, and say what actually changes the answer.
|
|
1746
|
+
//
|
|
1747
|
+
// no_routes is the server declining to deliver, and what it weighs is the
|
|
1748
|
+
// requester: the reputation of the IP, the history the number carries, and
|
|
1749
|
+
// whether the platform has a route for it. None of that is in the client, so
|
|
1750
|
+
// the advice has to point outside it. The old text suggested a VoIP number,
|
|
1751
|
+
// which is backwards — virtual numbers are among the most refused.
|
|
1710
1752
|
if (result && result._noRoutes) {
|
|
1711
|
-
const tried = autoFallbackDone ? `${method}
|
|
1753
|
+
const tried = autoFallbackDone ? `${method}, then ${fallbackMethod}` : method;
|
|
1712
1754
|
throw new Error(
|
|
1713
|
-
`Registration blocked (no_routes)
|
|
1714
|
-
`
|
|
1715
|
-
` •
|
|
1755
|
+
`Registration blocked (no_routes) — WhatsApp has no route to deliver the ` +
|
|
1756
|
+
`code (tried: ${tried}).\n` +
|
|
1757
|
+
` • Most often the IP: datacenter, VPS and VPN addresses are refused. ` +
|
|
1758
|
+
`Use a residential connection, or SOCKS_PROXY=socks5://…\n` +
|
|
1759
|
+
` • Try another method: --method voice\n` +
|
|
1760
|
+
` • Try the other platform: WA_OS=ios (routes differ per platform)\n` +
|
|
1761
|
+
` • wa_old only works if the number is already active on WhatsApp\n` +
|
|
1762
|
+
` • Real SIM numbers fare better than virtual or VoIP ones\n` +
|
|
1763
|
+
` • Leave the number alone for 24h rather than retrying — every attempt ` +
|
|
1764
|
+
`counts against it`
|
|
1716
1765
|
);
|
|
1717
1766
|
}
|
|
1718
1767
|
|
|
@@ -1824,5 +1873,5 @@ module.exports._token = { computeToken, androidMaterialPath, registrationHeaders
|
|
|
1824
1873
|
// Challenge / two-factor internals, exposed for tests. Not part of the public API.
|
|
1825
1874
|
module.exports._verify = {
|
|
1826
1875
|
hasChallenge, is2FARequired, decodeOrNull, isSuccessful,
|
|
1827
|
-
normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled
|
|
1876
|
+
normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled, waitHint
|
|
1828
1877
|
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Where a session's files live.
|
|
4
|
+
//
|
|
5
|
+
// Everything a number owns — the store, the Signal sessions, the sender keys,
|
|
6
|
+
// the app state, the device cache, the LID maps, the history — used to sit
|
|
7
|
+
// side by side in one directory, named after the number:
|
|
8
|
+
//
|
|
9
|
+
// ~/.waSession/40756469325.json
|
|
10
|
+
// ~/.waSession/40756469325.signal.json
|
|
11
|
+
// ~/.waSession/40756469325.sk.json
|
|
12
|
+
// ... × 11 files × every number
|
|
13
|
+
//
|
|
14
|
+
// Fifty numbers made that five hundred files in one place, with no way to move,
|
|
15
|
+
// back up or delete one account without picking its files out of the pile by
|
|
16
|
+
// prefix. A number now gets a directory of its own:
|
|
17
|
+
//
|
|
18
|
+
// <base>/40756469325/40756469325.json
|
|
19
|
+
// <base>/40756469325/40756469325.signal.json
|
|
20
|
+
// ...
|
|
21
|
+
//
|
|
22
|
+
// The file names inside are unchanged, so every path built as
|
|
23
|
+
// `join(sessionDir, phone + suffix)` keeps working — what changes is which
|
|
24
|
+
// directory `sessionDir` points at. Callers ask sessionDirFor() for it.
|
|
25
|
+
//
|
|
26
|
+
// A directory laid out the old way keeps working exactly as it was. The layout
|
|
27
|
+
// is decided per number, by looking for the file that must exist either way, so
|
|
28
|
+
// an existing installation is never asked to move anything and a number that
|
|
29
|
+
// has been moved is picked up on its own.
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const os = require('os');
|
|
34
|
+
|
|
35
|
+
// The file every session has, in either layout, and the one whose presence
|
|
36
|
+
// decides which layout a number is on.
|
|
37
|
+
const STORE_SUFFIX = '.json';
|
|
38
|
+
const WEB_STORE_SUFFIX = '.web.json';
|
|
39
|
+
|
|
40
|
+
// Every per-number file whalibmob writes. Kept here so that moving a session
|
|
41
|
+
// moves all of it — a list that goes out of date silently leaves a number's
|
|
42
|
+
// Signal sessions behind and looks like a working move until the first message
|
|
43
|
+
// fails to decrypt.
|
|
44
|
+
const SESSION_SUFFIXES = [
|
|
45
|
+
'.json',
|
|
46
|
+
'.signal.json',
|
|
47
|
+
'.sk.json',
|
|
48
|
+
'.tctoken.json',
|
|
49
|
+
'.device-cache.json',
|
|
50
|
+
'.lid-mapping.json',
|
|
51
|
+
'.lid-reverse-mapping.json',
|
|
52
|
+
'.history.json',
|
|
53
|
+
'.messages.json',
|
|
54
|
+
'.appState.json',
|
|
55
|
+
'.appStateKeys.json',
|
|
56
|
+
// The companion (pairing-code) half of the same number.
|
|
57
|
+
'.web.json',
|
|
58
|
+
'.web.signal.json',
|
|
59
|
+
'.web.sk.json',
|
|
60
|
+
'.web.tctoken.json',
|
|
61
|
+
'.web.device-cache.json',
|
|
62
|
+
'.web.lid-mapping.json',
|
|
63
|
+
'.web.lid-reverse-mapping.json',
|
|
64
|
+
'.web.history.json',
|
|
65
|
+
'.web.messages.json',
|
|
66
|
+
'.web.appState.json',
|
|
67
|
+
'.web.appStateKeys.json'
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// Files that belong to the installation rather than to any number, and stay at
|
|
71
|
+
// the top of the base directory.
|
|
72
|
+
const SHARED_FILES = [
|
|
73
|
+
'android-apk-material.json',
|
|
74
|
+
'android-apk-material-business.json'
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
function defaultBaseDir() {
|
|
78
|
+
return process.env.WA_SESSION_DIR || path.join(os.homedir(), '.waSession');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _isDir(p) {
|
|
82
|
+
try { return fs.statSync(p).isDirectory(); } catch (_) { return false; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function _exists(p) {
|
|
86
|
+
try { fs.accessSync(p); return true; } catch (_) { return false; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether this number's files sit loose in the base directory rather than in
|
|
91
|
+
* one of its own.
|
|
92
|
+
*
|
|
93
|
+
* Only true for a number that is already there in the old shape: a number with
|
|
94
|
+
* no files at all is not legacy, it is new, and new numbers get a directory.
|
|
95
|
+
*/
|
|
96
|
+
function isLegacyLayout(baseDir, phone) {
|
|
97
|
+
phone = String(phone).replace(/\D/g, '');
|
|
98
|
+
if (_exists(path.join(baseDir, phone, phone + STORE_SUFFIX)) ||
|
|
99
|
+
_exists(path.join(baseDir, phone, phone + WEB_STORE_SUFFIX))) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return _exists(path.join(baseDir, phone + STORE_SUFFIX)) ||
|
|
103
|
+
_exists(path.join(baseDir, phone + WEB_STORE_SUFFIX));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The directory a number's files belong in.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} baseDir the authentication folder
|
|
110
|
+
* @param {string} phone digits
|
|
111
|
+
* @param {object} [opts] { create } to make the directory if it is missing
|
|
112
|
+
*/
|
|
113
|
+
function sessionDirFor(baseDir, phone, opts) {
|
|
114
|
+
phone = String(phone).replace(/\D/g, '');
|
|
115
|
+
if (isLegacyLayout(baseDir, phone)) return baseDir;
|
|
116
|
+
|
|
117
|
+
const dir = path.join(baseDir, phone);
|
|
118
|
+
if (opts && opts.create) fs.mkdirSync(dir, { recursive: true });
|
|
119
|
+
return dir;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The store file for a number, wherever it lives. */
|
|
123
|
+
function storeFileFor(baseDir, phone) {
|
|
124
|
+
phone = String(phone).replace(/\D/g, '');
|
|
125
|
+
return path.join(sessionDirFor(baseDir, phone), phone + STORE_SUFFIX);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The companion store file for a number, wherever it lives. */
|
|
129
|
+
function webStoreFileFor(baseDir, phone) {
|
|
130
|
+
phone = String(phone).replace(/\D/g, '');
|
|
131
|
+
return path.join(sessionDirFor(baseDir, phone), phone + WEB_STORE_SUFFIX);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Every number the base directory holds, in either layout.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Array<{phone, dir, storeFile, webStoreFile, legacy, hasMobile, hasWeb}>}
|
|
138
|
+
*/
|
|
139
|
+
function listSessions(baseDir) {
|
|
140
|
+
let entries;
|
|
141
|
+
try { entries = fs.readdirSync(baseDir); } catch (_) { return []; }
|
|
142
|
+
|
|
143
|
+
const found = new Map();
|
|
144
|
+
|
|
145
|
+
const note = (phone, dir, legacy) => {
|
|
146
|
+
if (!found.has(phone)) {
|
|
147
|
+
found.set(phone, {
|
|
148
|
+
phone,
|
|
149
|
+
dir,
|
|
150
|
+
storeFile: path.join(dir, phone + STORE_SUFFIX),
|
|
151
|
+
webStoreFile: path.join(dir, phone + WEB_STORE_SUFFIX),
|
|
152
|
+
legacy,
|
|
153
|
+
hasMobile: _exists(path.join(dir, phone + STORE_SUFFIX)),
|
|
154
|
+
hasWeb: _exists(path.join(dir, phone + WEB_STORE_SUFFIX))
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// A directory named after a number, holding that number's store.
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
162
|
+
const dir = path.join(baseDir, entry);
|
|
163
|
+
if (!_isDir(dir)) continue;
|
|
164
|
+
if (_exists(path.join(dir, entry + STORE_SUFFIX)) ||
|
|
165
|
+
_exists(path.join(dir, entry + WEB_STORE_SUFFIX))) {
|
|
166
|
+
note(entry, dir, false);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Loose files from the old layout, for numbers not already found above.
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
const m = /^(\d+)(\.web)?\.json$/.exec(entry);
|
|
173
|
+
if (!m) continue;
|
|
174
|
+
note(m[1], baseDir, true);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return [...found.values()].sort((a, b) => a.phone.localeCompare(b.phone));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Move a number out of the old flat layout into a directory of its own.
|
|
182
|
+
*
|
|
183
|
+
* Every file listed in SESSION_SUFFIXES that exists is moved; anything already
|
|
184
|
+
* present at the destination is left alone and reported rather than
|
|
185
|
+
* overwritten, so a half-finished move can be run again safely.
|
|
186
|
+
*
|
|
187
|
+
* @returns {{phone, from, to, moved: string[], skipped: string[]}}
|
|
188
|
+
*/
|
|
189
|
+
function migrateSession(baseDir, phone) {
|
|
190
|
+
phone = String(phone).replace(/\D/g, '');
|
|
191
|
+
const to = path.join(baseDir, phone);
|
|
192
|
+
|
|
193
|
+
const moved = [], skipped = [];
|
|
194
|
+
fs.mkdirSync(to, { recursive: true });
|
|
195
|
+
|
|
196
|
+
for (const suffix of SESSION_SUFFIXES) {
|
|
197
|
+
const from = path.join(baseDir, phone + suffix);
|
|
198
|
+
if (!_exists(from)) continue;
|
|
199
|
+
const dest = path.join(to, phone + suffix);
|
|
200
|
+
if (_exists(dest)) { skipped.push(phone + suffix); continue; }
|
|
201
|
+
fs.renameSync(from, dest);
|
|
202
|
+
moved.push(phone + suffix);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { phone, from: baseDir, to, moved, skipped };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
defaultBaseDir,
|
|
210
|
+
isLegacyLayout,
|
|
211
|
+
sessionDirFor,
|
|
212
|
+
storeFileFor,
|
|
213
|
+
webStoreFileFor,
|
|
214
|
+
listSessions,
|
|
215
|
+
migrateSession,
|
|
216
|
+
SESSION_SUFFIXES,
|
|
217
|
+
SHARED_FILES
|
|
218
|
+
};
|