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 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)](#cli-app-state)
90
- - [Account Restriction Countdown](#cli-restriction)
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)](#cli-personal-invitations)
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-restriction)
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](#app-state-sync)
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 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
@@ -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 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
 
@@ -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](#app-state-sync) |
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-restriction) |
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
- await refreshSessionVersion('/home/you/.waSession/5568936182750.json')
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 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);
@@ -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
- _whaDbg('[DBG] RETRY_RECV msgId=' + msgId + ' from=' + fromStr + ' — clearing session + resending\n');
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
- if (addr.startsWith(recipientPhone + '.')) {
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
- this._devMgr.clearCache([recipientPhone]);
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.
@@ -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.1",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",