levix-bot 3.3.0-rc.2 → 4.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 (118) hide show
  1. package/README.md +13 -9
  2. package/app.cjs +49 -46
  3. package/package.json +9 -1
  4. package/public/dashboard/assets/index-BBBXWzM0.js +391 -0
  5. package/public/dashboard/assets/index-pYaivHON.css +1 -0
  6. package/public/dashboard/index.html +19 -0
  7. package/public/dashboard.css +391 -66
  8. package/public/dashboard.js +593 -42
  9. package/scheduler.cjs +9 -14
  10. package/src/auth/use-database-auth-state.js +2 -4
  11. package/src/bootstrap/core.js +2 -5
  12. package/src/bootstrap/panel.js +10 -5
  13. package/src/cli.js +3 -5
  14. package/src/commands/autoschedule.cjs +1 -4
  15. package/src/commands/blacklist.cjs +22 -20
  16. package/src/commands/block.cjs +2 -4
  17. package/src/commands/calc.cjs +9 -16
  18. package/src/commands/digit.cjs +97 -0
  19. package/src/commands/gemini.cjs +147 -161
  20. package/src/commands/group/add.cjs +7 -16
  21. package/src/commands/group/antiSpam.cjs +5 -11
  22. package/src/commands/group/antilink.cjs +34 -28
  23. package/src/commands/group/approveall.cjs +3 -11
  24. package/src/commands/group/clearwarns.cjs +2 -5
  25. package/src/commands/group/demote.cjs +3 -8
  26. package/src/commands/group/kick.cjs +12 -15
  27. package/src/commands/group/media.cjs +17 -24
  28. package/src/commands/group/members.cjs +1 -2
  29. package/src/commands/group/promote.cjs +4 -12
  30. package/src/commands/group/removeall.cjs +2 -5
  31. package/src/commands/group/setpp.cjs +1 -1
  32. package/src/commands/group/setrules.cjs +2 -8
  33. package/src/commands/group/setwarn.cjs +4 -2
  34. package/src/commands/group/tagadmins.cjs +2 -5
  35. package/src/commands/group/warn.cjs +26 -24
  36. package/src/commands/group/warns.cjs +1 -2
  37. package/src/commands/group/welcome.cjs +13 -17
  38. package/src/commands/group.cjs +7 -10
  39. package/src/commands/help.cjs +6 -12
  40. package/src/commands/lang.cjs +104 -0
  41. package/src/commands/listschedules.cjs +2 -6
  42. package/src/commands/loop.cjs +6 -13
  43. package/src/commands/memory.cjs +19 -32
  44. package/src/commands/mod.cjs +97 -35
  45. package/src/commands/perm.cjs +18 -56
  46. package/src/commands/ping.cjs +1 -5
  47. package/src/commands/poll.cjs +1 -1
  48. package/src/commands/rand.cjs +2 -5
  49. package/src/commands/schedule.cjs +4 -8
  50. package/src/commands/score.cjs +8 -2
  51. package/src/commands/setprefix.cjs +2 -2
  52. package/src/commands/shortlink.cjs +7 -15
  53. package/src/commands/shutdown.cjs +1 -1
  54. package/src/commands/sticker.cjs +222 -0
  55. package/src/commands/stt.cjs +136 -68
  56. package/src/commands/todo.cjs +9 -15
  57. package/src/commands/tts.cjs +10 -16
  58. package/src/commands/unblock.cjs +2 -6
  59. package/src/commands/weather.cjs +5 -12
  60. package/src/config/ai-persona.md +5 -3
  61. package/src/config/available-models.json +1156 -0
  62. package/src/config/baileys.config.js +2 -1
  63. package/src/config/brand.esm.js +1 -10
  64. package/src/config/constants.js +1 -1
  65. package/src/config/defaults.cjs +66 -66
  66. package/src/config/lock.cjs +2 -2
  67. package/src/config/modelCapabilities.cjs +172 -0
  68. package/src/config/runtime-config.cjs +8 -13
  69. package/src/config/secrets.cjs +18 -10
  70. package/src/config/settings.cjs +94 -10
  71. package/src/config/vault.cjs +107 -0
  72. package/src/core/connection.js +13 -12
  73. package/src/core/events.js +41 -23
  74. package/src/core/proxy.js +7 -7
  75. package/src/core/session.js +91 -48
  76. package/src/core/socket.js +12 -15
  77. package/src/db/db.cjs +14 -6
  78. package/src/db/store.cjs +221 -87
  79. package/src/domain/caddy.js +1 -1
  80. package/src/domain/command.js +40 -19
  81. package/src/domain/detect.js +1 -1
  82. package/src/domain/system.js +2 -5
  83. package/src/handlers/command.handler.js +22 -43
  84. package/src/handlers/group.handler.js +13 -13
  85. package/src/handlers/message.handler.js +11 -21
  86. package/src/index.js +8 -4
  87. package/src/middleware/antispam.middleware.js +46 -23
  88. package/src/middleware/blacklist.middleware.js +14 -9
  89. package/src/middleware/forward-tracking.middleware.js +3 -2
  90. package/src/middleware/permissions.middleware.js +7 -10
  91. package/src/panel/login-throttle.cjs +43 -0
  92. package/src/panel/session-auth.cjs +17 -0
  93. package/src/routes/dashboard.api.esm.js +394 -72
  94. package/src/services/aiAgent.cjs +136 -49
  95. package/src/services/aiProviders.cjs +193 -47
  96. package/src/services/aiRouter.cjs +137 -0
  97. package/src/services/aiTools.cjs +305 -128
  98. package/src/services/llmDiscovery.cjs +481 -0
  99. package/src/services/modelRegistry.cjs +412 -0
  100. package/src/utils/datetime.cjs +2 -2
  101. package/src/utils/lid-helper.esm.js +7 -7
  102. package/src/utils/logger.cjs +1 -1
  103. package/src/utils/markdownParser.cjs +159 -0
  104. package/src/utils/memory.cjs +43 -13
  105. package/src/utils/messageContent.cjs +40 -0
  106. package/src/utils/normalizeJid.esm.js +7 -7
  107. package/src/utils/numerals.cjs +75 -0
  108. package/src/utils/permissions.esm.js +10 -15
  109. package/src/utils/providerUrl.cjs +281 -0
  110. package/src/utils/recurrence.cjs +7 -11
  111. package/src/utils/sendBotMessage.esm.js +4 -14
  112. package/src/utils/statusMessage.cjs +5 -13
  113. package/src/utils/storage.cjs +10 -0
  114. package/src/utils/storage.esm.js +11 -0
  115. package/src/utils/textDecode.cjs +4 -9
  116. package/src/utils/thumbnail.cjs +8 -14
  117. package/src/utils/typing.esm.js +12 -12
  118. package/views/dashboard.ejs +134 -106
