whalibmob 5.13.3 → 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 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 custom session directory with `--session`:
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 automatically persisted to disk as JSON files under the `sessionDir` you provide. The file is named `<phone>.json`. On the next `client.init()` call the session is restored and no re-registration is needed.
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, '.waSession')
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
- > Each phone number uses its own session file. The library handles Signal Protocol key persistence automatically.
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
 
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 path.join(os.homedir(), '.waSession');
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(path.join(_sessDir, phone + '.json'));
902
+ return fs.existsSync(storeFileFor(_sessDir, phone));
821
903
  }
822
904
 
823
905
  function hasWebSession(phone) {
824
- const f = path.join(_sessDir, phone + '.web.json');
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
- if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
2080
- const sessFile = path.join(_sessDir, `${ph}.json`);
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 = path.join(_sessDir, `${ph}.json`);
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
- if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
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 = path.join(_sessDir, `${savedPhone}.json`);
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> session directory (default: ~/.waSession)
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 = fs.readdirSync(_sessDir)
2506
- .filter(f => /^\d+\.json$/.test(f))
2507
- .map(f => path.join(_sessDir, f));
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 = [path.join(_sessDir, one + '.json')];
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
- this._sessionDir = opts.sessionDir || process.env.HOME + '/.waSession';
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
- const SUFFIXES = ['.json', '.signal.json', '.sk.json', '.tctoken.json',
1099
- '.device-cache.json', '.lid-mapping.json',
1100
- '.lid-reverse-mapping.json', '.history.json', '.messages.json',
1101
- '.appState.json', '.appStateKeys.json'];
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 SUFFIXES) {
1104
- const from = path.join(this._sessionDir, current + suffix);
1105
- const to = path.join(this._sessionDir, canonical + suffix);
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);
@@ -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
- // Email method: request code via email instead of SMS/voice.
1623
- // Sends method=email + email=<address> in the /code request.
1624
- // No auto-fallback for email — it either works or fails.
1625
- if (method === 'email') {
1626
- const emailAddr = opts.email || '';
1627
- if (!emailAddr) throw new Error('requestSmsCode: email method requires opts.email address');
1628
- const { cc: _regCc } = parsePhone(store.phoneNumber);
1629
- const _regMeta = getCountryMeta(_regCc);
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.sms_wait || result.retry_after || null;
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 with useful message
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} and ${fallbackMethod}` : method;
1753
+ const tried = autoFallbackDone ? `${method}, then ${fallbackMethod}` : method;
1724
1754
  throw new Error(
1725
- `Registration blocked (no_routes) for both methods (${tried}).\n` +
1726
- ` • If the number IS on WhatsApp → re-install the app or wait 24h\n` +
1727
- ` • If the number is NEW → try a different carrier or use a VoIP number`
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.13.3",
3
+ "version": "5.14.0",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",