levix-bot 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/SETUP.md +242 -0
  4. package/app.cjs +337 -0
  5. package/bin/levix.js +7 -0
  6. package/deploy/install.sh +182 -0
  7. package/deploy/levix.service +56 -0
  8. package/deploy/nginx/levix.leviro.net.conf +79 -0
  9. package/package.json +78 -0
  10. package/public/brand/apple-touch-icon.png +0 -0
  11. package/public/brand/banner.jpg +0 -0
  12. package/public/brand/banner.webp +0 -0
  13. package/public/brand/icon-16.png +0 -0
  14. package/public/brand/icon-192.png +0 -0
  15. package/public/brand/icon-32.png +0 -0
  16. package/public/brand/icon-512.png +0 -0
  17. package/public/brand/icon-512.webp +0 -0
  18. package/public/brand/mark.png +0 -0
  19. package/public/brand/mark.svg +16 -0
  20. package/public/brand/mark.webp +0 -0
  21. package/public/brand/wordmark.png +0 -0
  22. package/public/brand/wordmark.webp +0 -0
  23. package/public/dashboard.css +881 -0
  24. package/public/dashboard.js +1057 -0
  25. package/public/logo.svg +16 -0
  26. package/public/qrcode.min.js +1 -0
  27. package/public/socket.io.min.js +7 -0
  28. package/scheduler.cjs +197 -0
  29. package/src/auth/auth-storage.cjs +74 -0
  30. package/src/auth/use-database-auth-state.js +130 -0
  31. package/src/bootstrap/core.js +74 -0
  32. package/src/bootstrap/events.cjs +43 -0
  33. package/src/bootstrap/panel.js +97 -0
  34. package/src/cli.js +153 -0
  35. package/src/commands/autoschedule.cjs +84 -0
  36. package/src/commands/blacklist.cjs +116 -0
  37. package/src/commands/block.cjs +70 -0
  38. package/src/commands/calc.cjs +387 -0
  39. package/src/commands/debt.cjs +175 -0
  40. package/src/commands/deleteschedule.cjs +36 -0
  41. package/src/commands/gemini.cjs +727 -0
  42. package/src/commands/group/add.cjs +136 -0
  43. package/src/commands/group/all.cjs +43 -0
  44. package/src/commands/group/antiSpam.cjs +96 -0
  45. package/src/commands/group/antilink.cjs +167 -0
  46. package/src/commands/group/approveall.cjs +54 -0
  47. package/src/commands/group/clearwarns.cjs +41 -0
  48. package/src/commands/group/deletenote.cjs +33 -0
  49. package/src/commands/group/demote.cjs +62 -0
  50. package/src/commands/group/kick.cjs +85 -0
  51. package/src/commands/group/media.cjs +120 -0
  52. package/src/commands/group/members.cjs +59 -0
  53. package/src/commands/group/note.cjs +32 -0
  54. package/src/commands/group/promote.cjs +60 -0
  55. package/src/commands/group/removeall.cjs +42 -0
  56. package/src/commands/group/rules.cjs +31 -0
  57. package/src/commands/group/save.cjs +36 -0
  58. package/src/commands/group/setname.cjs +41 -0
  59. package/src/commands/group/setpp.cjs +51 -0
  60. package/src/commands/group/setrules.cjs +46 -0
  61. package/src/commands/group/setwarn.cjs +58 -0
  62. package/src/commands/group/tagadmins.cjs +50 -0
  63. package/src/commands/group/warn.cjs +105 -0
  64. package/src/commands/group/warns.cjs +66 -0
  65. package/src/commands/group/welcome.cjs +109 -0
  66. package/src/commands/group.cjs +160 -0
  67. package/src/commands/help.cjs +230 -0
  68. package/src/commands/listschedules.cjs +47 -0
  69. package/src/commands/loop.cjs +163 -0
  70. package/src/commands/memory.cjs +201 -0
  71. package/src/commands/mod.cjs +155 -0
  72. package/src/commands/notes.cjs +28 -0
  73. package/src/commands/perm.cjs +236 -0
  74. package/src/commands/ping.cjs +17 -0
  75. package/src/commands/poll.cjs +80 -0
  76. package/src/commands/prayer.cjs +64 -0
  77. package/src/commands/qr.cjs +39 -0
  78. package/src/commands/rand.cjs +130 -0
  79. package/src/commands/restart.cjs +25 -0
  80. package/src/commands/schedule.cjs +86 -0
  81. package/src/commands/score.cjs +130 -0
  82. package/src/commands/setprefix.cjs +40 -0
  83. package/src/commands/shortlink.cjs +74 -0
  84. package/src/commands/shutdown.cjs +45 -0
  85. package/src/commands/status.cjs +75 -0
  86. package/src/commands/stt.cjs +150 -0
  87. package/src/commands/todo.cjs +88 -0
  88. package/src/commands/tts.cjs +170 -0
  89. package/src/commands/unblock.cjs +57 -0
  90. package/src/commands/weather.cjs +79 -0
  91. package/src/config/ai-persona.md +17 -0
  92. package/src/config/baileys.config.js +51 -0
  93. package/src/config/brand.cjs +36 -0
  94. package/src/config/brand.esm.js +17 -0
  95. package/src/config/constants.js +16 -0
  96. package/src/config/defaults.cjs +91 -0
  97. package/src/config/lock.cjs +110 -0
  98. package/src/config/paths.cjs +90 -0
  99. package/src/config/runtime-config.cjs +219 -0
  100. package/src/config/secrets.cjs +130 -0
  101. package/src/config/settings.cjs +386 -0
  102. package/src/core/connection.js +199 -0
  103. package/src/core/events.js +61 -0
  104. package/src/core/socket.js +64 -0
  105. package/src/db/db.cjs +282 -0
  106. package/src/db/store.cjs +837 -0
  107. package/src/db/store.esm.js +17 -0
  108. package/src/domain/caddy.js +120 -0
  109. package/src/domain/command.js +380 -0
  110. package/src/domain/detect.js +173 -0
  111. package/src/domain/nginx.js +138 -0
  112. package/src/domain/system.js +148 -0
  113. package/src/handlers/command.handler.js +414 -0
  114. package/src/handlers/group.handler.js +73 -0
  115. package/src/handlers/message.handler.js +149 -0
  116. package/src/index.js +217 -0
  117. package/src/middleware/antispam.middleware.js +68 -0
  118. package/src/middleware/blacklist.middleware.js +29 -0
  119. package/src/middleware/forward-tracking.middleware.js +49 -0
  120. package/src/middleware/permissions.middleware.js +114 -0
  121. package/src/routes/dashboard.api.esm.js +779 -0
  122. package/src/services/aiAgent.cjs +317 -0
  123. package/src/services/aiTools.cjs +635 -0
  124. package/src/utils/datetime.cjs +61 -0
  125. package/src/utils/lid-helper.esm.js +143 -0
  126. package/src/utils/logger.cjs +72 -0
  127. package/src/utils/memory.cjs +385 -0
  128. package/src/utils/normalizeJid.cjs +65 -0
  129. package/src/utils/normalizeJid.esm.js +37 -0
  130. package/src/utils/openBrowser.cjs +129 -0
  131. package/src/utils/permissions.cjs +154 -0
  132. package/src/utils/permissions.esm.js +375 -0
  133. package/src/utils/recentMessageCache.esm.js +70 -0
  134. package/src/utils/requestOrigin.cjs +102 -0
  135. package/src/utils/sendBotMessage.cjs +43 -0
  136. package/src/utils/sendBotMessage.esm.js +139 -0
  137. package/src/utils/statusMessage.cjs +179 -0
  138. package/src/utils/storage-hub.cjs +49 -0
  139. package/src/utils/storage-hub.esm.js +24 -0
  140. package/src/utils/storage.cjs +80 -0
  141. package/src/utils/storage.esm.js +88 -0
  142. package/src/utils/textDecode.cjs +197 -0
  143. package/src/utils/thumbnail.cjs +591 -0
  144. package/src/utils/typing.esm.js +102 -0
  145. package/views/dashboard.ejs +329 -0
  146. package/views/login.ejs +105 -0
  147. package/views/qr.ejs +108 -0
  148. package/views/setup.ejs +122 -0