@@ -12,34 +12,33 @@
12
12
  // src/config/ai-identity.cjs, which this file does not import and no route
13
13
  // below returns. Neither is an editable field anywhere in the panel.
14
14
 
15
- import { Router } from "express";
16
- import { createRequire } from "module";
17
15
  import fs from "node:fs";
18
16
  import path from "node:path";
19
-
17
+ import { Router } from "express";
18
+ import { createRequire } from "module";
19
+ import { groupMetadataCache } from "../core/socket.js";
20
+ import { getCommandCatalog, rebuildCommandIndex } from "../handlers/command.handler.js";
21
+ import { grantRole, listRoles, revokeRole } from "../utils/permissions.esm.js";
20
22
  import {
23
+ countDebts,
21
24
  countGroups,
22
- countWarnings,
23
- countTodos,
24
25
  countNotes,
25
- countDebts,
26
+ countSchedules,
27
+ countTodos,
28
+ countWarnings,
29
+ getAllGroupDirectory,
26
30
  getAllGroupSettings,
27
- getGroupSettings,
28
- saveGroupSettings,
29
31
  getAllNotesFlat,
30
- getAllWarnings,
31
32
  getAllTodos,
32
33
  getAllUsers,
34
+ getAllWarnings,
35
+ describePeer,
36
+ getGroupDirectory,
37
+ getGroupSettings,
33
38
  getRecentDebts,
34
- countSchedules,
35
39
  getSchedules,
40
+ saveGroupSettings,
36
41
  } from "../utils/storage.esm.js";
37
- import {
38
- getCommandCatalog,
39
- rebuildCommandIndex,
40
- } from "../handlers/command.handler.js";
41
- import { groupMetadataCache } from "../core/socket.js";
42
- import { grantRole, revokeRole, listRoles } from "../utils/permissions.esm.js";
43
42
 
44
43
  const require = createRequire(import.meta.url);
45
44
  const logger = require("../utils/logger.cjs");
@@ -47,13 +46,25 @@ const runtimeConfig = require("../config/runtime-config.cjs");
47
46
  const settings = require("../config/settings.cjs");
48
47
  const memory = require("../utils/memory.cjs");
49
48
  const secrets = require("../config/secrets.cjs");
49
+ const { clientAddress } = require("../utils/requestOrigin.cjs");
50
+ const { blockedFor, recordFailure, clearAttempts } = require("../panel/login-throttle.cjs");
51
+ const { stampPanelSession } = require("../panel/session-auth.cjs");
50
52
  const { DATA_DIR } = require("../config/paths.cjs");
