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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/SETUP.md +242 -0
  4. package/app.cjs +337 -0
  5. package/bin/levix.js +7 -0
  6. package/deploy/install.sh +182 -0
  7. package/deploy/levix.service +56 -0
  8. package/deploy/nginx/levix.leviro.net.conf +79 -0
  9. package/package.json +78 -0
  10. package/public/brand/apple-touch-icon.png +0 -0
  11. package/public/brand/banner.jpg +0 -0
  12. package/public/brand/banner.webp +0 -0
  13. package/public/brand/icon-16.png +0 -0
  14. package/public/brand/icon-192.png +0 -0
  15. package/public/brand/icon-32.png +0 -0
  16. package/public/brand/icon-512.png +0 -0
  17. package/public/brand/icon-512.webp +0 -0
  18. package/public/brand/mark.png +0 -0
  19. package/public/brand/mark.svg +16 -0
  20. package/public/brand/mark.webp +0 -0
  21. package/public/brand/wordmark.png +0 -0
  22. package/public/brand/wordmark.webp +0 -0
  23. package/public/dashboard.css +881 -0
  24. package/public/dashboard.js +1057 -0
  25. package/public/logo.svg +16 -0
  26. package/public/qrcode.min.js +1 -0
  27. package/public/socket.io.min.js +7 -0
  28. package/scheduler.cjs +197 -0
  29. package/src/auth/auth-storage.cjs +74 -0
  30. package/src/auth/use-database-auth-state.js +130 -0
  31. package/src/bootstrap/core.js +74 -0
  32. package/src/bootstrap/events.cjs +43 -0
  33. package/src/bootstrap/panel.js +97 -0
  34. package/src/cli.js +153 -0
  35. package/src/commands/autoschedule.cjs +84 -0
  36. package/src/commands/blacklist.cjs +116 -0
  37. package/src/commands/block.cjs +70 -0
  38. package/src/commands/calc.cjs +387 -0
  39. package/src/commands/debt.cjs +175 -0
  40. package/src/commands/deleteschedule.cjs +36 -0
  41. package/src/commands/gemini.cjs +727 -0
  42. package/src/commands/group/add.cjs +136 -0
  43. package/src/commands/group/all.cjs +43 -0
  44. package/src/commands/group/antiSpam.cjs +96 -0
  45. package/src/commands/group/antilink.cjs +167 -0
  46. package/src/commands/group/approveall.cjs +54 -0
  47. package/src/commands/group/clearwarns.cjs +41 -0
  48. package/src/commands/group/deletenote.cjs +33 -0
  49. package/src/commands/group/demote.cjs +62 -0
  50. package/src/commands/group/kick.cjs +85 -0
  51. package/src/commands/group/media.cjs +120 -0
  52. package/src/commands/group/members.cjs +59 -0
  53. package/src/commands/group/note.cjs +32 -0
  54. package/src/commands/group/promote.cjs +60 -0
  55. package/src/commands/group/removeall.cjs +42 -0
  56. package/src/commands/group/rules.cjs +31 -0
  57. package/src/commands/group/save.cjs +36 -0
  58. package/src/commands/group/setname.cjs +41 -0
  59. package/src/commands/group/setpp.cjs +51 -0
  60. package/src/commands/group/setrules.cjs +46 -0
  61. package/src/commands/group/setwarn.cjs +58 -0
  62. package/src/commands/group/tagadmins.cjs +50 -0
  63. package/src/commands/group/warn.cjs +105 -0
  64. package/src/commands/group/warns.cjs +66 -0
  65. package/src/commands/group/welcome.cjs +109 -0
  66. package/src/commands/group.cjs +160 -0
  67. package/src/commands/help.cjs +230 -0
  68. package/src/commands/listschedules.cjs +47 -0
  69. package/src/commands/loop.cjs +163 -0
  70. package/src/commands/memory.cjs +201 -0
  71. package/src/commands/mod.cjs +155 -0
  72. package/src/commands/notes.cjs +28 -0
  73. package/src/commands/perm.cjs +236 -0
  74. package/src/commands/ping.cjs +17 -0
  75. package/src/commands/poll.cjs +80 -0
  76. package/src/commands/prayer.cjs +64 -0
  77. package/src/commands/qr.cjs +39 -0
  78. package/src/commands/rand.cjs +130 -0
  79. package/src/commands/restart.cjs +25 -0
  80. package/src/commands/schedule.cjs +86 -0
  81. package/src/commands/score.cjs +130 -0
  82. package/src/commands/setprefix.cjs +40 -0
  83. package/src/commands/shortlink.cjs +74 -0
  84. package/src/commands/shutdown.cjs +45 -0
  85. package/src/commands/status.cjs +75 -0
  86. package/src/commands/stt.cjs +150 -0
  87. package/src/commands/todo.cjs +88 -0
  88. package/src/commands/tts.cjs +170 -0
  89. package/src/commands/unblock.cjs +57 -0
  90. package/src/commands/weather.cjs +79 -0
  91. package/src/config/ai-persona.md +17 -0
  92. package/src/config/baileys.config.js +51 -0
  93. package/src/config/brand.cjs +36 -0
  94. package/src/config/brand.esm.js +17 -0
  95. package/src/config/constants.js +16 -0
  96. package/src/config/defaults.cjs +91 -0
  97. package/src/config/lock.cjs +110 -0
  98. package/src/config/paths.cjs +90 -0
  99. package/src/config/runtime-config.cjs +219 -0
  100. package/src/config/secrets.cjs +130 -0
  101. package/src/config/settings.cjs +386 -0
  102. package/src/core/connection.js +199 -0
  103. package/src/core/events.js +61 -0
  104. package/src/core/socket.js +64 -0
  105. package/src/db/db.cjs +282 -0
  106. package/src/db/store.cjs +837 -0
  107. package/src/db/store.esm.js +17 -0
  108. package/src/domain/caddy.js +120 -0
  109. package/src/domain/command.js +380 -0
  110. package/src/domain/detect.js +173 -0
  111. package/src/domain/nginx.js +138 -0
  112. package/src/domain/system.js +148 -0
  113. package/src/handlers/command.handler.js +414 -0
  114. package/src/handlers/group.handler.js +73 -0
  115. package/src/handlers/message.handler.js +149 -0
  116. package/src/index.js +217 -0
  117. package/src/middleware/antispam.middleware.js +68 -0
  118. package/src/middleware/blacklist.middleware.js +29 -0
  119. package/src/middleware/forward-tracking.middleware.js +49 -0
  120. package/src/middleware/permissions.middleware.js +114 -0
  121. package/src/routes/dashboard.api.esm.js +779 -0
  122. package/src/services/aiAgent.cjs +317 -0
  123. package/src/services/aiTools.cjs +635 -0
  124. package/src/utils/datetime.cjs +61 -0
  125. package/src/utils/lid-helper.esm.js +143 -0
  126. package/src/utils/logger.cjs +72 -0
  127. package/src/utils/memory.cjs +385 -0
  128. package/src/utils/normalizeJid.cjs +65 -0
  129. package/src/utils/normalizeJid.esm.js +37 -0
  130. package/src/utils/openBrowser.cjs +129 -0
  131. package/src/utils/permissions.cjs +154 -0
  132. package/src/utils/permissions.esm.js +375 -0
  133. package/src/utils/recentMessageCache.esm.js +70 -0
  134. package/src/utils/requestOrigin.cjs +102 -0
  135. package/src/utils/sendBotMessage.cjs +43 -0
  136. package/src/utils/sendBotMessage.esm.js +139 -0
  137. package/src/utils/statusMessage.cjs +179 -0
  138. package/src/utils/storage-hub.cjs +49 -0
  139. package/src/utils/storage-hub.esm.js +24 -0
  140. package/src/utils/storage.cjs +80 -0
  141. package/src/utils/storage.esm.js +88 -0
  142. package/src/utils/textDecode.cjs +197 -0
  143. package/src/utils/thumbnail.cjs +591 -0
  144. package/src/utils/typing.esm.js +102 -0
  145. package/views/dashboard.ejs +329 -0
  146. package/views/login.ejs +105 -0
  147. package/views/qr.ejs +108 -0
  148. 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;