levix-bot 3.3.0-rc.2 → 4.0.0-beta

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 +11 -7
  2. package/app.cjs +42 -45
  3. package/package.json +9 -1
  4. package/public/dashboard/assets/index-C1b7ijfu.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 -125
  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
@@ -1,6 +1,7 @@
1
1
  import { Browsers } from "@whiskeysockets/baileys";
2
- import pino from "pino";
3
2
  import NodeCache from "node-cache";
3
+ import pino from "pino";
4
+
4
5
  // rc10 supports several pluggable caches; we keep them in-process so message
5
6
  // retries / device discovery don't keep slamming the network.
6
7
  const msgRetryCounterCache = new NodeCache({
@@ -5,13 +5,4 @@ const require = createRequire(import.meta.url);
5
5
  const brand = require("./brand.cjs");
6
6
 
7
7
  export default brand;
8
- export const {
9
- name,
10
- nameAr,
11
- tagline,
12
- taglineAr,
13
- developer,
14
- developerAr,
15
- studio,
16
- repo,
17
- } = brand;
8
+ export const { name, nameAr, tagline, taglineAr, developer, developerAr, studio, repo } = brand;
@@ -15,7 +15,7 @@
15
15
  export const RETRY_DELAY_MS = 5000;
16
16
  export const MAX_RETRIES = 5;
17
17
  export const RETRY_SCHEDULE_MS = Object.freeze(
18
- Array.from({ length: MAX_RETRIES }, (_, index) => RETRY_DELAY_MS * (index + 1))
18
+ Array.from({ length: MAX_RETRIES }, (_, index) => RETRY_DELAY_MS * (index + 1)),
19
19
  );
20
20
 
21
21
  // Group metadata cache
@@ -18,72 +18,72 @@
18
18
 
19
19
  const PREFIX = "!";
20
20
 
21
- const COMMAND_PERMISSIONS = Object.freeze(
22
- {
23
- "ping": "MEMBERS",
24
- "gemini": "MEMBERS",
25
- "qr": "MEMBERS",
26
- "shortlink": "MEMBERS",
27
- "schedule": "ADMINS_OWNER",
28
- "weather": "MEMBERS",
29
- "prayer": "MEMBERS",
30
- "tagadmins": "MEMBERS",
31
- "help": "MEMBERS",
32
- "score": "MEMBERS",
33
- "stt": "MEMBERS",
34
- "tts": "MEMBERS",
35
- "todo": "MEMBERS",
36
- "notes": "MEMBERS",
37
- "debt": "MEMBERS",
38
- "calc": "MEMBERS",
39
- "loop": "MEMBERS",
40
- "rand": "MEMBERS",
41
- "poll": "MEMBERS",
42
- "memory": "MEMBERS",
43
- "mod": "ADMINS_OWNER",
44
- "blacklist": "ADMINS_OWNER",
45
- "listschedules": "ADMINS_OWNER",
46
- "deleteschedule": "ADMINS_OWNER",
47
- "autoschedule": "OWNER_ONLY",
48
- "perm": "OWNER_ONLY",
49
- "setprefix": "OWNER_ONLY",
50
- "restart": "OWNER_ONLY",
51
- "shutdown": "OWNER_ONLY",
52
- "status": "OWNER_ONLY",
53
- "block": "OWNER_ONLY",
54
- "unblock": "OWNER_ONLY",
55
- "group": {
56
- "default_permission": "ADMINS_OWNER",
57
- "sub_commands": {
58
- "approveall": "ADMINS_OWNER",
59
- "setname": "ADMINS_OWNER",
60
- "setpp": "ADMINS_OWNER",
61
- "kick": "ADMINS_OWNER",
62
- "add": "ADMINS_OWNER",
63
- "promote": "ADMINS_OWNER",
64
- "demote": "ADMINS_OWNER",
65
- "removeall": "OWNER_ONLY",
66
- "setrules": "ADMINS_ONLY",
67
- "antilink": "ADMINS_ONLY",
68
- "media": "ADMINS_ONLY",
69
- "setwarn": "ADMINS_ONLY",
70
- "warn": "ADMINS_ONLY",
71
- "warns": "ADMINS_OWNER",
72
- "clearwarns": "ADMINS_ONLY",
73
- "rules": "MEMBERS",
74
- "notes": "MEMBERS",
75
- "members": "MEMBERS",
76
- "tagadmins": "MEMBERS",
77
- "welcome": "ADMINS_OWNER",
78
- "blacklist": "ADMINS_OWNER",
79
- "save": "ADMINS_OWNER",
80
- "deletenote": "ADMINS_OWNER",
81
- "remove": "ADMINS_ONLY",
82
- "all": "MEMBERS"
83
- }
84
- }
85
- }
86
- );
21
+ const COMMAND_PERMISSIONS = Object.freeze({
22
+ ping: "MEMBERS",
23
+ gemini: "MEMBERS",
24
+ qr: "MEMBERS",
25
+ shortlink: "MEMBERS",
26
+ schedule: "ADMINS_OWNER",
27
+ weather: "MEMBERS",
28
+ prayer: "MEMBERS",
29
+ tagadmins: "MEMBERS",
30
+ help: "MEMBERS",
31
+ score: "MEMBERS",
32
+ stt: "MEMBERS",
33
+ tts: "MEMBERS",
34
+ todo: "MEMBERS",
35
+ notes: "MEMBERS",
36
+ debt: "MEMBERS",
37
+ calc: "MEMBERS",
38
+ loop: "MEMBERS",
39
+ rand: "MEMBERS",
40
+ poll: "MEMBERS",
41
+ memory: "MEMBERS",
42
+ mod: "ADMINS_OWNER",
43
+ blacklist: "ADMINS_OWNER",
44
+ listschedules: "ADMINS_OWNER",
45
+ deleteschedule: "ADMINS_OWNER",
46
+ autoschedule: "OWNER_ONLY",
47
+ perm: "OWNER_ONLY",
48
+ setprefix: "OWNER_ONLY",
49
+ restart: "OWNER_ONLY",
50
+ shutdown: "OWNER_ONLY",
51
+ status: "OWNER_ONLY",
52
+ lang: "MEMBERS",
53
+ digit: "MEMBERS",
54
+ block: "OWNER_ONLY",
55
+ unblock: "OWNER_ONLY",
56
+ group: {
57
+ default_permission: "ADMINS_OWNER",
58
+ sub_commands: {
59
+ approveall: "ADMINS_OWNER",
60
+ setname: "ADMINS_OWNER",
61
+ setpp: "ADMINS_OWNER",
62
+ kick: "ADMINS_OWNER",
63
+ add: "ADMINS_OWNER",
64
+ promote: "ADMINS_OWNER",
65
+ demote: "ADMINS_OWNER",
66
+ removeall: "OWNER_ONLY",
67
+ setrules: "ADMINS_ONLY",
68
+ antilink: "ADMINS_ONLY",
69
+ media: "ADMINS_ONLY",
70
+ setwarn: "ADMINS_ONLY",
71
+ warn: "ADMINS_ONLY",
72
+ warns: "ADMINS_OWNER",
73
+ clearwarns: "ADMINS_ONLY",
74
+ rules: "MEMBERS",
75
+ notes: "MEMBERS",
76
+ members: "MEMBERS",
77
+ tagadmins: "MEMBERS",
78
+ welcome: "ADMINS_OWNER",
79
+ blacklist: "ADMINS_OWNER",
80
+ save: "ADMINS_OWNER",
81
+ deletenote: "ADMINS_OWNER",
82
+ remove: "ADMINS_ONLY",
83
+ all: "MEMBERS",
84
+ },
85
+ },
86
+ });
87
87
 
88
88
  module.exports = {
89
89
  prefix: PREFIX,
@@ -86,7 +86,7 @@ function acquireSingleInstanceLock() {
86
86
  ` ${LOCK_FILE}\n\n` +
87
87
  ` Only one Levix can run per data directory — a WhatsApp pairing is a\n` +
88
88
  ` single session. Stop the other one, or start this one with --data\n` +
89
- ` pointing somewhere else.`
89
+ ` pointing somewhere else.`,
90
90
  );
91
91
  error.code = "ELEVIXLOCKED";
92
92
  throw error;
@@ -94,7 +94,7 @@ function acquireSingleInstanceLock() {
94
94
 
95
95
  // The holder is gone (a crash, a kill -9, a reboot). Take it over.
96
96
  logger.warn(
97
- `[lock] Removing a stale lock from pid ${owner ?? "unknown"} — that process is gone`
97
+ `[lock] Removing a stale lock from pid ${owner ?? "unknown"} — that process is gone`,
98
98
  );
99
99
  fs.rmSync(LOCK_FILE, { force: true });
100
100
  claim();
@@ -0,0 +1,172 @@
1
+ // Official capability metadata for known model IDs, dated 2026-09-18.
2
+ //
3
+ // This is fallback metadata only. It is never proof that a user's API key can
4
+ // access the model. Live availability always comes from the provider API.
5
+ //
6
+ // Unknown / newly discovered IDs must NOT be inferred from name substrings.
7
+
8
+ const FULL_MULTIMODAL = Object.freeze({
9
+ textInput: true,
10
+ textOutput: true,
11
+ vision: true,
12
+ audioInput: true,
13
+ stt: true,
14
+ videoInput: true,
15
+ pdfInput: true,
16
+ });
17
+
18
+ const VISION_TEXT = Object.freeze({
19
+ textInput: true,
20
+ textOutput: true,
21
+ vision: true,
22
+ audioInput: false,
23
+ stt: false,
24
+ videoInput: false,
25
+ pdfInput: true,
26
+ });
27
+
28
+ const TEXT_VISION_ONLY = Object.freeze({
29
+ textInput: true,
30
+ textOutput: true,
31
+ vision: true,
32
+ audioInput: false,
33
+ stt: false,
34
+ videoInput: false,
35
+ pdfInput: false,
36
+ });
37
+
38
+ // Exact IDs from the 2026-09-18 seed catalog + official docs. No prefix matching.
39
+ const GEMINI_OFFICIAL = Object.freeze({
40
+ "gemini-2.5-flash": FULL_MULTIMODAL,
41
+ "gemini-2.5-pro": FULL_MULTIMODAL,
42
+ "gemini-2.5-flash-lite": FULL_MULTIMODAL,
43
+ "gemini-flash-latest": FULL_MULTIMODAL,
44
+ "gemini-flash-lite-latest": FULL_MULTIMODAL,
45
+ "gemini-pro-latest": FULL_MULTIMODAL,
46
+ "gemini-3-flash-preview": FULL_MULTIMODAL,
47
+ "gemini-3.1-pro-preview": FULL_MULTIMODAL,
48
+ "gemini-3.1-pro-preview-customtools": FULL_MULTIMODAL,
49
+ "gemini-3.1-flash-lite-preview": FULL_MULTIMODAL,
50
+ "gemini-3.1-flash-lite": FULL_MULTIMODAL,
51
+ "gemini-3.5-flash": FULL_MULTIMODAL,
52
+ "gemini-3.5-flash-lite": FULL_MULTIMODAL,
53
+ "gemini-3.6-flash": FULL_MULTIMODAL,
54
+ "gemini-3.7-flash": FULL_MULTIMODAL,
55
+ "gemini-3.8-flash": FULL_MULTIMODAL,
56
+ "gemini-omni-flash-preview": FULL_MULTIMODAL,
57
+ "gemini-omni-1.1-flash": FULL_MULTIMODAL,
58
+ "gemma-4-26b-a4b-it": TEXT_VISION_ONLY,
59
+ "gemma-4-31b-it": TEXT_VISION_ONLY,
60
+ "gemini-1.5-flash": FULL_MULTIMODAL,
61
+ });
62
+
63
+ const GEMINI_RECOMMENDED = new Set([
64
+ "gemini-3.8-flash",
65
+ "gemini-3.7-flash",
66
+ "gemini-3.5-flash",
67
+ "gemini-3.1-pro-preview",
68
+ "gemini-3.5-flash-lite",
69
+ ]);
70
+
71
+ function officialCapabilities(provider, modelId) {
72
+ const id = String(modelId || "")
73
+ .trim()
74
+ .replace(/^models\//, "");
75
+ if (!id) return null;
76
+ if (provider === "gemini") return GEMINI_OFFICIAL[id] || null;
77
+ return null;
78
+ }
79
+
80
+ function isGeminiRecommended(modelId) {
81
+ const id = String(modelId || "")
82
+ .trim()
83
+ .replace(/^models\//, "");
84
+ return GEMINI_RECOMMENDED.has(id);
85
+ }
86
+
87
+ /**
88
+ * Conversational-LLM filter for the main model selector.
89
+ * Non-chat families (embeddings, image-gen, TTS, video, live/realtime) are out.
90
+ */
91
+ function isConversationalLlm(provider, modelId, meta = {}) {
92
+ const id = String(modelId || "")
93
+ .toLowerCase()
94
+ .replace(/^models\//, "");
95
+ if (!id) return false;
96
+
97
+ const methods = Array.isArray(meta.methods)
98
+ ? meta.methods.map((m) => String(m))
99
+ : Array.isArray(meta.supportedGenerationMethods)
100
+ ? meta.supportedGenerationMethods.map((m) => String(m))
101
+ : Array.isArray(meta.supportedActions)
102
+ ? meta.supportedActions.map((m) => String(m))
103
+ : [];
104
+
105
+ if (provider === "openai") return isOpenAiChatModel(id);
106
+ if (provider === "anthropic") {
107
+ if (meta.type && String(meta.type).toLowerCase() !== "model") return false;
108
+ return true;
109
+ }
110
+ if (provider === "gemini") return isGeminiChatModel(id, methods);
111
+ return true;
112
+ }
113
+
114
+ function isOpenAiChatModel(id) {
115
+ if (id.includes("embedding") || id.includes("embed-")) return false;
116
+ if (id.includes("moderation")) return false;
117
+ if (id.startsWith("dall-e") || id.includes("dall-e")) return false;
118
+ if (id.startsWith("gpt-image") || id.startsWith("chatgpt-image")) return false;
119
+ if (id.startsWith("tts-") || id.includes("-tts")) return false;
120
+ if (id.startsWith("whisper-")) return false;
121
+ if (id.startsWith("sora")) return false;
122
+ if (id.includes("davinci") || id.includes("babbage") || id.includes("curie")) return false;
123
+ if (id.startsWith("ada-") || id.includes("text-similarity")) return false;
124
+ if (id.includes("canary")) return false;
125
+ return true;
126
+ }
127
+
128
+ function isGeminiChatModel(id, methods) {
129
+ if (id.includes("embedding") || id.includes("vector")) return false;
130
+ if (id === "aqa" || id.startsWith("aqa-")) return false;
131
+ if (id.startsWith("imagen") || id.includes("-image") || id.includes("nano-banana")) return false;
132
+ if (id.includes("-tts") || id.endsWith("tts")) return false;
133
+ if (id.startsWith("veo") || id.includes("lyria")) return false;
134
+ if (id.includes("transcribe")) return false;
135
+ if (id.includes("native-audio") || id.includes("realtime")) return false;
136
+ if (id.includes("-live") || /(^|-)live($|-)/.test(id)) return false;
137
+ if (
138
+ id.includes("robotics") ||
139
+ id.includes("computer-use") ||
140
+ id.includes("antigravity") ||
141
+ id.includes("deep-research")
142
+ ) {
143
+ return false;
144
+ }
145
+
146
+ if (methods.includes("embedContent") && !methods.includes("generateContent")) return false;
147
+ if (methods.includes("bidiGenerateContent") && !methods.includes("generateContent")) return false;
148
+ if (
149
+ methods.includes("bidiGenerateMusic") ||
150
+ methods.includes("predictLongRunning") ||
151
+ methods.includes("generateAnswer")
152
+ ) {
153
+ return false;
154
+ }
155
+ if (
156
+ methods.length &&
157
+ !methods.includes("generateContent") &&
158
+ !methods.includes("generateContentStream")
159
+ ) {
160
+ return false;
161
+ }
162
+ return true;
163
+ }
164
+
165
+ module.exports = {
166
+ FULL_MULTIMODAL,
167
+ VISION_TEXT,
168
+ TEXT_VISION_ONLY,
169
+ officialCapabilities,
170
+ isGeminiRecommended,
171
+ isConversationalLlm,
172
+ };
@@ -17,12 +17,7 @@
17
17
  const defaults = require("./defaults.cjs");
18
18
  const logger = require("../utils/logger.cjs");
19
19
 
20
- const PERMISSION_LEVELS = Object.freeze([
21
- "MEMBERS",
22
- "ADMINS_ONLY",
23
- "ADMINS_OWNER",
24
- "OWNER_ONLY",
25
- ]);
20
+ const PERMISSION_LEVELS = Object.freeze(["MEMBERS", "ADMINS_ONLY", "ADMINS_OWNER", "OWNER_ONLY"]);
26
21
 
27
22
  const KEY_PERMISSIONS = "command_permissions";
28
23
  const KEY_ALIASES = "command_aliases";
@@ -84,11 +79,7 @@ function defaultPermission(commandKey) {
84
79
  if (sub) {
85
80
  const groupEntry = table[head];
86
81
  if (isPlainObject(groupEntry)) {
87
- return (
88
- groupEntry.sub_commands?.[sub] ||
89
- groupEntry.default_permission ||
90
- "MEMBERS"
91
- );
82
+ return groupEntry.sub_commands?.[sub] || groupEntry.default_permission || "MEMBERS";
92
83
  }
93
84
  return "MEMBERS";
94
85
  }
@@ -166,7 +157,9 @@ function setAliases(commandName, aliases) {
166
157
  if (!Array.isArray(aliases)) throw new Error("Aliases must be a list");
167
158
  const cleaned = [];
168
159
  for (const raw of aliases) {
169
- const alias = String(raw ?? "").trim().toLowerCase();
160
+ const alias = String(raw ?? "")
161
+ .trim()
162
+ .toLowerCase();
170
163
  if (!alias) continue;
171
164
  if (/\s/.test(alias)) throw new Error(`Alias "${alias}" can't contain spaces`);
172
165
  if (alias.length > 20) throw new Error(`Alias "${alias}" is too long`);
@@ -177,7 +170,9 @@ function setAliases(commandName, aliases) {
177
170
  }
178
171
 
179
172
  writeSetting(KEY_ALIASES, overrides);
180
- logger.info(`[config] aliases ${commandName} -> ${JSON.stringify(overrides[commandName] ?? "(default)")}`);
173
+ logger.info(
174
+ `[config] aliases ${commandName} -> ${JSON.stringify(overrides[commandName] ?? "(default)")}`,
175
+ );
181
176
  return getAliases(commandName);
182
177
  }
183
178
 
@@ -9,7 +9,11 @@
9
9
  // secret:session the express-session signing key. 48 random bytes, made on
10
10
  // first start and reused forever after. Nobody ever sees it.
11
11
  // auth:password the dashboard password as an scrypt hash + salt. The
12
- // password itself is never written anywhere.
12
+ // password itself is never written anywhere (not encrypted
13
+ // for recovery — it is a one-way hash).
14
+ //
15
+ // WhatsApp session blobs and third-party API keys are sealed separately by
16
+ // src/config/vault.cjs (AES-256-GCM, key in levix.key next to the database).
13
17
  //
14
18
  // Both live in `bot_settings`, i.e. inside the same SQLite file as the rest of
15
19
  // the bot's state — so a backup of that one file is a backup of the login too.
@@ -57,6 +61,13 @@ function hasDashboardPassword() {
57
61
  return !!(record && record.hash && record.salt);
58
62
  }
59
63
 
64
+ /** Epoch stored on panel sessions so a password change or wipe revokes them. */
65
+ function getPasswordUpdatedAt() {
66
+ const record = store.getBotSetting(PASSWORD_KEY, null);
67
+ if (!record?.hash || !record?.salt) return 0;
68
+ return Number(record.updatedAt) || 0;
69
+ }
70
+
60
71
  /**
61
72
  * @throws {Error} when the password is too short — the dashboard holds a full
62
73
  * WhatsApp account, so this is not a place for a 3-character password.
@@ -64,13 +75,13 @@ function hasDashboardPassword() {
64
75
  function setDashboardPassword(plain) {
65
76
  const password = String(plain ?? "");
66
77
  if (password.length < MIN_PASSWORD_LENGTH) {
67
- throw new Error(
68
- `Password must be at least ${MIN_PASSWORD_LENGTH} characters`
69
- );
78
+ throw new Error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
70
79
  }
71
80
 
81
+ const previousEpoch = getPasswordUpdatedAt();
72
82
  const { salt, hash } = hashPassword(password);
73
- store.saveBotSetting(PASSWORD_KEY, { alg: "scrypt", salt, hash, updatedAt: Date.now() });
83
+ const updatedAt = Math.max(Date.now(), previousEpoch + 1);
84
+ store.saveBotSetting(PASSWORD_KEY, { alg: "scrypt", salt, hash, updatedAt });
74
85
  logger.info("[secrets] Dashboard password updated");
75
86
  }
76
87
 
@@ -81,11 +92,7 @@ function verifyDashboardPassword(candidate) {
81
92
  if (typeof candidate !== "string") return false;
82
93
 
83
94
  const expected = Buffer.from(record.hash, "hex");
84
- const actual = crypto.scryptSync(
85
- candidate,
86
- Buffer.from(record.salt, "hex"),
87
- expected.length
88
- );
95
+ const actual = crypto.scryptSync(candidate, Buffer.from(record.salt, "hex"), expected.length);
89
96
  return crypto.timingSafeEqual(expected, actual);
90
97
  }
91
98
 
@@ -140,6 +147,7 @@ module.exports = {
140
147
  MIN_PASSWORD_LENGTH,
141
148
  getSessionSecret,
142
149
  hasDashboardPassword,
150
+ getPasswordUpdatedAt,
143
151
  setDashboardPassword,
144
152
  verifyDashboardPassword,
145
153
  getSetupCode,
@@ -16,6 +16,7 @@
16
16
  // dashboard only ever learns whether one is configured.
17
17
 
18
18
  const logger = require("../utils/logger.cjs");
19
+ const { assertProviderBaseUrl } = require("../utils/providerUrl.cjs");
19
20
 
20
21
  const SETTINGS = [
21
22
  // --- General -----------------------------------------------------------
@@ -27,6 +28,20 @@ const SETTINGS = [
27
28
  label: "Timezone",
28
29
  hint: "IANA name, e.g. Africa/Cairo. Used by the AI, !prayer and the scheduler.",
29
30
  },
31
+ {
32
+ key: "bot_language",
33
+ type: "string",
34
+ default: "auto",
35
+ choices: ["auto", "ar", "en"],
36
+ choiceLabels: {
37
+ auto: "Auto-detect (follow user message)",
38
+ ar: "Modern Standard Arabic (العربية الفصحى)",
39
+ en: "English",
40
+ },
41
+ group: "general",
42
+ label: "Bot response language",
43
+ hint: "Preferred language for AI replies and status updates. Can also be switched with !lang.",
44
+ },
30
45
  {
31
46
  key: "bot_min_delay_ms",
32
47
  type: "int",
@@ -160,6 +175,52 @@ const SETTINGS = [
160
175
  label: "Gemini Google Search",
161
176
  hint: "Lets Gemini use Google's own search grounding when a question needs current information. Gemini only — the openai and anthropic providers never see it.",
162
177
  },
178
+ {
179
+ key: "ai_vision_enabled",
180
+ type: "bool",
181
+ default: false,
182
+ group: "ai",
183
+ label: "Vision & image analysis",
184
+ hint: "Allow the active model to inspect and analyze incoming images. Enabled natively on Gemini, and available on OpenAI-compatible/Anthropic when using vision-capable models (e.g. llama-3.2-11b-vision, gpt-4o, claude-3).",
185
+ },
186
+ {
187
+ key: "ai_stt_enabled",
188
+ type: "bool",
189
+ default: true,
190
+ group: "ai",
191
+ label: "Audio & Speech-to-text (STT)",
192
+ hint: "Enable audio processing and speech-to-text transcription with compatible models.",
193
+ },
194
+ {
195
+ key: "ai_auto_detect_capabilities",
196
+ type: "bool",
197
+ default: true,
198
+ group: "ai",
199
+ label: "Auto-detect model capabilities",
200
+ hint: "When the selected model does not support Vision or Speech-to-text, those toggles are turned off and locked automatically. Capabilities come from the provider API and the local registry — never from guessing at the model name.",
201
+ },
202
+ {
203
+ key: "ai_stt_provider",
204
+ type: "string",
205
+ default: "auto",
206
+ choices: ["auto", "gemini", "openai"],
207
+ choiceLabels: {
208
+ auto: "Auto (follow active provider)",
209
+ gemini: "Google Gemini",
210
+ openai: "OpenAI-compatible (Whisper)",
211
+ },
212
+ group: "ai",
213
+ label: "Speech-to-text provider",
214
+ hint: "Which engine powers !stt audio transcription. 'Auto' follows your active chat provider.",
215
+ },
216
+ {
217
+ key: "openai_stt_model",
218
+ type: "string",
219
+ default: "whisper-large-v3",
220
+ group: "ai",
221
+ label: "OpenAI-compatible Whisper model",
222
+ hint: "Model used for speech transcription when OpenAI-compatible is selected for STT (whisper-large-v3, whisper-1).",
223
+ },
163
224
  {
164
225
  key: "openai_api_key",
165
226
  type: "secret",
@@ -232,6 +293,14 @@ const SETTINGS = [
232
293
  label: "OpenWeatherMap API key",
233
294
  hint: "Needed by !weather.",
234
295
  },
296
+ {
297
+ key: "youtube_api_key",
298
+ type: "secret",
299
+ default: "",
300
+ group: "integrations",
301
+ label: "YouTube Data API key",
302
+ hint: "For YouTube video and search tools.",
303
+ },
235
304
 
236
305
  // --- Media -------------------------------------------------------------
237
306
  {
@@ -387,6 +456,9 @@ const SETTINGS = [
387
456
  ];
388
457
 
389
458
  const BY_KEY = new Map(SETTINGS.map((definition) => [definition.key, definition]));
459
+ const SECRET_SETTING_KEYS = SETTINGS.filter((definition) => definition.type === "secret").map(
460
+ (definition) => definition.key,
461
+ );
390
462
 
391
463
  const GROUP_LABELS = {
392
464
  general: "General",
@@ -402,11 +474,13 @@ const GROUP_LABELS = {
402
474
  // comes too early falls back to the default instead of throwing.
403
475
  function storedValue(key) {
404
476
  try {
405
- const value = require("../utils/storage.cjs").getBotSetting(
406
- `setting:${key}`,
407
- undefined
408
- );
409
- return value === undefined || value === null ? undefined : value;
477
+ const value = require("../utils/storage.cjs").getBotSetting(`setting:${key}`, undefined);
478
+ if (value === undefined || value === null) return undefined;
479
+ const definition = BY_KEY.get(key);
480
+ if (definition?.type === "secret" && typeof value === "string") {
481
+ return require("./vault.cjs").open(value);
482
+ }
483
+ return value;
410
484
  } catch {
411
485
  return undefined;
412
486
  }
@@ -480,6 +554,9 @@ function validate(definition, value) {
480
554
  throw new Error(`"${text}" is not a valid timezone`);
481
555
  }
482
556
  }
557
+ if (["gemini_base_url", "openai_base_url", "anthropic_base_url"].includes(definition.key)) {
558
+ return assertProviderBaseUrl(text, { allowLoopback: true });
559
+ }
483
560
  return text;
484
561
  }
485
562
 
@@ -501,11 +578,11 @@ function set(key, value) {
501
578
  }
502
579
 
503
580
  const clean = validate(definition, value);
504
- storage.saveBotSetting(`setting:${key}`, clean);
581
+ const toStore =
582
+ definition.type === "secret" ? require("./vault.cjs").seal(String(clean)) : clean;
583
+ storage.saveBotSetting(`setting:${key}`, toStore);
505
584
  // Never log the value of a secret.
506
- logger.info(
507
- `[settings] ${key} -> ${definition.type === "secret" ? "(updated)" : clean}`
508
- );
585
+ logger.info(`[settings] ${key} -> ${definition.type === "secret" ? "(updated)" : clean}`);
509
586
  return get(key);
510
587
  }
511
588
 
@@ -537,4 +614,11 @@ function describe() {
537
614
  });
538
615
  }
539
616
 
540
- module.exports = { get, set, describe, sourceOf, SETTING_KEYS: [...BY_KEY.keys()] };
617
+ module.exports = {
618
+ get,
619
+ set,
620
+ describe,
621
+ sourceOf,
622
+ SETTING_KEYS: [...BY_KEY.keys()],
623
+ SECRET_SETTING_KEYS: [...SECRET_SETTING_KEYS],
624
+ };