53
+ const { PERSONA_FILE, activeProviderKeySetting } = require("../services/aiAgent.cjs");
51
54
  const {
52
- PERSONA_FILE,
53
- activeProviderKeySetting,
54
- } = require("../services/aiAgent.cjs");
55
- const { deleteScheduledJob, retryScheduledJob } = require("../../scheduler.cjs");
55
+ deleteScheduledJob,
56
+ retryScheduledJob,
57
+ scheduleNewJob,
58
+ saveScheduledJob,
59
+ } = require("../../scheduler.cjs");
56
60
  const { describeScheduledJob } = require("../utils/recurrence.cjs");
61
+ const { discoverProviderModels, DiscoveryError } = require("../services/llmDiscovery.cjs");
62
+ const {
63
+ invalidateDiscoveryCache,
64
+ applyCapabilityGuards,
65
+ resolveCapabilities,
66
+ } = require("../services/modelRegistry.cjs");
67
+ const cron = require("node-cron");
57
68
 
58
69
  const router = Router();
59
70
 
@@ -61,9 +72,16 @@ const router = Router();
61
72
  // it questions and give it orders; they never create or destroy a socket
62
73
  // themselves, and nothing a browser does to its websocket reaches it.
63
74
  let session = null;
75
+ const DASHBOARD_START_COALESCE_MS = 500;
76
+ let dashboardStartPromise = null;
77
+ let dashboardStartSettledAt = 0;
78
+ let dashboardStartKey = null;
64
79
 
65
80
  export function setSession(manager) {
66
81
  session = manager;
82
+ dashboardStartPromise = null;
83
+ dashboardStartSettledAt = 0;
84
+ dashboardStartKey = null;
67
85
  }
68
86
 
69
87
  /** The live socket, or null. Re-read every time — a reconnect replaces it. */
