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.
- package/README.md +13 -9
- package/app.cjs +49 -46
- package/package.json +9 -1
- package/public/dashboard/assets/index-BBBXWzM0.js +391 -0
- package/public/dashboard/assets/index-pYaivHON.css +1 -0
- package/public/dashboard/index.html +19 -0
- package/public/dashboard.css +391 -66
- package/public/dashboard.js +593 -42
- package/scheduler.cjs +9 -14
- package/src/auth/use-database-auth-state.js +2 -4
- package/src/bootstrap/core.js +2 -5
- package/src/bootstrap/panel.js +10 -5
- package/src/cli.js +3 -5
- package/src/commands/autoschedule.cjs +1 -4
- package/src/commands/blacklist.cjs +22 -20
- package/src/commands/block.cjs +2 -4
- package/src/commands/calc.cjs +9 -16
- package/src/commands/digit.cjs +97 -0
- package/src/commands/gemini.cjs +147 -161
- package/src/commands/group/add.cjs +7 -16
- package/src/commands/group/antiSpam.cjs +5 -11
- package/src/commands/group/antilink.cjs +34 -28
- package/src/commands/group/approveall.cjs +3 -11
- package/src/commands/group/clearwarns.cjs +2 -5
- package/src/commands/group/demote.cjs +3 -8
- package/src/commands/group/kick.cjs +12 -15
- package/src/commands/group/media.cjs +17 -24
- package/src/commands/group/members.cjs +1 -2
- package/src/commands/group/promote.cjs +4 -12
- package/src/commands/group/removeall.cjs +2 -5
- package/src/commands/group/setpp.cjs +1 -1
- package/src/commands/group/setrules.cjs +2 -8
- package/src/commands/group/setwarn.cjs +4 -2
- package/src/commands/group/tagadmins.cjs +2 -5
- package/src/commands/group/warn.cjs +26 -24
- package/src/commands/group/warns.cjs +1 -2
- package/src/commands/group/welcome.cjs +13 -17
- package/src/commands/group.cjs +7 -10
- package/src/commands/help.cjs +6 -12
- package/src/commands/lang.cjs +104 -0
- package/src/commands/listschedules.cjs +2 -6
- package/src/commands/loop.cjs +6 -13
- package/src/commands/memory.cjs +19 -32
- package/src/commands/mod.cjs +97 -35
- package/src/commands/perm.cjs +18 -56
- package/src/commands/ping.cjs +1 -5
- package/src/commands/poll.cjs +1 -1
- package/src/commands/rand.cjs +2 -5
- package/src/commands/schedule.cjs +4 -8
- package/src/commands/score.cjs +8 -2
- package/src/commands/setprefix.cjs +2 -2
- package/src/commands/shortlink.cjs +7 -15
- package/src/commands/shutdown.cjs +1 -1
- package/src/commands/sticker.cjs +222 -0
- package/src/commands/stt.cjs +136 -68
- package/src/commands/todo.cjs +9 -15
- package/src/commands/tts.cjs +10 -16
- package/src/commands/unblock.cjs +2 -6
- package/src/commands/weather.cjs +5 -12
- package/src/config/ai-persona.md +5 -3
- package/src/config/available-models.json +1156 -0
- package/src/config/baileys.config.js +2 -1
- package/src/config/brand.esm.js +1 -10
- package/src/config/constants.js +1 -1
- package/src/config/defaults.cjs +66 -66
- package/src/config/lock.cjs +2 -2
- package/src/config/modelCapabilities.cjs +172 -0
- package/src/config/runtime-config.cjs +8 -13
- package/src/config/secrets.cjs +18 -10
- package/src/config/settings.cjs +94 -10
- package/src/config/vault.cjs +107 -0
- package/src/core/connection.js +13 -12
- package/src/core/events.js +41 -23
- package/src/core/proxy.js +7 -7
- package/src/core/session.js +91 -48
- package/src/core/socket.js +12 -15
- package/src/db/db.cjs +14 -6
- package/src/db/store.cjs +221 -87
- package/src/domain/caddy.js +1 -1
- package/src/domain/command.js +40 -19
- package/src/domain/detect.js +1 -1
- package/src/domain/system.js +2 -5
- package/src/handlers/command.handler.js +22 -43
- package/src/handlers/group.handler.js +13 -13
- package/src/handlers/message.handler.js +11 -21
- package/src/index.js +8 -4
- package/src/middleware/antispam.middleware.js +46 -23
- package/src/middleware/blacklist.middleware.js +14 -9
- package/src/middleware/forward-tracking.middleware.js +3 -2
- package/src/middleware/permissions.middleware.js +7 -10
- package/src/panel/login-throttle.cjs +43 -0
- package/src/panel/session-auth.cjs +17 -0
- package/src/routes/dashboard.api.esm.js +394 -72
- package/src/services/aiAgent.cjs +136 -49
- package/src/services/aiProviders.cjs +193 -47
- package/src/services/aiRouter.cjs +137 -0
- package/src/services/aiTools.cjs +305 -128
- package/src/services/llmDiscovery.cjs +481 -0
- package/src/services/modelRegistry.cjs +412 -0
- package/src/utils/datetime.cjs +2 -2
- package/src/utils/lid-helper.esm.js +7 -7
- package/src/utils/logger.cjs +1 -1
- package/src/utils/markdownParser.cjs +159 -0
- package/src/utils/memory.cjs +43 -13
- package/src/utils/messageContent.cjs +40 -0
- package/src/utils/normalizeJid.esm.js +7 -7
- package/src/utils/numerals.cjs +75 -0
- package/src/utils/permissions.esm.js +10 -15
- package/src/utils/providerUrl.cjs +281 -0
- package/src/utils/recurrence.cjs +7 -11
- package/src/utils/sendBotMessage.esm.js +4 -14
- package/src/utils/statusMessage.cjs +5 -13
- package/src/utils/storage.cjs +10 -0
- package/src/utils/storage.esm.js +11 -0
- package/src/utils/textDecode.cjs +4 -9
- package/src/utils/thumbnail.cjs +8 -14
- package/src/utils/typing.esm.js +12 -12
- 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
|
|
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
|
|
175
|
-
- **`node:sqlite`** for a single-file datastore
|
|
176
|
-
|
|
177
|
-
- **Express + EJS** for
|
|
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
|
|
194
|
-
npm
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
48
|
-
//
|
|
49
|
-
|
|
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.
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
186
|
+
if (isPanelSessionValid(socket.request.session)) return next();
|
|
182
187
|
next(new Error("unauthorized"));
|
|
183
188
|
});
|
|
184
189
|
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
208
|
-
const
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
"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"
|