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
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
// Dashboard API.
|
|
2
|
+
//
|
|
3
|
+
// Everything the dashboard can read or change goes through here. It is mounted
|
|
4
|
+
// behind the session check (see src/index.js), so every handler already knows
|
|
5
|
+
// it is talking to whoever holds the panel password — the operator.
|
|
6
|
+
//
|
|
7
|
+
// Two rules the handlers follow:
|
|
8
|
+
// * error text stays in the log. The internal message names collections and
|
|
9
|
+
// server paths, and this response is rendered in a browser.
|
|
10
|
+
// * nothing here can change the bot's identity. The name and the credit come
|
|
11
|
+
// from src/config/brand.cjs, are injected into the AI prompt, and are not
|
|
12
|
+
// exposed as an editable field anywhere below.
|
|
13
|
+
|
|
14
|
+
import { Router } from "express";
|
|
15
|
+
import { createRequire } from "module";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
countGroups,
|
|
21
|
+
countWarnings,
|
|
22
|
+
countTodos,
|
|
23
|
+
countNotes,
|
|
24
|
+
countDebts,
|
|
25
|
+
getAllGroupSettings,
|
|
26
|
+
getGroupSettings,
|
|
27
|
+
saveGroupSettings,
|
|
28
|
+
getAllNotesFlat,
|
|
29
|
+
getAllWarnings,
|
|
30
|
+
getAllTodos,
|
|
31
|
+
getAllUsers,
|
|
32
|
+
getRecentDebts,
|
|
33
|
+
countSchedules,
|
|
34
|
+
getSchedules,
|
|
35
|
+
} from "../utils/storage.esm.js";
|
|
36
|
+
import {
|
|
37
|
+
getCommandCatalog,
|
|
38
|
+
rebuildCommandIndex,
|
|
39
|
+
} from "../handlers/command.handler.js";
|
|
40
|
+
import { groupMetadataCache } from "../core/socket.js";
|
|
41
|
+
import { grantRole, revokeRole, listRoles } from "../utils/permissions.esm.js";
|
|
42
|
+
|
|
43
|
+
const require = createRequire(import.meta.url);
|
|
44
|
+
const logger = require("../utils/logger.cjs");
|
|
45
|
+
const runtimeConfig = require("../config/runtime-config.cjs");
|
|
46
|
+
const settings = require("../config/settings.cjs");
|
|
47
|
+
const memory = require("../utils/memory.cjs");
|
|
48
|
+
const secrets = require("../config/secrets.cjs");
|
|
49
|
+
const { DATA_DIR } = require("../config/paths.cjs");
|
|
50
|
+
const { PERSONA_FILE } = require("../services/aiAgent.cjs");
|
|
51
|
+
const { deleteScheduledJob } = require("../../scheduler.cjs");
|
|
52
|
+
|
|
53
|
+
const router = Router();
|
|
54
|
+
|
|
55
|
+
// The live socket, and the auth-clearing callback that comes with it.
|
|
56
|
+
let botInstance = null;
|
|
57
|
+
let botControls = { clearAll: null };
|
|
58
|
+
|
|
59
|
+
export function setBotInstance(sock) {
|
|
60
|
+
botInstance = sock;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Handed the pieces of the current socket that /bot/* needs. */
|
|
64
|
+
export function setBotControls(controls = {}) {
|
|
65
|
+
botControls = { ...botControls, ...controls };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- helpers --------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
function fail(res, error, label, status = 500) {
|
|
71
|
+
logger.error({ err: error }, `[Dashboard API] ${label}`);
|
|
72
|
+
res.status(status).json({ success: false, error: "Internal server error" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A caller mistake (bad value, unknown command): safe to echo back. */
|
|
76
|
+
function badRequest(res, message) {
|
|
77
|
+
res.status(400).json({ success: false, error: message });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function asyncRoute(handler) {
|
|
81
|
+
return (req, res) => {
|
|
82
|
+
Promise.resolve(handler(req, res)).catch((error) =>
|
|
83
|
+
fail(res, error, `${req.method} ${req.path} failed`)
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ===========================================================================
|
|
89
|
+
// Overview
|
|
90
|
+
// ===========================================================================
|
|
91
|
+
|
|
92
|
+
router.get("/stats", (req, res) => {
|
|
93
|
+
try {
|
|
94
|
+
res.json({
|
|
95
|
+
success: true,
|
|
96
|
+
stats: {
|
|
97
|
+
totalGroups: countGroups(),
|
|
98
|
+
totalWarnings: countWarnings(),
|
|
99
|
+
totalTodos: countTodos(),
|
|
100
|
+
totalNotes: countNotes(),
|
|
101
|
+
totalDebts: countDebts(false),
|
|
102
|
+
totalSettledDebts: countDebts(true),
|
|
103
|
+
totalSchedules: countSchedules(),
|
|
104
|
+
|
|
105
|
+
isConnected: !!botInstance?.user,
|
|
106
|
+
botNumber: botInstance?.user?.id || null,
|
|
107
|
+
botName: botInstance?.user?.name || null,
|
|
108
|
+
|
|
109
|
+
prefix: runtimeConfig.getPrefix(),
|
|
110
|
+
commandCount: getCommandCatalog().length,
|
|
111
|
+
aiConfigured: !!settings.get("gemini_api_key"),
|
|
112
|
+
|
|
113
|
+
uptime: process.uptime(),
|
|
114
|
+
memory: process.memoryUsage(),
|
|
115
|
+
nodeVersion: process.version,
|
|
116
|
+
dataDir: DATA_DIR,
|
|
117
|
+
database: true,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
fail(res, error, "Error fetching stats");
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
router.get("/health", (req, res) => {
|
|
126
|
+
res.json({
|
|
127
|
+
success: true,
|
|
128
|
+
health: {
|
|
129
|
+
status: "healthy",
|
|
130
|
+
database: true,
|
|
131
|
+
bot: botInstance?.user ? "connected" : "disconnected",
|
|
132
|
+
uptime: process.uptime(),
|
|
133
|
+
timestamp: Date.now(),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ===========================================================================
|
|
139
|
+
// Commands — prefix, permissions, aliases, on/off
|
|
140
|
+
// ===========================================================================
|
|
141
|
+
|
|
142
|
+
router.get("/commands", (req, res) => {
|
|
143
|
+
try {
|
|
144
|
+
res.json({
|
|
145
|
+
success: true,
|
|
146
|
+
prefix: runtimeConfig.getPrefix(),
|
|
147
|
+
levels: runtimeConfig.PERMISSION_LEVELS,
|
|
148
|
+
commands: getCommandCatalog(),
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
fail(res, error, "Error listing commands");
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
router.patch("/commands/:name", (req, res) => {
|
|
156
|
+
const catalog = getCommandCatalog();
|
|
157
|
+
const command = catalog.find((entry) => entry.name === req.params.name);
|
|
158
|
+
if (!command) return badRequest(res, "Unknown command");
|
|
159
|
+
|
|
160
|
+
const { permission, aliases, enabled } = req.body || {};
|
|
161
|
+
let indexNeedsRebuild = false;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
if (permission !== undefined) {
|
|
165
|
+
// A command that calls groupParticipantsUpdate needs a real WhatsApp
|
|
166
|
+
// admin whatever this table says, so offering to lower it would lie.
|
|
167
|
+
if (command.permissionLocked) {
|
|
168
|
+
return badRequest(
|
|
169
|
+
res,
|
|
170
|
+
"This command always requires a WhatsApp group admin, so its permission is fixed."
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
runtimeConfig.setPermission(command.key, permission || null);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (aliases !== undefined) {
|
|
177
|
+
if (aliases !== null && !Array.isArray(aliases)) {
|
|
178
|
+
return badRequest(res, "Aliases must be a list");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// An alias that already belongs to another command would silently lose
|
|
182
|
+
// the race in the loader; reject it here where we can say why.
|
|
183
|
+
if (Array.isArray(aliases)) {
|
|
184
|
+
const taken = new Map();
|
|
185
|
+
for (const entry of catalog) {
|
|
186
|
+
if (entry.name === command.name) continue;
|
|
187
|
+
taken.set(entry.name, entry.name);
|
|
188
|
+
for (const alias of entry.aliases) taken.set(alias, entry.name);
|
|
189
|
+
}
|
|
190
|
+
for (const raw of aliases) {
|
|
191
|
+
const alias = String(raw ?? "").trim().toLowerCase();
|
|
192
|
+
if (taken.has(alias)) {
|
|
193
|
+
return badRequest(
|
|
194
|
+
res,
|
|
195
|
+
`"${alias}" is already used by !${taken.get(alias)}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
runtimeConfig.setAliases(command.name, aliases);
|
|
202
|
+
indexNeedsRebuild = true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (enabled !== undefined) {
|
|
206
|
+
runtimeConfig.setEnabled(command.name, !!enabled);
|
|
207
|
+
}
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return badRequest(res, error.message);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Make the new triggers live now rather than after a restart.
|
|
213
|
+
if (indexNeedsRebuild) rebuildCommandIndex();
|
|
214
|
+
|
|
215
|
+
const updated = getCommandCatalog().find(
|
|
216
|
+
(entry) => entry.name === command.name
|
|
217
|
+
);
|
|
218
|
+
res.json({ success: true, command: updated });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ===========================================================================
|
|
222
|
+
// Settings — everything the operator can change while the bot runs
|
|
223
|
+
// ===========================================================================
|
|
224
|
+
|
|
225
|
+
router.get("/settings", (req, res) => {
|
|
226
|
+
try {
|
|
227
|
+
res.json({
|
|
228
|
+
success: true,
|
|
229
|
+
prefix: runtimeConfig.getPrefix(),
|
|
230
|
+
settings: settings.describe(),
|
|
231
|
+
});
|
|
232
|
+
} catch (error) {
|
|
233
|
+
fail(res, error, "Error reading settings");
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
router.patch("/settings", (req, res) => {
|
|
238
|
+
const { key, value } = req.body || {};
|
|
239
|
+
if (typeof key !== "string" || !key) return badRequest(res, "key is required");
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
if (key === "prefix") {
|
|
243
|
+
const prefix = runtimeConfig.setPrefix(value);
|
|
244
|
+
return res.json({ success: true, prefix });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
settings.set(key, value);
|
|
248
|
+
return res.json({
|
|
249
|
+
success: true,
|
|
250
|
+
// Never echo a value back: for a secret that would hand it to anyone who
|
|
251
|
+
// gets a look at the page.
|
|
252
|
+
settings: settings.describe(),
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return badRequest(res, error.message);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ===========================================================================
|
|
260
|
+
// AI — the editable system prompt and the long-term memory files
|
|
261
|
+
// ===========================================================================
|
|
262
|
+
|
|
263
|
+
// The persona file is "note to the human" + `---` + the actual prompt. Only
|
|
264
|
+
// the prompt half is editable here; the note is preserved so the file keeps
|
|
265
|
+
// explaining itself to whoever opens it in an editor.
|
|
266
|
+
const PERSONA_SEPARATOR = "\n---\n";
|
|
267
|
+
|
|
268
|
+
function splitPersona(raw) {
|
|
269
|
+
const index = raw.indexOf(PERSONA_SEPARATOR);
|
|
270
|
+
if (index === -1) return { header: "", body: raw.trim() };
|
|
271
|
+
return {
|
|
272
|
+
header: raw.slice(0, index).trim(),
|
|
273
|
+
body: raw.slice(index + PERSONA_SEPARATOR.length).trim(),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
router.get("/ai/persona", (req, res) => {
|
|
278
|
+
try {
|
|
279
|
+
const raw = fs.existsSync(PERSONA_FILE)
|
|
280
|
+
? fs.readFileSync(PERSONA_FILE, "utf8")
|
|
281
|
+
: "";
|
|
282
|
+
const { header, body } = splitPersona(raw);
|
|
283
|
+
res.json({
|
|
284
|
+
success: true,
|
|
285
|
+
persona: { header, body, file: path.basename(PERSONA_FILE) },
|
|
286
|
+
});
|
|
287
|
+
} catch (error) {
|
|
288
|
+
fail(res, error, "Error reading persona");
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
router.put("/ai/persona", (req, res) => {
|
|
293
|
+
const body = req.body?.body;
|
|
294
|
+
if (typeof body !== "string") return badRequest(res, "body must be text");
|
|
295
|
+
if (body.length > 20000) return badRequest(res, "Persona is too long (20k max)");
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const raw = fs.existsSync(PERSONA_FILE)
|
|
299
|
+
? fs.readFileSync(PERSONA_FILE, "utf8")
|
|
300
|
+
: "";
|
|
301
|
+
const { header } = splitPersona(raw);
|
|
302
|
+
const next = `${header}\n${PERSONA_SEPARATOR}\n${body.trim()}\n`;
|
|
303
|
+
fs.writeFileSync(PERSONA_FILE, next, "utf8");
|
|
304
|
+
logger.info("[Dashboard] AI persona updated");
|
|
305
|
+
res.json({ success: true, persona: { header, body: body.trim() } });
|
|
306
|
+
} catch (error) {
|
|
307
|
+
fail(res, error, "Error writing persona");
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Memory files: `global`, or one chat. The scope is turned into a path by
|
|
312
|
+
// memory.cjs and then re-checked against the memory root, so a crafted scope
|
|
313
|
+
// can't walk out of it.
|
|
314
|
+
function memoryPathFor(scope) {
|
|
315
|
+
const filePath =
|
|
316
|
+
scope === "global"
|
|
317
|
+
? memory.GLOBAL_FILE
|
|
318
|
+
: memory.memoryFilePath("chat", scope);
|
|
319
|
+
const resolved = path.resolve(filePath);
|
|
320
|
+
if (!resolved.startsWith(path.resolve(memory.ROOT) + path.sep)) {
|
|
321
|
+
throw new Error("Refusing to touch a file outside the memory folder");
|
|
322
|
+
}
|
|
323
|
+
return resolved;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
router.get("/ai/memory", (req, res) => {
|
|
327
|
+
try {
|
|
328
|
+
const scopes = [];
|
|
329
|
+
|
|
330
|
+
if (fs.existsSync(memory.GLOBAL_FILE)) {
|
|
331
|
+
const stat = fs.statSync(memory.GLOBAL_FILE);
|
|
332
|
+
scopes.push({
|
|
333
|
+
scope: "global",
|
|
334
|
+
label: "Global memory",
|
|
335
|
+
entries: memory.listMemory({ scope: "global" }).length,
|
|
336
|
+
bytes: stat.size,
|
|
337
|
+
updatedAt: stat.mtimeMs,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (fs.existsSync(memory.CHATS_DIR)) {
|
|
342
|
+
for (const file of fs.readdirSync(memory.CHATS_DIR)) {
|
|
343
|
+
if (!file.endsWith(".md")) continue;
|
|
344
|
+
const chatId = file.slice(0, -3);
|
|
345
|
+
const stat = fs.statSync(path.join(memory.CHATS_DIR, file));
|
|
346
|
+
scopes.push({
|
|
347
|
+
scope: chatId,
|
|
348
|
+
label: chatId,
|
|
349
|
+
entries: memory.listMemory({ scope: "chat", chatId }).length,
|
|
350
|
+
bytes: stat.size,
|
|
351
|
+
updatedAt: stat.mtimeMs,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
res.json({ success: true, scopes });
|
|
357
|
+
} catch (error) {
|
|
358
|
+
fail(res, error, "Error listing memory");
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
router.get("/ai/memory/:scope", (req, res) => {
|
|
363
|
+
try {
|
|
364
|
+
const filePath = memoryPathFor(req.params.scope);
|
|
365
|
+
const content = fs.existsSync(filePath)
|
|
366
|
+
? fs.readFileSync(filePath, "utf8")
|
|
367
|
+
: "";
|
|
368
|
+
res.json({ success: true, scope: req.params.scope, content });
|
|
369
|
+
} catch (error) {
|
|
370
|
+
return badRequest(res, error.message);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
router.put("/ai/memory/:scope", (req, res) => {
|
|
375
|
+
const content = req.body?.content;
|
|
376
|
+
if (typeof content !== "string") return badRequest(res, "content must be text");
|
|
377
|
+
if (content.length > 200000) return badRequest(res, "Memory file is too long");
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const filePath = memoryPathFor(req.params.scope);
|
|
381
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
382
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
383
|
+
logger.info(`[Dashboard] memory updated: ${req.params.scope}`);
|
|
384
|
+
res.json({ success: true });
|
|
385
|
+
} catch (error) {
|
|
386
|
+
return badRequest(res, error.message);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
router.delete("/ai/memory/:scope", (req, res) => {
|
|
391
|
+
try {
|
|
392
|
+
const filePath = memoryPathFor(req.params.scope);
|
|
393
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
394
|
+
logger.info(`[Dashboard] memory deleted: ${req.params.scope}`);
|
|
395
|
+
res.json({ success: true });
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return badRequest(res, error.message);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// ===========================================================================
|
|
402
|
+
// Groups
|
|
403
|
+
// ===========================================================================
|
|
404
|
+
|
|
405
|
+
function groupView(groupId, stored) {
|
|
406
|
+
const metadata = groupMetadataCache.get(groupId) || null;
|
|
407
|
+
const config = stored || {};
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
id: groupId,
|
|
411
|
+
subject: metadata?.subject || null,
|
|
412
|
+
participants: metadata?.participants?.length ?? null,
|
|
413
|
+
antilink: {
|
|
414
|
+
enabled: !!config.antilink?.enabled,
|
|
415
|
+
mode: config.antilink?.mode || "ALL",
|
|
416
|
+
allowed_domains: config.antilink?.allowed_domains || [],
|
|
417
|
+
},
|
|
418
|
+
antispam: {
|
|
419
|
+
enabled: !!config.antispam?.enabled,
|
|
420
|
+
time_window: config.antispam?.time_window ?? 10,
|
|
421
|
+
message_count: config.antispam?.message_count ?? 5,
|
|
422
|
+
action: config.antispam?.action || "WARN",
|
|
423
|
+
},
|
|
424
|
+
media_control: {
|
|
425
|
+
enabled: !!config.media_control?.enabled,
|
|
426
|
+
blocked_types: config.media_control?.blocked_types || [],
|
|
427
|
+
},
|
|
428
|
+
welcome_system: {
|
|
429
|
+
enabled: !!config.welcome_system?.enabled,
|
|
430
|
+
messages: config.welcome_system?.messages || [],
|
|
431
|
+
},
|
|
432
|
+
warn_system: {
|
|
433
|
+
max_warnings: config.warn_system?.max_warnings ?? 3,
|
|
434
|
+
action: config.warn_system?.action || "NONE",
|
|
435
|
+
},
|
|
436
|
+
join_requests: {
|
|
437
|
+
auto_approve_enabled: !!config.join_requests?.auto_approve_enabled,
|
|
438
|
+
},
|
|
439
|
+
rules: config.rules || "",
|
|
440
|
+
blacklist: config.blacklist || [],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
router.get("/groups", (req, res) => {
|
|
445
|
+
try {
|
|
446
|
+
const stored = new Map(
|
|
447
|
+
getAllGroupSettings().map((row) => [row.group_id, row.settings || {}])
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// Groups the bot is in but that have no settings row yet still belong in
|
|
451
|
+
// the list — that's exactly where the operator goes to configure them.
|
|
452
|
+
const ids = new Set(stored.keys());
|
|
453
|
+
for (const jid of groupMetadataCache.keys()) ids.add(jid);
|
|
454
|
+
|
|
455
|
+
const groups = [...ids]
|
|
456
|
+
.map((id) => groupView(id, stored.get(id)))
|
|
457
|
+
.sort((a, b) => (a.subject || a.id).localeCompare(b.subject || b.id));
|
|
458
|
+
|
|
459
|
+
res.json({ success: true, groups });
|
|
460
|
+
} catch (error) {
|
|
461
|
+
fail(res, error, "Error fetching groups");
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const MEDIA_TYPES = ["image", "video", "sticker", "audio"];
|
|
466
|
+
const ANTILINK_MODES = ["ALL", "WHITELIST", "BLACKLIST"];
|
|
467
|
+
|
|
468
|
+
router.patch("/groups/:id", (req, res) => {
|
|
469
|
+
const groupId = req.params.id;
|
|
470
|
+
if (!groupId.endsWith("@g.us")) return badRequest(res, "Not a group id");
|
|
471
|
+
|
|
472
|
+
const patch = req.body || {};
|
|
473
|
+
const current = getGroupSettings(groupId) || {};
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
if (patch.antilink) {
|
|
477
|
+
const next = { ...(current.antilink || {}) };
|
|
478
|
+
if (patch.antilink.enabled !== undefined) next.enabled = !!patch.antilink.enabled;
|
|
479
|
+
if (patch.antilink.mode !== undefined) {
|
|
480
|
+
const mode = String(patch.antilink.mode).toUpperCase();
|
|
481
|
+
if (!ANTILINK_MODES.includes(mode)) throw new Error("Unknown antilink mode");
|
|
482
|
+
next.mode = mode;
|
|
483
|
+
}
|
|
484
|
+
if (patch.antilink.allowed_domains !== undefined) {
|
|
485
|
+
if (!Array.isArray(patch.antilink.allowed_domains)) {
|
|
486
|
+
throw new Error("allowed_domains must be a list");
|
|
487
|
+
}
|
|
488
|
+
next.allowed_domains = patch.antilink.allowed_domains
|
|
489
|
+
.map((domain) => String(domain).trim().toLowerCase())
|
|
490
|
+
.filter(Boolean)
|
|
491
|
+
.slice(0, 50);
|
|
492
|
+
}
|
|
493
|
+
next.mode = next.mode || "ALL";
|
|
494
|
+
next.allowed_domains = next.allowed_domains || [];
|
|
495
|
+
current.antilink = next;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (patch.antispam) {
|
|
499
|
+
const next = { ...(current.antispam || {}) };
|
|
500
|
+
if (patch.antispam.enabled !== undefined) next.enabled = !!patch.antispam.enabled;
|
|
501
|
+
if (patch.antispam.time_window !== undefined) {
|
|
502
|
+
const seconds = Number(patch.antispam.time_window);
|
|
503
|
+
if (!Number.isFinite(seconds) || seconds < 1 || seconds > 600) {
|
|
504
|
+
throw new Error("time_window must be between 1 and 600 seconds");
|
|
505
|
+
}
|
|
506
|
+
next.time_window = Math.trunc(seconds);
|
|
507
|
+
}
|
|
508
|
+
if (patch.antispam.message_count !== undefined) {
|
|
509
|
+
const count = Number(patch.antispam.message_count);
|
|
510
|
+
if (!Number.isFinite(count) || count < 2 || count > 100) {
|
|
511
|
+
throw new Error("message_count must be between 2 and 100");
|
|
512
|
+
}
|
|
513
|
+
next.message_count = Math.trunc(count);
|
|
514
|
+
}
|
|
515
|
+
if (patch.antispam.action !== undefined) {
|
|
516
|
+
const action = String(patch.antispam.action).toUpperCase();
|
|
517
|
+
if (!["KICK", "WARN"].includes(action)) throw new Error("Unknown antispam action");
|
|
518
|
+
next.action = action;
|
|
519
|
+
}
|
|
520
|
+
current.antispam = {
|
|
521
|
+
enabled: !!next.enabled,
|
|
522
|
+
time_window: next.time_window ?? 10,
|
|
523
|
+
message_count: next.message_count ?? 5,
|
|
524
|
+
action: next.action || "WARN",
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (patch.media_control) {
|
|
529
|
+
const next = { ...(current.media_control || {}) };
|
|
530
|
+
if (patch.media_control.enabled !== undefined) {
|
|
531
|
+
next.enabled = !!patch.media_control.enabled;
|
|
532
|
+
}
|
|
533
|
+
if (patch.media_control.blocked_types !== undefined) {
|
|
534
|
+
if (!Array.isArray(patch.media_control.blocked_types)) {
|
|
535
|
+
throw new Error("blocked_types must be a list");
|
|
536
|
+
}
|
|
537
|
+
const types = patch.media_control.blocked_types.map((type) =>
|
|
538
|
+
String(type).toLowerCase()
|
|
539
|
+
);
|
|
540
|
+
for (const type of types) {
|
|
541
|
+
if (!MEDIA_TYPES.includes(type)) throw new Error(`Unknown media type: ${type}`);
|
|
542
|
+
}
|
|
543
|
+
next.blocked_types = [...new Set(types)];
|
|
544
|
+
}
|
|
545
|
+
current.media_control = {
|
|
546
|
+
enabled: !!next.enabled,
|
|
547
|
+
blocked_types: next.blocked_types || [],
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (patch.welcome_system) {
|
|
552
|
+
const next = { ...(current.welcome_system || {}) };
|
|
553
|
+
if (patch.welcome_system.enabled !== undefined) {
|
|
554
|
+
next.enabled = !!patch.welcome_system.enabled;
|
|
555
|
+
}
|
|
556
|
+
if (patch.welcome_system.messages !== undefined) {
|
|
557
|
+
if (!Array.isArray(patch.welcome_system.messages)) {
|
|
558
|
+
throw new Error("messages must be a list");
|
|
559
|
+
}
|
|
560
|
+
next.messages = patch.welcome_system.messages
|
|
561
|
+
.map((message) => String(message).slice(0, 2000))
|
|
562
|
+
.filter(Boolean)
|
|
563
|
+
.slice(0, 20);
|
|
564
|
+
}
|
|
565
|
+
current.welcome_system = {
|
|
566
|
+
enabled: !!next.enabled,
|
|
567
|
+
messages: next.messages || [],
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (patch.warn_system) {
|
|
572
|
+
const next = { ...(current.warn_system || {}) };
|
|
573
|
+
if (patch.warn_system.max_warnings !== undefined) {
|
|
574
|
+
const max = Number(patch.warn_system.max_warnings);
|
|
575
|
+
if (!Number.isFinite(max) || max < 1 || max > 50) {
|
|
576
|
+
throw new Error("max_warnings must be between 1 and 50");
|
|
577
|
+
}
|
|
578
|
+
next.max_warnings = Math.trunc(max);
|
|
579
|
+
}
|
|
580
|
+
if (patch.warn_system.action !== undefined) {
|
|
581
|
+
const action = String(patch.warn_system.action).toUpperCase();
|
|
582
|
+
if (!["KICK", "NONE"].includes(action)) throw new Error("Unknown warn action");
|
|
583
|
+
next.action = action;
|
|
584
|
+
}
|
|
585
|
+
current.warn_system = {
|
|
586
|
+
max_warnings: next.max_warnings ?? 3,
|
|
587
|
+
action: next.action || "NONE",
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (patch.join_requests) {
|
|
592
|
+
current.join_requests = {
|
|
593
|
+
auto_approve_enabled: !!patch.join_requests.auto_approve_enabled,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (patch.rules !== undefined) {
|
|
598
|
+
current.rules = String(patch.rules).slice(0, 4000);
|
|
599
|
+
}
|
|
600
|
+
} catch (error) {
|
|
601
|
+
return badRequest(res, error.message);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
saveGroupSettings(groupId, current);
|
|
606
|
+
logger.info(`[Dashboard] group settings updated: ${groupId}`);
|
|
607
|
+
res.json({ success: true, group: groupView(groupId, current) });
|
|
608
|
+
} catch (error) {
|
|
609
|
+
fail(res, error, "Error saving group settings");
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// ===========================================================================
|
|
614
|
+
// Data tables
|
|
615
|
+
// ===========================================================================
|
|
616
|
+
|
|
617
|
+
router.get("/debts", (req, res) => {
|
|
618
|
+
try {
|
|
619
|
+
res.json({ success: true, debts: getRecentDebts(200) });
|
|
620
|
+
} catch (error) {
|
|
621
|
+
fail(res, error, "Error fetching debts");
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
router.get("/notes", (req, res) => {
|
|
626
|
+
try {
|
|
627
|
+
res.json({ success: true, notes: getAllNotesFlat(300) });
|
|
628
|
+
} catch (error) {
|
|
629
|
+
fail(res, error, "Error fetching notes");
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
router.get("/warnings", (req, res) => {
|
|
634
|
+
try {
|
|
635
|
+
const warnings = getAllWarnings().sort((a, b) =>
|
|
636
|
+
a.group_id.localeCompare(b.group_id)
|
|
637
|
+
);
|
|
638
|
+
res.json({ success: true, warnings });
|
|
639
|
+
} catch (error) {
|
|
640
|
+
fail(res, error, "Error fetching warnings");
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
router.get("/todos", (req, res) => {
|
|
645
|
+
try {
|
|
646
|
+
res.json({ success: true, todos: getAllTodos() });
|
|
647
|
+
} catch (error) {
|
|
648
|
+
fail(res, error, "Error fetching todos");
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
router.get("/users", (req, res) => {
|
|
653
|
+
try {
|
|
654
|
+
const users = getAllUsers()
|
|
655
|
+
.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
|
|
656
|
+
.slice(0, 500);
|
|
657
|
+
res.json({ success: true, users });
|
|
658
|
+
} catch (error) {
|
|
659
|
+
fail(res, error, "Error fetching users");
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// ===========================================================================
|
|
664
|
+
// Roles (bot owner / bot admin)
|
|
665
|
+
// ===========================================================================
|
|
666
|
+
|
|
667
|
+
router.get("/roles", (req, res) => {
|
|
668
|
+
try {
|
|
669
|
+
res.json({ success: true, roles: listRoles() });
|
|
670
|
+
} catch (error) {
|
|
671
|
+
fail(res, error, "Error listing roles");
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
router.post("/roles", (req, res) => {
|
|
676
|
+
const { target, role, action } = req.body || {};
|
|
677
|
+
|
|
678
|
+
const phone = String(target ?? "").replace(/\D/g, "");
|
|
679
|
+
if (!phone || phone.length < 8 || phone.length > 15) {
|
|
680
|
+
return badRequest(res, "Enter a phone number in international format");
|
|
681
|
+
}
|
|
682
|
+
if (!["owner", "admin"].includes(role)) return badRequest(res, "Unknown role");
|
|
683
|
+
if (!["grant", "revoke"].includes(action)) return badRequest(res, "Unknown action");
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
const jid = `${phone}@s.whatsapp.net`;
|
|
687
|
+
if (action === "grant") grantRole(jid, role);
|
|
688
|
+
else revokeRole(jid, role);
|
|
689
|
+
logger.info(`[Dashboard] ${action} ${role}: ${phone}`);
|
|
690
|
+
res.json({ success: true, roles: listRoles() });
|
|
691
|
+
} catch (error) {
|
|
692
|
+
fail(res, error, "Error changing a role");
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// ===========================================================================
|
|
697
|
+
// Scheduled messages
|
|
698
|
+
// ===========================================================================
|
|
699
|
+
|
|
700
|
+
router.get("/schedules", (req, res) => {
|
|
701
|
+
try {
|
|
702
|
+
res.json({ success: true, schedules: getSchedules() });
|
|
703
|
+
} catch (error) {
|
|
704
|
+
fail(res, error, "Error listing schedules");
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
router.delete("/schedules/:id", (req, res) => {
|
|
709
|
+
try {
|
|
710
|
+
// Through the scheduler, not the store: the running timer/cron task has to
|
|
711
|
+
// be stopped too, or a deleted job still fires until the next restart.
|
|
712
|
+
deleteScheduledJob(req.params.id);
|
|
713
|
+
res.json({ success: true, schedules: getSchedules() });
|
|
714
|
+
} catch (error) {
|
|
715
|
+
fail(res, error, "Error deleting a schedule");
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// ===========================================================================
|
|
720
|
+
// Security — the panel's own password
|
|
721
|
+
// ===========================================================================
|
|
722
|
+
|
|
723
|
+
router.post("/security/password", (req, res) => {
|
|
724
|
+
const { current, next } = req.body || {};
|
|
725
|
+
|
|
726
|
+
// Being signed in isn't enough: an unattended browser shouldn't be able to
|
|
727
|
+
// lock the real operator out.
|
|
728
|
+
if (!secrets.verifyDashboardPassword(current)) {
|
|
729
|
+
return res.status(401).json({ success: false, error: "Current password is wrong" });
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
try {
|
|
733
|
+
secrets.setDashboardPassword(next);
|
|
734
|
+
} catch (error) {
|
|
735
|
+
return badRequest(res, error.message);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
res.json({ success: true });
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// ===========================================================================
|
|
742
|
+
// Bot control
|
|
743
|
+
// ===========================================================================
|
|
744
|
+
|
|
745
|
+
// Unlink the WhatsApp account. The connection handler sees the logout, clears
|
|
746
|
+
// the stored credentials and comes back with a fresh QR — no restart needed.
|
|
747
|
+
router.post(
|
|
748
|
+
"/bot/logout",
|
|
749
|
+
asyncRoute(async (req, res) => {
|
|
750
|
+
if (!botInstance) {
|
|
751
|
+
return res.status(409).json({ success: false, error: "Bot is not connected" });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
try {
|
|
755
|
+
await botInstance.logout();
|
|
756
|
+
} catch (error) {
|
|
757
|
+
// A logout that fails still has to clear the local credentials, or the
|
|
758
|
+
// bot reconnects with a session WhatsApp already dropped.
|
|
759
|
+
logger.warn({ err: error }, "[Dashboard] logout call failed, clearing anyway");
|
|
760
|
+
if (botControls.clearAll) await botControls.clearAll();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
logger.warn("[Dashboard] WhatsApp account unlinked from the dashboard");
|
|
764
|
+
res.json({ success: true });
|
|
765
|
+
})
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
// Only useful under a supervisor (pm2 / systemd / docker restart:always) —
|
|
769
|
+
// this exits the process and something else has to bring it back.
|
|
770
|
+
router.post("/bot/restart", (req, res) => {
|
|
771
|
+
logger.warn("[Dashboard] restart requested");
|
|
772
|
+
res.json({
|
|
773
|
+
success: true,
|
|
774
|
+
message: "Restarting. If the bot is not running under a supervisor, start it again yourself.",
|
|
775
|
+
});
|
|
776
|
+
setTimeout(() => process.exit(0), 500);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
export default router;
|