levix-bot 3.3.0-rc.2 → 4.0.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.
Files changed (118) hide show
  1. package/README.md +13 -9
  2. package/app.cjs +49 -46
  3. package/package.json +9 -1
  4. package/public/dashboard/assets/index-BBBXWzM0.js +391 -0
  5. package/public/dashboard/assets/index-pYaivHON.css +1 -0
  6. package/public/dashboard/index.html +19 -0
  7. package/public/dashboard.css +391 -66
  8. package/public/dashboard.js +593 -42
  9. package/scheduler.cjs +9 -14
  10. package/src/auth/use-database-auth-state.js +2 -4
  11. package/src/bootstrap/core.js +2 -5
  12. package/src/bootstrap/panel.js +10 -5
  13. package/src/cli.js +3 -5
  14. package/src/commands/autoschedule.cjs +1 -4
  15. package/src/commands/blacklist.cjs +22 -20
  16. package/src/commands/block.cjs +2 -4
  17. package/src/commands/calc.cjs +9 -16
  18. package/src/commands/digit.cjs +97 -0
  19. package/src/commands/gemini.cjs +147 -161
  20. package/src/commands/group/add.cjs +7 -16
  21. package/src/commands/group/antiSpam.cjs +5 -11
  22. package/src/commands/group/antilink.cjs +34 -28
  23. package/src/commands/group/approveall.cjs +3 -11
  24. package/src/commands/group/clearwarns.cjs +2 -5
  25. package/src/commands/group/demote.cjs +3 -8
  26. package/src/commands/group/kick.cjs +12 -15
  27. package/src/commands/group/media.cjs +17 -24
  28. package/src/commands/group/members.cjs +1 -2
  29. package/src/commands/group/promote.cjs +4 -12
  30. package/src/commands/group/removeall.cjs +2 -5
  31. package/src/commands/group/setpp.cjs +1 -1
  32. package/src/commands/group/setrules.cjs +2 -8
  33. package/src/commands/group/setwarn.cjs +4 -2
  34. package/src/commands/group/tagadmins.cjs +2 -5
  35. package/src/commands/group/warn.cjs +26 -24
  36. package/src/commands/group/warns.cjs +1 -2
  37. package/src/commands/group/welcome.cjs +13 -17
  38. package/src/commands/group.cjs +7 -10
  39. package/src/commands/help.cjs +6 -12
  40. package/src/commands/lang.cjs +104 -0
  41. package/src/commands/listschedules.cjs +2 -6
  42. package/src/commands/loop.cjs +6 -13
  43. package/src/commands/memory.cjs +19 -32
  44. package/src/commands/mod.cjs +97 -35
  45. package/src/commands/perm.cjs +18 -56
  46. package/src/commands/ping.cjs +1 -5
  47. package/src/commands/poll.cjs +1 -1
  48. package/src/commands/rand.cjs +2 -5
  49. package/src/commands/schedule.cjs +4 -8
  50. package/src/commands/score.cjs +8 -2
  51. package/src/commands/setprefix.cjs +2 -2
  52. package/src/commands/shortlink.cjs +7 -15
  53. package/src/commands/shutdown.cjs +1 -1
  54. package/src/commands/sticker.cjs +222 -0
  55. package/src/commands/stt.cjs +136 -68
  56. package/src/commands/todo.cjs +9 -15
  57. package/src/commands/tts.cjs +10 -16
  58. package/src/commands/unblock.cjs +2 -6
  59. package/src/commands/weather.cjs +5 -12
  60. package/src/config/ai-persona.md +5 -3
  61. package/src/config/available-models.json +1156 -0
  62. package/src/config/baileys.config.js +2 -1
  63. package/src/config/brand.esm.js +1 -10
  64. package/src/config/constants.js +1 -1
  65. package/src/config/defaults.cjs +66 -66
  66. package/src/config/lock.cjs +2 -2
  67. package/src/config/modelCapabilities.cjs +172 -0
  68. package/src/config/runtime-config.cjs +8 -13
  69. package/src/config/secrets.cjs +18 -10
  70. package/src/config/settings.cjs +94 -10
  71. package/src/config/vault.cjs +107 -0
  72. package/src/core/connection.js +13 -12
  73. package/src/core/events.js +41 -23
  74. package/src/core/proxy.js +7 -7
  75. package/src/core/session.js +91 -48
  76. package/src/core/socket.js +12 -15
  77. package/src/db/db.cjs +14 -6
  78. package/src/db/store.cjs +221 -87
  79. package/src/domain/caddy.js +1 -1
  80. package/src/domain/command.js +40 -19
  81. package/src/domain/detect.js +1 -1
  82. package/src/domain/system.js +2 -5
  83. package/src/handlers/command.handler.js +22 -43
  84. package/src/handlers/group.handler.js +13 -13
  85. package/src/handlers/message.handler.js +11 -21
  86. package/src/index.js +8 -4
  87. package/src/middleware/antispam.middleware.js +46 -23
  88. package/src/middleware/blacklist.middleware.js +14 -9
  89. package/src/middleware/forward-tracking.middleware.js +3 -2
  90. package/src/middleware/permissions.middleware.js +7 -10
  91. package/src/panel/login-throttle.cjs +43 -0
  92. package/src/panel/session-auth.cjs +17 -0
  93. package/src/routes/dashboard.api.esm.js +394 -72
  94. package/src/services/aiAgent.cjs +136 -49
  95. package/src/services/aiProviders.cjs +193 -47
  96. package/src/services/aiRouter.cjs +137 -0
  97. package/src/services/aiTools.cjs +305 -128
  98. package/src/services/llmDiscovery.cjs +481 -0
  99. package/src/services/modelRegistry.cjs +412 -0
  100. package/src/utils/datetime.cjs +2 -2
  101. package/src/utils/lid-helper.esm.js +7 -7
  102. package/src/utils/logger.cjs +1 -1
  103. package/src/utils/markdownParser.cjs +159 -0
  104. package/src/utils/memory.cjs +43 -13
  105. package/src/utils/messageContent.cjs +40 -0
  106. package/src/utils/normalizeJid.esm.js +7 -7
  107. package/src/utils/numerals.cjs +75 -0
  108. package/src/utils/permissions.esm.js +10 -15
  109. package/src/utils/providerUrl.cjs +281 -0
  110. package/src/utils/recurrence.cjs +7 -11
  111. package/src/utils/sendBotMessage.esm.js +4 -14
  112. package/src/utils/statusMessage.cjs +5 -13
  113. package/src/utils/storage.cjs +10 -0
  114. package/src/utils/storage.esm.js +11 -0
  115. package/src/utils/textDecode.cjs +4 -9
  116. package/src/utils/thumbnail.cjs +8 -14
  117. package/src/utils/typing.esm.js +12 -12
  118. package/views/dashboard.ejs +134 -106
