whalibmob 5.13.3 → 5.14.1
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 +111 -15
- package/cli.js +139 -17
- package/index.js +10 -0
- package/lib/Client.js +64 -12
- package/lib/Registration.js +63 -26
- package/lib/SessionPaths.js +218 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,8 +86,8 @@ npm install -g whalibmob
|
|
|
86
86
|
- [Pin / Unpin](#pin--unpin)
|
|
87
87
|
- [Archive / Unarchive](#archive--unarchive)
|
|
88
88
|
- [Star / Unstar a Message](#star--unstar-a-message-cli)
|
|
89
|
-
- [Sync App State (CLI)](#
|
|
90
|
-
- [Account Restriction Countdown](#
|
|
89
|
+
- [Sync App State (CLI)](#sync-app-state)
|
|
90
|
+
- [Account Restriction Countdown](#account-restriction-countdown)
|
|
91
91
|
- [Disappearing Messages](#cli-disappearing-messages)
|
|
92
92
|
- [Default Disappearing Timer](#default-disappearing-timer)
|
|
93
93
|
- [Block / Unblock](#block--unblock)
|
|
@@ -109,7 +109,7 @@ npm install -g whalibmob
|
|
|
109
109
|
- [List Group Participants](#list-group-participants)
|
|
110
110
|
- [Pending Join Requests](#pending-join-requests)
|
|
111
111
|
- [Approve / Reject Join Requests](#approve--reject-join-requests)
|
|
112
|
-
- [Personal Invitations (CLI)](#
|
|
112
|
+
- [Personal Invitations (CLI)](#personal-invitations)
|
|
113
113
|
- [Group Settings](#group-settings)
|
|
114
114
|
- [Community Commands](#community-commands-cli)
|
|
115
115
|
- [Newsletter / Channel Commands](#newsletter--channel-commands)
|
|
@@ -165,7 +165,7 @@ npm install -g whalibmob
|
|
|
165
165
|
- [Persistent Files Written to Disk](#persistent-files-written-to-disk)
|
|
166
166
|
- [Reading the History Store](#reading-the-history-store)
|
|
167
167
|
- [tcToken — Error 463 Defense](#tctoken--error-463-defense)
|
|
168
|
-
- [When 463 Means the Account Is Restricted](#account-
|
|
168
|
+
- [When 463 Means the Account Is Restricted](#when-463-means-the-account-is-restricted)
|
|
169
169
|
- [What Is Automatic vs What You Need to Do](#what-is-automatic-vs-what-you-need-to-do)
|
|
170
170
|
- [Receiving Media](#receiving-media)
|
|
171
171
|
- [Sending Messages](#sending-messages)
|
|
@@ -200,7 +200,7 @@ npm install -g whalibmob
|
|
|
200
200
|
- [Mark a Chat Read / Unread](#mark-a-chat-read--unread)
|
|
201
201
|
- [Pin / Unpin a Chat](#pin--unpin-a-chat)
|
|
202
202
|
- [Star / Unstar a Message](#star--unstar-a-message)
|
|
203
|
-
- [Reading Changes Made Elsewhere](#
|
|
203
|
+
- [Reading Changes Made Elsewhere](#reading-changes-made-elsewhere)
|
|
204
204
|
- [Disappearing Messages](#disappearing-messages)
|
|
205
205
|
- [User Queries](#user-queries)
|
|
206
206
|
- [Check If a Number Has WhatsApp](#check-if-a-number-has-whatsapp)
|
|
@@ -357,7 +357,7 @@ wa> /reg confirm 919634847671 123456
|
|
|
357
357
|
On success you will see:
|
|
358
358
|
|
|
359
359
|
```
|
|
360
|
-
registered session saved to /home/user/.waSession/919634847671.json
|
|
360
|
+
registered session saved to /home/user/.waSession/919634847671/919634847671.json
|
|
361
361
|
now run: /connect 919634847671
|
|
362
362
|
```
|
|
363
363
|
|
|
@@ -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
|
|
@@ -1375,7 +1379,7 @@ wa> /reg code 919634847671 wa_old
|
|
|
1375
1379
|
|
|
1376
1380
|
# confirm the code received
|
|
1377
1381
|
wa> /reg confirm 919634847671 123456
|
|
1378
|
-
registered session saved to /home/user/.waSession/919634847671.json
|
|
1382
|
+
registered session saved to /home/user/.waSession/919634847671/919634847671.json
|
|
1379
1383
|
now run: /connect 919634847671
|
|
1380
1384
|
```
|
|
1381
1385
|
|
|
@@ -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
|
|
|
@@ -2665,11 +2760,11 @@ connect()
|
|
|
2665
2760
|
| `chat_removed` | `{ jid, kind, remote }` | A chat was cleared or deleted on another device |
|
|
2666
2761
|
| `contact_update` | `{ jid, name, firstName, lid, username, removed, remote }` | A contact was renamed or removed elsewhere |
|
|
2667
2762
|
| `push_name_update` | `{ name, remote }` | Your own display name changed on another device |
|
|
2668
|
-
| `app_state_sync` | `{ collections, applied }` | An app-state sync finished; see [Reading Changes Made Elsewhere](#
|
|
2763
|
+
| `app_state_sync` | `{ collections, applied }` | An app-state sync finished; see [Reading Changes Made Elsewhere](#reading-changes-made-elsewhere) |
|
|
2669
2764
|
| `app_state_mutation` | `{ collection, index, action, removed }` | An app-state change this library does not model |
|
|
2670
2765
|
| `app_state_key_missing` | `{ collection, keyId }` | App state cannot be read until your phone shares this key |
|
|
2671
2766
|
| `app_state_keys` | `{ keys }` | Your phone shared app-state sync keys; a sync starts automatically |
|
|
2672
|
-
| `account_restriction` | `{ active, remaining, remainingMs, endsAtDate, enforcementType, reason, source }` | The account was restricted or the restriction was lifted — see [When 463 Means the Account Is Restricted](#account-
|
|
2767
|
+
| `account_restriction` | `{ active, remaining, remainingMs, endsAtDate, enforcementType, reason, source }` | The account was restricted or the restriction was lifted — see [When 463 Means the Account Is Restricted](#when-463-means-the-account-is-restricted) |
|
|
2673
2768
|
| `mex_notification` | `{ opName, data }` | A server push over `w:mex` this library does not model |
|
|
2674
2769
|
|
|
2675
2770
|
`remote: true` on a chat event means the change was made on your phone or another
|
|
@@ -4634,9 +4729,10 @@ writes.
|
|
|
4634
4729
|
From Node, the same thing:
|
|
4635
4730
|
|
|
4636
4731
|
```js
|
|
4637
|
-
const { refreshSessionVersion, currentVersionFor } = require('whalibmob')
|
|
4732
|
+
const { refreshSessionVersion, currentVersionFor, storeFileFor } = require('whalibmob')
|
|
4638
4733
|
|
|
4639
|
-
|
|
4734
|
+
const base = path.join(process.env.HOME, '.waSession')
|
|
4735
|
+
await refreshSessionVersion(storeFileFor(base, '5568936182750'))
|
|
4640
4736
|
await currentVersionFor({ os: 'android' }) // { version, source }
|
|
4641
4737
|
```
|
|
4642
4738
|
|
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)
|
|
@@ -2399,7 +2482,7 @@ function askDebugMode(cmd) {
|
|
|
2399
2482
|
// trace to show, and stopping a maintenance command to ask is what makes
|
|
2400
2483
|
// `wa apk-material --download && wa refresh-version --all` prompt twice.
|
|
2401
2484
|
const OFFLINE = ['version', '--version', '-v', 'help', '--help', '-h',
|
|
2402
|
-
'apk-material', 'refresh-version'];
|
|
2485
|
+
'apk-material', 'refresh-version', 'migrate-sessions'];
|
|
2403
2486
|
if (cmd && OFFLINE.includes(cmd)) return Promise.resolve();
|
|
2404
2487
|
if (!process.stdin.isTTY) return Promise.resolve();
|
|
2405
2488
|
|
|
@@ -2425,7 +2508,7 @@ function askDonation(cmd) {
|
|
|
2425
2508
|
// trace to show, and stopping a maintenance command to ask is what makes
|
|
2426
2509
|
// `wa apk-material --download && wa refresh-version --all` prompt twice.
|
|
2427
2510
|
const OFFLINE = ['version', '--version', '-v', 'help', '--help', '-h',
|
|
2428
|
-
'apk-material', 'refresh-version'];
|
|
2511
|
+
'apk-material', 'refresh-version', 'migrate-sessions'];
|
|
2429
2512
|
if (cmd && OFFLINE.includes(cmd)) return Promise.resolve();
|
|
2430
2513
|
if (!process.stdin.isTTY) return Promise.resolve();
|
|
2431
2514
|
if (process.env.WA_NO_DONATE === '1') return Promise.resolve();
|
|
@@ -2455,11 +2538,14 @@ function askDonation(cmd) {
|
|
|
2455
2538
|
async function main() {
|
|
2456
2539
|
const { cmd, sub, flags, pos } = parseArgs(process.argv);
|
|
2457
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
|
+
|
|
2458
2546
|
await askDebugMode(cmd);
|
|
2459
2547
|
await askDonation(cmd);
|
|
2460
2548
|
|
|
2461
|
-
_sessDir = flags.session || defaultSessionDir();
|
|
2462
|
-
|
|
2463
2549
|
// `--business` is the flag form of WA_BUSINESS, mapped onto the environment
|
|
2464
2550
|
// before anything reads a device profile. The device config is env-driven, so
|
|
2465
2551
|
// this is the whole of it: the profile, the token material, the version
|
|
@@ -2483,6 +2569,42 @@ async function main() {
|
|
|
2483
2569
|
return;
|
|
2484
2570
|
}
|
|
2485
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
|
+
|
|
2486
2608
|
// Bring a session's stored version up to date.
|
|
2487
2609
|
//
|
|
2488
2610
|
// A session announces the version it registered with, forever — nothing else
|
|
@@ -2502,13 +2624,13 @@ async function main() {
|
|
|
2502
2624
|
let files;
|
|
2503
2625
|
if (flags.all) {
|
|
2504
2626
|
try {
|
|
2505
|
-
files =
|
|
2506
|
-
.filter(
|
|
2507
|
-
.map(
|
|
2627
|
+
files = listSessions(_sessDir)
|
|
2628
|
+
.filter(x => x.hasMobile)
|
|
2629
|
+
.map(x => x.storeFile);
|
|
2508
2630
|
} catch (_) { files = []; }
|
|
2509
2631
|
if (!files.length) { fail('no sessions in ' + _sessDir); process.exit(1); }
|
|
2510
2632
|
} else {
|
|
2511
|
-
files = [
|
|
2633
|
+
files = [storeFileFor(_sessDir, one)];
|
|
2512
2634
|
}
|
|
2513
2635
|
|
|
2514
2636
|
if (process.env.WA_VERSION) {
|
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);
|
|
@@ -2406,23 +2432,49 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2406
2432
|
: fromStr;
|
|
2407
2433
|
const recipientPhone = targetJid.split('@')[0].split(':')[0].split('.')[0];
|
|
2408
2434
|
|
|
2409
|
-
|
|
2435
|
+
// The retry names the device under one address family, but a contact holds
|
|
2436
|
+
// a session under each: a Signal address is (user, device) with the server
|
|
2437
|
+
// stripped, and the LID user and the phone user are different strings, so
|
|
2438
|
+
// one session is not reachable under the other's name. Purging only the
|
|
2439
|
+
// family the receipt happened to arrive in left the other one in place —
|
|
2440
|
+
// the resend picked it straight back up and produced the same ciphertext
|
|
2441
|
+
// the device had just said it could not read, so the message stayed
|
|
2442
|
+
// undecryptable until something else rebuilt the session.
|
|
2443
|
+
//
|
|
2444
|
+
// This is what made a reply sent after a companion-device exchange stick on
|
|
2445
|
+
// "Waiting for this message" on the primary: the receipt came in one family,
|
|
2446
|
+
// the stale session sat in the other, and every resend re-used it.
|
|
2447
|
+
const recipientUsers = new Set([recipientPhone]);
|
|
2448
|
+
const mappedPn = this._lidToPn && this._lidToPn.get(recipientPhone);
|
|
2449
|
+
const mappedLid = this._pnToLid && this._pnToLid.get(recipientPhone);
|
|
2450
|
+
if (mappedPn) recipientUsers.add(mappedPn);
|
|
2451
|
+
if (mappedLid) recipientUsers.add(mappedLid);
|
|
2452
|
+
|
|
2453
|
+
_whaDbg('[DBG] RETRY_RECV msgId=' + msgId + ' from=' + fromStr +
|
|
2454
|
+
' users=[' + [...recipientUsers].join(',') + '] — clearing session + resending\n');
|
|
2410
2455
|
|
|
2411
2456
|
// Delete all Signal sessions for the recipient's devices so next send creates fresh pkmsg
|
|
2412
2457
|
const sigStore = this._signal && this._signal.store;
|
|
2413
2458
|
if (sigStore && sigStore._sessions) {
|
|
2414
2459
|
const sessions = sigStore._sessions;
|
|
2415
2460
|
Object.keys(sessions).forEach(addr => {
|
|
2416
|
-
|
|
2461
|
+
// Match on the user segment rather than a bare prefix: "4071.0" must
|
|
2462
|
+
// not be cleared by a retry for "407".
|
|
2463
|
+
const user = addr.slice(0, addr.lastIndexOf('.'));
|
|
2464
|
+
if (recipientUsers.has(user)) {
|
|
2417
2465
|
_whaDbg('[DBG] RETRY deleting session for ' + addr);
|
|
2418
2466
|
delete sessions[addr];
|
|
2419
2467
|
}
|
|
2420
2468
|
});
|
|
2421
2469
|
}
|
|
2422
2470
|
|
|
2423
|
-
// Clear device manager cache so we re-fetch devices on next send
|
|
2471
|
+
// Clear device manager cache so we re-fetch devices on next send. LID device
|
|
2472
|
+
// lists are filed under a "lid:" key, so clearing the bare user only ever
|
|
2473
|
+
// dropped the phone-side list.
|
|
2424
2474
|
if (this._devMgr) {
|
|
2425
|
-
|
|
2475
|
+
const cacheKeys = [];
|
|
2476
|
+
for (const u of recipientUsers) cacheKeys.push(u, 'lid:' + u);
|
|
2477
|
+
this._devMgr.clearCache(cacheKeys);
|
|
2426
2478
|
}
|
|
2427
2479
|
|
|
2428
2480
|
// Re-send the message — will create fresh pre-key (pkmsg) sessions.
|
package/lib/Registration.js
CHANGED
|
@@ -959,6 +959,37 @@ function getAccessSessionId(store) {
|
|
|
959
959
|
// Cobalt's "000" placeholder), and advertising_id / backup_token come from the
|
|
960
960
|
// store.
|
|
961
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
|
+
|
|
962
993
|
function buildClientMetrics(attempt) {
|
|
963
994
|
const json = '{"attempts":' + (attempt || 1)
|
|
964
995
|
+ ',"app_campaign_download_source":"google-play|unknown"'
|
|
@@ -1619,27 +1650,19 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1619
1650
|
store.version = waVersion;
|
|
1620
1651
|
store.device = _device;
|
|
1621
1652
|
|
|
1622
|
-
//
|
|
1623
|
-
//
|
|
1624
|
-
//
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
const extra = [
|
|
1631
|
-
...getRequestVerificationCodeParameters(store, 'email', _regMeta, _device, 1),
|
|
1632
|
-
'email', emailAddr
|
|
1633
|
-
];
|
|
1634
|
-
const result = await sendRequest('/code', store, waVersion, true, extra);
|
|
1635
|
-
const status = result.status;
|
|
1636
|
-
if (status === 'ok' || status === 'sent') return result;
|
|
1637
|
-
const reason = result.reason || status || '';
|
|
1638
|
-
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');
|
|
1639
1661
|
}
|
|
1640
1662
|
|
|
1641
1663
|
// Auto-fallback: if the primary method gets no_routes, try the alternate once.
|
|
1642
|
-
// 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.
|
|
1643
1666
|
const fallbackMethod = method === 'wa_old' ? 'sms' : (method === 'sms' ? 'wa_old' : 'sms');
|
|
1644
1667
|
let autoFallbackDone = false;
|
|
1645
1668
|
|
|
@@ -1661,6 +1684,7 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1661
1684
|
while (true) {
|
|
1662
1685
|
// Rebuilt per attempt so client_metrics carries the current attempt count.
|
|
1663
1686
|
const extra = getRequestVerificationCodeParameters(store, m, _regMeta, _device, attemptNum);
|
|
1687
|
+
if (m === 'email') extra.push('email', emailAddr);
|
|
1664
1688
|
|
|
1665
1689
|
// The screen a later event belongs to is named after the method asked
|
|
1666
1690
|
// for here, so record it before the request rather than after.
|
|
@@ -1681,7 +1705,7 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1681
1705
|
// isTooRecent() — throw immediately, no point retrying
|
|
1682
1706
|
if (/too_recent|too_many|too_many_guesses|too_many_all_methods/i.test(reason) ||
|
|
1683
1707
|
/too_recent|too_many|too_many_guesses|too_many_all_methods/i.test(status)) {
|
|
1684
|
-
const waitSec = result
|
|
1708
|
+
const waitSec = waitHint(result, m);
|
|
1685
1709
|
const waitMsg = waitSec ? ` (wait ${Math.ceil(waitSec / 60)} min, ${waitSec}s)` : '';
|
|
1686
1710
|
throw new Error(`code already sent recently — check your phone or wait a few minutes (${reason})${waitMsg}`);
|
|
1687
1711
|
}
|
|
@@ -1712,19 +1736,32 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1712
1736
|
let result = await _tryMethod(method);
|
|
1713
1737
|
|
|
1714
1738
|
// Auto-fallback on no_routes
|
|
1715
|
-
if (result && result._noRoutes && !autoFallbackDone) {
|
|
1739
|
+
if (result && result._noRoutes && !autoFallbackDone && method !== 'email') {
|
|
1716
1740
|
autoFallbackDone = true;
|
|
1717
1741
|
process.stderr.write(`[REG] ${method} returned no_routes — auto-trying ${fallbackMethod}\n`);
|
|
1718
1742
|
result = await _tryMethod(fallbackMethod);
|
|
1719
1743
|
}
|
|
1720
1744
|
|
|
1721
|
-
// 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.
|
|
1722
1752
|
if (result && result._noRoutes) {
|
|
1723
|
-
const tried = autoFallbackDone ? `${method}
|
|
1753
|
+
const tried = autoFallbackDone ? `${method}, then ${fallbackMethod}` : method;
|
|
1724
1754
|
throw new Error(
|
|
1725
|
-
`Registration blocked (no_routes)
|
|
1726
|
-
`
|
|
1727
|
-
` •
|
|
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`
|
|
1728
1765
|
);
|
|
1729
1766
|
}
|
|
1730
1767
|
|
|
@@ -1836,5 +1873,5 @@ module.exports._token = { computeToken, androidMaterialPath, registrationHeaders
|
|
|
1836
1873
|
// Challenge / two-factor internals, exposed for tests. Not part of the public API.
|
|
1837
1874
|
module.exports._verify = {
|
|
1838
1875
|
hasChallenge, is2FARequired, decodeOrNull, isSuccessful,
|
|
1839
|
-
normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled
|
|
1876
|
+
normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled, waitHint
|
|
1840
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
|
+
};
|