levix-bot 2.0.0 → 2.1.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 +23 -5
- package/SETUP.md +24 -4
- package/app.cjs +22 -1
- package/deploy/install.sh +97 -11
- package/deploy/levix.service +3 -0
- package/package.json +18 -9
- package/public/dashboard.css +9 -0
- package/public/dashboard.js +233 -43
- package/scheduler.cjs +15 -0
- package/src/bootstrap/core.js +36 -32
- package/src/bootstrap/events.cjs +4 -2
- package/src/bootstrap/panel.js +9 -7
- package/src/commands/autoschedule.cjs +2 -3
- package/src/commands/gemini.cjs +45 -28
- package/src/commands/restart.cjs +6 -2
- package/src/commands/stt.cjs +43 -26
- package/src/commands/tts.cjs +55 -35
- package/src/config/ai-identity.cjs +61 -0
- package/src/config/ai-persona.md +92 -17
- package/src/config/brand.cjs +8 -18
- package/src/config/constants.js +11 -2
- package/src/config/secrets.cjs +19 -0
- package/src/config/settings.cjs +96 -2
- package/src/core/connection.js +140 -134
- package/src/core/events.js +64 -22
- package/src/core/proxy.js +266 -0
- package/src/core/session.js +828 -0
- package/src/core/socket.js +39 -5
- package/src/index.js +136 -29
- package/src/routes/dashboard.api.esm.js +127 -30
- package/src/services/aiAgent.cjs +278 -48
- package/src/services/aiTools.cjs +14 -12
- package/src/utils/storage.esm.js +5 -0
- package/views/dashboard.ejs +20 -11
- package/views/qr.ejs +23 -3
package/README.md
CHANGED
|
@@ -14,8 +14,13 @@ levix
|
|
|
14
14
|
The npm package is named `levix-bot`; the installed CLI command stays `levix`.
|
|
15
15
|
|
|
16
16
|
On a desktop that opens the panel in your browser by itself. Pick a password,
|
|
17
|
-
|
|
18
|
-
and running it as a
|
|
17
|
+
go to **Connection**, press **Start session** and scan the QR — see
|
|
18
|
+
[SETUP.md](SETUP.md) for the longer version, Docker, and running it as a
|
|
19
|
+
service.
|
|
20
|
+
|
|
21
|
+
Starting Levix does not connect it to WhatsApp. The panel, the database and the
|
|
22
|
+
commands come up on their own, and the WhatsApp session is started from the
|
|
23
|
+
Connection screen when you want it.
|
|
19
24
|
|
|
20
25
|
```
|
|
21
26
|
levix start Levix with the web panel
|
|
@@ -41,7 +46,9 @@ warnings with auto-kick, rules and notes, plus the usual `!group kick`,
|
|
|
41
46
|
**An AI agent, not a chat box** — `!gemini` can search the web, open a page and
|
|
42
47
|
read it, save something to its long-term memory and hand out bot roles, across
|
|
43
48
|
several tool rounds, narrating the whole run inside a single message it keeps
|
|
44
|
-
editing.
|
|
49
|
+
editing. Gemini's own Google Search is available to it too, so questions about
|
|
50
|
+
current things get grounded answers with their sources listed. Its personality
|
|
51
|
+
is a Markdown file you can edit from the panel.
|
|
45
52
|
|
|
46
53
|
**Long-term memory** — "remember that…" writes to `memory/global.md` or a
|
|
47
54
|
per-chat file. Plain Markdown, hand-editable, injected into every prompt.
|
|
@@ -49,9 +56,20 @@ per-chat file. Plain Markdown, hand-editable, injected into every prompt.
|
|
|
49
56
|
**Scheduled messages** — one-off (`!schedule`) or recurring (`!autoschedule`),
|
|
50
57
|
stored in the database and listed in the panel.
|
|
51
58
|
|
|
59
|
+
**Optional proxy** — Settings → WhatsApp proxy routes the WhatsApp connection
|
|
60
|
+
through an HTTP, HTTPS or SOCKS5 proxy, including the media it sends and
|
|
61
|
+
receives. Nothing else changes: the control panel and the AI still connect
|
|
62
|
+
directly.
|
|
63
|
+
|
|
64
|
+
**A connection you control** — the panel starts, stops and unlinks the
|
|
65
|
+
WhatsApp session, and shows what it is actually doing: waiting for a scan,
|
|
66
|
+
connected, reconnecting (5s, 10s, 15s, 20s, 25s), or stopped. Reconnects happen
|
|
67
|
+
in the bot whether or not a browser is open, and a WhatsApp connection that will
|
|
68
|
+
not come back never takes the panel down with it.
|
|
69
|
+
|
|
52
70
|
**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;
|
|
54
|
-
QR straight to the terminal.
|
|
71
|
+
Express, no socket.io and no port open at all; having no screen to press Start
|
|
72
|
+
on, it connects by itself and prints its QR straight to the terminal.
|
|
55
73
|
|
|
56
74
|
**A domain, without a takeover** — `levix domain bot.example.com` looks at what
|
|
57
75
|
the server already runs and works with it: it adds one nginx or Caddy site and
|
package/SETUP.md
CHANGED
|
@@ -40,10 +40,15 @@ The terminal prints something like:
|
|
|
40
40
|
Data: ~/.levix
|
|
41
41
|
|
|
42
42
|
First run — that link asks you to pick a password.
|
|
43
|
-
Opening it from another machine also needs this code:
|
|
43
|
+
Opening it from another machine also needs this code:
|
|
44
|
+
|
|
45
|
+
[Setup] Setup code: 43CB5162
|
|
44
46
|
|
|
45
47
|
Opening Levix in your browser...
|
|
46
48
|
|
|
49
|
+
Not linked to WhatsApp yet? Open the panel, go to Connection and
|
|
50
|
+
press Start session.
|
|
51
|
+
|
|
47
52
|
Press Ctrl+C to stop.
|
|
48
53
|
```
|
|
49
54
|
|
|
@@ -51,8 +56,9 @@ The terminal prints something like:
|
|
|
51
56
|
2. Choose a password. (Opening the page from a *different* machine also asks
|
|
52
57
|
for the setup code above — that's what stops a stranger from claiming your
|
|
53
58
|
bot before you do.)
|
|
54
|
-
3. You land on the control panel. Go to **Connection
|
|
55
|
-
WhatsApp → Settings → Linked devices → Link a device.
|
|
59
|
+
3. You land on the control panel. Go to **Connection**, press **Start session**,
|
|
60
|
+
and scan the QR with WhatsApp → Settings → Linked devices → Link a device.
|
|
61
|
+
Levix does not connect to WhatsApp until you ask it to.
|
|
56
62
|
4. Send `!ping` in any chat. The bot answers.
|
|
57
63
|
|
|
58
64
|
Levix only opens a browser when there is one to open: over SSH, under systemd,
|
|
@@ -76,7 +82,7 @@ docker compose up -d
|
|
|
76
82
|
Then open <http://localhost:3001>. The setup code is in the logs:
|
|
77
83
|
|
|
78
84
|
```bash
|
|
79
|
-
docker compose logs levix | grep -
|
|
85
|
+
docker compose logs levix | grep -F '[Setup] Setup code:' | tail -1
|
|
80
86
|
```
|
|
81
87
|
|
|
82
88
|
Everything the bot owns lives in the `levix-data` volume.
|
|
@@ -166,6 +172,13 @@ Closing the terminal stops the bot. Pick one:
|
|
|
166
172
|
curl -fsSL https://levix.leviro.net/install.sh | bash
|
|
167
173
|
```
|
|
168
174
|
|
|
175
|
+
Opening the panel from your laptop counts as another machine, so it asks for
|
|
176
|
+
the setup code. Print it with:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
journalctl -u levix --no-pager | grep -F '[Setup] Setup code:' | tail -1
|
|
180
|
+
```
|
|
181
|
+
|
|
169
182
|
Read the script first if you like — it is short, and it explains each step.
|
|
170
183
|
|
|
171
184
|
That always installs the newest stable Levix. To stay on one version — the same
|
|
@@ -175,6 +188,13 @@ script, pinned — use its own URL:
|
|
|
175
188
|
curl -fsSL https://levix.leviro.net/install/v2.0.0.sh | bash
|
|
176
189
|
```
|
|
177
190
|
|
|
191
|
+
**Behind a proxy** — if this server reaches the internet through a proxy, the
|
|
192
|
+
panel has a **WhatsApp proxy** section under Settings: protocol (HTTP, HTTPS or
|
|
193
|
+
SOCKS5), host, port, and a username and password if yours needs them. It applies
|
|
194
|
+
to the WhatsApp connection only — the panel itself and the AI keep connecting
|
|
195
|
+
directly. Saving it never interrupts a working session; the Connection screen
|
|
196
|
+
offers **Reconnect to apply** when a change is waiting.
|
|
197
|
+
|
|
178
198
|
**Your own machine** — `pm2` works well:
|
|
179
199
|
|
|
180
200
|
```bash
|
package/app.cjs
CHANGED
|
@@ -40,6 +40,27 @@ function isAllowedOrigin(origin, host) {
|
|
|
40
40
|
return origin === `http://${host}` || origin === `https://${host}`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Browsers may legitimately serialize the Origin header as the literal string
|
|
44
|
+
// "null" for a same-origin navigation in an opaque-origin context. Treating
|
|
45
|
+
// that as a foreign origin breaks first-run/login forms in real browsers even
|
|
46
|
+
// though Sec-Fetch-Site still proves the navigation is same-origin. Fetch
|
|
47
|
+
// metadata headers are forbidden to page JavaScript, so they are a stronger
|
|
48
|
+
// browser signal than trying to special-case the string "null" globally.
|
|
49
|
+
function isAllowedMutationRequest(req) {
|
|
50
|
+
const origin = req.get("origin");
|
|
51
|
+
const host = req.get("host");
|
|
52
|
+
|
|
53
|
+
// Preserve the explicit dashboard_origin escape hatch before consulting
|
|
54
|
+
// fetch metadata: a deliberately separate dashboard can be cross-site.
|
|
55
|
+
if (origin && origin !== "null" && isAllowedOrigin(origin, host)) return true;
|
|
56
|
+
|
|
57
|
+
const fetchSite = req.get("sec-fetch-site");
|
|
58
|
+
if (fetchSite === "same-origin") return true;
|
|
59
|
+
if (fetchSite === "cross-site") return false;
|
|
60
|
+
|
|
61
|
+
return isAllowedOrigin(origin, host);
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
// serveClient reads the browser bundle out of node_modules on every request,
|
|
44
65
|
// which a packaged build has no way to do. We vendor it in public/ instead —
|
|
45
66
|
// same reason qrcode.min.js is there rather than on a CDN.
|
|
@@ -91,7 +112,7 @@ app.use((req, res, next) => {
|
|
|
91
112
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
92
113
|
app.use((req, res, next) => {
|
|
93
114
|
if (SAFE_METHODS.has(req.method)) return next();
|
|
94
|
-
if (
|
|
115
|
+
if (isAllowedMutationRequest(req)) return next();
|
|
95
116
|
return res.status(403).json({ error: "Cross-origin request rejected" });
|
|
96
117
|
});
|
|
97
118
|
|
package/deploy/install.sh
CHANGED
|
@@ -11,16 +11,20 @@
|
|
|
11
11
|
# What it does, in order — read it before you pipe it into a shell, as you
|
|
12
12
|
# should with any script that asks for sudo:
|
|
13
13
|
#
|
|
14
|
-
# 1.
|
|
14
|
+
# 1. makes sure Node 24+ is available (bootstraps it on Debian/Ubuntu)
|
|
15
15
|
# 2. installs the `levix` command globally from the `levix-bot` npm package
|
|
16
16
|
# 3. creates a `levix` system user and /var/lib/levix for its data
|
|
17
17
|
# 4. installs the systemd unit, pointed at the levix it just installed
|
|
18
18
|
# 5. enables it, starts it, and prints where to go next
|
|
19
19
|
# 6. offers — but never assumes — to connect a domain
|
|
20
20
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
21
|
+
# On a machine that already has Node 24+, the installer does not touch the
|
|
22
|
+
# system package manager. On a fresh Debian/Ubuntu machine it also installs
|
|
23
|
+
# Node.js from NodeSource, which adds its signing key and APT source.
|
|
24
|
+
#
|
|
25
|
+
# Levix itself writes exactly three things outside npm's own prefix: the system
|
|
26
|
+
# user, /var/lib/levix, and /etc/systemd/system/levix.service. It never touches
|
|
27
|
+
# an existing installation's data, and it can be re-run to upgrade.
|
|
24
28
|
|
|
25
29
|
set -euo pipefail
|
|
26
30
|
|
|
@@ -48,15 +52,97 @@ sudo_if_needed() {
|
|
|
48
52
|
|
|
49
53
|
# --- 1. Node ---------------------------------------------------------------
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
node_major() {
|
|
56
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
57
|
+
printf '0\n'
|
|
58
|
+
return
|
|
59
|
+
fi
|
|
53
60
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
local major
|
|
62
|
+
major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)"
|
|
63
|
+
case "$major" in
|
|
64
|
+
''|*[!0-9]*) printf '0\n' ;;
|
|
65
|
+
*) printf '%s\n' "$major" ;;
|
|
66
|
+
esac
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
install_node24() {
|
|
70
|
+
[ -r /etc/os-release ] ||
|
|
71
|
+
die "Node ${MIN_NODE_MAJOR}+ is required. Automatic Node installation is supported on Debian and Ubuntu only."
|
|
72
|
+
|
|
73
|
+
# shellcheck disable=SC1091
|
|
74
|
+
. /etc/os-release
|
|
75
|
+
case "${ID:-}" in
|
|
76
|
+
debian|ubuntu) ;;
|
|
77
|
+
*)
|
|
78
|
+
die "Node ${MIN_NODE_MAJOR}+ is required. Automatic Node installation is supported on Debian and Ubuntu only (found ${ID:-unknown}). Install Node ${MIN_NODE_MAJOR}+ and run this installer again."
|
|
79
|
+
;;
|
|
80
|
+
esac
|
|
81
|
+
|
|
82
|
+
command -v apt-get >/dev/null 2>&1 ||
|
|
83
|
+
die "This looks like ${ID}, but apt-get is unavailable. Install Node ${MIN_NODE_MAJOR}+ and run this installer again."
|
|
84
|
+
|
|
85
|
+
say "Installing Node ${MIN_NODE_MAJOR} from NodeSource…"
|
|
86
|
+
|
|
87
|
+
sudo_if_needed apt-get update
|
|
88
|
+
sudo_if_needed apt-get install -y ca-certificates curl
|
|
89
|
+
sudo_if_needed install -d -m 0755 /etc/apt/keyrings
|
|
90
|
+
|
|
91
|
+
local key_tmp repo_tmp
|
|
92
|
+
key_tmp="$(mktemp)"
|
|
93
|
+
repo_tmp="$(mktemp)"
|
|
94
|
+
|
|
95
|
+
# Download the signing key as data; do not execute a second remote setup
|
|
96
|
+
# script inside the Levix installer.
|
|
97
|
+
if ! curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o "$key_tmp"; then
|
|
98
|
+
rm -f "$key_tmp" "$repo_tmp"
|
|
99
|
+
die "Could not download the NodeSource signing key."
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
printf '%s\n' \
|
|
103
|
+
"deb [signed-by=/etc/apt/keyrings/nodesource.asc] https://deb.nodesource.com/node_${MIN_NODE_MAJOR}.x nodistro main" \
|
|
104
|
+
> "$repo_tmp"
|
|
105
|
+
|
|
106
|
+
sudo_if_needed install -m 0644 "$key_tmp" /etc/apt/keyrings/nodesource.asc
|
|
107
|
+
sudo_if_needed install -m 0644 "$repo_tmp" /etc/apt/sources.list.d/nodesource.list
|
|
108
|
+
rm -f "$key_tmp" "$repo_tmp"
|
|
109
|
+
|
|
110
|
+
sudo_if_needed apt-get update
|
|
111
|
+
sudo_if_needed apt-get install -y nodejs
|
|
112
|
+
|
|
113
|
+
# A pre-existing nvm/asdf installation can leave an older Node earlier in
|
|
114
|
+
# PATH even after the system package is upgraded. Prefer the freshly installed
|
|
115
|
+
# NodeSource binary for the rest of this installer when it is suitable.
|
|
116
|
+
if [ -x /usr/bin/node ]; then
|
|
117
|
+
local system_major
|
|
118
|
+
system_major="$(/usr/bin/node -p 'process.versions.node.split(".")[0]' 2>/dev/null || printf '0')"
|
|
119
|
+
case "$system_major" in
|
|
120
|
+
''|*[!0-9]*) system_major=0 ;;
|
|
121
|
+
esac
|
|
122
|
+
if [ "$system_major" -ge "$MIN_NODE_MAJOR" ]; then
|
|
123
|
+
PATH="/usr/bin:$PATH"
|
|
124
|
+
export PATH
|
|
125
|
+
hash -r
|
|
126
|
+
fi
|
|
127
|
+
fi
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
NODE_MAJOR="$(node_major)"
|
|
131
|
+
if [ "$NODE_MAJOR" -lt "$MIN_NODE_MAJOR" ] || ! command -v npm >/dev/null 2>&1; then
|
|
132
|
+
if [ "$NODE_MAJOR" -eq 0 ]; then
|
|
133
|
+
say "Node ${MIN_NODE_MAJOR}+ was not found; bootstrapping it for you."
|
|
134
|
+
else
|
|
135
|
+
say "Node $(node -v) is too old; upgrading to Node ${MIN_NODE_MAJOR}."
|
|
136
|
+
fi
|
|
137
|
+
install_node24
|
|
138
|
+
NODE_MAJOR="$(node_major)"
|
|
57
139
|
fi
|
|
58
140
|
|
|
59
|
-
|
|
141
|
+
if [ "$NODE_MAJOR" -lt "$MIN_NODE_MAJOR" ]; then
|
|
142
|
+
die "Node ${MIN_NODE_MAJOR}+ is required, but the automatic installation did not provide it (found $(node -v 2>/dev/null || printf 'no Node'))."
|
|
143
|
+
fi
|
|
144
|
+
command -v npm >/dev/null 2>&1 ||
|
|
145
|
+
die "Node $(node -v) is installed, but npm is missing. Install npm and run this installer again."
|
|
60
146
|
command -v systemctl >/dev/null 2>&1 ||
|
|
61
147
|
die "This script installs a systemd service, and systemd isn't here. Run 'npm i -g ${PACKAGE}' and start 'levix' yourself."
|
|
62
148
|
|
|
@@ -151,7 +237,7 @@ cat <<EOF
|
|
|
151
237
|
Open the panel and pick a password. Opening it from another machine also
|
|
152
238
|
asks for a setup code — this prints it:
|
|
153
239
|
|
|
154
|
-
journalctl -u ${SERVICE} -
|
|
240
|
+
journalctl -u ${SERVICE} --no-pager | grep -F '[Setup] Setup code:' | tail -1
|
|
155
241
|
|
|
156
242
|
Then scan the QR from the Connection screen with WhatsApp.
|
|
157
243
|
|
package/deploy/levix.service
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
# sudo systemctl enable --now levix
|
|
5
5
|
# journalctl -u levix -f # the setup code is printed here on first run
|
|
6
6
|
#
|
|
7
|
+
# The first-run setup code is one line on stdout, so the journal has it:
|
|
8
|
+
# journalctl -u levix --no-pager | grep -F '[Setup] Setup code:' | tail -1
|
|
9
|
+
#
|
|
7
10
|
# deploy/install.sh installs this and rewrites ExecStart with the real path of
|
|
8
11
|
# the `levix` command it just installed — `/usr/bin/env levix` only works when
|
|
9
12
|
# npm's global bin is on systemd's own PATH, which it is not under nvm and not
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "levix-bot",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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": {
|
|
@@ -26,23 +26,32 @@
|
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"type": "module",
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@google/
|
|
30
|
-
"@
|
|
29
|
+
"@google/genai": "^2.19.0",
|
|
30
|
+
"@sefinek/google-tts-api": "^2.1.15",
|
|
31
|
+
"@whiskeysockets/baileys": "7.0.0-rc14",
|
|
31
32
|
"axios": "^1.9.0",
|
|
32
|
-
"ejs": "^
|
|
33
|
+
"ejs": "^6.0.1",
|
|
33
34
|
"express": "^5.1.0",
|
|
34
35
|
"express-session": "^1.18.1",
|
|
35
|
-
"ffmpeg-static": "^5.
|
|
36
|
-
"
|
|
36
|
+
"ffmpeg-static": "^5.3.0",
|
|
37
|
+
"https-proxy-agent": "^9.1.0",
|
|
37
38
|
"node-cache": "^5.1.2",
|
|
38
39
|
"node-cron": "^4.1.0",
|
|
39
|
-
"
|
|
40
|
-
"pino": "^9.7.0",
|
|
40
|
+
"pino": "^10.3.1",
|
|
41
41
|
"pino-pretty": "^13.0.0",
|
|
42
42
|
"qrcode": "^1.5.4",
|
|
43
43
|
"qrcode-terminal": "^0.12.0",
|
|
44
44
|
"socket.io": "^4.8.1",
|
|
45
|
-
"
|
|
45
|
+
"socks": "^2.8.9",
|
|
46
|
+
"socks-proxy-agent": "^10.1.0",
|
|
47
|
+
"undici": "^8.10.0"
|
|
48
|
+
},
|
|
49
|
+
"allowScripts": {
|
|
50
|
+
"@whiskeysockets/baileys": true,
|
|
51
|
+
"esbuild": true,
|
|
52
|
+
"ffmpeg-static": true,
|
|
53
|
+
"protobufjs": true,
|
|
54
|
+
"@google/genai": true
|
|
46
55
|
},
|
|
47
56
|
"repository": {
|
|
48
57
|
"type": "git",
|
package/public/dashboard.css
CHANGED
|
@@ -302,6 +302,15 @@ kbd {
|
|
|
302
302
|
.pill.off .dot {
|
|
303
303
|
background: var(--danger);
|
|
304
304
|
}
|
|
305
|
+
/* Connecting / waiting for a scan / reconnecting: neither linked nor dead. */
|
|
306
|
+
.pill.warn {
|
|
307
|
+
color: var(--warn);
|
|
308
|
+
border-color: color-mix(in srgb, var(--warn) 40%, transparent);
|
|
309
|
+
}
|
|
310
|
+
.pill.warn .dot {
|
|
311
|
+
background: var(--warn);
|
|
312
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 22%, transparent);
|
|
313
|
+
}
|
|
305
314
|
|
|
306
315
|
.btn {
|
|
307
316
|
display: inline-flex;
|