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
package/README.md CHANGED
@@ -69,9 +69,9 @@ docker compose logs levix
69
69
 
70
70
  ### Android phone (No server required)
71
71
 
72
- Run Levix directly on an ARM64 Android device (Android 10+) as a self-contained background companion:
72
+ Run Levix directly on an Android device (Android 10+, ARM64 or ARMv7) as a self-contained background companion:
73
73
 
74
- 1. Download **`levix-android-arm64.apk`** from [Releases](https://github.com/Abdodiab2005/levix/releases).
74
+ 1. Download **`levix-android-arm64.apk`** (64-bit phones) or **`levix-android-armv7.apk`** (32-bit phones) from [Releases](https://github.com/Abdodiab2005/levix/releases).
75
75
  2. Install the APK and tap **Start Host**.
76
76
  3. Tap **Open Panel** to set your master password and link WhatsApp via 8-digit Pairing Code.
77
77
 
@@ -171,14 +171,15 @@ start.
171
171
  ## Architecture
172
172
 
173
173
  - **Node.js 24+** with ES modules and CommonJS where needed.
174
- - **Baileys v7** for the WhatsApp connection and LID support.
175
- - **`node:sqlite`** for a single-file datastore with no database server or
176
- native SQLite dependency.
177
- - **Express + EJS** for a panel with no frontend build step or CDN dependency.
174
+ - **Baileys v7** for the WhatsApp protocol engine, LID support, and phone pairing codes.
175
+ - **`node:sqlite`** for a zero-dependency, single-file datastore.
176
+ - **React + Vite Control Panel** (`frontend/`) with TypeScript, native RTL Arabic / English internationalization, Lucide icons, responsive drawer, modal dialogs, and real-time Socket.IO synchronization.
177
+ - **Express + EJS Gateways** for lightweight setup, login, and pairing QR gateway screens.
178
178
  - **Pluggable AI providers** — Google Gemini, OpenAI / any OpenAI-compatible endpoint, or
179
179
  Anthropic — each with its own endpoint, API key, and model in the control panel, no env file.
180
+ - **Biome** for fast formatting and linting across the codebase.
180
181
  - **GitHub Actions** validation for tests, npm packaging, Docker persistence,
181
- and standalone executables on Linux, macOS, and Windows.
182
+ Android APK packaging, and standalone executables on Linux, macOS, and Windows.
182
183
 
183
184
  [AGENTS.md](AGENTS.md) documents the module layout, message flow, storage API,
184
185
  and architectural constraints. [PACKAGING.md](PACKAGING.md) covers npm, Docker,
@@ -190,8 +191,11 @@ systemd, installers, and single-executable releases.
190
191
  git clone https://github.com/Abdodiab2005/levix
191
192
  cd levix
192
193
  npm ci
193
- npm test
194
- npm start
194
+ cd frontend && npm ci && cd ..
195
+ npm run build # compiles the React frontend to public/dashboard
196
+ npm run lint # runs Biome linter across the project
197
+ npm test # runs the full test suite
198
+ npm start # starts Levix
195
199
  ```
196
200
 
197
201
  Please read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a substantial
package/app.cjs CHANGED
@@ -9,6 +9,8 @@
9
9
  // (port, proxy hops, extra origin) are settings in the database that the panel
10
10
  // itself can change.
11
11
 
12
+ const fs = require("node:fs");
13
+ const path = require("node:path");
12
14
  const http = require("node:http");
13
15
  const express = require("express");
14
16
  const { Server } = require("socket.io");
@@ -21,10 +23,9 @@ const settings = require("./src/config/settings.cjs");
21
23
  const secrets = require("./src/config/secrets.cjs");
22
24
  const { PanelSessionStore } = require("./src/panel/session-store.cjs");
23
25
  const { getQrCode } = require("./src/utils/storage.cjs");
24
- const {
25
- clientAddress,
26
- isDirectLocalRequest,
27
- } = require("./src/utils/requestOrigin.cjs");
26
+ const { clientAddress, isDirectLocalRequest } = require("./src/utils/requestOrigin.cjs");
27
+ const { blockedFor, recordFailure, clearAttempts } = require("./src/panel/login-throttle.cjs");
28
+ const { isPanelSessionValid, stampPanelSession } = require("./src/panel/session-auth.cjs");
28
29
 
29
30
  const SESSION_COOKIE_NAME = "wa.sid";
30
31
  const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000;
@@ -44,9 +45,10 @@ function isAllowedOrigin(origin, host) {
44
45
  // Browsers may legitimately serialize the Origin header as the literal string
45
46
  // "null" for a same-origin navigation in an opaque-origin context. Prefer
46
47
  // Fetch Metadata when the browser sends it. Some Chromium form navigations omit
47
- // Sec-Fetch-Site entirely, so only the two credential-gated auth forms get a
48
- // narrow fallback for Origin:null; every other mutation remains strict.
49
- const OPAQUE_ORIGIN_AUTH_PATHS = new Set(["/setup", "/login"]);
48
+ // Sec-Fetch-Site entirely, so password-gated login gets a narrow fallback for
49
+ // Origin:null. First-run setup is deliberately excluded: a loopback request
50
+ // otherwise skips the setup code and could claim an unconfigured panel.
51
+ const OPAQUE_ORIGIN_AUTH_PATHS = new Set(["/login"]);
50
52
  function isAllowedMutationRequest(req) {
51
53
  const origin = req.get("origin");
52
54
  const host = req.get("host");
@@ -60,9 +62,9 @@ function isAllowedMutationRequest(req) {
60
62
  if (fetchSite === "cross-site") return false;
61
63
 
62
64
  // Real Chromium can submit a top-level auth form with Origin:null and no
63
- // Sec-Fetch-Site header. Setup is still protected by the one-time setup code
64
- // for remote clients; login by the password; both share TCP-peer throttling.
65
- // Do not generalize this exception to dashboard APIs or logout.
65
+ // Sec-Fetch-Site header. Login is still protected by the password and
66
+ // TCP-peer throttling. Do not generalize this exception to setup, dashboard
67
+ // APIs, or logout.
66
68
  if (origin === "null" && !fetchSite && OPAQUE_ORIGIN_AUTH_PATHS.has(req.path)) {
67
69
  return true;
68
70
  }
@@ -126,7 +128,7 @@ app.use((req, res, next) => {
126
128
  });
127
129
 
128
130
  function requireLoginApi(req, res, next) {
129
- if (req.session?.loggedIn) return next();
131
+ if (isPanelSessionValid(req.session)) return next();
130
132
  return res.status(401).json({ error: "Unauthorized" });
131
133
  }
132
134
 
@@ -138,7 +140,7 @@ function noStore(req, res, next) {
138
140
  }
139
141
 
140
142
  function requireLoginPage(req, res, next) {
141
- if (req.session?.loggedIn) return next();
143
+ if (isPanelSessionValid(req.session)) return next();
142
144
  return res.redirect("/");
143
145
  }
144
146
 
@@ -148,7 +150,10 @@ app.engine("ejs", require("ejs").__express);
148
150
  app.set("view engine", "ejs");
149
151
  app.set("views", assetPath("views"));
150
152
  // Name, tagline and credit for every template. Frozen — see brand.cjs.
151
- app.locals.brand = brand;
153
+ // The one addition is the version from package.json, so the panel shows the
154
+ // shipped version instead of a string frozen in the frontend bundle.
155
+ const pkg = require("./package.json");
156
+ app.locals.brand = { ...brand, version: pkg.version };
152
157
  app.use(express.static(assetPath("public")));
153
158
  app.use(express.urlencoded({ extended: true, limit: "100kb" }));
154
159
 
@@ -178,40 +183,20 @@ app.use(sessionMiddleware);
178
183
  // Every socket receives the pairing QR, so it goes through the same session.
179
184
  io.engine.use(sessionMiddleware);
180
185
  io.use((socket, next) => {
181
- if (socket.request.session?.loggedIn) return next();
186
+ if (isPanelSessionValid(socket.request.session)) return next();
182
187
  next(new Error("unauthorized"));
183
188
  });
184
189
 
185
- // --- Attempt throttling ---------------------------------------------------
186
- // Shared by /login and /setup: both are a guess at a secret.
187
- //
188
- // Keyed on the TCP peer, never on `req.ip`: with `trust proxy` set, a client
189
- // picks its own `req.ip` by writing a header, so it could rotate the value and
190
- // never hit the limit.
191
-
192
- const ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
193
- const MAX_ATTEMPTS = 5;
194
- const attempts = new Map();
195
-
196
- function blockedFor(ip) {
197
- const now = Date.now();
198
- for (const [key, entry] of attempts) {
199
- if (now - entry.firstAt > ATTEMPT_WINDOW_MS) attempts.delete(key);
190
+ function disconnectInvalidPanelSockets() {
191
+ for (const socket of io.of("/").sockets.values()) {
192
+ if (!isPanelSessionValid(socket.request.session)) socket.disconnect(true);
200
193
  }
201
- const entry = attempts.get(ip);
202
- if (!entry || entry.count < MAX_ATTEMPTS) return 0;
203
- const remaining = ATTEMPT_WINDOW_MS - (now - entry.firstAt);
204
- return remaining > 0 ? Math.ceil(remaining / 1000) : 0;
205
194
  }
206
195
 
207
- function recordFailure(ip) {
208
- const now = Date.now();
209
- const entry = attempts.get(ip);
210
- if (!entry || now - entry.firstAt > ATTEMPT_WINDOW_MS) {
211
- attempts.set(ip, { count: 1, firstAt: now });
212
- return;
196
+ function disconnectSessionSockets(sid) {
197
+ for (const socket of io.of("/").sockets.values()) {
198
+ if (socket.request.sessionID === sid) socket.disconnect(true);
213
199
  }
214
- entry.count += 1;
215
200
  }
216
201
 
217
202
  // --- First run ------------------------------------------------------------
@@ -271,12 +256,12 @@ app.post("/setup", (req, res, next) => {
271
256
  return renderSetup(req, res, error.message, 400);
272
257
  }
273
258
 
274
- attempts.delete(peer);
259
+ clearAttempts(peer);
275
260
  logger.info({ ip: peer }, "[dashboard] Password set — first run complete");
276
261
 
277
262
  req.session.regenerate((err) => {
278
263
  if (err) return next(err);
279
- req.session.loggedIn = true;
264
+ stampPanelSession(req.session);
280
265
  req.session.save((saveErr) => {
281
266
  if (saveErr) return next(saveErr);
282
267
  res.redirect(303, "/");
@@ -288,13 +273,27 @@ app.post("/setup", (req, res, next) => {
288
273
 
289
274
  app.get("/", noStore, (req, res) => {
290
275
  if (!secrets.hasDashboardPassword()) return res.redirect("/setup");
291
- if (req.session.loggedIn) return res.render("dashboard");
276
+ if (isPanelSessionValid(req.session)) {
277
+ const dashboardHtmlPath = path.join(assetPath("public"), "dashboard", "index.html");
278
+ if (fs.existsSync(dashboardHtmlPath)) {
279
+ let html = fs.readFileSync(dashboardHtmlPath, "utf8");
280
+ const credits = `<div class="brand-credits" style="display:none"><a href="${app.locals.brand.developerSite}">${app.locals.brand.developer}</a><a href="${app.locals.brand.studioSite}">${app.locals.brand.studio}</a></div>`;
281
+ html = html
282
+ .replace(
283
+ "</head>",
284
+ `<script>window.__BRAND__ = ${JSON.stringify(app.locals.brand)};</script></head>`,
285
+ )
286
+ .replace("</body>", `${credits}</body>`);
287
+ return res.type("html").send(html);
288
+ }
289
+ return res.render("dashboard");
290
+ }
292
291
  return res.render("login", { error: null });
293
292
  });
294
293
 
295
294
  app.get("/login", noStore, (req, res) => {
296
295
  if (!secrets.hasDashboardPassword()) return res.redirect("/setup");
297
- if (req.session.loggedIn) return res.redirect("/");
296
+ if (isPanelSessionValid(req.session)) return res.redirect("/");
298
297
  return res.render("login", { error: null });
299
298
  });
300
299
 
@@ -316,12 +315,12 @@ app.post("/login", (req, res, next) => {
316
315
  return res.status(401).render("login", { error: "Incorrect Password" });
317
316
  }
318
317
 
319
- attempts.delete(peer);
318
+ clearAttempts(peer);
320
319
 
321
320
  // A fresh id after login, or the id an attacker planted beforehand still works.
322
321
  req.session.regenerate((err) => {
323
322
  if (err) return next(err);
324
- req.session.loggedIn = true;
323
+ stampPanelSession(req.session);
325
324
  req.session.save((saveErr) => {
326
325
  if (saveErr) return next(saveErr);
327
326
  res.redirect(303, "/");
@@ -332,8 +331,10 @@ app.post("/login", (req, res, next) => {
332
331
  // POST, not GET: a link that changes state gets fired by an <img> or by the
333
332
  // browser's prefetch.
334
333
  app.post("/logout", (req, res, next) => {
334
+ const sid = req.sessionID;
335
335
  req.session.destroy((err) => {
336
336
  if (err) return next(err);
337
+ disconnectSessionSockets(sid);
337
338
  res.clearCookie(SESSION_COOKIE_NAME, {
338
339
  httpOnly: true,
339
340
  sameSite: "lax",
@@ -375,4 +376,6 @@ module.exports = {
375
376
  requireLoginPage,
376
377
  noStore,
377
378
  installFinalHandlers,
379
+ disconnectInvalidPanelSockets,
380
+ disconnectSessionSockets,
378
381
  };
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "levix-bot",
3
- "version": "3.3.0-rc.2",
3
+ "version": "4.0.0",
4
4
  "description": "A self-hosted personal WhatsApp bot with an AI agent, group moderation and a web control panel.",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
7
7
  "start": "node bin/levix.js",
8
8
  "dev": "nodemon bin/levix.js",
9
9
  "test": "node tests/run.mjs",
10
+ "lint": "biome lint",
11
+ "format": "biome format --write .",
12
+ "check": "biome check .",
13
+ "check:fix": "biome check --write .",
10
14
  "brand:assets": "node scripts/build-brand-assets.mjs",
15
+ "build": "npm run build:frontend",
16
+ "build:frontend": "node scripts/build-frontend.mjs",
17
+ "prepack": "npm run build:frontend",
11
18
  "build:sea": "node scripts/build-sea.mjs",
12
19
  "validate:tarball": "node scripts/validate-tarball.mjs",
13
20
  "validate:sea": "node scripts/validate-sea.mjs",
@@ -68,6 +75,7 @@
68
75
  "node": ">=24"
69
76
  },
70
77
  "devDependencies": {
78
+ "@biomejs/biome": "^2.5.14",
71
79
  "esbuild": "^0.28.2",
72
80
  "postject": "^1.0.0-alpha.6",
73
81
  "sharp": "^0.35.3"