@@ -0,0 +1,386 @@
1
+ // Runtime settings — everything that used to live in a .env file.
2
+ //
3
+ // There is no .env file any more. A setting resolves in exactly two steps:
4
+ //
5
+ // 1. the value the operator saved from the dashboard (`setting:*` in the
6
+ // bot_settings table)
7
+ // 2. the default baked in here
8
+ //
9
+ // So a fresh install runs with no configuration at all, and the control panel
10
+ // is the only place a value is ever changed. Consumers must read through
11
+ // `get()` at CALL time (not once at import) or a change wouldn't take effect
12
+ // until a restart — the few settings that genuinely can't apply live are
13
+ // marked `restart: true` and the dashboard says so next to the field.
14
+ //
15
+ // Secrets (API keys) are stored the same way but never leave the server: the
16
+ // dashboard only ever learns whether one is configured.
17
+
18
+ const logger = require("../utils/logger.cjs");
19
+
20
+ const SETTINGS = [
21
+ // --- General -----------------------------------------------------------
22
+ {
23
+ key: "bot_timezone",
24
+ type: "string",
25
+ default: "Africa/Cairo",
26
+ group: "general",
27
+ label: "Timezone",
28
+ hint: "IANA name, e.g. Africa/Cairo. Used by the AI, !prayer and the scheduler.",
29
+ },
30
+ {
31
+ key: "bot_min_delay_ms",
32
+ type: "int",
33
+ default: 400,
34
+ min: 0,
35
+ max: 10000,
36
+ group: "general",
37
+ label: "Min reply delay (ms)",
38
+ hint: "Small human-looking pause before the bot answers.",
39
+ },
40
+ {
41
+ key: "bot_max_delay_ms",
42
+ type: "int",
43
+ default: 900,
44
+ min: 0,
45
+ max: 30000,
46
+ group: "general",
47
+ label: "Max reply delay (ms)",
48
+ },
49
+
50
+ // --- AI ----------------------------------------------------------------
51
+ {
52
+ key: "gemini_api_key",
53
+ type: "secret",
54
+ default: "",
55
+ group: "ai",
56
+ label: "Gemini API key",
57
+ hint: "Free key from aistudio.google.com/apikey — without it the AI commands stay off.",
58
+ },
59
+ {
60
+ key: "gemini_model",
61
+ type: "string",
62
+ default: "gemini-2.5-flash",
63
+ group: "ai",
64
+ label: "Gemini model",
65
+ },
66
+ {
67
+ key: "ai_agent",
68
+ type: "bool",
69
+ default: true,
70
+ group: "ai",
71
+ label: "Tool use (agent mode)",
72
+ hint: "Off = the AI answers from the prompt only, no search / page reading / memory writes.",
73
+ },
74
+ {
75
+ key: "ai_max_tool_steps",
76
+ type: "int",
77
+ default: 6,
78
+ min: 1,
79
+ max: 20,
80
+ group: "ai",
81
+ label: "Max tool rounds",
82
+ },
83
+ {
84
+ key: "ai_tool_timeout_ms",
85
+ type: "int",
86
+ default: 30000,
87
+ min: 1000,
88
+ max: 120000,
89
+ group: "ai",
90
+ label: "Tool timeout (ms)",
91
+ },
92
+ {
93
+ key: "ai_max_history",
94
+ type: "int",
95
+ default: 24,
96
+ min: 2,
97
+ max: 200,
98
+ group: "ai",
99
+ label: "Conversation turns kept",
100
+ },
101
+ {
102
+ key: "memory_context_chars",
103
+ type: "int",
104
+ default: 6000,
105
+ min: 500,
106
+ max: 40000,
107
+ group: "ai",
108
+ label: "Memory injected (chars)",
109
+ hint: "How much of the long-term memory files goes into every prompt.",
110
+ },
111
+ {
112
+ key: "groq_api_key",
113
+ type: "secret",
114
+ default: "",
115
+ group: "ai",
116
+ label: "Groq API key (fallback)",
117
+ hint: "Used only when Gemini is out of quota.",
118
+ },
119
+ {
120
+ key: "groq_model",
121
+ type: "string",
122
+ default: "llama-3.3-70b-versatile",
123
+ group: "ai",
124
+ label: "Groq model",
125
+ },
126
+ {
127
+ key: "google_search_api_key",
128
+ type: "secret",
129
+ default: "",
130
+ group: "ai",
131
+ label: "Google Search API key",
132
+ hint: "Optional. Without it the search tool uses DuckDuckGo.",
133
+ },
134
+ {
135
+ key: "google_search_cx",
136
+ type: "string",
137
+ default: "",
138
+ group: "ai",
139
+ label: "Google Search engine ID (cx)",
140
+ },
141
+
142
+ // --- Integrations ------------------------------------------------------
143
+ {
144
+ key: "openweathermap_api_key",
145
+ type: "secret",
146
+ default: "",
147
+ group: "integrations",
148
+ label: "OpenWeatherMap API key",
149
+ hint: "Needed by !weather.",
150
+ },
151
+
152
+ // --- Media -------------------------------------------------------------
153
+ {
154
+ key: "thumbnail_remote",
155
+ type: "bool",
156
+ default: true,
157
+ group: "media",
158
+ label: "Previews for remote media",
159
+ hint: "Download media sent by URL to build a thumbnail before forwarding it.",
160
+ },
161
+ {
162
+ key: "thumbnail_remote_max_mb",
163
+ type: "int",
164
+ default: 20,
165
+ min: 1,
166
+ max: 200,
167
+ group: "media",
168
+ label: "Max remote media (MB)",
169
+ },
170
+ {
171
+ key: "thumbnail_remote_timeout_ms",
172
+ type: "int",
173
+ default: 20000,
174
+ min: 1000,
175
+ max: 120000,
176
+ group: "media",
177
+ label: "Remote media timeout (ms)",
178
+ },
179
+ {
180
+ key: "ffmpeg_path",
181
+ type: "string",
182
+ default: "",
183
+ group: "media",
184
+ label: "ffmpeg path",
185
+ hint: "Only if the bundled ffmpeg-static binary can't run on this machine.",
186
+ },
187
+
188
+ // --- Server ------------------------------------------------------------
189
+ {
190
+ key: "port",
191
+ type: "int",
192
+ default: 3001,
193
+ min: 1,
194
+ max: 65535,
195
+ group: "server",
196
+ label: "Control panel port",
197
+ hint: "Takes effect on the next start — and only inside the bot. Under Docker or behind a reverse proxy, change the mapping there instead.",
198
+ restart: true,
199
+ },
200
+ {
201
+ key: "trust_proxy",
202
+ type: "string",
203
+ default: "",
204
+ group: "server",
205
+ label: "Proxy hops in front",
206
+ hint: 'Number of proxies (nginx = "1"). Leave empty when the panel is reachable directly — a wrong value lets a client forge its address.',
207
+ restart: true,
208
+ },
209
+ {
210
+ key: "public_domain",
211
+ type: "string",
212
+ default: "",
213
+ group: "server",
214
+ label: "Public domain",
215
+ hint: "Set by `levix domain`. When present it is the address the bot prints and the panel calls home.",
216
+ },
217
+ {
218
+ key: "bind_address",
219
+ type: "string",
220
+ default: "",
221
+ group: "server",
222
+ label: "Bind address",
223
+ hint: "Empty = every interface. `levix domain` sets 127.0.0.1 once a reverse proxy is in front, so nobody can reach the panel past the certificate.",
224
+ restart: true,
225
+ },
226
+ {
227
+ key: "dashboard_origin",
228
+ type: "string",
229
+ default: "",
230
+ group: "server",
231
+ label: "Extra allowed origin",
232
+ hint: "Only if you open the panel from a different domain, e.g. https://bot.example.com.",
233
+ },
234
+ {
235
+ key: "forward_score_ttl_days",
236
+ type: "int",
237
+ default: 30,
238
+ min: 1,
239
+ max: 365,
240
+ group: "server",
241
+ label: "Forward counters kept (days)",
242
+ hint: "How long the !score counters survive. They gain a row per forwarded message.",
243
+ },
244
+ ];
245
+
246
+ const BY_KEY = new Map(SETTINGS.map((definition) => [definition.key, definition]));
247
+
248
+ const GROUP_LABELS = {
249
+ general: "General",
250
+ ai: "AI",
251
+ integrations: "Integrations",
252
+ media: "Media",
253
+ server: "Server",
254
+ };
255
+
256
+ // This module is required by files that load before the database is opened in
257
+ // a few edge paths (a command file pulled in by a script, say), so a read that
258
+ // comes too early falls back to the default instead of throwing.
259
+ function storedValue(key) {
260
+ try {
261
+ const value = require("../utils/storage.cjs").getBotSetting(
262
+ `setting:${key}`,
263
+ undefined
264
+ );
265
+ return value === undefined || value === null ? undefined : value;
266
+ } catch {
267
+ return undefined;
268
+ }
269
+ }
270
+
271
+ function coerce(definition, raw) {
272
+ switch (definition.type) {
273
+ case "int": {
274
+ const number = Number(raw);
275
+ if (!Number.isFinite(number)) return definition.default;
276
+ return Math.trunc(number);
277
+ }
278
+ case "bool": {
279
+ if (typeof raw === "boolean") return raw;
280
+ const text = String(raw).trim().toLowerCase();
281
+ if (["false", "off", "0", "no"].includes(text)) return false;
282
+ if (["true", "on", "1", "yes"].includes(text)) return true;
283
+ return definition.default;
284
+ }
285
+ default:
286
+ return String(raw);
287
+ }
288
+ }
289
+
290
+ /** The value in force for `key`: what the dashboard saved, else the default. */
291
+ function get(key) {
292
+ const definition = BY_KEY.get(key);
293
+ if (!definition) throw new Error(`Unknown setting: ${key}`);
294
+
295
+ const stored = storedValue(key);
296
+ if (stored !== undefined && stored !== "") return coerce(definition, stored);
297
+
298
+ return definition.default;
299
+ }
300
+
301
+ /** Where the value in force came from — shown next to each field in the UI. */
302
+ function sourceOf(key) {
303
+ const stored = storedValue(key);
304
+ return stored !== undefined && stored !== "" ? "dashboard" : "default";
305
+ }
306
+
307
+ function validate(definition, value) {
308
+ if (definition.type === "int") {
309
+ const number = Number(value);
310
+ if (!Number.isFinite(number)) throw new Error(`${definition.label} must be a number`);
311
+ if (definition.min !== undefined && number < definition.min) {
312
+ throw new Error(`${definition.label} must be ${definition.min} or more`);
313
+ }
314
+ if (definition.max !== undefined && number > definition.max) {
315
+ throw new Error(`${definition.label} must be ${definition.max} or less`);
316
+ }
317
+ return Math.trunc(number);
318
+ }
319
+
320
+ if (definition.type === "bool") return coerce(definition, value);
321
+
322
+ const text = String(value ?? "");
323
+ if (text.length > 500) throw new Error(`${definition.label} is too long`);
324
+ if (definition.key === "bot_timezone" && text) {
325
+ try {
326
+ new Intl.DateTimeFormat("en", { timeZone: text });
327
+ } catch {
328
+ throw new Error(`"${text}" is not a valid timezone`);
329
+ }
330
+ }
331
+ return text;
332
+ }
333
+
334
+ /**
335
+ * Save one setting. An empty string clears the override, so the value falls
336
+ * back to the default again.
337
+ * @throws {Error} on an unknown key or a value that fails validation.
338
+ */
339
+ function set(key, value) {
340
+ const definition = BY_KEY.get(key);
341
+ if (!definition) throw new Error(`Unknown setting: ${key}`);
342
+
343
+ const storage = require("../utils/storage.cjs");
344
+
345
+ if (value === null || value === "" || value === undefined) {
346
+ storage.deleteBotSetting(`setting:${key}`);
347
+ logger.info(`[settings] ${key} cleared (back to the default)`);
348
+ return get(key);
349
+ }
350
+
351
+ const clean = validate(definition, value);
352
+ storage.saveBotSetting(`setting:${key}`, clean);
353
+ // Never log the value of a secret.
354
+ logger.info(
355
+ `[settings] ${key} -> ${definition.type === "secret" ? "(updated)" : clean}`
356
+ );
357
+ return get(key);
358
+ }
359
+
360
+ /**
361
+ * Everything the settings screen renders. Secrets report only whether they are
362
+ * configured and where they came from — the value itself never goes out.
363
+ */
364
+ function describe() {
365
+ return SETTINGS.map((definition) => {
366
+ const base = {
367
+ key: definition.key,
368
+ type: definition.type,
369
+ group: definition.group,
370
+ groupLabel: GROUP_LABELS[definition.group] || definition.group,
371
+ label: definition.label,
372
+ hint: definition.hint || null,
373
+ min: definition.min ?? null,
374
+ max: definition.max ?? null,
375
+ restart: definition.restart === true,
376
+ source: sourceOf(definition.key),
377
+ };
378
+
379
+ if (definition.type === "secret") {
380
+ return { ...base, configured: !!get(definition.key), value: null };
381
+ }
382
+ return { ...base, value: get(definition.key), default: definition.default };
383
+ });
384
+ }
385
+
386
+ module.exports = { get, set, describe, sourceOf, SETTING_KEYS: [...BY_KEY.keys()] };
@@ -0,0 +1,199 @@
1
+ import { delay, DisconnectReason } from "@whiskeysockets/baileys";
2
+ import qrcode from "qrcode-terminal";
3
+ import { createRequire } from "module";
4
+ import { MAX_RETRIES, RETRY_DELAY_MS } from "../config/constants.js";
5
+ import { groupMetadataCache } from "./socket.js";
6
+ import {
7
+ saveQrCode,
8
+ deleteQrCode,
9
+ saveUserMetadata,
10
+ getAllOwners,
11
+ getAllBotAdmins,
12
+ } from "../utils/storage.esm.js";
13
+ import {
14
+ bootstrapOwners,
15
+ bootstrapAdmins,
16
+ } from "../utils/permissions.esm.js";
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const logger = require("../utils/logger.cjs");
20
+ const { primePermissions } = require("../utils/permissions.cjs");
21
+
22
+ let retryCount = 0;
23
+
24
+ // Handle connection updates
25
+ export async function handleConnectionUpdate(
26
+ update,
27
+ sock,
28
+ clearAll,
29
+ io,
30
+ initializeScheduledJobs
31
+ ) {
32
+ const { connection, lastDisconnect, qr } = update;
33
+
34
+ if (qr) {
35
+ logger.info("Scan the QR code below:");
36
+ saveQrCode(qr);
37
+ io.emit("qr", qr);
38
+ io.emit("status_update", { status: "QR Code Received" });
39
+ qrcode.generate(qr, { small: true });
40
+ }
41
+
42
+ if (connection === "close") {
43
+ const statusCode = lastDisconnect?.error?.output?.statusCode;
44
+ const reason =
45
+ lastDisconnect?.error?.output?.payload?.error ||
46
+ lastDisconnect?.error?.message ||
47
+ "Unknown";
48
+
49
+ // Define which status codes should trigger reconnection
50
+ // 515: QR timeout, 500/503: Server errors, 404: Not found, etc.
51
+ const noReconnectCodes = [401, 403, DisconnectReason.loggedOut];
52
+ const shouldReconnect = !noReconnectCodes.includes(statusCode);
53
+
54
+ io.emit("status_update", { status: "Disconnected" });
55
+
56
+ // Log full disconnect info for debugging
57
+ logger.debug(
58
+ { lastDisconnect, statusCode, reason },
59
+ "[Connection] Disconnect details"
60
+ );
61
+
62
+ if (shouldReconnect && retryCount < MAX_RETRIES) {
63
+ retryCount++;
64
+ const delayMs = RETRY_DELAY_MS * retryCount; // Exponential backoff: 5s, 10s, 15s, 20s, 25s
65
+
66
+ // Special message for QR timeout
67
+ const messagePrefix =
68
+ statusCode === 515 ? "🔄 QR code expired" : "🔌 Connection closed";
69
+
70
+ logger.warn(
71
+ `${messagePrefix}. Reason: ${statusCode || reason}. Retrying in ${
72
+ delayMs / 1000
73
+ }s... (Attempt ${retryCount}/${MAX_RETRIES})`
74
+ );
75
+
76
+ await delay(delayMs);
77
+ return { shouldReconnect: true };
78
+ } else if (!shouldReconnect) {
79
+ if (statusCode === DisconnectReason.loggedOut) {
80
+ logger.error(
81
+ "❌ Connection closed permanently. Logged out. Clearing auth data from database."
82
+ );
83
+
84
+ // Clear authentication data from database
85
+ if (clearAll) {
86
+ await clearAll();
87
+ logger.info("[Auth] Authentication data cleared from database");
88
+ }
89
+
90
+ // Reset retry count and trigger reconnection for new QR
91
+ retryCount = 0;
92
+ return { shouldReconnect: true };
93
+ } else {
94
+ logger.error(
95
+ `❌ Connection closed with non-recoverable error: ${statusCode}. Not retrying.`
96
+ );
97
+ }
98
+ } else {
99
+ logger.error(`❌ Max retries (${MAX_RETRIES}) reached. Exiting.`);
100
+
101
+ process.exit(1);
102
+ }
103
+ } else if (connection === "open") {
104
+ logger.info("✅ Connection opened successfully!");
105
+ deleteQrCode();
106
+
107
+ retryCount = 0;
108
+ io.emit("status_update", { status: "Connected" });
109
+
110
+ // The paired account is always an owner — that's the whole bootstrap.
111
+ // Everyone else is granted from the dashboard (Roles) or with `!perm`, and
112
+ // is already a row in user_metadata by the time we get here.
113
+ await saveBotOwnerMetadata(sock);
114
+
115
+ const botPn = sock.user?.id
116
+ ? `${sock.user.id.split(":")[0].split("@")[0]}@s.whatsapp.net`
117
+ : null;
118
+ const botLid = sock.user?.lid
119
+ ? `${sock.user.lid.split(":")[0].split("@")[0]}@lid`
120
+ : null;
121
+
122
+ // Both rosters are seeded from the database so a permission check never
123
+ // has to hit it on the hot path.
124
+ const ownersRoster = [
125
+ botPn,
126
+ botLid,
127
+ ...getAllOwners().flatMap((user) => [user.jid, user.lid]),
128
+ ].filter(Boolean);
129
+ const adminsRoster = getAllBotAdmins()
130
+ .flatMap((user) => [user.jid, user.lid])
131
+ .filter(Boolean);
132
+
133
+ bootstrapOwners(ownersRoster);
134
+ bootstrapAdmins(adminsRoster);
135
+
136
+ await primePermissions();
137
+ logger.info(
138
+ `[Permissions] Owner roster: ${ownersRoster.length} entries; ` +
139
+ `admin roster: ${adminsRoster.length} entries`
140
+ );
141
+
142
+ // Initialize scheduled jobs
143
+ initializeScheduledJobs(sock);
144
+
145
+ // Cache all groups
146
+ await cacheAllGroups(sock);
147
+ }
148
+
149
+ return { shouldReconnect: false };
150
+ }
151
+
152
+ // Record the paired WhatsApp account as the bot's first owner. Whoever scanned
153
+ // the QR owns the bot — there is no owner list to configure anywhere.
154
+ async function saveBotOwnerMetadata(sock) {
155
+ try {
156
+ logger.info("[User Metadata] Saving bot owner metadata...");
157
+
158
+ const phoneNumber = sock.user?.id?.split(":")[0]?.split("@")[0];
159
+ const userJid = phoneNumber ? `${phoneNumber}@s.whatsapp.net` : null;
160
+ const userLid = sock.user?.lid
161
+ ? `${sock.user.lid.split(":")[0].split("@")[0]}@lid`
162
+ : null;
163
+ const displayName = sock.user?.name || sock.user?.verifiedName || null;
164
+
165
+ if (userJid) {
166
+ saveUserMetadata({
167
+ jid: userJid,
168
+ lid: userLid,
169
+ phone: phoneNumber,
170
+ isOwner: true,
171
+ displayName,
172
+ });
173
+ logger.info(
174
+ `[User Metadata] Saved bot owner: ${phoneNumber} (JID: ${userJid}, LID: ${userLid})`
175
+ );
176
+ }
177
+ } catch (err) {
178
+ logger.error({ err }, "[Error] Failed to save bot owner metadata");
179
+ }
180
+ }
181
+
182
+ // Cache all group metadata
183
+ async function cacheAllGroups(sock) {
184
+ logger.info("[Cache] Fetching and caching metadata for all groups...");
185
+
186
+ try {
187
+ const groups = await sock.groupFetchAllParticipating();
188
+ let cachedCount = 0;
189
+
190
+ for (const jid in groups) {
191
+ groupMetadataCache.set(jid, groups[jid]);
192
+ cachedCount++;
193
+ }
194
+
195
+ logger.info(`[Cache] Successfully cached ${cachedCount} groups.`);
196
+ } catch (err) {
197
+ logger.error("[Error] Failed to fetch and cache groups:", err);
198
+ }
199
+ }
@@ -0,0 +1,61 @@
1
+ import { createRequire } from 'module';
2
+ import { handleConnectionUpdate } from './connection.js';
3
+ import { handleIncomingMessage } from '../handlers/message.handler.js';
4
+ import { handleGroupParticipantsUpdate, handleGroupJoinRequests } from '../handlers/group.handler.js';
5
+ import { groupMetadataCache } from './socket.js';
6
+ import { storeLidPnMapping, storeLidPnMappings } from '../utils/storage.esm.js';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const logger = require('../utils/logger.cjs');
10
+
11
+ // Setup all event listeners for WhatsApp socket
12
+ export function setupEventListeners(sock, saveCreds, clearAll, io, initializeScheduledJobs, reconnectCallback) {
13
+ // Connection updates
14
+ sock.ev.on('connection.update', async (update) => {
15
+ const result = await handleConnectionUpdate(update, sock, clearAll, io, initializeScheduledJobs);
16
+ if (result?.shouldReconnect && reconnectCallback) {
17
+ // Trigger reconnection
18
+ await reconnectCallback();
19
+ }
20
+ });
21
+
22
+ // Save credentials
23
+ sock.ev.on('creds.update', saveCreds);
24
+
25
+ // V7: LID mapping updates
26
+ sock.ev.on('lid-mapping.update', (updates) => {
27
+ logger.info(`[LID] Received ${Object.keys(updates).length} LID mapping updates`);
28
+ const mappings = [];
29
+ for (const [lid, pn] of Object.entries(updates)) {
30
+ if (lid && pn) {
31
+ mappings.push({ lid, pn, deviceIndex: 0 });
32
+ }
33
+ }
34
+ if (mappings.length > 0) {
35
+ storeLidPnMappings(mappings);
36
+ }
37
+ });
38
+
39
+ // Group metadata updates
40
+ sock.ev.on('groups.upsert', (updates) => {
41
+ for (const group of updates) {
42
+ logger.info(`[Cache] Caching metadata for group: ${group.id}`);
43
+ groupMetadataCache.set(group.id, group);
44
+ }
45
+ });
46
+
47
+ // Incoming messages
48
+ sock.ev.on('messages.upsert', async (m) => {
49
+ await handleIncomingMessage(sock, m);
50
+ });
51
+
52
+ // Group participant updates
53
+ sock.ev.on('group-participants.update', async (update) => {
54
+ await handleGroupParticipantsUpdate(sock, update);
55
+ });
56
+
57
+ // Group join requests
58
+ sock.ev.on('group-requests.update', async (events) => {
59
+ await handleGroupJoinRequests(sock, events);
60
+ });
61
+ }
@@ -0,0 +1,64 @@
1
+ import { makeWASocket } from '@whiskeysockets/baileys';
2
+ import NodeCache from 'node-cache';
3
+ import {
4
+ getBaileysConfig,
5
+ setRecentMessageGetter,
6
+ } from '../config/baileys.config.js';
7
+ import { CACHE_CONFIG } from '../config/constants.js';
8
+ import { useDatabaseAuthState } from '../auth/use-database-auth-state.js';
9
+ import { getMessageFromRecent } from '../utils/recentMessageCache.esm.js';
10
+ import { createRequire } from 'module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const logger = require('../utils/logger.cjs');
14
+ const { withMediaThumbnail } = require('../utils/thumbnail.cjs');
15
+
16
+ // Group metadata cache
17
+ export const groupMetadataCache = new NodeCache(CACHE_CONFIG);
18
+
19
+ // Wire the recent-message cache into Baileys' getMessage hook so retries can
20
+ // recover (rc10 enableAutoSessionRecreation needs this).
21
+ setRecentMessageGetter(getMessageFromRecent);
22
+
23
+ /**
24
+ * Baileys can't generate media previews in this install (no sharp/jimp, and its
25
+ * video path shells out to a bare `ffmpeg` that isn't on the PATH), so every
26
+ * image/video we sent went out with a blank thumbnail. Patch sendMessage once,
27
+ * at the socket level, so EVERY caller gets a preview — commands that use
28
+ * sendBotMessage and the handful of legacy commands still calling
29
+ * sock.sendMessage directly.
30
+ */
31
+ function withThumbnailSupport(sock) {
32
+ const original = sock.sendMessage.bind(sock);
33
+
34
+ sock.sendMessage = async function sendMessageWithThumbnail(jid, content, options) {
35
+ let payload = content;
36
+ try {
37
+ payload = await withMediaThumbnail(content);
38
+ } catch (err) {
39
+ logger.debug({ err: err?.message }, '[Socket] thumbnail step skipped');
40
+ payload = content;
41
+ }
42
+ return original(jid, payload, options);
43
+ };
44
+
45
+ return sock;
46
+ }
47
+
48
+ // Create and configure WhatsApp socket
49
+ export async function createWhatsAppSocket() {
50
+ logger.info('[Socket] Initializing WhatsApp socket with database auth');
51
+
52
+ const { state, saveCreds, clearAll } = await useDatabaseAuthState();
53
+
54
+ const cachedGroupMetadata = (jid) => groupMetadataCache.get(jid);
55
+
56
+ const sock = withThumbnailSupport(
57
+ makeWASocket({
58
+ auth: state,
59
+ ...getBaileysConfig(cachedGroupMetadata),
60
+ })
61
+ );
62
+
63
+ return { sock, saveCreds, clearAll };
64
+ }