@@ -0,0 +1,107 @@
1
+ // Data-at-rest encryption for high-value secrets.
2
+ //
3
+ // The WhatsApp session (baileys_auth) and operator API keys must not sit in
4
+ // levix.db as plaintext. A 32-byte AES-256-GCM key is generated on first use
5
+ // and stored as `levix.key` in the data directory (mode 0600), never in the
6
+ // database — a leaked .db backup is not enough to recover the session or keys.
7
+ //
8
+ // On Android that directory is already the app-private files dir
9
+ // (`/data/data/net.leviro.levix/files/data`). On a desktop install it is the
10
+ // same folder as the database. Root on the device can still read both files;
11
+ // this stops a copied database, a partial backup, or a casual file dump.
12
+ //
13
+ // Format: enc:v1:<iv_hex>.<tag_hex>.<ciphertext_hex>
14
+ // Unsealed legacy values pass through `open()` so upgrades re-encrypt on write.
15
+
16
+ const crypto = require("node:crypto");
17
+ const fs = require("node:fs");
18
+
19
+ const logger = require("../utils/logger.cjs");
20
+
21
+ const PREFIX = "enc:v1:";
22
+ const KEY_BYTES = 32;
23
+ const IV_BYTES = 12;
24
+ const KEY_FILE = "levix.key";
25
+
26
+ let cachedKey = null;
27
+
28
+ function keyPath() {
29
+ const { dataPath, ensureDataDir } = require("./paths.cjs");
30
+ ensureDataDir();
31
+ return dataPath(KEY_FILE);
32
+ }
33
+
34
+ function loadMasterKey() {
35
+ if (cachedKey) return cachedKey;
36
+ const file = keyPath();
37
+ if (fs.existsSync(file)) {
38
+ const raw = fs.readFileSync(file);
39
+ if (raw.length === KEY_BYTES) {
40
+ cachedKey = raw;
41
+ return cachedKey;
42
+ }
43
+ const hex = raw.toString("utf8").trim();
44
+ if (/^[0-9a-f]{64}$/i.test(hex)) {
45
+ cachedKey = Buffer.from(hex, "hex");
46
+ return cachedKey;
47
+ }
48
+ throw new Error("levix.key is not a valid 32-byte encryption key");
49
+ }
50
+
51
+ const key = crypto.randomBytes(KEY_BYTES);
52
+ fs.writeFileSync(file, key, { mode: 0o600 });
53
+ try {
54
+ fs.chmodSync(file, 0o600);
55
+ } catch {
56
+ // Windows has no POSIX modes; the data directory still holds the file.
57
+ }
58
+ logger.info("[vault] Generated a new data-at-rest encryption key");
59
+ cachedKey = key;
60
+ return cachedKey;
61
+ }
62
+
63
+ function isSealed(value) {
64
+ return typeof value === "string" && value.startsWith(PREFIX);
65
+ }
66
+
67
+ function seal(plaintext) {
68
+ const text = plaintext == null ? "" : String(plaintext);
69
+ const key = loadMasterKey();
70
+ const iv = crypto.randomBytes(IV_BYTES);
71
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
72
+ const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
73
+ const tag = cipher.getAuthTag();
74
+ return `${PREFIX}${iv.toString("hex")}.${tag.toString("hex")}.${encrypted.toString("hex")}`;
75
+ }
76
+
77
+ function open(value) {
78
+ if (value == null) return value;
79
+ if (!isSealed(value)) return value;
80
+ const body = value.slice(PREFIX.length);
81
+ const parts = body.split(".");
82
+ if (parts.length !== 3) throw new Error("Malformed sealed secret");
83
+ const [ivHex, tagHex, dataHex] = parts;
84
+ const key = loadMasterKey();
85
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
86
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
87
+ return Buffer.concat([
88
+ decipher.update(Buffer.from(dataHex, "hex")),
89
+ decipher.final(),
90
+ ]).toString("utf8");
91
+ }
92
+
93
+ /** Reset the in-process key cache. Tests that switch data dirs need this. */
94
+ function resetVaultCache() {
95
+ cachedKey = null;
96
+ }
97
+
98
+ module.exports = {
99
+ PREFIX,
100
+ KEY_FILE,
101
+ isSealed,
102
+ seal,
103
+ open,
104
+ loadMasterKey,
105
+ keyPath,
106
+ resetVaultCache,
107
+ };
@@ -11,13 +11,14 @@
11
11
 
