levix-bot 2.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/LICENSE +21 -0
- package/README.md +105 -0
- package/SETUP.md +242 -0
- package/app.cjs +337 -0
- package/bin/levix.js +7 -0
- package/deploy/install.sh +182 -0
- package/deploy/levix.service +56 -0
- package/deploy/nginx/levix.leviro.net.conf +79 -0
- package/package.json +78 -0
- package/public/brand/apple-touch-icon.png +0 -0
- package/public/brand/banner.jpg +0 -0
- package/public/brand/banner.webp +0 -0
- package/public/brand/icon-16.png +0 -0
- package/public/brand/icon-192.png +0 -0
- package/public/brand/icon-32.png +0 -0
- package/public/brand/icon-512.png +0 -0
- package/public/brand/icon-512.webp +0 -0
- package/public/brand/mark.png +0 -0
- package/public/brand/mark.svg +16 -0
- package/public/brand/mark.webp +0 -0
- package/public/brand/wordmark.png +0 -0
- package/public/brand/wordmark.webp +0 -0
- package/public/dashboard.css +881 -0
- package/public/dashboard.js +1057 -0
- package/public/logo.svg +16 -0
- package/public/qrcode.min.js +1 -0
- package/public/socket.io.min.js +7 -0
- package/scheduler.cjs +197 -0
- package/src/auth/auth-storage.cjs +74 -0
- package/src/auth/use-database-auth-state.js +130 -0
- package/src/bootstrap/core.js +74 -0
- package/src/bootstrap/events.cjs +43 -0
- package/src/bootstrap/panel.js +97 -0
- package/src/cli.js +153 -0
- package/src/commands/autoschedule.cjs +84 -0
- package/src/commands/blacklist.cjs +116 -0
- package/src/commands/block.cjs +70 -0
- package/src/commands/calc.cjs +387 -0
- package/src/commands/debt.cjs +175 -0
- package/src/commands/deleteschedule.cjs +36 -0
- package/src/commands/gemini.cjs +727 -0
- package/src/commands/group/add.cjs +136 -0
- package/src/commands/group/all.cjs +43 -0
- package/src/commands/group/antiSpam.cjs +96 -0
- package/src/commands/group/antilink.cjs +167 -0
- package/src/commands/group/approveall.cjs +54 -0
- package/src/commands/group/clearwarns.cjs +41 -0
- package/src/commands/group/deletenote.cjs +33 -0
- package/src/commands/group/demote.cjs +62 -0
- package/src/commands/group/kick.cjs +85 -0
- package/src/commands/group/media.cjs +120 -0
- package/src/commands/group/members.cjs +59 -0
- package/src/commands/group/note.cjs +32 -0
- package/src/commands/group/promote.cjs +60 -0
- package/src/commands/group/removeall.cjs +42 -0
- package/src/commands/group/rules.cjs +31 -0
- package/src/commands/group/save.cjs +36 -0
- package/src/commands/group/setname.cjs +41 -0
- package/src/commands/group/setpp.cjs +51 -0
- package/src/commands/group/setrules.cjs +46 -0
- package/src/commands/group/setwarn.cjs +58 -0
- package/src/commands/group/tagadmins.cjs +50 -0
- package/src/commands/group/warn.cjs +105 -0
- package/src/commands/group/warns.cjs +66 -0
- package/src/commands/group/welcome.cjs +109 -0
- package/src/commands/group.cjs +160 -0
- package/src/commands/help.cjs +230 -0
- package/src/commands/listschedules.cjs +47 -0
- package/src/commands/loop.cjs +163 -0
- package/src/commands/memory.cjs +201 -0
- package/src/commands/mod.cjs +155 -0
- package/src/commands/notes.cjs +28 -0
- package/src/commands/perm.cjs +236 -0
- package/src/commands/ping.cjs +17 -0
- package/src/commands/poll.cjs +80 -0
- package/src/commands/prayer.cjs +64 -0
- package/src/commands/qr.cjs +39 -0
- package/src/commands/rand.cjs +130 -0
- package/src/commands/restart.cjs +25 -0
- package/src/commands/schedule.cjs +86 -0
- package/src/commands/score.cjs +130 -0
- package/src/commands/setprefix.cjs +40 -0
- package/src/commands/shortlink.cjs +74 -0
- package/src/commands/shutdown.cjs +45 -0
- package/src/commands/status.cjs +75 -0
- package/src/commands/stt.cjs +150 -0
- package/src/commands/todo.cjs +88 -0
- package/src/commands/tts.cjs +170 -0
- package/src/commands/unblock.cjs +57 -0
- package/src/commands/weather.cjs +79 -0
- package/src/config/ai-persona.md +17 -0
- package/src/config/baileys.config.js +51 -0
- package/src/config/brand.cjs +36 -0
- package/src/config/brand.esm.js +17 -0
- package/src/config/constants.js +16 -0
- package/src/config/defaults.cjs +91 -0
- package/src/config/lock.cjs +110 -0
- package/src/config/paths.cjs +90 -0
- package/src/config/runtime-config.cjs +219 -0
- package/src/config/secrets.cjs +130 -0
- package/src/config/settings.cjs +386 -0
- package/src/core/connection.js +199 -0
- package/src/core/events.js +61 -0
- package/src/core/socket.js +64 -0
- package/src/db/db.cjs +282 -0
- package/src/db/store.cjs +837 -0
- package/src/db/store.esm.js +17 -0
- package/src/domain/caddy.js +120 -0
- package/src/domain/command.js +380 -0
- package/src/domain/detect.js +173 -0
- package/src/domain/nginx.js +138 -0
- package/src/domain/system.js +148 -0
- package/src/handlers/command.handler.js +414 -0
- package/src/handlers/group.handler.js +73 -0
- package/src/handlers/message.handler.js +149 -0
- package/src/index.js +217 -0
- package/src/middleware/antispam.middleware.js +68 -0
- package/src/middleware/blacklist.middleware.js +29 -0
- package/src/middleware/forward-tracking.middleware.js +49 -0
- package/src/middleware/permissions.middleware.js +114 -0
- package/src/routes/dashboard.api.esm.js +779 -0
- package/src/services/aiAgent.cjs +317 -0
- package/src/services/aiTools.cjs +635 -0
- package/src/utils/datetime.cjs +61 -0
- package/src/utils/lid-helper.esm.js +143 -0
- package/src/utils/logger.cjs +72 -0
- package/src/utils/memory.cjs +385 -0
- package/src/utils/normalizeJid.cjs +65 -0
- package/src/utils/normalizeJid.esm.js +37 -0
- package/src/utils/openBrowser.cjs +129 -0
- package/src/utils/permissions.cjs +154 -0
- package/src/utils/permissions.esm.js +375 -0
- package/src/utils/recentMessageCache.esm.js +70 -0
- package/src/utils/requestOrigin.cjs +102 -0
- package/src/utils/sendBotMessage.cjs +43 -0
- package/src/utils/sendBotMessage.esm.js +139 -0
- package/src/utils/statusMessage.cjs +179 -0
- package/src/utils/storage-hub.cjs +49 -0
- package/src/utils/storage-hub.esm.js +24 -0
- package/src/utils/storage.cjs +80 -0
- package/src/utils/storage.esm.js +88 -0
- package/src/utils/textDecode.cjs +197 -0
- package/src/utils/thumbnail.cjs +591 -0
- package/src/utils/typing.esm.js +102 -0
- package/views/dashboard.ejs +329 -0
- package/views/login.ejs +105 -0
- package/views/qr.ejs +108 -0
- package/views/setup.ejs +122 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abdelrhman Diab (Leviro)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Levix
|
|
2
|
+
|
|
3
|
+
A self-hosted personal WhatsApp bot: 55 commands, group moderation, an AI agent
|
|
4
|
+
on Google Gemini, scheduled messages, and a web control panel that can change
|
|
5
|
+
almost all of it while the bot runs.
|
|
6
|
+
|
|
7
|
+
Built by Abdelrhman Diab (Leviro).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g levix-bot
|
|
11
|
+
levix
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The npm package is named `levix-bot`; the installed CLI command stays `levix`.
|
|
15
|
+
|
|
16
|
+
On a desktop that opens the panel in your browser by itself. Pick a password,
|
|
17
|
+
scan the QR, done — see [SETUP.md](SETUP.md) for the longer version, Docker,
|
|
18
|
+
and running it as a service.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
levix start Levix with the web panel
|
|
22
|
+
levix headless start Levix with no web UI at all
|
|
23
|
+
levix where print the data directory
|
|
24
|
+
levix reset-password reset the panel password
|
|
25
|
+
levix domain [name] point a domain at Levix (may need sudo)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## What it does
|
|
31
|
+
|
|
32
|
+
**Commands** — `!ping`, `!help`, `!calc`, `!poll`, `!rand`, `!loop`, `!todo`,
|
|
33
|
+
`!notes`, `!debt`, `!weather`, `!prayer`, `!tts`, `!stt`, `!shortlink`,
|
|
34
|
+
`!qr`, `!score`… every one of them can be renamed, re-permissioned or turned
|
|
35
|
+
off from the panel.
|
|
36
|
+
|
|
37
|
+
**Group moderation** — welcome messages, anti-link, anti-spam, media rules,
|
|
38
|
+
warnings with auto-kick, rules and notes, plus the usual `!group kick`,
|
|
39
|
+
`promote`, `demote`, `add`, `tagadmins`.
|
|
40
|
+
|
|
41
|
+
**An AI agent, not a chat box** — `!gemini` can search the web, open a page and
|
|
42
|
+
read it, save something to its long-term memory and hand out bot roles, across
|
|
43
|
+
several tool rounds, narrating the whole run inside a single message it keeps
|
|
44
|
+
editing. Its personality is a Markdown file you can edit from the panel.
|
|
45
|
+
|
|
46
|
+
**Long-term memory** — "remember that…" writes to `memory/global.md` or a
|
|
47
|
+
per-chat file. Plain Markdown, hand-editable, injected into every prompt.
|
|
48
|
+
|
|
49
|
+
**Scheduled messages** — one-off (`!schedule`) or recurring (`!autoschedule`),
|
|
50
|
+
stored in the database and listed in the panel.
|
|
51
|
+
|
|
52
|
+
**A panel you can also do without** — `levix headless` runs the bot with no
|
|
53
|
+
Express, no socket.io and no port open at all; an unpaired install prints its
|
|
54
|
+
QR straight to the terminal.
|
|
55
|
+
|
|
56
|
+
**A domain, without a takeover** — `levix domain bot.example.com` looks at what
|
|
57
|
+
the server already runs and works with it: it adds one nginx or Caddy site and
|
|
58
|
+
validates the whole configuration before reloading, and on a panel-managed or
|
|
59
|
+
containerised host it changes nothing and prints the exact reverse-proxy
|
|
60
|
+
settings instead.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## How it is put together
|
|
65
|
+
|
|
66
|
+
- **Node 24+**, ES modules and CommonJS side by side (see `CLAUDE.md`).
|
|
67
|
+
- **[Baileys v7](https://github.com/WhiskeySockets/Baileys)** for WhatsApp,
|
|
68
|
+
including the LID system.
|
|
69
|
+
- **SQLite through `node:sqlite`** — Node's own module, so the whole datastore
|
|
70
|
+
is one file and zero dependencies. No database server, nothing to compile.
|
|
71
|
+
- **Express + EJS** for the panel. No framework, no CDN, no build step.
|
|
72
|
+
- **Google Gemini** for the AI, with an optional Groq fallback.
|
|
73
|
+
|
|
74
|
+
Three things follow from that, and they are the point of the design:
|
|
75
|
+
|
|
76
|
+
**The database is the source of truth, the panel is how you edit it.** There is
|
|
77
|
+
no `.env` file and no config file — the prefix, every permission, every API key
|
|
78
|
+
and every tuning value is a row in `bot_settings`, and every read goes through
|
|
79
|
+
an accessor at call time so a change applies to the next message.
|
|
80
|
+
|
|
81
|
+
**Secrets are generated, not configured.** The session signing key is made on
|
|
82
|
+
first start. The panel password is chosen once, in the browser, and stored as
|
|
83
|
+
an scrypt hash.
|
|
84
|
+
|
|
85
|
+
**Everything mutable lives in one directory** — database, WhatsApp session,
|
|
86
|
+
memory files, logs. `levix where` prints it; copying it is a full backup.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## For developers
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
git clone https://github.com/Abdodiab2005/levix
|
|
94
|
+
cd levix
|
|
95
|
+
npm install
|
|
96
|
+
npm start # data lands in ./data
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`CLAUDE.md` is the architecture document: module layout, the storage API, the
|
|
100
|
+
message flow, and the rules a change has to respect. `PACKAGING.md` covers
|
|
101
|
+
shipping it — npm, Docker, systemd, and the single-executable build.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT.
|
package/SETUP.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Setting up Levix
|
|
2
|
+
|
|
3
|
+
Levix is a WhatsApp bot you run yourself. It links to your own WhatsApp account
|
|
4
|
+
by scanning a QR code, exactly like WhatsApp Web, and everything after that is
|
|
5
|
+
configured from a control panel in your browser.
|
|
6
|
+
|
|
7
|
+
There is no configuration file to edit. There are no keys to generate. If a
|
|
8
|
+
guide anywhere tells you to create a `.env` file, it is out of date.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## What you need
|
|
13
|
+
|
|
14
|
+
- A computer or a small server that stays on. A Raspberry Pi is enough.
|
|
15
|
+
- **Node.js 24 or newer** — [nodejs.org](https://nodejs.org), pick the LTS
|
|
16
|
+
button. (Or skip Node entirely and use Docker, below.)
|
|
17
|
+
- A phone with WhatsApp, to scan the QR once.
|
|
18
|
+
|
|
19
|
+
Levix stores everything in one file using SQLite, which Node 24 includes.
|
|
20
|
+
Nothing is compiled, nothing else is installed, no database server is needed.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## The quickest way
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g levix-bot
|
|
28
|
+
levix
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`levix-bot` is the npm package name. The command you run after installing it is
|
|
32
|
+
still just `levix`.
|
|
33
|
+
|
|
34
|
+
The terminal prints something like:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
Levix is running
|
|
38
|
+
|
|
39
|
+
Panel: http://localhost:3001/setup
|
|
40
|
+
Data: ~/.levix
|
|
41
|
+
|
|
42
|
+
First run — that link asks you to pick a password.
|
|
43
|
+
Opening it from another machine also needs this code: 43CB5162
|
|
44
|
+
|
|
45
|
+
Opening Levix in your browser...
|
|
46
|
+
|
|
47
|
+
Press Ctrl+C to stop.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
1. On a desktop the browser opens by itself. Otherwise open that address.
|
|
51
|
+
2. Choose a password. (Opening the page from a *different* machine also asks
|
|
52
|
+
for the setup code above — that's what stops a stranger from claiming your
|
|
53
|
+
bot before you do.)
|
|
54
|
+
3. You land on the control panel. Go to **Connection** and scan the QR with
|
|
55
|
+
WhatsApp → Settings → Linked devices → Link a device.
|
|
56
|
+
4. Send `!ping` in any chat. The bot answers.
|
|
57
|
+
|
|
58
|
+
Levix only opens a browser when there is one to open: over SSH, under systemd,
|
|
59
|
+
in Docker or in CI it just prints the address and says why. `--no-open` turns
|
|
60
|
+
it off anywhere.
|
|
61
|
+
|
|
62
|
+
That's the whole setup. Keep the terminal open, or see *Keeping it running*.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## With Docker instead
|
|
67
|
+
|
|
68
|
+
No Node install, nothing on your system but the container:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/Abdodiab2005/levix
|
|
72
|
+
cd levix
|
|
73
|
+
docker compose up -d
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Then open <http://localhost:3001>. The setup code is in the logs:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
docker compose logs levix | grep -A3 "Setup code"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Everything the bot owns lives in the `levix-data` volume.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Running it without the web panel
|
|
87
|
+
|
|
88
|
+
On a server you may not want a panel at all:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
levix headless
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
That starts the bot and nothing else — no web interface, no port opened. If the
|
|
95
|
+
bot has never been paired it prints the QR straight into the terminal:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Levix — headless
|
|
99
|
+
|
|
100
|
+
No WhatsApp session found.
|
|
101
|
+
|
|
102
|
+
Scan this QR from:
|
|
103
|
+
WhatsApp -> Linked devices -> Link a device
|
|
104
|
+
|
|
105
|
+
[ the QR ]
|
|
106
|
+
|
|
107
|
+
✓ WhatsApp connected
|
|
108
|
+
✓ Levix is ready
|
|
109
|
+
|
|
110
|
+
55 commands loaded
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
You can switch modes freely: the data directory is the same either way.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Putting it on a domain
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
sudo levix domain bot.example.com
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Levix looks at what the server already runs and fits in with it:
|
|
124
|
+
|
|
125
|
+
| What it finds | What it does |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| nginx (+ certbot) | adds one nginx site, runs `nginx -t`, reloads, offers HTTPS |
|
|
128
|
+
| Caddy | adds one site file to the existing Caddy |
|
|
129
|
+
| Apache | prints the reverse-proxy settings; changes nothing |
|
|
130
|
+
| a hosting panel (Plesk, cPanel, CloudPanel, …) | prints the settings and where to enter them; changes nothing |
|
|
131
|
+
| nothing at all | offers to install Caddy, which handles HTTPS by itself |
|
|
132
|
+
| something unrecognised on :80/:443 | stops and tells you what is there |
|
|
133
|
+
|
|
134
|
+
It never edits another site, never overwrites `nginx.conf`, and never reloads a
|
|
135
|
+
configuration that failed validation. Running it twice does not create a second
|
|
136
|
+
copy of anything.
|
|
137
|
+
|
|
138
|
+
The bot itself never needs root — only this command does, and only when it is
|
|
139
|
+
actually going to write to `/etc`.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Turning on the parts that need a key
|
|
144
|
+
|
|
145
|
+
The bot works out of the box. Two features need a free key, and both are set in
|
|
146
|
+
**Settings** in the panel — never in a file:
|
|
147
|
+
|
|
148
|
+
| Feature | Where to get the key |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| AI replies (`!gemini`, `!ai`) | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) |
|
|
151
|
+
| Weather (`!weather`) | [openweathermap.org/api](https://openweathermap.org/api) |
|
|
152
|
+
|
|
153
|
+
Paste the key, press Save. It applies to the next message — no restart.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Keeping it running
|
|
158
|
+
|
|
159
|
+
Closing the terminal stops the bot. Pick one:
|
|
160
|
+
|
|
161
|
+
**Docker** — already handled; the container restarts by itself.
|
|
162
|
+
|
|
163
|
+
**A Linux server** — the installer sets up a service that starts on boot:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
curl -fsSL https://levix.leviro.net/install.sh | bash
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Read the script first if you like — it is short, and it explains each step.
|
|
170
|
+
|
|
171
|
+
That always installs the newest stable Levix. To stay on one version — the same
|
|
172
|
+
script, pinned — use its own URL:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
curl -fsSL https://levix.leviro.net/install/v2.0.0.sh | bash
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Your own machine** — `pm2` works well:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
npm install -g pm2
|
|
182
|
+
pm2 start levix --name levix
|
|
183
|
+
pm2 save && pm2 startup
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Where your data is
|
|
189
|
+
|
|
190
|
+
One directory holds all of it: the database, the WhatsApp session, the bot's
|
|
191
|
+
long-term memory, the logs.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
levix where
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- installed with npm: `~/.levix`
|
|
198
|
+
- running from a git clone: `./data` inside the clone
|
|
199
|
+
- Docker: the `levix-data` volume
|
|
200
|
+
|
|
201
|
+
**Backing up** means copying that directory. **Moving to another machine**
|
|
202
|
+
means copying it across and starting Levix there — including the WhatsApp
|
|
203
|
+
session, so you don't even rescan the QR.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## When something goes wrong
|
|
208
|
+
|
|
209
|
+
**"Port 3001 is already taken"** — Levix is already running, or something else
|
|
210
|
+
holds that port. Open <http://localhost:3001> and see. To move it: Settings →
|
|
211
|
+
Server → Control panel port, then restart.
|
|
212
|
+
|
|
213
|
+
**Forgot the panel password**
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
levix reset-password
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Then start it again and pick a new one. The WhatsApp link is untouched.
|
|
220
|
+
|
|
221
|
+
**The bot stopped answering** — WhatsApp dropped the link, usually because the
|
|
222
|
+
phone was offline for a long time. Open the panel; if it asks for a QR, scan it
|
|
223
|
+
again.
|
|
224
|
+
|
|
225
|
+
**"Levix needs Node 24 or newer"** — your Node is older. Install the LTS build
|
|
226
|
+
from [nodejs.org](https://nodejs.org) and try again.
|
|
227
|
+
|
|
228
|
+
**Start over completely** — stop the bot, delete the data directory
|
|
229
|
+
(`levix where` prints it), start it again. That wipes everything: settings,
|
|
230
|
+
memory, the WhatsApp link.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Two things worth knowing
|
|
235
|
+
|
|
236
|
+
**Whoever scans the QR owns the bot.** That account is the owner and can run
|
|
237
|
+
every command. You can add more owners and admins from **Roles** in the panel.
|
|
238
|
+
|
|
239
|
+
**The panel has no HTTPS of its own.** On your own machine that's fine. On a
|
|
240
|
+
server reachable from the internet, run `sudo levix domain bot.example.com` —
|
|
241
|
+
it puts a reverse proxy with a certificate in front, sets the proxy-hop count
|
|
242
|
+
for you, and stops the panel listening publicly on its raw port.
|
package/app.cjs
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// file: app.cjs
|
|
2
|
+
//
|
|
3
|
+
// The control panel's HTTP surface. It is not optional: the panel is the only
|
|
4
|
+
// place the bot is configured, so there is always a server and always a login.
|
|
5
|
+
//
|
|
6
|
+
// Nothing here reads a configuration file or an environment variable. The
|
|
7
|
+
// session key is generated on first start (src/config/secrets.cjs), the
|
|
8
|
+
// password is chosen once from the browser, and the handful of HTTP knobs
|
|
9
|
+
// (port, proxy hops, extra origin) are settings in the database that the panel
|
|
10
|
+
// itself can change.
|
|
11
|
+
|
|
12
|
+
const http = require("node:http");
|
|
13
|
+
const express = require("express");
|
|
14
|
+
const { Server } = require("socket.io");
|
|
15
|
+
const session = require("express-session");
|
|
16
|
+
|
|
17
|
+
const logger = require("./src/utils/logger.cjs");
|
|
18
|
+
const { assetPath } = require("./src/config/paths.cjs");
|
|
19
|
+
const brand = require("./src/config/brand.cjs");
|
|
20
|
+
const settings = require("./src/config/settings.cjs");
|
|
21
|
+
const secrets = require("./src/config/secrets.cjs");
|
|
22
|
+
const { getQrCode } = require("./src/utils/storage.cjs");
|
|
23
|
+
const {
|
|
24
|
+
clientAddress,
|
|
25
|
+
isDirectLocalRequest,
|
|
26
|
+
} = require("./src/utils/requestOrigin.cjs");
|
|
27
|
+
|
|
28
|
+
const SESSION_COOKIE_NAME = "wa.sid";
|
|
29
|
+
const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
const app = express();
|
|
32
|
+
const server = http.createServer(app);
|
|
33
|
+
|
|
34
|
+
// Origin check for the socket: socket.io's `cors` option only covers polling,
|
|
35
|
+
// and the browser doesn't apply CORS to a websocket at all.
|
|
36
|
+
function isAllowedOrigin(origin, host) {
|
|
37
|
+
if (!origin) return true; // curl / a mobile app: no browser to protect
|
|
38
|
+
const extra = settings.get("dashboard_origin");
|
|
39
|
+
if (extra && origin === extra) return true;
|
|
40
|
+
return origin === `http://${host}` || origin === `https://${host}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// serveClient reads the browser bundle out of node_modules on every request,
|
|
44
|
+
// which a packaged build has no way to do. We vendor it in public/ instead —
|
|
45
|
+
// same reason qrcode.min.js is there rather than on a CDN.
|
|
46
|
+
const io = new Server(server, {
|
|
47
|
+
serveClient: false,
|
|
48
|
+
maxHttpBufferSize: 1e6,
|
|
49
|
+
allowRequest: (req, callback) => {
|
|
50
|
+
callback(null, isAllowedOrigin(req.headers.origin, req.headers.host));
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
app.disable("x-powered-by");
|
|
55
|
+
|
|
56
|
+
// Behind a proxy (nginx / Cloudflare) this has to match the real number of
|
|
57
|
+
// hops, or req.ip is the proxy's address — or worse, one the client forged.
|
|
58
|
+
// Default: off, which is right when the panel is exposed directly.
|
|
59
|
+
const trustProxy = settings.get("trust_proxy");
|
|
60
|
+
// Remembered rather than re-read: whether a proxy is in front decides whether
|
|
61
|
+
// the first-run page can ever skip the setup code, and that must not change
|
|
62
|
+
// under us mid-process. See src/utils/requestOrigin.cjs.
|
|
63
|
+
const PROXY_CONFIGURED = Boolean(trustProxy);
|
|
64
|
+
if (PROXY_CONFIGURED) {
|
|
65
|
+
app.set("trust proxy", /^\d+$/.test(trustProxy) ? Number(trustProxy) : trustProxy);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
app.use((req, res, next) => {
|
|
69
|
+
res.set("X-Content-Type-Options", "nosniff");
|
|
70
|
+
res.set("Referrer-Policy", "no-referrer");
|
|
71
|
+
res.set("X-Frame-Options", "DENY");
|
|
72
|
+
res.set("Content-Security-Policy", "frame-ancestors 'none'");
|
|
73
|
+
res.set("Cross-Origin-Opener-Policy", "same-origin");
|
|
74
|
+
next();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// 100 KB is plenty for the small dashboard mutations. The dashboard API is the
|
|
78
|
+
// exception: it carries whole Markdown files (the AI persona, a memory file),
|
|
79
|
+
// so src/index.js mounts `dashboardJson` in front of it and this parser skips
|
|
80
|
+
// those paths.
|
|
81
|
+
const jsonBody = express.json({ limit: "100kb" });
|
|
82
|
+
const dashboardJson = express.json({ limit: "2mb" });
|
|
83
|
+
|
|
84
|
+
app.use((req, res, next) => {
|
|
85
|
+
if (req.path.startsWith("/dashboard/api/")) return next();
|
|
86
|
+
return jsonBody(req, res, next);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// SameSite=lax closes the classic CSRF; this is a cheap check on top of it for
|
|
90
|
+
// anything that changes state.
|
|
91
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
92
|
+
app.use((req, res, next) => {
|
|
93
|
+
if (SAFE_METHODS.has(req.method)) return next();
|
|
94
|
+
if (isAllowedOrigin(req.get("origin"), req.get("host"))) return next();
|
|
95
|
+
return res.status(403).json({ error: "Cross-origin request rejected" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function requireLoginApi(req, res, next) {
|
|
99
|
+
if (req.session?.loggedIn) return next();
|
|
100
|
+
return res.status(401).json({ error: "Unauthorized" });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The QR and the dashboard are sensitive: no-store so a proxy or the browser's
|
|
104
|
+
// back button can't hand a signed-in page to someone else.
|
|
105
|
+
function noStore(req, res, next) {
|
|
106
|
+
res.set("Cache-Control", "no-store");
|
|
107
|
+
next();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function requireLoginPage(req, res, next) {
|
|
111
|
+
if (req.session?.loggedIn) return next();
|
|
112
|
+
return res.redirect("/");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Bind the engine by value rather than letting express require("ejs") lazily
|
|
116
|
+
// by name: a packaged build has no module resolver to answer that call.
|
|
117
|
+
app.engine("ejs", require("ejs").__express);
|
|
118
|
+
app.set("view engine", "ejs");
|
|
119
|
+
app.set("views", assetPath("views"));
|
|
120
|
+
// Name, tagline and credit for every template. Frozen — see brand.cjs.
|
|
121
|
+
app.locals.brand = brand;
|
|
122
|
+
app.use(express.static(assetPath("public")));
|
|
123
|
+
app.use(express.urlencoded({ extended: true, limit: "100kb" }));
|
|
124
|
+
|
|
125
|
+
const sessionMiddleware = session({
|
|
126
|
+
name: SESSION_COOKIE_NAME,
|
|
127
|
+
secret: secrets.getSessionSecret(),
|
|
128
|
+
resave: false,
|
|
129
|
+
saveUninitialized: false,
|
|
130
|
+
cookie: {
|
|
131
|
+
httpOnly: true,
|
|
132
|
+
sameSite: "lax",
|
|
133
|
+
// "auto" = Secure only on an HTTPS request, so the same install works on
|
|
134
|
+
// http://localhost and behind a TLS proxy without a setting to get wrong.
|
|
135
|
+
secure: "auto",
|
|
136
|
+
path: "/",
|
|
137
|
+
maxAge: SESSION_MAX_AGE_MS,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
app.use(sessionMiddleware);
|
|
142
|
+
|
|
143
|
+
// Every socket receives the pairing QR, so it goes through the same session.
|
|
144
|
+
io.engine.use(sessionMiddleware);
|
|
145
|
+
io.use((socket, next) => {
|
|
146
|
+
if (socket.request.session?.loggedIn) return next();
|
|
147
|
+
next(new Error("unauthorized"));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// --- Attempt throttling ---------------------------------------------------
|
|
151
|
+
// Shared by /login and /setup: both are a guess at a secret.
|
|
152
|
+
//
|
|
153
|
+
// Keyed on the TCP peer, never on `req.ip`: with `trust proxy` set, a client
|
|
154
|
+
// picks its own `req.ip` by writing a header, so it could rotate the value and
|
|
155
|
+
// never hit the limit.
|
|
156
|
+
|
|
157
|
+
const ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
|
|
158
|
+
const MAX_ATTEMPTS = 5;
|
|
159
|
+
const attempts = new Map();
|
|
160
|
+
|
|
161
|
+
function blockedFor(ip) {
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
for (const [key, entry] of attempts) {
|
|
164
|
+
if (now - entry.firstAt > ATTEMPT_WINDOW_MS) attempts.delete(key);
|
|
165
|
+
}
|
|
166
|
+
const entry = attempts.get(ip);
|
|
167
|
+
if (!entry || entry.count < MAX_ATTEMPTS) return 0;
|
|
168
|
+
const remaining = ATTEMPT_WINDOW_MS - (now - entry.firstAt);
|
|
169
|
+
return remaining > 0 ? Math.ceil(remaining / 1000) : 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function recordFailure(ip) {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
const entry = attempts.get(ip);
|
|
175
|
+
if (!entry || now - entry.firstAt > ATTEMPT_WINDOW_MS) {
|
|
176
|
+
attempts.set(ip, { count: 1, firstAt: now });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
entry.count += 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- First run ------------------------------------------------------------
|
|
183
|
+
//
|
|
184
|
+
// Until a password exists there is nothing to log into. The panel shows a
|
|
185
|
+
// "choose your password" page instead.
|
|
186
|
+
//
|
|
187
|
+
// From a direct loopback connection that is all it asks: whoever is sitting at
|
|
188
|
+
// the machine is the operator. Every other request — remote, proxied, or merely
|
|
189
|
+
// carrying a forwarding header — also needs the setup code printed on the
|
|
190
|
+
// server's console, so claiming the bot requires access to the machine rather
|
|
191
|
+
// than to the port. See src/utils/requestOrigin.cjs for why this can't be
|
|
192
|
+
// `req.ip`.
|
|
193
|
+
|
|
194
|
+
function isLocalRequest(req) {
|
|
195
|
+
return isDirectLocalRequest(req, { proxyConfigured: PROXY_CONFIGURED });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderSetup(req, res, error, status = 200) {
|
|
199
|
+
res.status(status).render("setup", {
|
|
200
|
+
error,
|
|
201
|
+
needsCode: !isLocalRequest(req),
|
|
202
|
+
minLength: secrets.MIN_PASSWORD_LENGTH,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
app.get("/setup", noStore, (req, res) => {
|
|
207
|
+
if (secrets.hasDashboardPassword()) return res.redirect("/");
|
|
208
|
+
renderSetup(req, res, null);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
app.post("/setup", (req, res, next) => {
|
|
212
|
+
if (secrets.hasDashboardPassword()) return res.redirect("/");
|
|
213
|
+
|
|
214
|
+
const peer = clientAddress(req);
|
|
215
|
+
const retryAfter = blockedFor(peer);
|
|
216
|
+
if (retryAfter) {
|
|
217
|
+
res.set("Retry-After", String(retryAfter));
|
|
218
|
+
return renderSetup(req, res, "Too many attempts. Try again later.", 429);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { password, confirm, code } = req.body || {};
|
|
222
|
+
|
|
223
|
+
if (!isLocalRequest(req) && !secrets.setupCodeMatches(code)) {
|
|
224
|
+
recordFailure(peer);
|
|
225
|
+
logger.warn({ ip: peer }, "[dashboard] Setup attempted with a wrong code");
|
|
226
|
+
return renderSetup(req, res, "Wrong setup code.", 401);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (password !== confirm) {
|
|
230
|
+
return renderSetup(req, res, "The two passwords don't match.", 400);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
secrets.setDashboardPassword(password);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return renderSetup(req, res, error.message, 400);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
attempts.delete(peer);
|
|
240
|
+
logger.info({ ip: peer }, "[dashboard] Password set — first run complete");
|
|
241
|
+
|
|
242
|
+
req.session.regenerate((err) => {
|
|
243
|
+
if (err) return next(err);
|
|
244
|
+
req.session.loggedIn = true;
|
|
245
|
+
req.session.save((saveErr) => {
|
|
246
|
+
if (saveErr) return next(saveErr);
|
|
247
|
+
res.redirect(303, "/");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// --- Routes ---------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
app.get("/", noStore, (req, res) => {
|
|
255
|
+
if (!secrets.hasDashboardPassword()) return res.redirect("/setup");
|
|
256
|
+
if (req.session.loggedIn) return res.render("dashboard");
|
|
257
|
+
return res.render("login", { error: null });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
app.post("/login", (req, res, next) => {
|
|
261
|
+
if (!secrets.hasDashboardPassword()) return res.redirect("/setup");
|
|
262
|
+
|
|
263
|
+
const peer = clientAddress(req);
|
|
264
|
+
const retryAfter = blockedFor(peer);
|
|
265
|
+
if (retryAfter) {
|
|
266
|
+
res.set("Retry-After", String(retryAfter));
|
|
267
|
+
return res.status(429).render("login", {
|
|
268
|
+
error: "Too many attempts. Try again later.",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!secrets.verifyDashboardPassword(req.body?.password)) {
|
|
273
|
+
recordFailure(peer);
|
|
274
|
+
logger.warn({ ip: peer }, "[dashboard] Failed login attempt");
|
|
275
|
+
return res.status(401).render("login", { error: "Incorrect Password" });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
attempts.delete(peer);
|
|
279
|
+
|
|
280
|
+
// A fresh id after login, or the id an attacker planted beforehand still works.
|
|
281
|
+
req.session.regenerate((err) => {
|
|
282
|
+
if (err) return next(err);
|
|
283
|
+
req.session.loggedIn = true;
|
|
284
|
+
req.session.save((saveErr) => {
|
|
285
|
+
if (saveErr) return next(saveErr);
|
|
286
|
+
res.redirect(303, "/");
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// POST, not GET: a link that changes state gets fired by an <img> or by the
|
|
292
|
+
// browser's prefetch.
|
|
293
|
+
app.post("/logout", (req, res, next) => {
|
|
294
|
+
req.session.destroy((err) => {
|
|
295
|
+
if (err) return next(err);
|
|
296
|
+
res.clearCookie(SESSION_COOKIE_NAME, {
|
|
297
|
+
httpOnly: true,
|
|
298
|
+
sameSite: "lax",
|
|
299
|
+
path: "/",
|
|
300
|
+
});
|
|
301
|
+
res.redirect(303, "/");
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
app.get("/qr", requireLoginPage, noStore, (req, res) => {
|
|
306
|
+
const qr = getQrCode();
|
|
307
|
+
res.render("qr", { qr: qr || "" });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Unlink / restart / everything else live on the dashboard API
|
|
311
|
+
// (/dashboard/api/*), registered in src/index.js once the socket exists.
|
|
312
|
+
|
|
313
|
+
// The 404 and the error handler have to be registered after every route — put
|
|
314
|
+
// them here and they would swallow the routes index.js adds later.
|
|
315
|
+
function installFinalHandlers() {
|
|
316
|
+
app.use((req, res) => {
|
|
317
|
+
res.status(404).json({ error: "Not found" });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// eslint-disable-next-line no-unused-vars
|
|
321
|
+
app.use((err, req, res, next) => {
|
|
322
|
+
logger.error({ err, method: req.method, path: req.path }, "[http] Unhandled error");
|
|
323
|
+
if (res.headersSent) return;
|
|
324
|
+
res.status(err.statusCode || 500).json({ error: "Internal server error" });
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
module.exports = {
|
|
329
|
+
app,
|
|
330
|
+
server,
|
|
331
|
+
io,
|
|
332
|
+
dashboardJson,
|
|
333
|
+
requireLoginApi,
|
|
334
|
+
requireLoginPage,
|
|
335
|
+
noStore,
|
|
336
|
+
installFinalHandlers,
|
|
337
|
+
};
|