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.
- package/README.md +13 -9
- package/app.cjs +49 -46
- package/package.json +9 -1
- package/public/dashboard/assets/index-BBBXWzM0.js +391 -0
- package/public/dashboard/assets/index-pYaivHON.css +1 -0
- package/public/dashboard/index.html +19 -0
- package/public/dashboard.css +391 -66
- package/public/dashboard.js +593 -42
- package/scheduler.cjs +9 -14
- package/src/auth/use-database-auth-state.js +2 -4
- package/src/bootstrap/core.js +2 -5
- package/src/bootstrap/panel.js +10 -5
- package/src/cli.js +3 -5
- package/src/commands/autoschedule.cjs +1 -4
- package/src/commands/blacklist.cjs +22 -20
- package/src/commands/block.cjs +2 -4
- package/src/commands/calc.cjs +9 -16
- package/src/commands/digit.cjs +97 -0
- package/src/commands/gemini.cjs +147 -161
- package/src/commands/group/add.cjs +7 -16
- package/src/commands/group/antiSpam.cjs +5 -11
- package/src/commands/group/antilink.cjs +34 -28
- package/src/commands/group/approveall.cjs +3 -11
- package/src/commands/group/clearwarns.cjs +2 -5
- package/src/commands/group/demote.cjs +3 -8
- package/src/commands/group/kick.cjs +12 -15
- package/src/commands/group/media.cjs +17 -24
- package/src/commands/group/members.cjs +1 -2
- package/src/commands/group/promote.cjs +4 -12
- package/src/commands/group/removeall.cjs +2 -5
- package/src/commands/group/setpp.cjs +1 -1
- package/src/commands/group/setrules.cjs +2 -8
- package/src/commands/group/setwarn.cjs +4 -2
- package/src/commands/group/tagadmins.cjs +2 -5
- package/src/commands/group/warn.cjs +26 -24
- package/src/commands/group/warns.cjs +1 -2
- package/src/commands/group/welcome.cjs +13 -17
- package/src/commands/group.cjs +7 -10
- package/src/commands/help.cjs +6 -12
- package/src/commands/lang.cjs +104 -0
- package/src/commands/listschedules.cjs +2 -6
- package/src/commands/loop.cjs +6 -13
- package/src/commands/memory.cjs +19 -32
- package/src/commands/mod.cjs +97 -35
- package/src/commands/perm.cjs +18 -56
- package/src/commands/ping.cjs +1 -5
- package/src/commands/poll.cjs +1 -1
- package/src/commands/rand.cjs +2 -5
- package/src/commands/schedule.cjs +4 -8
- package/src/commands/score.cjs +8 -2
- package/src/commands/setprefix.cjs +2 -2
- package/src/commands/shortlink.cjs +7 -15
- package/src/commands/shutdown.cjs +1 -1
- package/src/commands/sticker.cjs +222 -0
- package/src/commands/stt.cjs +136 -68
- package/src/commands/todo.cjs +9 -15
- package/src/commands/tts.cjs +10 -16
- package/src/commands/unblock.cjs +2 -6
- package/src/commands/weather.cjs +5 -12
- package/src/config/ai-persona.md +5 -3
- package/src/config/available-models.json +1156 -0
- package/src/config/baileys.config.js +2 -1
- package/src/config/brand.esm.js +1 -10
- package/src/config/constants.js +1 -1
- package/src/config/defaults.cjs +66 -66
- package/src/config/lock.cjs +2 -2
- package/src/config/modelCapabilities.cjs +172 -0
- package/src/config/runtime-config.cjs +8 -13
- package/src/config/secrets.cjs +18 -10
- package/src/config/settings.cjs +94 -10
- package/src/config/vault.cjs +107 -0
- package/src/core/connection.js +13 -12
- package/src/core/events.js +41 -23
- package/src/core/proxy.js +7 -7
- package/src/core/session.js +91 -48
- package/src/core/socket.js +12 -15
- package/src/db/db.cjs +14 -6
- package/src/db/store.cjs +221 -87
- package/src/domain/caddy.js +1 -1
- package/src/domain/command.js +40 -19
- package/src/domain/detect.js +1 -1
- package/src/domain/system.js +2 -5
- package/src/handlers/command.handler.js +22 -43
- package/src/handlers/group.handler.js +13 -13
- package/src/handlers/message.handler.js +11 -21
- package/src/index.js +8 -4
- package/src/middleware/antispam.middleware.js +46 -23
- package/src/middleware/blacklist.middleware.js +14 -9
- package/src/middleware/forward-tracking.middleware.js +3 -2
- package/src/middleware/permissions.middleware.js +7 -10
- package/src/panel/login-throttle.cjs +43 -0
- package/src/panel/session-auth.cjs +17 -0
- package/src/routes/dashboard.api.esm.js +394 -72
- package/src/services/aiAgent.cjs +136 -49
- package/src/services/aiProviders.cjs +193 -47
- package/src/services/aiRouter.cjs +137 -0
- package/src/services/aiTools.cjs +305 -128
- package/src/services/llmDiscovery.cjs +481 -0
- package/src/services/modelRegistry.cjs +412 -0
- package/src/utils/datetime.cjs +2 -2
- package/src/utils/lid-helper.esm.js +7 -7
- package/src/utils/logger.cjs +1 -1
- package/src/utils/markdownParser.cjs +159 -0
- package/src/utils/memory.cjs +43 -13
- package/src/utils/messageContent.cjs +40 -0
- package/src/utils/normalizeJid.esm.js +7 -7
- package/src/utils/numerals.cjs +75 -0
- package/src/utils/permissions.esm.js +10 -15
- package/src/utils/providerUrl.cjs +281 -0
- package/src/utils/recurrence.cjs +7 -11
- package/src/utils/sendBotMessage.esm.js +4 -14
- package/src/utils/statusMessage.cjs +5 -13
- package/src/utils/storage.cjs +10 -0
- package/src/utils/storage.esm.js +11 -0
- package/src/utils/textDecode.cjs +4 -9
- package/src/utils/thumbnail.cjs +8 -14
- package/src/utils/typing.esm.js +12 -12
- 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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 ?? "")
|
|
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
|
|
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
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
862
|
-
//
|
|
863
|
-
//
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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;
|