12
12
  import { DisconnectReason } from "@whiskeysockets/baileys";
13
13
  import { createRequire } from "module";
14
- import { groupMetadataCache } from "./socket.js";
14
+ import { bootstrapAdmins, bootstrapOwners } from "../utils/permissions.esm.js";
15
15
  import {
16
- saveUserMetadata,
17
- getAllOwners,
18
16
  getAllBotAdmins,
17
+ getAllOwners,
18
+ saveUserMetadata,
19
+ upsertGroupDirectory,
19
20
  } from "../utils/storage.esm.js";
20
- import { bootstrapOwners, bootstrapAdmins } from "../utils/permissions.esm.js";
21
+ import { groupMetadataCache } from "./socket.js";
21
22
 
22
23
  const require = createRequire(import.meta.url);
23
24
  const logger = require("../utils/logger.cjs");
@@ -123,9 +124,7 @@ export async function handleConnectionOpen(sock, initializeScheduledJobs) {
123
124
  const botPn = sock?.user?.id
124
125
  ? `${sock.user.id.split(":")[0].split("@")[0]}@s.whatsapp.net`
125
126
  : null;
126
- const botLid = sock?.user?.lid
127
- ? `${sock.user.lid.split(":")[0].split("@")[0]}@lid`
128
- : null;
127
+ const botLid = sock?.user?.lid ? `${sock.user.lid.split(":")[0].split("@")[0]}@lid` : null;
129
128
 
130
129
  // Both rosters are seeded from the database so a permission check never has
131
130
  // to hit it on the hot path.
@@ -144,7 +143,7 @@ export async function handleConnectionOpen(sock, initializeScheduledJobs) {
144
143
  await primePermissions();
145
144
  logger.info(
146
145
  `[Permissions] Owner roster: ${ownersRoster.length} entries; ` +
147
- `admin roster: ${adminsRoster.length} entries`
146
+ `admin roster: ${adminsRoster.length} entries`,
148
147
  );
149
148
 
150
149
  // Called on every open, including a reconnect: scheduleNewJob() stops the
@@ -163,9 +162,7 @@ async function saveBotOwnerMetadata(sock) {
163
162
 
164
163
  const phoneNumber = sock?.user?.id?.split(":")[0]?.split("@")[0];
165
164
  const userJid = phoneNumber ? `${phoneNumber}@s.whatsapp.net` : null;
166
- const userLid = sock?.user?.lid
167
- ? `${sock.user.lid.split(":")[0].split("@")[0]}@lid`
168
- : null;
165
+ const userLid = sock?.user?.lid ? `${sock.user.lid.split(":")[0].split("@")[0]}@lid` : null;
169
166
  const displayName = sock?.user?.name || sock?.user?.verifiedName || null;
170
167
 
171
168
  if (userJid) {
@@ -177,7 +174,7 @@ async function saveBotOwnerMetadata(sock) {
177
174
  displayName,
178
175
  });
179
176
  logger.info(
180
- `[User Metadata] Saved bot owner: ${phoneNumber} (JID: ${userJid}, LID: ${userLid})`
177
+ `[User Metadata] Saved bot owner: ${phoneNumber} (JID: ${userJid}, LID: ${userLid})`,
181
178
  );
182
179
  }
183
180
  } catch (err) {
@@ -195,6 +192,10 @@ async function cacheAllGroups(sock) {
195
192
 
196
193
  for (const jid in groups) {
197
194
  groupMetadataCache.set(jid, groups[jid]);
195
+ upsertGroupDirectory(jid, {
196
+ subject: groups[jid]?.subject || null,
197
+ participantCount: groups[jid]?.participants?.length ?? null,
198
+ });
198
199
  cachedCount++;
199
200
  }
200
201
 
@@ -1,11 +1,14 @@
1
- import { createRequire } from 'module';
2
- import { handleIncomingMessage } from '../handlers/message.handler.js';
3
- import { handleGroupParticipantsUpdate, handleGroupJoinRequests } from '../handlers/group.handler.js';
4
- import { groupMetadataCache } from './socket.js';
5
- import { storeLidPnMappings } from '../utils/storage.esm.js';
1
+ import { createRequire } from "module";
2
+ import {
3
+ handleGroupJoinRequests,
4
+ handleGroupParticipantsUpdate,
5
+ } from "../handlers/group.handler.js";
6
+ import { handleIncomingMessage } from "../handlers/message.handler.js";
7
+ import { storeLidPnMappings, upsertGroupDirectory } from "../utils/storage.esm.js";
8
+ import { groupMetadataCache } from "./socket.js";
6
9
 
7
10
  const require = createRequire(import.meta.url);
8
- const logger = require('../utils/logger.cjs');
11
+ const logger = require("../utils/logger.cjs");
9
12
 
10
13
  /**
11
14
  * Wire one socket up.
@@ -22,7 +25,7 @@ const logger = require('../utils/logger.cjs');
22
25
  */
23
26
  export function setupEventListeners(
24
27
  sock,
25
- { saveCreds, onConnectionUpdate, onHandshakeRejected } = {}
28
+ { saveCreds, onConnectionUpdate, onHandshakeRejected } = {},
26
29
  ) {
27
30
  // Every listener below goes through this. A bare `async` listener hands its
28
31
  // promise to Baileys' emitter, which drops it — so one rejection anywhere in
@@ -36,8 +39,8 @@ export function setupEventListeners(
36
39
  };
37
40
 
38
41
  sock.ev.on(
39
- 'connection.update',
40
- contained('connection.update', (update) => onConnectionUpdate?.(update))
42
+ "connection.update",
43
+ contained("connection.update", (update) => onConnectionUpdate?.(update)),
41
44
  );
42
45
 
43
46
  // A handshake the server answered with an HTTP status instead of an upgrade.
@@ -50,17 +53,17 @@ export function setupEventListeners(
50
53
  // therefore produced no error, no close and no connection.update at all: the
51
54
  // session simply sat in `starting` until somebody noticed.
52
55
  sock.ws?.on?.(
53
- 'unexpected-response',
54
- contained('unexpected-response', (_request, response) => {
56
+ "unexpected-response",
57
+ contained("unexpected-response", (_request, response) => {
55
58
  onHandshakeRejected?.(response);
56
- })
59
+ }),
57
60
  );
58
61
 
59
62
  // Save credentials
60
- if (saveCreds) sock.ev.on('creds.update', saveCreds);
63
+ if (saveCreds) sock.ev.on("creds.update", saveCreds);
61
64
 
62
65
  // V7: LID mapping updates
63
- sock.ev.on('lid-mapping.update', (updates) => {
66
+ sock.ev.on("lid-mapping.update", (updates) => {
64
67
  logger.info(`[LID] Received ${Object.keys(updates).length} LID mapping updates`);
65
68
  const mappings = [];
66
69
  for (const [lid, pn] of Object.entries(updates)) {
@@ -74,30 +77,45 @@ export function setupEventListeners(
74
77
  });
75
78
 
76
79
  // Group metadata updates
77
- sock.ev.on('groups.upsert', (updates) => {
80
+ sock.ev.on("groups.upsert", (updates) => {
78
81
  for (const group of updates) {
79
82
  logger.info(`[Cache] Caching metadata for group: ${group.id}`);
80
83
  groupMetadataCache.set(group.id, group);
84
+ upsertGroupDirectory(group.id, {
85
+ subject: group.subject || null,
86
+ participantCount: group.participants?.length ?? null,
87
+ });
88
+ }
89
+ });
90
+
91
+ sock.ev.on("groups.update", (updates) => {
92
+ for (const group of updates || []) {
93
+ if (!group?.id) continue;
94
+ const current = groupMetadataCache.get(group.id) || {};
95
+ const next = { ...current, ...group };
96
+ groupMetadataCache.set(group.id, next);
97
+ upsertGroupDirectory(group.id, {
98
+ subject: next.subject || null,
99
+ participantCount: next.participants?.length ?? null,
100
+ });
81
101
  }
82
102
  });
83
103
 
84
104
  // Incoming messages
85
105
  sock.ev.on(
86
- 'messages.upsert',
87
- contained('messages.upsert', (m) => handleIncomingMessage(sock, m))
106
+ "messages.upsert",
107
+ contained("messages.upsert", (m) => handleIncomingMessage(sock, m)),
88
108
  );
89
109
 
90
110
  // Group participant updates
91
111
  sock.ev.on(
92
- 'group-participants.update',
93
- contained('group-participants.update', (update) =>
94
- handleGroupParticipantsUpdate(sock, update)
95
- )
112
+ "group-participants.update",
113
+ contained("group-participants.update", (update) => handleGroupParticipantsUpdate(sock, update)),
96
114
  );
97
115
 
98
116
  // Group join requests
99
117
  sock.ev.on(
100
- 'group-requests.update',
101
- contained('group-requests.update', (events) => handleGroupJoinRequests(sock, events))
118
+ "group-requests.update",
119
+ contained("group-requests.update", (events) => handleGroupJoinRequests(sock, events)),
102
120
  );
103
121
  }
package/src/core/proxy.js CHANGED
@@ -37,12 +37,12 @@
37
37
  // string, its result never reaches a log or an HTTP response, and everything
38
38
  // human-facing goes through `redactProxy()`.
39
39
 
40
- import { createRequire } from "module";
40
+ import tls from "node:tls";
41
41
  import { HttpsProxyAgent } from "https-proxy-agent";
42
+ import { createRequire } from "module";
43
+ import { SocksClient } from "socks";
42
44
  import { SocksProxyAgent } from "socks-proxy-agent";
43
45
  import { Agent as UndiciAgent, ProxyAgent as UndiciProxyAgent } from "undici";
44
- import { SocksClient } from "socks";
45
- import tls from "node:tls";
46
46
 
47
47
  const require = createRequire(import.meta.url);
48
48
  const settings = require("../config/settings.cjs");
@@ -74,7 +74,9 @@ export function readProxyConfig() {
74
74
  */
75
75
  export function normalizeProxyConfig(raw = {}) {
76
76
  const enabled = raw.enabled === true || raw.enabled === "true" || raw.enabled === 1;
77
- const protocol = String(raw.protocol ?? "http").trim().toLowerCase();
77
+ const protocol = String(raw.protocol ?? "http")
78
+ .trim()
79
+ .toLowerCase();
78
80
  const host = String(raw.host ?? "").trim();
79
81
  const port = Number(raw.port ?? 0);
80
82
  // Usernames and passwords are taken exactly as given except for surrounding
@@ -179,9 +181,7 @@ function socksDispatcher(config) {
179
181
  host: config.host,
180
182
  port: config.port,
181
183
  type: 5,
182
- ...(config.username
183
- ? { userId: config.username, password: config.password }
184
- : {}),
184
+ ...(config.username ? { userId: config.username, password: config.password } : {}),
185
185
  };
186
186
 
187
187
  return new UndiciAgent({
@@ -46,14 +46,21 @@
46
46
  // Regenerating one forever is how the old code burned a socket a minute on
47
47
  // installs nobody was pairing.
48
48
 
49
- import { createRequire } from "module";
50
49
  import { DisconnectReason } from "@whiskeysockets/baileys";
51
-
52
- import { createWhatsAppSocket } from "./socket.js";
53
- import { setupEventListeners } from "./events.js";
54
- import { handleConnectionOpen, classifyDisconnect } from "./connection.js";
50
+ import { createRequire } from "module";
55
51
  import { RETRY_SCHEDULE_MS } from "../config/constants.js";
56
- import { clearAuthState, deleteQrCode, saveQrCode } from "../utils/storage.esm.js";
52
+ import {
53
+ clearAuthState,
54
+ clearWhatsAppDirectory,
55
+ deleteAllChatHistories,
56
+ deleteQrCode,
57
+ isPairedSession,
58
+ pauseAllSchedules,
59
+ saveQrCode,
60
+ } from "../utils/storage.esm.js";
61
+ import { bootstrapAdmins, bootstrapOwners } from "../utils/permissions.esm.js";
62
+ import { classifyDisconnect, handleConnectionOpen } from "./connection.js";
63
+ import { setupEventListeners } from "./events.js";
57
64
  import {
58
65
  createProxyAgents,
59
66
  describeProxyFailure,
@@ -61,10 +68,33 @@ import {
61
68
  readProxyConfig,
62
69
  redactProxy,
63
70
  } from "./proxy.js";
71
+ import { createWhatsAppSocket, groupMetadataCache } from "./socket.js";
64
72
 
65
73
  const require = createRequire(import.meta.url);
66
74
  const logger = require("../utils/logger.cjs");
67
75
 
76
+ function clearAccountScopedState() {
77
+ clearWhatsAppDirectory();
78
+ deleteAllChatHistories();
79
+ pauseAllSchedules();
80
+ bootstrapOwners([]);
81
+ bootstrapAdmins([]);
82
+
83
+ try {
84
+ require("../utils/memory.cjs").clearAllMemoryFiles();
85
+ } catch (error) {
86
+ logger.warn({ err: error }, "[Session] failed to clear AI memory after unlink");
87
+ }
88
+ try {
89
+ require("../commands/gemini.cjs").clearAllContextBuffers();
90
+ } catch (error) {
91
+ logger.warn({ err: error }, "[Session] failed to clear AI context buffers after unlink");
92
+ }
93
+ try {
94
+ groupMetadataCache.flushAll();
95
+ } catch {}
96
+ }
97
+
68
98
  // Baileys' `end()` awaits `ws.close()`, which waits for the socket's own
69
99
  // 'close' event with no timeout of its own. A half-dead TCP connection can
70
100
  // therefore hang it forever — and this is on the SIGTERM path, so "forever"
@@ -89,14 +119,14 @@ export function normalizePairingPhone(input) {
89
119
  const digits = value.replace(/\D/g, "");
90
120
  if (digits.startsWith("0")) {
91
121
  const error = new Error(
92
- "Drop the leading 0 and include the country code (e.g. 2010… not 010…)."
122
+ "Drop the leading 0 and include the country code (e.g. 2010… not 010…).",
93
123
  );
94
124
  error.code = "PAIRING_PHONE";
95
125
  throw error;
96
126
  }
97
127
  if (digits.length < 8 || digits.length > 15) {
98
128
  const error = new Error(
99
- "Enter the WhatsApp number with country code, digits only (e.g. 2010…)."
129
+ "Enter the WhatsApp number with country code, digits only (e.g. 2010…).",
100
130
  );
101
131
  error.code = "PAIRING_PHONE";
102
132
  throw error;
@@ -221,12 +251,16 @@ export class WhatsAppSession {
221
251
  retryDelaysMs = RETRY_SCHEDULE_MS,
222
252
  log = logger,
223
253
  pairingCodeDelayMs = 1500,
254
+ isLinked = isPairedSession,
255
+ clearDirectory = clearAccountScopedState,
224
256
  } = {}) {
225
257
  this.createSocket = createSocket;
226
258
  this.attachListeners = attachListeners;
227
259
  this.onOpen = onOpen;
228
260
  this.initializeScheduledJobs = initializeScheduledJobs;
229
261
  this.stopScheduledJobs = stopScheduledJobs;
262
+ this.isLinked = isLinked;
263
+ this.clearDirectory = clearDirectory;
230
264
  this.loadProxy = loadProxy;
231
265
  this.buildProxyAgents = buildProxyAgents;
232
266
  this.emitEvent = emit;
@@ -271,9 +305,7 @@ export class WhatsAppSession {
271
305
  /** Everything the dashboard needs to render the Connection screen. */
272
306
  getState() {
273
307
  const terminal =
274
- this.#state === S.LOGGED_OUT ||
275
- this.#state === S.RETRY_EXHAUSTED ||
276
- this.#state === S.ERROR;
308
+ this.#state === S.LOGGED_OUT || this.#state === S.RETRY_EXHAUSTED || this.#state === S.ERROR;
277
309
 
278
310
  return {
279
311
  state: this.#state,
@@ -288,6 +320,8 @@ export class WhatsAppSession {
288
320
  // leaves dead credentials behind, and unlinking is the only way out of
289
321
  // it. The one state with nothing to unlink is the one that just did.
290
322
  canUnlink: this.#state !== S.LOGGED_OUT && !this.#shuttingDown,
323
+ // Finished WhatsApp pairing — resume/connect, do not offer QR/pair.
324
+ linked: this.#state !== S.LOGGED_OUT && this.isLinked(),
291
325
  // Redacted — never the password. Null when the socket is direct.
292
326
  proxy: this.#proxyLabel,
293
327
  // True when the saved proxy settings differ from what the live socket was
@@ -300,8 +334,7 @@ export class WhatsAppSession {
300
334
  hasQr: !!this.#qr,
301
335
  hasPairingCode: !!this.#pairingCode,
302
336
  pairingMethod: this.#pairingIntent?.method || "qr",
303
- pairingPhone:
304
- this.#pairingIntent?.method === "pairing" ? this.#pairingIntent.phone : null,
337
+ pairingPhone: this.#pairingIntent?.method === "pairing" ? this.#pairingIntent.phone : null,
305
338
  reason: this.#reason,
306
339
  detail: this.#detail,
307
340
  lastDisconnect: this.#lastDisconnect,
@@ -333,8 +366,7 @@ export class WhatsAppSession {
333
366
 
334
367
  if (!isOnline) {
335
368
  this.log.warn("[Session] Device is offline — pausing WhatsApp connection and retries");
336
- this.#wasActiveBeforeOffline =
337
- BUSY_STATES.has(this.#state) || this.#state === S.PAUSED;
369
+ this.#wasActiveBeforeOffline = BUSY_STATES.has(this.#state) || this.#state === S.PAUSED;
338
370
  this.#cancelRetry();
339
371
 
340
372
  if (this.#socket) {
@@ -378,7 +410,12 @@ export class WhatsAppSession {
378
410
  }
379
411
  if (BUSY_STATES.has(this.#state) && this.#state !== S.PAUSED) return this.getState();
380
412
 
381
- this.#pairingIntent = parseStartOptions({ method, phone });
413
+ // A known pairing reuses the stored session. Do not request a pairing code.
414
+ if (this.isLinked()) {
415
+ this.#pairingIntent = { method: "qr", phone: null };
416
+ } else {
417
+ this.#pairingIntent = parseStartOptions({ method, phone });
418
+ }
382
419
 
383
420
  // A manual start clears whatever the last failure left behind.
384
421
  this.#cancelRetry();
@@ -467,6 +504,13 @@ export class WhatsAppSession {
467
504
  this.#clearAll = null;
468
505
  this.#pairing = false;
469
506
  this.#clearQr();
507
+ this.#stopJobs();
508
+ try {
509
+ this.clearDirectory?.();
510
+ } catch (error) {
511
+ this.log.warn({ err: error }, "[Session] failed to clear WhatsApp directory after unlink");
512
+ }
513
+
470
514
  this.#transition(S.LOGGED_OUT, {
471
515
  reason: "unlinked",
472
516
  detail: "The WhatsApp account was unlinked. Start a session to pair a new one.",
@@ -534,10 +578,7 @@ export class WhatsAppSession {
534
578
  proxyConfig = this.loadProxy();
535
579
  proxy = this.buildProxyAgents(proxyConfig);
536
580
  } catch (error) {
537
- this.log.error(
538
- { err: error?.message },
539
- "[Session] the proxy configuration is not usable"
540
- );
581
+ this.log.error({ err: error?.message }, "[Session] the proxy configuration is not usable");
541
582
  this.#proxyFingerprint = null;
542
583
  this.#proxyLabel = null;
543
584
  this.#transition(S.ERROR, {
@@ -557,7 +598,7 @@ export class WhatsAppSession {
557
598
  try {
558
599
  created = await this.createSocket({
559
600
  proxy,
560
- pairingCode: this.#pairingIntent.method === "pairing",
601
+ pairingCode: !this.isLinked() && this.#pairingIntent.method === "pairing",
561
602
  });
562
603
  } catch (error) {
563
604
  this.log.error({ err: error }, "[Session] failed to create the WhatsApp socket");
@@ -594,12 +635,10 @@ export class WhatsAppSession {
594
635
  this.attachListeners(sock, {
595
636
  saveCreds,
596
637
  onConnectionUpdate: (update) => this.#onConnectionUpdate(generation, update),
597
- onHandshakeRejected: (response) =>
598
- this.#onHandshakeRejected(generation, sock, response),
638
+ onHandshakeRejected: (response) => this.#onHandshakeRejected(generation, sock, response),
599
639
  });
600
640
 
601
- const waitingForCode =
602
- this.#pairing && this.#pairingIntent.method === "pairing";
641
+ const waitingForCode = this.#pairing && this.#pairingIntent.method === "pairing";
603
642
  this.#transition(this.#pairing ? S.WAITING_FOR_QR : S.STARTING, {
604
643
  reason,
605
644
  detail: this.#pairing
@@ -632,8 +671,8 @@ export class WhatsAppSession {
632
671
  viaProxy && (status === 407 || status === 401)
633
672
  ? `The proxy at ${this.#proxyLabel} rejected the username or password (HTTP ${status}).`
634
673
  : viaProxy
635
- ? `The proxy at ${this.#proxyLabel} refused to connect to WhatsApp (HTTP ${status}).`
636
- : `WhatsApp refused the connection (HTTP ${status}).`;
674
+ ? `The proxy at ${this.#proxyLabel} refused to connect to WhatsApp (HTTP ${status}).`
675
+ : `WhatsApp refused the connection (HTTP ${status}).`;
637
676
 
638
677
  this.log.error(`[Session] handshake rejected with HTTP ${status} — ${detail}`);
639
678
 
@@ -732,11 +771,7 @@ export class WhatsAppSession {
732
771
  async #onQr(qr) {
733
772
  // Pair-device readiness. In pairing-code mode the QR must not be shown;
734
773
  // requestPairingCode waits for this stanza (or queues until it arrives).
735
- if (
736
- this.#pairing &&
737
- this.#pairingIntent.method === "pairing" &&
738
- this.#pairingIntent.phone
739
- ) {
774
+ if (this.#pairing && this.#pairingIntent.method === "pairing" && this.#pairingIntent.phone) {
740
775
  await this.#requestPairingCode();
741
776
  return;
742
777
  }
@@ -779,18 +814,14 @@ export class WhatsAppSession {
779
814
  this.#pairingCode = String(raw || "").replace(/\s|-/g, "");
780
815
  this.#transition(S.WAITING_FOR_QR, {
781
816
  reason: "pairing_code",
782
- detail:
783
- "Enter the code in WhatsApp → Linked devices → Link with phone number.",
817
+ detail: "Enter the code in WhatsApp → Linked devices → Link with phone number.",
784
818
  });
785
819
  this.emitEvent("pairing_code", {
786
820
  code: this.#pairingCode,
787
821
  phone,
788
822
  });
789
823
  } catch (error) {
790
- this.log.error(
791
- { err: error?.message },
792
- "[Session] pairing code request failed"
793
- );
824
+ this.log.error({ err: error?.message }, "[Session] pairing code request failed");
794
825
  if (!this.#pairing || this.#shuttingDown) return;
795
826
  this.#transition(S.ERROR, {
796
827
  reason: "pairing_failed",
@@ -822,9 +853,7 @@ export class WhatsAppSession {
822
853
  async #onClose(lastDisconnect) {
823
854
  const statusCode = lastDisconnect?.error?.output?.statusCode;
824
855
  const reasonText =
825
- lastDisconnect?.error?.output?.payload?.error ||
826
- lastDisconnect?.error?.message ||
827
- "Unknown";
856
+ lastDisconnect?.error?.output?.payload?.error || lastDisconnect?.error?.message || "Unknown";
828
857
 
829
858
  this.#lastDisconnect = { statusCode: statusCode ?? null, reason: reasonText, at: Date.now() };
830
859
  const closedSocket = this.#socket;
@@ -850,7 +879,7 @@ export class WhatsAppSession {
850
879
 
851
880
  this.log.warn(
852
881
  `[Session] Connection closed (${statusCode ?? "no status"}: ${reasonText}) — ${verdict.label}` +
853
- (this.#proxyLabel ? ` via ${this.#proxyLabel}` : "")
882
+ (this.#proxyLabel ? ` via ${this.#proxyLabel}` : ""),
854
883
  );
855
884
 
856
885
  if (this.#shuttingDown) return;
@@ -868,7 +897,9 @@ export class WhatsAppSession {
868
897
  if (verdict.terminal) {
869
898
  this.#clearQr();
870
899
  this.#pairing = false;
871
- const codeInfo = this.#lastDisconnect?.statusCode ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})` : "";
900
+ const codeInfo = this.#lastDisconnect?.statusCode
901
+ ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})`
902
+ : "";
872
903
  return this.#transition(S.DISCONNECTED, {
873
904
  reason: verdict.reason,
874
905
  detail: `${verdict.detail || ""}${codeInfo}`.trim(),
@@ -880,7 +911,9 @@ export class WhatsAppSession {
880
911
  if (this.#pairing && !verdict.restartRequired) {
881
912
  this.#pairing = false;
882
913
  this.#clearQr();
883
- const codeInfo = this.#lastDisconnect?.statusCode ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})` : "";
914
+ const codeInfo = this.#lastDisconnect?.statusCode
915
+ ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})`
916
+ : "";
884
917
  return this.#transition(S.DISCONNECTED, {
885
918
  reason: "pairing_cancelled",
886
919
  detail: `The pairing attempt ended before the code was scanned${codeInfo}. Start a session to try again.`,
@@ -914,6 +947,12 @@ export class WhatsAppSession {
914
947
  this.#clearQr();
915
948
  this.#cancelRetry();
916
949
  this.#attempt = 0;
950
+ this.#stopJobs();
951
+ try {
952
+ this.clearDirectory?.();
953
+ } catch (error) {
954
+ this.log.warn({ err: error }, "[Session] failed to clear WhatsApp directory after logout");
955
+ }
917
956
  this.#transition(S.LOGGED_OUT, { reason: verdict.reason, detail: verdict.detail });
918
957
  }
919
958
 
@@ -937,10 +976,12 @@ export class WhatsAppSession {
937
976
 
938
977
  if (this.#attempt >= this.retryDelaysMs.length) {
939
978
  this.log.error(
940
- `[Session] ${this.retryDelaysMs.length} reconnect attempts failed. Levix stays up; start the session again from the panel.`
979
+ `[Session] ${this.retryDelaysMs.length} reconnect attempts failed. Levix stays up; start the session again from the panel.`,
941
980
  );
942
981
  this.#clearQr();
943
- const codeInfo = this.#lastDisconnect?.statusCode ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})` : "";
982
+ const codeInfo = this.#lastDisconnect?.statusCode
983
+ ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})`
984
+ : "";
944
985
  return this.#transition(S.RETRY_EXHAUSTED, {
945
986
  reason: "retry_exhausted",
946
987
  detail: `Gave up after ${this.retryDelaysMs.length} attempts${codeInfo}. Start the session to try again.`,
@@ -952,10 +993,12 @@ export class WhatsAppSession {
952
993
  this.#nextRetryAt = Date.now() + delayMs;
953
994
 
954
995
  this.log.warn(
955
- `[Session] Reconnecting in ${Math.round(delayMs / 1000)}s (attempt ${this.#attempt}/${this.retryDelaysMs.length})`
996
+ `[Session] Reconnecting in ${Math.round(delayMs / 1000)}s (attempt ${this.#attempt}/${this.retryDelaysMs.length})`,
956
997
  );
957
998
 
958
- const codeInfo = this.#lastDisconnect?.statusCode ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})` : "";
999
+ const codeInfo = this.#lastDisconnect?.statusCode
1000
+ ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})`
1001
+ : "";
959
1002
  this.#transition(S.RECONNECTING, {
960
1003
  reason: verdict.reason,
961
1004
  detail: