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,1057 @@
1
+ /* Levix dashboard — plain browser JS, no framework, no build step.
2
+ *
3
+ * Everything it shows comes from /dashboard/api/*, which sits behind the same
4
+ * session as this page. Every value rendered here started life as a WhatsApp
5
+ * message (a note, a warning reason, a group subject), so it goes through
6
+ * esc() before it touches innerHTML.
7
+ */
8
+
9
+ (() => {
10
+ "use strict";
11
+
12
+ // --- tiny helpers -------------------------------------------------------
13
+
14
+ const $ = (selector, root = document) => root.querySelector(selector);
15
+ const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
16
+
17
+ const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
18
+ const esc = (value) =>
19
+ String(value ?? "").replace(/[&<>"']/g, (char) => ESCAPES[char]);
20
+
21
+ const shortJid = (jid) => String(jid ?? "").split("@")[0];
22
+
23
+ function timeAgo(ms) {
24
+ if (!ms) return "—";
25
+ const seconds = Math.max(0, (Date.now() - ms) / 1000);
26
+ if (seconds < 60) return "just now";
27
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
28
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
29
+ return `${Math.floor(seconds / 86400)}d ago`;
30
+ }
31
+
32
+ function humanUptime(seconds) {
33
+ const days = Math.floor(seconds / 86400);
34
+ const hours = Math.floor((seconds % 86400) / 3600);
35
+ const minutes = Math.floor((seconds % 3600) / 60);
36
+ if (days) return `${days}d ${hours}h`;
37
+ if (hours) return `${hours}h ${minutes}m`;
38
+ return `${minutes}m`;
39
+ }
40
+
41
+ function toast(message, kind = "") {
42
+ const box = $("#toasts");
43
+ const node = document.createElement("div");
44
+ node.className = `toast ${kind}`;
45
+ node.textContent = message;
46
+ box.appendChild(node);
47
+ setTimeout(() => node.remove(), 4200);
48
+ }
49
+
50
+ /**
51
+ * Every call goes through here so an expired session sends the operator back
52
+ * to the login page instead of leaving a dashboard that silently stops
53
+ * updating.
54
+ */
55
+ async function api(path, options = {}) {
56
+ const response = await fetch(`/dashboard/api${path}`, {
57
+ headers: { "Content-Type": "application/json" },
58
+ ...options,
59
+ body: options.body ? JSON.stringify(options.body) : undefined,
60
+ });
61
+
62
+ if (response.status === 401) {
63
+ window.location.assign("/");
64
+ throw new Error("Session expired");
65
+ }
66
+
67
+ const data = await response.json().catch(() => ({}));
68
+ if (!response.ok || data.success === false) {
69
+ throw new Error(data.error || `Request failed (${response.status})`);
70
+ }
71
+ return data;
72
+ }
73
+
74
+ /** Wrap an action so a failure lands in a toast instead of the console. */
75
+ function guard(fn) {
76
+ return async (...args) => {
77
+ try {
78
+ await fn(...args);
79
+ } catch (error) {
80
+ toast(error.message, "error");
81
+ }
82
+ };
83
+ }
84
+
85
+ // --- theme --------------------------------------------------------------
86
+
87
+ const THEME_KEY = "levix.theme";
88
+
89
+ function applyTheme(theme) {
90
+ document.documentElement.setAttribute("data-theme", theme);
91
+ const button = $("#theme-btn");
92
+ button.textContent = theme === "light" ? "☾" : "☀";
93
+ button.setAttribute(
94
+ "aria-label",
95
+ theme === "light" ? "Switch to dark theme" : "Switch to light theme"
96
+ );
97
+ try {
98
+ localStorage.setItem(THEME_KEY, theme);
99
+ } catch {
100
+ /* private mode — the choice just won't stick */
101
+ }
102
+ }
103
+
104
+ function initTheme() {
105
+ let saved = "dark";
106
+ try {
107
+ saved = localStorage.getItem(THEME_KEY) || "dark";
108
+ } catch {
109
+ /* ignore */
110
+ }
111
+ applyTheme(saved);
112
+ $("#theme-btn").addEventListener("click", () => {
113
+ const next =
114
+ document.documentElement.getAttribute("data-theme") === "light"
115
+ ? "dark"
116
+ : "light";
117
+ applyTheme(next);
118
+ });
119
+ }
120
+
121
+ // --- routing ------------------------------------------------------------
122
+
123
+ const VIEWS = {};
124
+ let currentView = null;
125
+
126
+ function setSidebar(open) {
127
+ $(".sidebar").classList.toggle("open", open);
128
+ $("#sidebar-backdrop").classList.toggle("open", open);
129
+ $("#menu-btn").setAttribute("aria-expanded", String(open));
130
+ $("#menu-btn").setAttribute(
131
+ "aria-label",
132
+ open ? "Close navigation" : "Open navigation"
133
+ );
134
+ }
135
+
136
+ function show(name) {
137
+ const target = VIEWS[name] ? name : "overview";
138
+ currentView = target;
139
+
140
+ $$(".view").forEach((view) =>
141
+ view.classList.toggle("active", view.id === `view-${target}`)
142
+ );
143
+ $$(".nav a").forEach((link) =>
144
+ link.classList.toggle("active", link.dataset.view === target)
145
+ );
146
+ $("#page-title").textContent = VIEWS[target].title;
147
+ setSidebar(false);
148
+
149
+ VIEWS[target].load();
150
+ }
151
+
152
+ function initRouting() {
153
+ $$(".nav a").forEach((link) => {
154
+ link.addEventListener("click", (event) => {
155
+ event.preventDefault();
156
+ window.location.hash = link.dataset.view;
157
+ });
158
+ });
159
+
160
+ window.addEventListener("hashchange", () =>
161
+ show(window.location.hash.slice(1))
162
+ );
163
+ $("#menu-btn").addEventListener("click", () =>
164
+ setSidebar(!$(".sidebar").classList.contains("open"))
165
+ );
166
+ $("#sidebar-backdrop").addEventListener("click", () => setSidebar(false));
167
+ window.addEventListener("keydown", (event) => {
168
+ if (event.key === "Escape") setSidebar(false);
169
+ });
170
+ $("#refresh-btn").addEventListener("click", () => {
171
+ VIEWS[currentView].load();
172
+ loadStats();
173
+ });
174
+ }
175
+
176
+ // --- connection status --------------------------------------------------
177
+
178
+ function setStatus(connected, label) {
179
+ const pill = $("#status-pill");
180
+ pill.className = `pill ${connected ? "on" : "off"}`;
181
+ pill.innerHTML = `<span class="dot"></span>${esc(label)}`;
182
+ }
183
+
184
+ // --- overview -----------------------------------------------------------
185
+
186
+ async function loadStats() {
187
+ const { stats } = await api("/stats");
188
+
189
+ setStatus(stats.isConnected, stats.isConnected ? "Connected" : "Disconnected");
190
+
191
+ const cards = [
192
+ ["Groups", stats.totalGroups],
193
+ ["Commands", stats.commandCount],
194
+ ["Open debts", stats.totalDebts],
195
+ ["Warnings", stats.totalWarnings],
196
+ ["Notes", stats.totalNotes],
197
+ ["Todo lists", stats.totalTodos],
198
+ ];
199
+
200
+ $("#stat-cards").innerHTML = cards
201
+ .map(
202
+ ([label, value]) => `
203
+ <div class="stat">
204
+ <div class="label">${esc(label)}</div>
205
+ <div class="value">${esc(value ?? 0)}</div>
206
+ </div>`
207
+ )
208
+ .join("");
209
+
210
+ const rows = [
211
+ ["WhatsApp account", stats.botNumber ? shortJid(stats.botNumber) : "Not linked"],
212
+ ["Command prefix", stats.prefix],
213
+ ["AI key", stats.aiConfigured ? "Configured" : "Not set"],
214
+ ["Database", stats.database ? "Connected" : "Down"],
215
+ ["Uptime", humanUptime(stats.uptime)],
216
+ ["Memory", `${(stats.memory.heapUsed / 1048576).toFixed(0)} MB`],
217
+ ["Node", stats.nodeVersion],
218
+ ];
219
+
220
+ $("#overview-info").innerHTML = rows
221
+ .map(
222
+ ([label, value]) => `
223
+ <div>
224
+ <div class="label" style="font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--faint)">${esc(label)}</div>
225
+ <div style="font-weight:600;margin-top:2px">${esc(value)}</div>
226
+ </div>`
227
+ )
228
+ .join("");
229
+ }
230
+
231
+ VIEWS.overview = {
232
+ title: "Overview",
233
+ load: guard(loadStats),
234
+ };
235
+
236
+ // --- connection ---------------------------------------------------------
237
+
238
+ let qrRendered = null;
239
+
240
+ function renderQr(text) {
241
+ if (!text || qrRendered === text) return;
242
+ qrRendered = text;
243
+
244
+ const frame = $("#qr-frame");
245
+ frame.innerHTML = "";
246
+ // eslint-disable-next-line no-undef
247
+ new QRCode(frame, {
248
+ text,
249
+ width: 240,
250
+ height: 240,
251
+ correctLevel: QRCode.CorrectLevel.H,
252
+ });
253
+ $("#qr-note").textContent = "Scan it from WhatsApp → Linked devices.";
254
+ }
255
+
256
+ VIEWS.connection = {
257
+ title: "Connection",
258
+ load: guard(async () => {
259
+ const { stats } = await api("/stats");
260
+ const connected = stats.isConnected;
261
+
262
+ $("#conn-state").innerHTML = connected
263
+ ? `<span class="tag ok">Linked</span> <span class="hint">${esc(shortJid(stats.botNumber))}</span>`
264
+ : `<span class="tag danger">Not linked</span>`;
265
+
266
+ $("#qr-panel").style.display = connected ? "none" : "block";
267
+ if (connected) {
268
+ qrRendered = null;
269
+ $("#qr-frame").innerHTML = "";
270
+ $("#qr-note").textContent = "";
271
+ }
272
+ }),
273
+ };
274
+
275
+ function initConnectionActions() {
276
+ $("#unlink-btn").addEventListener(
277
+ "click",
278
+ guard(async () => {
279
+ if (
280
+ !confirm(
281
+ "Unlink this WhatsApp account? The bot stops answering until you scan a new QR code."
282
+ )
283
+ ) {
284
+ return;
285
+ }
286
+ await api("/bot/logout", { method: "POST" });
287
+ toast("Unlinked. A new QR code will appear here shortly.", "ok");
288
+ setStatus(false, "Disconnected");
289
+ })
290
+ );
291
+
292
+ $("#restart-btn").addEventListener(
293
+ "click",
294
+ guard(async () => {
295
+ if (
296
+ !confirm(
297
+ "Restart the bot? It only comes back on its own if it runs under a supervisor (pm2, systemd, Docker)."
298
+ )
299
+ ) {
300
+ return;
301
+ }
302
+ const data = await api("/bot/restart", { method: "POST" });
303
+ toast(data.message || "Restarting…", "ok");
304
+ })
305
+ );
306
+ }
307
+
308
+ // --- commands -----------------------------------------------------------
309
+
310
+ let commandCache = [];
311
+
312
+ function commandRow(command, levels) {
313
+ const permissionCell = command.permissionLocked
314
+ ? `<span class="locked" title="This command calls a WhatsApp admin action, so it always requires a group admin.">🔒 Group admin</span>`
315
+ : `<select data-cmd="${esc(command.name)}" data-field="permission">
316
+ ${levels
317
+ .map(
318
+ (level) =>
319
+ `<option value="${esc(level)}"${level === command.permission ? " selected" : ""}>${esc(
320
+ level.replace("_", " ").toLowerCase()
321
+ )}</option>`
322
+ )
323
+ .join("")}
324
+ </select>`;
325
+
326
+ return `
327
+ <tr>
328
+ <td>
329
+ <div class="cmd-name">${esc(command.name)}</div>
330
+ <div class="hint">${esc(command.description || "")}</div>
331
+ </td>
332
+ <td>${command.category === "group" ? '<span class="tag info">group</span>' : '<span class="tag">general</span>'}</td>
333
+ <td>
334
+ <input type="text" class="alias-input" value="${esc(command.aliases.join(", "))}"
335
+ data-cmd="${esc(command.name)}" data-field="aliases"
336
+ placeholder="no aliases" />
337
+ </td>
338
+ <td>${permissionCell}</td>
339
+ <td>
340
+ <label class="switch">
341
+ <input type="checkbox" data-cmd="${esc(command.name)}" data-field="enabled"
342
+ ${command.enabled ? "checked" : ""} />
343
+ <span class="track"></span>
344
+ </label>
345
+ </td>
346
+ </tr>`;
347
+ }
348
+
349
+ function renderCommands() {
350
+ const query = $("#cmd-search").value.trim().toLowerCase();
351
+ const category = $("#cmd-filter").value;
352
+
353
+ const rows = commandCache.commands.filter((command) => {
354
+ if (category !== "all" && command.category !== category) return false;
355
+ if (!query) return true;
356
+ return (
357
+ command.name.includes(query) ||
358
+ command.aliases.some((alias) => alias.includes(query)) ||
359
+ (command.description || "").toLowerCase().includes(query)
360
+ );
361
+ });
362
+
363
+ $("#cmd-body").innerHTML = rows.length
364
+ ? rows.map((command) => commandRow(command, commandCache.levels)).join("")
365
+ : `<tr><td colspan="5"><div class="empty">Nothing matches that.</div></td></tr>`;
366
+ }
367
+
368
+ const patchCommand = guard(async (name, patch) => {
369
+ const { command } = await api(`/commands/${encodeURIComponent(name)}`, {
370
+ method: "PATCH",
371
+ body: patch,
372
+ });
373
+ const index = commandCache.commands.findIndex((entry) => entry.name === name);
374
+ if (index !== -1) commandCache.commands[index] = command;
375
+ toast(`!${name} updated`, "ok");
376
+ });
377
+
378
+ VIEWS.commands = {
379
+ title: "Commands",
380
+ load: guard(async () => {
381
+ commandCache = await api("/commands");
382
+ $("#prefix-input").value = commandCache.prefix;
383
+ renderCommands();
384
+ }),
385
+ };
386
+
387
+ function initCommandActions() {
388
+ $("#cmd-search").addEventListener("input", renderCommands);
389
+ $("#cmd-filter").addEventListener("change", renderCommands);
390
+
391
+ $("#prefix-save").addEventListener(
392
+ "click",
393
+ guard(async () => {
394
+ const value = $("#prefix-input").value.trim();
395
+ await api("/settings", { method: "PATCH", body: { key: "prefix", value } });
396
+ toast(`Prefix is now "${value}"`, "ok");
397
+ VIEWS.commands.load();
398
+ })
399
+ );
400
+
401
+ // One listener for the whole table: the rows are re-rendered on every
402
+ // search keystroke, so per-row handlers would have to be re-attached.
403
+ const table = $("#cmd-body");
404
+
405
+ table.addEventListener("change", (event) => {
406
+ const target = event.target;
407
+ const name = target.dataset?.cmd;
408
+ if (!name) return;
409
+
410
+ if (target.dataset.field === "permission") {
411
+ patchCommand(name, { permission: target.value });
412
+ } else if (target.dataset.field === "enabled") {
413
+ patchCommand(name, { enabled: target.checked });
414
+ } else if (target.dataset.field === "aliases") {
415
+ const aliases = target.value
416
+ .split(",")
417
+ .map((alias) => alias.trim())
418
+ .filter(Boolean);
419
+ patchCommand(name, { aliases }).then(() => VIEWS.commands.load());
420
+ }
421
+ });
422
+ }
423
+
424
+ // --- AI -----------------------------------------------------------------
425
+
426
+ let memoryScope = null;
427
+
428
+ VIEWS.ai = {
429
+ title: "AI",
430
+ load: guard(async () => {
431
+ const [{ persona }, { scopes }] = await Promise.all([
432
+ api("/ai/persona"),
433
+ api("/ai/memory"),
434
+ ]);
435
+
436
+ $("#persona-body").value = persona.body;
437
+ $("#persona-file").textContent = persona.file;
438
+
439
+ $("#memory-list").innerHTML = scopes.length
440
+ ? scopes
441
+ .map(
442
+ (entry) => `
443
+ <button class="btn btn-sm" data-scope="${esc(entry.scope)}">
444
+ ${entry.scope === "global" ? "🌍" : "💬"} ${esc(entry.label)}
445
+ <span class="hint">${esc(entry.entries)} entries</span>
446
+ </button>`
447
+ )
448
+ .join("")
449
+ : `<div class="hint">No memory files yet. The bot writes one the first time someone asks it to remember something.</div>`;
450
+
451
+ if (memoryScope && !scopes.some((entry) => entry.scope === memoryScope)) {
452
+ memoryScope = null;
453
+ $("#memory-editor").style.display = "none";
454
+ }
455
+ }),
456
+ };
457
+
458
+ const openMemory = guard(async (scope) => {
459
+ const { content } = await api(`/ai/memory/${encodeURIComponent(scope)}`);
460
+ memoryScope = scope;
461
+ $("#memory-editor").style.display = "block";
462
+ $("#memory-scope").textContent = scope;
463
+ $("#memory-content").value = content;
464
+ });
465
+
466
+ function initAiActions() {
467
+ $("#persona-save").addEventListener(
468
+ "click",
469
+ guard(async () => {
470
+ await api("/ai/persona", {
471
+ method: "PUT",
472
+ body: { body: $("#persona-body").value },
473
+ });
474
+ toast("System prompt saved — it applies to the next message.", "ok");
475
+ })
476
+ );
477
+
478
+ $("#memory-list").addEventListener("click", (event) => {
479
+ const button = event.target.closest("[data-scope]");
480
+ if (button) openMemory(button.dataset.scope);
481
+ });
482
+
483
+ $("#memory-save").addEventListener(
484
+ "click",
485
+ guard(async () => {
486
+ await api(`/ai/memory/${encodeURIComponent(memoryScope)}`, {
487
+ method: "PUT",
488
+ body: { content: $("#memory-content").value },
489
+ });
490
+ toast("Memory saved", "ok");
491
+ VIEWS.ai.load();
492
+ })
493
+ );
494
+
495
+ $("#memory-delete").addEventListener(
496
+ "click",
497
+ guard(async () => {
498
+ if (!confirm(`Delete the memory file for ${memoryScope}? This can't be undone.`)) {
499
+ return;
500
+ }
501
+ await api(`/ai/memory/${encodeURIComponent(memoryScope)}`, {
502
+ method: "DELETE",
503
+ });
504
+ memoryScope = null;
505
+ $("#memory-editor").style.display = "none";
506
+ toast("Memory file deleted", "ok");
507
+ VIEWS.ai.load();
508
+ })
509
+ );
510
+ }
511
+
512
+ // --- groups -------------------------------------------------------------
513
+
514
+ function groupCard(group) {
515
+ const id = esc(group.id);
516
+ const title = esc(group.subject || group.id);
517
+ const members =
518
+ group.participants === null ? "" : `<span class="tag">${group.participants} members</span>`;
519
+
520
+ const toggle = (field, label, checked) => `
521
+ <label class="switch" style="margin-bottom:9px">
522
+ <input type="checkbox" data-group="${id}" data-field="${field}" ${checked ? "checked" : ""} />
523
+ <span class="track"></span>
524
+ <span>${esc(label)}</span>
525
+ </label>`;
526
+
527
+ return `
528
+ <details class="card group-card">
529
+ <summary>
530
+ <span>${title}</span>
531
+ ${members}
532
+ ${group.antilink.enabled ? '<span class="tag warn">antilink</span>' : ""}
533
+ ${group.antispam.enabled ? '<span class="tag warn">antispam</span>' : ""}
534
+ <span class="chev">›</span>
535
+ </summary>
536
+
537
+ <div class="group-body">
538
+ <div>
539
+ <h3>Links</h3>
540
+ ${toggle("antilink.enabled", "Block links", group.antilink.enabled)}
541
+ <label class="field">
542
+ <span>Mode</span>
543
+ <select data-group="${id}" data-field="antilink.mode">
544
+ ${["ALL", "WHITELIST", "BLACKLIST"]
545
+ .map(
546
+ (mode) =>
547
+ `<option value="${mode}"${mode === group.antilink.mode ? " selected" : ""}>${mode.toLowerCase()}</option>`
548
+ )
549
+ .join("")}
550
+ </select>
551
+ </label>
552
+ <label class="field">
553
+ <span>Allowed domains</span>
554
+ <input type="text" data-group="${id}" data-field="antilink.allowed_domains"
555
+ value="${esc(group.antilink.allowed_domains.join(", "))}"
556
+ placeholder="youtube.com, github.com" />
557
+ </label>
558
+ </div>
559
+
560
+ <div>
561
+ <h3>Spam</h3>
562
+ ${toggle("antispam.enabled", "Anti-spam", group.antispam.enabled)}
563
+ <label class="field">
564
+ <span>Messages allowed</span>
565
+ <input type="number" min="2" max="100" data-group="${id}" data-field="antispam.message_count"
566
+ value="${esc(group.antispam.message_count)}" />
567
+ </label>
568
+ <label class="field">
569
+ <span>Within (seconds)</span>
570
+ <input type="number" min="1" max="600" data-group="${id}" data-field="antispam.time_window"
571
+ value="${esc(group.antispam.time_window)}" />
572
+ </label>
573
+ <label class="field">
574
+ <span>Then</span>
575
+ <select data-group="${id}" data-field="antispam.action">
576
+ ${["WARN", "KICK"]
577
+ .map(
578
+ (action) =>
579
+ `<option value="${action}"${action === group.antispam.action ? " selected" : ""}>${action.toLowerCase()}</option>`
580
+ )
581
+ .join("")}
582
+ </select>
583
+ </label>
584
+ </div>
585
+
586
+ <div>
587
+ <h3>Media</h3>
588
+ ${toggle("media_control.enabled", "Restrict media", group.media_control.enabled)}
589
+ <div class="hint" style="margin-bottom:6px">Blocked types</div>
590
+ <div class="inline" style="margin-bottom:12px">
591
+ ${["image", "video", "sticker", "audio"]
592
+ .map(
593
+ (type) => `
594
+ <label class="inline" style="gap:5px">
595
+ <input type="checkbox" data-group="${id}" data-field="media.${type}"
596
+ ${group.media_control.blocked_types.includes(type) ? "checked" : ""} />
597
+ <span class="hint">${type}</span>
598
+ </label>`
599
+ )
600
+ .join("")}
601
+ </div>
602
+
603
+ <h3>Joining</h3>
604
+ ${toggle(
605
+ "join_requests.auto_approve_enabled",
606
+ "Auto-approve requests",
607
+ group.join_requests.auto_approve_enabled
608
+ )}
609
+ </div>
610
+
611
+ <div>
612
+ <h3>Warnings</h3>
613
+ <label class="field">
614
+ <span>Max warnings</span>
615
+ <input type="number" min="1" max="50" data-group="${id}" data-field="warn_system.max_warnings"
616
+ value="${esc(group.warn_system.max_warnings)}" />
617
+ </label>
618
+ <label class="field">
619
+ <span>Then</span>
620
+ <select data-group="${id}" data-field="warn_system.action">
621
+ ${["NONE", "KICK"]
622
+ .map(
623
+ (action) =>
624
+ `<option value="${action}"${action === group.warn_system.action ? " selected" : ""}>${action.toLowerCase()}</option>`
625
+ )
626
+ .join("")}
627
+ </select>
628
+ </label>
629
+
630
+ <h3>Welcome</h3>
631
+ ${toggle("welcome_system.enabled", "Welcome new members", group.welcome_system.enabled)}
632
+ </div>
633
+
634
+ <div style="grid-column:1/-1">
635
+ <h3>Rules</h3>
636
+ <textarea data-group="${id}" data-field="rules" style="min-height:110px"
637
+ placeholder="Shown by !group rules">${esc(group.rules)}</textarea>
638
+ </div>
639
+
640
+ <div style="grid-column:1/-1">
641
+ <button class="btn btn-primary" data-save-group="${id}">Save this group</button>
642
+ <span class="hint" style="margin-left:8px">Blacklisted: ${group.blacklist.length}</span>
643
+ </div>
644
+ </div>
645
+ </details>`;
646
+ }
647
+
648
+ // Scoped to the group's own <details> element rather than to a selector
649
+ // built from the JID — a group id is full of characters a CSS selector would
650
+ // have to escape.
651
+ function collectGroupPatch(card) {
652
+ const read = (field) => $(`[data-field="${field}"]`, card);
653
+
654
+ const blocked = ["image", "video", "sticker", "audio"].filter(
655
+ (type) => read(`media.${type}`)?.checked
656
+ );
657
+
658
+ return {
659
+ antilink: {
660
+ enabled: read("antilink.enabled").checked,
661
+ mode: read("antilink.mode").value,
662
+ allowed_domains: read("antilink.allowed_domains")
663
+ .value.split(",")
664
+ .map((domain) => domain.trim())
665
+ .filter(Boolean),
666
+ },
667
+ antispam: {
668
+ enabled: read("antispam.enabled").checked,
669
+ message_count: Number(read("antispam.message_count").value),
670
+ time_window: Number(read("antispam.time_window").value),
671
+ action: read("antispam.action").value,
672
+ },
673
+ media_control: {
674
+ enabled: read("media_control.enabled").checked,
675
+ blocked_types: blocked,
676
+ },
677
+ welcome_system: { enabled: read("welcome_system.enabled").checked },
678
+ warn_system: {
679
+ max_warnings: Number(read("warn_system.max_warnings").value),
680
+ action: read("warn_system.action").value,
681
+ },
682
+ join_requests: {
683
+ auto_approve_enabled: read("join_requests.auto_approve_enabled").checked,
684
+ },
685
+ rules: read("rules").value,
686
+ };
687
+ }
688
+
689
+ VIEWS.groups = {
690
+ title: "Groups",
691
+ load: guard(async () => {
692
+ const { groups } = await api("/groups");
693
+ $("#groups-list").innerHTML = groups.length
694
+ ? groups.map(groupCard).join("")
695
+ : `<div class="card"><div class="empty">The bot isn't in any group yet.</div></div>`;
696
+ }),
697
+ };
698
+
699
+ function initGroupActions() {
700
+ $("#groups-list").addEventListener("click", (event) => {
701
+ const button = event.target.closest("[data-save-group]");
702
+ if (!button) return;
703
+
704
+ const groupId = button.dataset.saveGroup;
705
+ const card = button.closest("details");
706
+ guard(async () => {
707
+ await api(`/groups/${encodeURIComponent(groupId)}`, {
708
+ method: "PATCH",
709
+ body: collectGroupPatch(card),
710
+ });
711
+ toast("Group settings saved", "ok");
712
+ })();
713
+ });
714
+ }
715
+
716
+ // --- data tables --------------------------------------------------------
717
+
718
+ const TABLES = {
719
+ debts: {
720
+ endpoint: "/debts",
721
+ key: "debts",
722
+ columns: ["Group", "Debtor", "Creditor", "Amount", "Note", "Status"],
723
+ row: (debt) => [
724
+ `<code>${esc(shortJid(debt.group_id))}</code>`,
725
+ `<code>${esc(shortJid(debt.debtor_id))}</code>`,
726
+ `<code>${esc(shortJid(debt.creditor_id))}</code>`,
727
+ `<strong>${esc(debt.amount)} ${esc(debt.currency || "")}</strong>`,
728
+ `<span class="wrap">${esc(debt.description || "—")}</span>`,
729
+ debt.settled
730
+ ? '<span class="tag ok">settled</span>'
731
+ : '<span class="tag warn">open</span>',
732
+ ],
733
+ },
734
+ warnings: {
735
+ endpoint: "/warnings",
736
+ key: "warnings",
737
+ columns: ["Group", "User", "Count", "Reasons"],
738
+ row: (warning) => [
739
+ `<code>${esc(shortJid(warning.group_id))}</code>`,
740
+ `<code>${esc(shortJid(warning.user_id))}</code>`,
741
+ `<span class="tag warn">${warning.warnings.length}</span>`,
742
+ `<span class="wrap">${esc(
743
+ warning.warnings.map((entry) => entry.reason).join(" · ")
744
+ )}</span>`,
745
+ ],
746
+ },
747
+ notes: {
748
+ endpoint: "/notes",
749
+ key: "notes",
750
+ columns: ["Group", "Keyword", "Text"],
751
+ row: (note) => [
752
+ `<code>${esc(shortJid(note.group_id))}</code>`,
753
+ `<strong>${esc(note.keyword)}</strong>`,
754
+ `<span class="wrap">${esc(String(note.note_text ?? "").slice(0, 160))}</span>`,
755
+ ],
756
+ },
757
+ todos: {
758
+ endpoint: "/todos",
759
+ key: "todos",
760
+ columns: ["User", "Tasks", "Open"],
761
+ row: (todo) => [
762
+ `<code>${esc(shortJid(todo.user_id))}</code>`,
763
+ `<span class="wrap">${esc(
764
+ todo.tasks
765
+ .map((task) => (typeof task === "string" ? task : task.text || ""))
766
+ .join(" · ")
767
+ .slice(0, 160)
768
+ )}</span>`,
769
+ `<span class="tag">${todo.tasks.length}</span>`,
770
+ ],
771
+ },
772
+ schedules: {
773
+ endpoint: "/schedules",
774
+ key: "schedules",
775
+ columns: ["Target", "When", "Message", "Status", ""],
776
+ row: (job) => [
777
+ `<code>${esc(shortJid(job.targetJid))}</code>`,
778
+ job.type === "recurring"
779
+ ? `<code>${esc(job.cronString || "—")}</code>`
780
+ : esc(new Date(job.date).toLocaleString()),
781
+ `<span class="wrap">${esc(String(job.message ?? "").slice(0, 120))}</span>`,
782
+ `<span class="tag${job.status === "active" || job.status === "pending" ? " ok" : ""}">${esc(job.status)}</span>`,
783
+ `<button class="btn btn-sm btn-danger" data-drop-schedule="${esc(job.id)}">Delete</button>`,
784
+ ],
785
+ },
786
+ users: {
787
+ endpoint: "/users",
788
+ key: "users",
789
+ columns: ["Number", "Name", "Role", "Last seen"],
790
+ row: (user) => [
791
+ `<code>${esc(user.phone || shortJid(user.jid))}</code>`,
792
+ esc(user.displayName || "—"),
793
+ user.isOwner
794
+ ? '<span class="tag info">owner</span>'
795
+ : user.isAdmin
796
+ ? '<span class="tag ok">admin</span>'
797
+ : '<span class="tag">member</span>',
798
+ esc(timeAgo(user.lastSeen)),
799
+ ],
800
+ },
801
+ };
802
+
803
+ let dataTab = "debts";
804
+
805
+ const loadTable = guard(async () => {
806
+ const table = TABLES[dataTab];
807
+ $("#data-table").innerHTML = `<div class="loading">Loading…</div>`;
808
+
809
+ const payload = await api(table.endpoint);
810
+ const rows = payload[table.key] || [];
811
+
812
+ $("#data-table").innerHTML = rows.length
813
+ ? `<div class="table-wrap"><table>
814
+ <thead><tr>${table.columns.map((column) => `<th>${esc(column)}</th>`).join("")}</tr></thead>
815
+ <tbody>${rows
816
+ .map((row) => `<tr>${table.row(row).map((cell) => `<td>${cell}</td>`).join("")}</tr>`)
817
+ .join("")}</tbody>
818
+ </table></div>`
819
+ : `<div class="empty">Nothing here yet.</div>`;
820
+ });
821
+
822
+ VIEWS.data = {
823
+ title: "Data",
824
+ load: loadTable,
825
+ };
826
+
827
+ function initDataActions() {
828
+ $("#data-table").addEventListener("click", (event) => {
829
+ const button = event.target.closest("[data-drop-schedule]");
830
+ if (!button) return;
831
+ guard(async () => {
832
+ await api(`/schedules/${encodeURIComponent(button.dataset.dropSchedule)}`, {
833
+ method: "DELETE",
834
+ });
835
+ toast("Schedule deleted", "ok");
836
+ loadTable();
837
+ })();
838
+ });
839
+
840
+ $("#data-tabs").addEventListener("click", (event) => {
841
+ const button = event.target.closest("[data-table]");
842
+ if (!button) return;
843
+ dataTab = button.dataset.table;
844
+ $$("#data-tabs .btn").forEach((tab) =>
845
+ tab.classList.toggle("btn-primary", tab.dataset.table === dataTab)
846
+ );
847
+ loadTable();
848
+ });
849
+ }
850
+
851
+ // --- roles --------------------------------------------------------------
852
+
853
+ function roleRows(list, role) {
854
+ if (!list.length) {
855
+ return `<tr><td colspan="3"><div class="empty">No ${role}s yet.</div></td></tr>`;
856
+ }
857
+ return list
858
+ .map(
859
+ (user) => `
860
+ <tr>
861
+ <td><code>${esc(user.phone || shortJid(user.jid))}</code></td>
862
+ <td>${esc(user.displayName || "—")}</td>
863
+ <td>
864
+ <button class="btn btn-sm btn-danger" data-revoke="${esc(user.phone || shortJid(user.jid))}"
865
+ data-role="${esc(role)}">Revoke</button>
866
+ </td>
867
+ </tr>`
868
+ )
869
+ .join("");
870
+ }
871
+
872
+ VIEWS.roles = {
873
+ title: "Roles",
874
+ load: guard(async () => {
875
+ const { roles } = await api("/roles");
876
+ $("#owners-body").innerHTML = roleRows(roles.owners, "owner");
877
+ $("#admins-body").innerHTML = roleRows(roles.admins, "admin");
878
+ }),
879
+ };
880
+
881
+ function initRoleActions() {
882
+ $("#role-add").addEventListener(
883
+ "click",
884
+ guard(async () => {
885
+ const target = $("#role-number").value.trim();
886
+ const role = $("#role-kind").value;
887
+ await api("/roles", { method: "POST", body: { target, role, action: "grant" } });
888
+ $("#role-number").value = "";
889
+ toast(`Added as ${role}`, "ok");
890
+ VIEWS.roles.load();
891
+ })
892
+ );
893
+
894
+ $("#view-roles").addEventListener("click", (event) => {
895
+ const button = event.target.closest("[data-revoke]");
896
+ if (!button) return;
897
+ guard(async () => {
898
+ await api("/roles", {
899
+ method: "POST",
900
+ body: {
901
+ target: button.dataset.revoke,
902
+ role: button.dataset.role,
903
+ action: "revoke",
904
+ },
905
+ });
906
+ toast("Revoked", "ok");
907
+ VIEWS.roles.load();
908
+ })();
909
+ });
910
+ }
911
+
912
+ // --- settings -----------------------------------------------------------
913
+
914
+ function settingField(setting) {
915
+ const id = `set-${setting.key}`;
916
+ const source =
917
+ setting.source === "dashboard"
918
+ ? '<span class="source dashboard">saved here</span>'
919
+ : '<span class="source">default</span>';
920
+ const restart = setting.restart
921
+ ? '<span class="source">restart to apply</span>'
922
+ : "";
923
+
924
+ let control;
925
+ if (setting.type === "secret") {
926
+ control = `
927
+ <div class="inline">
928
+ <input type="password" id="${id}" placeholder="${setting.configured ? "•••••••• (set)" : "not set"}" autocomplete="new-password" />
929
+ <button class="btn btn-sm" data-save-setting="${esc(setting.key)}">Save</button>
930
+ ${setting.configured ? `<button class="btn btn-sm btn-danger" data-clear-setting="${esc(setting.key)}">Clear</button>` : ""}
931
+ </div>`;
932
+ } else if (setting.type === "bool") {
933
+ control = `
934
+ <div class="inline">
935
+ <label class="switch">
936
+ <input type="checkbox" id="${id}" ${setting.value ? "checked" : ""} />
937
+ <span class="track"></span>
938
+ </label>
939
+ <button class="btn btn-sm" data-save-setting="${esc(setting.key)}">Save</button>
940
+ </div>`;
941
+ } else {
942
+ control = `
943
+ <div class="inline">
944
+ <input type="${setting.type === "int" ? "number" : "text"}" id="${id}"
945
+ value="${esc(setting.value)}"
946
+ ${setting.min !== null ? `min="${setting.min}"` : ""}
947
+ ${setting.max !== null ? `max="${setting.max}"` : ""} />
948
+ <button class="btn btn-sm" data-save-setting="${esc(setting.key)}">Save</button>
949
+ </div>`;
950
+ }
951
+
952
+ return `
953
+ <div style="margin-bottom:18px">
954
+ <div class="inline" style="margin-bottom:5px">
955
+ <label for="${id}" style="font-size:.86rem;font-weight:550">${esc(setting.label)}</label>
956
+ ${source}${restart}
957
+ </div>
958
+ ${control}
959
+ ${setting.hint ? `<div class="hint" style="margin-top:5px">${esc(setting.hint)}</div>` : ""}
960
+ </div>`;
961
+ }
962
+
963
+ VIEWS.settings = {
964
+ title: "Settings",
965
+ load: guard(async () => {
966
+ const { settings } = await api("/settings");
967
+
968
+ const groups = new Map();
969
+ for (const setting of settings) {
970
+ if (!groups.has(setting.group)) {
971
+ groups.set(setting.group, { label: setting.groupLabel, items: [] });
972
+ }
973
+ groups.get(setting.group).items.push(setting);
974
+ }
975
+
976
+ $("#settings-list").innerHTML = [...groups.values()]
977
+ .map(
978
+ (group) => `
979
+ <div class="card">
980
+ <div class="card-head"><h2>${esc(group.label)}</h2></div>
981
+ ${group.items.map(settingField).join("")}
982
+ </div>`
983
+ )
984
+ .join("");
985
+ }),
986
+ };
987
+
988
+ function initSettingsActions() {
989
+ $("#pw-save").addEventListener(
990
+ "click",
991
+ guard(async () => {
992
+ const current = $("#pw-current").value;
993
+ const next = $("#pw-next").value;
994
+ await api("/security/password", { method: "POST", body: { current, next } });
995
+ $("#pw-current").value = "";
996
+ $("#pw-next").value = "";
997
+ toast("Password changed", "ok");
998
+ })
999
+ );
1000
+
1001
+ $("#settings-list").addEventListener("click", (event) => {
1002
+ const save = event.target.closest("[data-save-setting]");
1003
+ const clear = event.target.closest("[data-clear-setting]");
1004
+ if (!save && !clear) return;
1005
+
1006
+ const key = save ? save.dataset.saveSetting : clear.dataset.clearSetting;
1007
+ const input = document.getElementById(`set-${key}`);
1008
+ const value = clear
1009
+ ? ""
1010
+ : input.type === "checkbox"
1011
+ ? input.checked
1012
+ : input.value;
1013
+
1014
+ guard(async () => {
1015
+ await api("/settings", { method: "PATCH", body: { key, value } });
1016
+ toast(clear ? "Cleared" : "Saved", "ok");
1017
+ VIEWS.settings.load();
1018
+ })();
1019
+ });
1020
+ }
1021
+
1022
+ // --- boot ---------------------------------------------------------------
1023
+
1024
+ function initSocket() {
1025
+ // eslint-disable-next-line no-undef
1026
+ const socket = io();
1027
+
1028
+ socket.on("qr", (qr) => {
1029
+ renderQr(qr);
1030
+ setStatus(false, "Waiting for scan");
1031
+ if (currentView !== "connection") {
1032
+ toast("A new QR code is ready — open Connection to scan it.");
1033
+ }
1034
+ });
1035
+
1036
+ socket.on("status_update", (data) => {
1037
+ const connected = data.status === "Connected";
1038
+ setStatus(connected, data.status);
1039
+ if (connected && currentView === "connection") VIEWS.connection.load();
1040
+ });
1041
+ }
1042
+
1043
+ initTheme();
1044
+ initRouting();
1045
+ initConnectionActions();
1046
+ initCommandActions();
1047
+ initAiActions();
1048
+ initGroupActions();
1049
+ initDataActions();
1050
+ initRoleActions();
1051
+ initSettingsActions();
1052
+ initSocket();
1053
+
1054
+ show(window.location.hash.slice(1));
1055
+ loadStats().catch(() => {});
1056
+ setInterval(() => loadStats().catch(() => {}), 15000);
1057
+ })();