@@ -112,7 +130,7 @@ function badRequest(res, message) {
112
130
  function asyncRoute(handler) {
113
131
  return (req, res) => {
114
132
  Promise.resolve(handler(req, res)).catch((error) =>
115
- fail(res, error, `${req.method} ${req.path} failed`)
133
+ fail(res, error, `${req.method} ${req.path} failed`),
116
134
  );
117
135
  };
118
136
  }
@@ -208,7 +226,7 @@ router.patch("/commands/:name", (req, res) => {
208
226
  if (command.permissionLocked) {
209
227
  return badRequest(
210
228
  res,
211
- "This command always requires a WhatsApp group admin, so its permission is fixed."
229
+ "This command always requires a WhatsApp group admin, so its permission is fixed.",
212
230
  );
213
231
  }
214
232
  runtimeConfig.setPermission(command.key, permission || null);
@@ -229,12 +247,11 @@ router.patch("/commands/:name", (req, res) => {
229
247
  for (const alias of entry.aliases) taken.set(alias, entry.name);
230
248
  }
231
249
  for (const raw of aliases) {
232
- const alias = String(raw ?? "").trim().toLowerCase();
250
+ const alias = String(raw ?? "")
251
+ .trim()
252
+ .toLowerCase();
233
253
  if (taken.has(alias)) {
234
- return badRequest(
235
- res,
236
- `"${alias}" is already used by !${taken.get(alias)}`
237
- );
254
+ return badRequest(res, `"${alias}" is already used by !${taken.get(alias)}`);
238
255
  }
239
256
  }
240
257
  }
@@ -253,9 +270,7 @@ router.patch("/commands/:name", (req, res) => {
253
270
  // Make the new triggers live now rather than after a restart.
254
271
  if (indexNeedsRebuild) rebuildCommandIndex();
255
272
 
256
- const updated = getCommandCatalog().find(
257
- (entry) => entry.name === command.name
258
- );
273
+ const updated = getCommandCatalog().find((entry) => entry.name === command.name);
259
274
  res.json({ success: true, command: updated });
260
275
  });
261
276
 
@@ -276,7 +291,8 @@ router.get("/settings", (req, res) => {
276
291
  });
277
292
 
278
293
  router.patch("/settings", (req, res) => {
279
- const { key, value } = req.body || {};
294
+ const { key } = req.body || {};
295
+ let { value } = req.body || {};
280
296
  if (typeof key !== "string" || !key) return badRequest(res, "key is required");
281
297
 
282
298
  try {
@@ -285,7 +301,63 @@ router.patch("/settings", (req, res) => {
285
301
  return res.json({ success: true, prefix });
286
302
  }
287
303
 
304
+ if (key === "ai_vision_enabled" || key === "ai_stt_enabled") {
305
+ const provider = String(settings.get("ai_provider") || "gemini");
306
+ const modelKey =
307
+ provider === "gemini"
308
+ ? "gemini_model"
309
+ : provider === "anthropic"
310
+ ? "anthropic_model"
311
+ : "openai_model";
312
+ const resolved = resolveCapabilities(provider, settings.get(modelKey));
313
+ const next = applyCapabilityGuards({
314
+ visionEnabled: key === "ai_vision_enabled" ? value : settings.get("ai_vision_enabled"),
315
+ sttEnabled: key === "ai_stt_enabled" ? value : settings.get("ai_stt_enabled"),
316
+ capabilities: resolved.capabilities,
317
+ });
318
+ if (key === "ai_vision_enabled") value = next.visionEnabled;
319
+ if (key === "ai_stt_enabled") value = next.sttEnabled;
320
+ }
321
+
288
322
  settings.set(key, value);
323
+ if (typeof key === "string" && (key.includes("api_key") || key.includes("base_url"))) {
324
+ const prov = key.startsWith("gemini")
325
+ ? "gemini"
326
+ : key.startsWith("anthropic")
327
+ ? "anthropic"
328
+ : key.startsWith("openai")
329
+ ? "openai"
330
+ : null;
331
+ if (prov) invalidateDiscoveryCache(prov);
332
+ else invalidateDiscoveryCache();
333
+ }
334
+ if (
335
+ key === "ai_provider" ||
336
+ key === "gemini_model" ||
337
+ key === "openai_model" ||
338
+ key === "anthropic_model"
339
+ ) {
340
+ const provider = key === "ai_provider" ? String(value || "gemini") : key.split("_")[0];
341
+ const modelKey =
342
+ provider === "gemini"
343
+ ? "gemini_model"
344
+ : provider === "anthropic"
345
+ ? "anthropic_model"
346
+ : "openai_model";
347
+ const modelId = key.endsWith("_model") ? value : settings.get(modelKey);
348
+ const resolved = resolveCapabilities(provider, modelId);
349
+ const next = applyCapabilityGuards({
350
+ visionEnabled: settings.get("ai_vision_enabled"),
351
+ sttEnabled: settings.get("ai_stt_enabled"),
352
+ capabilities: resolved.capabilities,
353
+ });
354
+ if (Boolean(settings.get("ai_vision_enabled")) !== next.visionEnabled) {
355
+ settings.set("ai_vision_enabled", next.visionEnabled);
356
+ }
357
+ if (Boolean(settings.get("ai_stt_enabled")) !== next.sttEnabled) {
358
+ settings.set("ai_stt_enabled", next.sttEnabled);
359
+ }
360
+ }
289
361
  return res.json({
290
362
  success: true,
291
363
  // Never echo a value back: for a secret that would hand it to anyone who
@@ -317,9 +389,7 @@ function splitPersona(raw) {
317
389
 
318
390
  router.get("/ai/persona", (req, res) => {
319
391
  try {
320
- const raw = fs.existsSync(PERSONA_FILE)
321
- ? fs.readFileSync(PERSONA_FILE, "utf8")
322
- : "";
392
+ const raw = fs.existsSync(PERSONA_FILE) ? fs.readFileSync(PERSONA_FILE, "utf8") : "";
323
393
  const { header, body } = splitPersona(raw);
324
394
  res.json({
325
395
  success: true,
@@ -336,9 +406,7 @@ router.put("/ai/persona", (req, res) => {
336
406
  if (body.length > 20000) return badRequest(res, "Persona is too long (20k max)");
337
407
 
338
408
  try {
339
- const raw = fs.existsSync(PERSONA_FILE)
340
- ? fs.readFileSync(PERSONA_FILE, "utf8")
341
- : "";
409
+ const raw = fs.existsSync(PERSONA_FILE) ? fs.readFileSync(PERSONA_FILE, "utf8") : "";
342
410
  const { header } = splitPersona(raw);
343
411
  const next = `${header}\n${PERSONA_SEPARATOR}\n${body.trim()}\n`;
344
412
  fs.writeFileSync(PERSONA_FILE, next, "utf8");
@@ -349,14 +417,81 @@ router.put("/ai/persona", (req, res) => {
349
417
  }
350
418
  });
351
419
 
420
+ router.post(
421
+ "/ai/models",
422
+ asyncRoute(async (req, res) => {
423
+ const {
424
+ provider = settings.get("ai_provider") || "gemini",
425
+ apiKey,
426
+ baseUrl,
427
+ refresh = false,
428
+ } = req.body || {};
429
+
430
+ const provKey = String(provider || "gemini").toLowerCase();
431
+ const activeKeySetting =
432
+ provKey === "gemini"
433
+ ? "gemini_api_key"
434
+ : provKey === "anthropic"
435
+ ? "anthropic_api_key"
436
+ : "openai_api_key";
437
+ const activeBaseUrlSetting =
438
+ provKey === "gemini"
439
+ ? "gemini_base_url"
440
+ : provKey === "anthropic"
441
+ ? "anthropic_base_url"
442
+ : "openai_base_url";
443
+
444
+ const offeredKey = typeof apiKey === "string" ? apiKey.trim() : "";
445
+ const effectiveKey = offeredKey || settings.get(activeKeySetting) || "";
446
+ const effectiveBaseUrl = baseUrl || settings.get(activeBaseUrlSetting) || "";
447
+
448
+ try {
449
+ const result = await discoverProviderModels({
450
+ provider: provKey,
451
+ apiKey: effectiveKey,
452
+ baseUrl: effectiveBaseUrl,
453
+ refresh: Boolean(refresh),
454
+ });
455
+
456
+ return res.json(result);
457
+ } catch (err) {
458
+ if (err instanceof DiscoveryError) {
459
+ return res.json({
460
+ success: false,
461
+ live: false,
462
+ fallback: true,
463
+ provider: provKey,
464
+ error: {
465
+ code: err.code,
466
+ type: err.type,
467
+ message: err.message,
468
+ },
469
+ models: [],
470
+ });
471
+ }
472
+
473
+ logger.warn({ provider: provKey }, "[Dashboard API] Live model fetch failed");
474
+ return res.json({
475
+ success: false,
476
+ live: false,
477
+ fallback: true,
478
+ provider: provKey,
479
+ error: {
480
+ code: 500,
481
+ type: "server_error",
482
+ message: "An unexpected error occurred while discovering models.",
483
+ },
484
+ models: [],
485
+ });
486
+ }
487
+ }),
488
+ );
489
+
352
490
  // Memory files: `global`, or one chat. The scope is turned into a path by
353
491
  // memory.cjs and then re-checked against the memory root, so a crafted scope
354
492
  // can't walk out of it.
355
493
  function memoryPathFor(scope) {
356
- const filePath =
357
- scope === "global"
358
- ? memory.GLOBAL_FILE
359
- : memory.memoryFilePath("chat", scope);
494
+ const filePath = scope === "global" ? memory.GLOBAL_FILE : memory.memoryFilePath("chat", scope);
360
495
  const resolved = path.resolve(filePath);
361
496
  if (!resolved.startsWith(path.resolve(memory.ROOT) + path.sep)) {
362
497
  throw new Error("Refusing to touch a file outside the memory folder");
@@ -403,9 +538,7 @@ router.get("/ai/memory", (req, res) => {
403
538
  router.get("/ai/memory/:scope", (req, res) => {
404
539
  try {
405
540
  const filePath = memoryPathFor(req.params.scope);
406
- const content = fs.existsSync(filePath)
407
- ? fs.readFileSync(filePath, "utf8")
408
- : "";
541
+ const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
409
542
  res.json({ success: true, scope: req.params.scope, content });
410
543
  } catch (error) {
411
544
  return badRequest(res, error.message);
@@ -444,13 +577,26 @@ router.delete("/ai/memory/:scope", (req, res) => {
444
577
  // ===========================================================================
445
578
 
446
579
  function groupView(groupId, stored) {
447
- const metadata = groupMetadataCache.get(groupId) || null;
580
+ const live = groupMetadataCache.get(groupId) || null;
581
+ const directory = getGroupDirectory(groupId) || null;
448
582
  const config = stored || {};
583
+ const subject = live?.subject || directory?.subject || null;
584
+ const memberCount =
585
+ live?.participants?.length ?? directory?.participant_count ?? null;
586
+ const mediaEnabled = !!config.media_control?.enabled;
587
+ const blocked = config.media_control?.blocked_types || [];
588
+ const mediaRestriction = !mediaEnabled
589
+ ? "none"
590
+ : blocked.length >= 4
591
+ ? "block_all"
592
+ : "custom";
449
593
 
450
594
  return {
451
595
  id: groupId,
452
- subject: metadata?.subject || null,
453
- participants: metadata?.participants?.length ?? null,
596
+ jid: groupId,
597
+ subject,
598
+ participants: memberCount,
599
+ memberCount,
454
600
  antilink: {
455
601
  enabled: !!config.antilink?.enabled,
456
602
  mode: config.antilink?.mode || "ALL",
@@ -479,19 +625,21 @@ function groupView(groupId, stored) {
479
625
  },
480
626
  rules: config.rules || "",
481
627
  blacklist: config.blacklist || [],
628
+ antilinkEnabled: !!config.antilink?.enabled,
629
+ welcomeEnabled: !!config.welcome_system?.enabled,
630
+ mediaRestriction,
482
631
  };
483
632
  }
484
633
 
485
634
  router.get("/groups", (req, res) => {
486
635
  try {
487
- const stored = new Map(
488
- getAllGroupSettings().map((row) => [row.group_id, row.settings || {}])
489
- );
636
+ const stored = new Map(getAllGroupSettings().map((row) => [row.group_id, row.settings || {}]));
490
637
 
491
638
  // Groups the bot is in but that have no settings row yet still belong in
492
639
  // the list — that's exactly where the operator goes to configure them.
493
640
  const ids = new Set(stored.keys());
494
641
  for (const jid of groupMetadataCache.keys()) ids.add(jid);
642
+ for (const row of getAllGroupDirectory()) ids.add(row.group_id);
495
643
 
496
644
  const groups = [...ids]
497
645
  .map((id) => groupView(id, stored.get(id)))
@@ -511,6 +659,19 @@ router.patch("/groups/:id", (req, res) => {
511
659
  if (!groupId.endsWith("@g.us")) return badRequest(res, "Not a group id");
512
660
 
513
661
  const patch = req.body || {};
662
+ if (typeof patch.antilink === "boolean") {
663
+ patch.antilink = { enabled: patch.antilink };
664
+ }
665
+ if (patch.welcomeEnabled !== undefined && !patch.welcome_system) {
666
+ patch.welcome_system = { enabled: !!patch.welcomeEnabled };
667
+ }
668
+ if (patch.mediaRestriction && !patch.media_control) {
669
+ if (patch.mediaRestriction === "none") {
670
+ patch.media_control = { enabled: false, blocked_types: [] };
671
+ } else if (patch.mediaRestriction === "block_all") {
672
+ patch.media_control = { enabled: true, blocked_types: [...MEDIA_TYPES] };
673
+ }
674
+ }
514
675
  const current = getGroupSettings(groupId) || {};
515
676
 
516
677
  try {
@@ -575,9 +736,7 @@ router.patch("/groups/:id", (req, res) => {
575
736
  if (!Array.isArray(patch.media_control.blocked_types)) {
576
737
  throw new Error("blocked_types must be a list");
577
738
  }
578
- const types = patch.media_control.blocked_types.map((type) =>
579
- String(type).toLowerCase()
580
- );
739
+ const types = patch.media_control.blocked_types.map((type) => String(type).toLowerCase());
581
740
  for (const type of types) {
582
741
  if (!MEDIA_TYPES.includes(type)) throw new Error(`Unknown media type: ${type}`);
583
742
  }
@@ -673,9 +832,7 @@ router.get("/notes", (req, res) => {
673
832
 
674
833
  router.get("/warnings", (req, res) => {
675
834
  try {
676
- const warnings = getAllWarnings().sort((a, b) =>
677
- a.group_id.localeCompare(b.group_id)
678
- );
835
+ const warnings = getAllWarnings().sort((a, b) => a.group_id.localeCompare(b.group_id));
679
836
  res.json({ success: true, warnings });
680
837
  } catch (error) {
681
838
  fail(res, error, "Error fetching warnings");
@@ -741,13 +898,109 @@ router.post("/roles", (req, res) => {
741
898
  function scheduleView(job) {
742
899
  if (!job) return null;
743
900
  const timezone = settings.get("bot_timezone");
744
- return { ...job, when: describeScheduledJob(job, timezone) };
901
+ const peer = describePeer(job.targetJid);
902
+ return {
903
+ ...job,
904
+ when: describeScheduledJob(job, timezone),
905
+ targetLabel: peer.label,
906
+ targetKind: peer.kind,
907
+ targetPhone: peer.phone,
908
+ };
745
909
  }
746
910
 
747
911
  function scheduleViews() {
748
912
  return getSchedules().map(scheduleView);
749
913
  }
750
914
 
915
+ router.get("/recipients", (req, res) => {
916
+ try {
917
+ const recipients = [];
918
+ const seen = new Set();
919
+
920
+ const addGroup = (gid) => {
921
+ if (!gid || seen.has(gid)) return;
922
+ seen.add(gid);
923
+ const live = groupMetadataCache.get(gid);
924
+ const peer = describePeer(gid);
925
+ recipients.push({
926
+ id: gid,
927
+ name: live?.subject || peer.label,
928
+ type: "group",
929
+ memberCount: live?.participants?.length ?? peer.memberCount,
930
+ });
931
+ };
932
+
933
+ for (const g of getAllGroupSettings() || []) addGroup(g.group_id);
934
+ for (const row of getAllGroupDirectory()) addGroup(row.group_id);
935
+ for (const gid of groupMetadataCache.keys()) addGroup(gid);
936
+
937
+ const users = getAllUsers() || [];
938
+ users.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0));
939
+ for (const u of users.slice(0, 80)) {
940
+ const jid = u.jid;
941
+ if (!jid || seen.has(jid) || String(jid).endsWith("@g.us")) continue;
942
+ seen.add(jid);
943
+ const peer = describePeer(jid);
944
+ recipients.push({
945
+ id: jid,
946
+ name: peer.label,
947
+ phone: peer.phone,
948
+ type: "contact",
949
+ });
950
+ }
951
+
952
+ res.json({ success: true, recipients });
953
+ } catch (error) {
954
+ fail(res, error, "Error fetching recipients");
955
+ }
956
+ });
957
+
958
+ router.post(
959
+ "/schedules",
960
+ asyncRoute(async (req, res) => {
961
+ const existing = getSchedules();
962
+ if (existing.length >= 3) {
963
+ return badRequest(res, "Maximum 3 scheduled messages reached");
964
+ }
965
+
966
+ const { targetJid, message, type, cronString, scheduledTime } = req.body || {};
967
+ if (!targetJid || !message) {
968
+ return badRequest(res, "Recipient and message are required");
969
+ }
970
+
971
+ const id = Date.now().toString();
972
+ const job = {
973
+ id,
974
+ type: type === "once" ? "once" : "recurring",
975
+ targetJid: String(targetJid).trim(),
976
+ message: String(message).trim(),
977
+ creatorJid: "dashboard@levix",
978
+ status: "active",
979
+ };
980
+
981
+ if (job.type === "recurring") {
982
+ if (!cronString || !cron.validate(cronString)) {
983
+ return badRequest(res, "Invalid cron expression");
984
+ }
985
+ job.cron = cronString;
986
+ } else {
987
+ const timeMs = Number(scheduledTime);
988
+ if (!timeMs || timeMs <= Date.now()) {
989
+ return badRequest(res, "Scheduled time must be in the future");
990
+ }
991
+ job.date = new Date(timeMs).toISOString();
992
+ }
993
+
994
+ saveScheduledJob(job);
995
+ const sock = currentSocket();
996
+ if (sock) {
997
+ scheduleNewJob(sock, job);
998
+ }
999
+
1000
+ res.json({ success: true, schedule: scheduleView(job) });
1001
+ }),
1002
+ );
1003
+
751
1004
  router.get("/schedules", (req, res) => {
752
1005
  try {
753
1006
  res.json({
@@ -810,7 +1063,7 @@ router.post(
810
1063
  }
811
1064
 
812
1065
  res.json({ success: true, schedule: scheduleView(result.job) });
813
- })
1066
+ }),
814
1067
  );
815
1068
 
816
1069
  // ===========================================================================
@@ -819,10 +1072,17 @@ router.post(
819
1072
 
820
1073
  router.post("/security/password", (req, res) => {
821
1074
  const { current, next } = req.body || {};
1075
+ const peer = clientAddress(req);
1076
+ const retryAfter = blockedFor(peer);
1077
+ if (retryAfter) {
1078
+ res.set("Retry-After", String(retryAfter));
1079
+ return res.status(429).json({ success: false, error: "Too many attempts. Try again later." });
1080
+ }
822
1081
 
823
1082
  // Being signed in isn't enough: an unattended browser shouldn't be able to
824
1083
  // lock the real operator out.
825
1084
  if (!secrets.verifyDashboardPassword(current)) {
1085
+ recordFailure(peer);
826
1086
  return res.status(401).json({ success: false, error: "Current password is wrong" });
827
1087
  }
828
1088
 
@@ -832,7 +1092,16 @@ router.post("/security/password", (req, res) => {
832
1092
  return badRequest(res, error.message);
833
1093
  }
834
1094
 
835
- res.json({ success: true });
1095
+ clearAttempts(peer);
1096
+ stampPanelSession(req.session);
1097
+ req.session.save((err) => {
1098
+ if (err) return res.status(500).json({ success: false, error: "Could not refresh the session" });
1099
+ try {
1100
+ const { disconnectInvalidPanelSockets } = require("../../app.cjs");
1101
+ disconnectInvalidPanelSockets();
1102
+ } catch {}
1103
+ res.json({ success: true });
1104
+ });
836
1105
  });
837
1106
 
838
1107
  // ===========================================================================
@@ -858,9 +1127,10 @@ router.get("/bot/session", (req, res) => {
858
1127
  });
859
1128
  });
860
1129
 
861
- // Idempotent by construction: the manager returns the state it is already in
862
- // when a socket exists or is being made, so a double-click, two operators and a
863
- // retry firing at the same moment all end up with exactly one socket.
1130
+ // Coalesce repeated browser requests as well as relying on the manager's own
1131
+ // in-flight guard. A socket can fail before three concurrent HTTP requests have
1132
+ // all reached this handler; the short settled-request window prevents a late
1133
+ // duplicate from immediately creating a replacement socket.
864
1134
  router.post(
865
1135
  "/bot/session/start",
866
1136
  asyncRoute(async (req, res) => {
@@ -869,18 +1139,34 @@ router.post(
869
1139
  }
870
1140
  let state;
871
1141
  try {
872
- state = await session.start({
873
- reason: "dashboard",
874
- method: req.body?.method,
875
- phone: req.body?.phone,
876
- });
1142
+ const method = req.body?.method;
1143
+ const phone = req.body?.phone;
1144
+ const requestKey = JSON.stringify([method ?? null, phone ?? null]);
1145
+ const isDuplicate =
1146
+ requestKey === dashboardStartKey &&
1147
+ Date.now() - dashboardStartSettledAt < DASHBOARD_START_COALESCE_MS;
1148
+
1149
+ if (dashboardStartPromise) {
1150
+ state = await dashboardStartPromise;
1151
+ } else if (isDuplicate) {
1152
+ state = sessionState();
1153
+ } else {
1154
+ dashboardStartKey = requestKey;
1155
+ dashboardStartPromise = session.start({ reason: "dashboard", method, phone });
1156
+ try {
1157
+ state = await dashboardStartPromise;
1158
+ } finally {
1159
+ dashboardStartSettledAt = Date.now();
1160
+ dashboardStartPromise = null;
1161
+ }
1162
+ }
877
1163
  } catch (error) {
878
1164
  if (error.code === "PAIRING_PHONE") return badRequest(res, error.message);
879
1165
  throw error;
880
1166
  }
881
1167
  logger.info(`[Dashboard] Start session requested — now ${state.state}`);
882
1168
  res.json({ success: true, session: state });
883
- })
1169
+ }),
884
1170
  );
885
1171
 
886
1172
  // Apply configuration that only a new socket can pick up — the proxy, today.
@@ -895,7 +1181,7 @@ router.post(
895
1181
  const state = await session.reconnect({ reason: "dashboard" });
896
1182
  logger.info(`[Dashboard] Reconnect requested — now ${state.state}`);
897
1183
  res.json({ success: true, session: state });
898
- })
1184
+ }),
899
1185
  );
900
1186
 
901
1187
  // Stop talking to WhatsApp but keep the pairing. No retry follows a manual
@@ -909,7 +1195,7 @@ router.post(
909
1195
  const state = await session.stop({ reason: "dashboard" });
910
1196
  logger.warn("[Dashboard] WhatsApp session stopped from the dashboard");
911
1197
  res.json({ success: true, session: state });
912
- })
1198
+ }),
913
1199
  );
914
1200
 
915
1201
  router.post(
@@ -924,7 +1210,7 @@ router.post(
924
1210
  }
925
1211
  const state = await session.setInternetOnline(online);
926
1212
  res.json({ success: true, session: state });
927
- })
1213
+ }),
928
1214
  );
929
1215
 
930
1216
  // Unlink the WhatsApp account: WhatsApp drops the companion device and the
@@ -946,7 +1232,7 @@ router.post(
946
1232
  const state = await session.logout();
947
1233
  logger.warn("[Dashboard] WhatsApp account unlinked from the dashboard");
948
1234
  res.json({ success: true, session: state });
949
- })
1235
+ }),
950
1236
  );
951
1237
 
952
1238
  // Only useful under a supervisor (pm2 / systemd / docker restart:always) —
@@ -965,4 +1251,40 @@ router.post("/bot/restart", (req, res) => {
965
1251
  setTimeout(() => process.kill(process.pid, "SIGTERM"), 500).unref();
966
1252
  });
967
1253
 
1254
+ // --- Aliases for frontend API client compatibility -------------------------
1255
+ router.get("/session", (req, res) => {
1256
+ res.json({
1257
+ success: true,
1258
+ status: sessionState(),
1259
+ session: sessionState(),
1260
+ qr: session?.qr ?? null,
1261
+ pairingCode: session?.pairingCode ?? null,
1262
+ });
1263
+ });
1264
+
1265
+ router.post("/session/start", (req, res, next) => {
1266
+ req.url = "/bot/session/start";
1267
+ router.handle(req, res, next);
1268
+ });
1269
+
1270
+ router.post("/session/reconnect", (req, res, next) => {
1271
+ req.url = "/bot/session/reconnect";
1272
+ router.handle(req, res, next);
1273
+ });
1274
+
1275
+ router.post("/session/stop", (req, res, next) => {
1276
+ req.url = "/bot/session/stop";
1277
+ router.handle(req, res, next);
1278
+ });
1279
+
1280
+ router.post("/session/unlink", (req, res, next) => {
1281
+ req.url = "/bot/logout";
1282
+ router.handle(req, res, next);
1283
+ });
1284
+
1285
+ router.post("/session/restart", (req, res, next) => {
1286
+ req.url = "/bot/restart";
1287
+ router.handle(req, res, next);
1288
+ });
1289
+
968
1290
  export default router;