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,837 @@
1
+ // The bot's view of the database.
2
+ //
3
+ // Every read and every write in here is synchronous, because SQLite is
4
+ // synchronous and so are the ~40 call sites that use it — the anti-spam and
5
+ // blacklist middleware, the prefix lookup, every permission check all run
6
+ // inline on the per-message path with no `await` in sight.
7
+ //
8
+ // There is no cache and no write-behind queue: a query against a local SQLite
9
+ // file is a function call, not a round trip. What you read is what is on disk.
10
+ //
11
+ // The file itself, the schema and the migrations are in src/db/db.cjs.
12
+ //
13
+ // This module is CommonJS on purpose: both the CJS command files and the ESM
14
+ // handlers need the *same* singleton, and only a CJS module can be required
15
+ // synchronously from both sides.
16
+
17
+ const logger = require("../utils/logger.cjs");
18
+ const { q, parseJson, sweepExpired, checkpoint, DB_PATH } = require("./db.cjs");
19
+
20
+ const DAY_MS = 24 * 60 * 60 * 1000;
21
+ const DEFAULT_FORWARD_TTL_DAYS = 30;
22
+
23
+ // ===================================================================
24
+ // --- Lifecycle ---
25
+ // ===================================================================
26
+ //
27
+ // The database is opened the moment db.cjs is required, so there is nothing
28
+ // to await. These exist because the rest of the codebase calls them; they now
29
+ // do the housekeeping the boot sequence needs: sweep, and report.
30
+
31
+ let sweepTimer = null;
32
+
33
+ async function initStore() {
34
+ sweepExpired();
35
+ if (!sweepTimer) {
36
+ sweepTimer = setInterval(sweepExpired, 6 * 60 * 60 * 1000);
37
+ sweepTimer.unref();
38
+ }
39
+ logger.info(
40
+ `[Store] SQLite ready at ${DB_PATH} — ${countGroups()} group(s), ` +
41
+ `${countUsers()} user(s), ${countNotes()} note(s), ${countLidMappings()} LID mapping(s)`
42
+ );
43
+ }
44
+
45
+ /** Nothing is ever in flight, but shutdown paths call this. Flush the WAL. */
46
+ async function flushStore() {
47
+ checkpoint();
48
+ }
49
+
50
+ function isStoreReady() {
51
+ return true;
52
+ }
53
+
54
+ /** Kept for the dashboard's health card. Always zero now. */
55
+ function pendingWrites() {
56
+ return 0;
57
+ }
58
+
59
+ // ===================================================================
60
+ // --- Helpers ---
61
+ // ===================================================================
62
+
63
+ function digitsOf(value) {
64
+ const match = String(value || "").match(/\d{5,}/);
65
+ return match ? match[0] : null;
66
+ }
67
+
68
+ const bool = (value) => (value ? 1 : 0);
69
+
70
+ /** Forward-score retention. Read at call time so the dashboard can change it. */
71
+ function forwardExpiry() {
72
+ let days = DEFAULT_FORWARD_TTL_DAYS;
73
+ try {
74
+ days = require("../config/settings.cjs").get("forward_score_ttl_days");
75
+ } catch {
76
+ // settings.cjs reads through this module; during its own load we take the
77
+ // default rather than recursing.
78
+ }
79
+ return Date.now() + Number(days || DEFAULT_FORWARD_TTL_DAYS) * DAY_MS;
80
+ }
81
+
82
+ // ===================================================================
83
+ // --- Bot settings ---
84
+ // ===================================================================
85
+ //
86
+ // One table for the prefix, everything the dashboard saves ("setting:*"), the
87
+ // permission / alias / disabled override maps, and the generated secrets.
88
+ // Values are JSON, so an object or an array round-trips as itself.
89
+
90
+ function getBotSetting(key, defaultValue = null) {
91
+ const row = q("SELECT value FROM bot_settings WHERE key = ?").get(key);
92
+ if (!row) return defaultValue;
93
+ const value = parseJson(row.value, undefined);
94
+ return value === undefined ? defaultValue : value;
95
+ }
96
+
97
+ function saveBotSetting(key, value) {
98
+ q(
99
+ `INSERT INTO bot_settings (key, value) VALUES (?, ?)
100
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
101
+ ).run(key, JSON.stringify(value ?? null));
102
+ }
103
+
104
+ function deleteBotSetting(key) {
105
+ q("DELETE FROM bot_settings WHERE key = ?").run(key);
106
+ }
107
+
108
+ // ===================================================================
109
+ // --- Group settings ---
110
+ // ===================================================================
111
+
112
+ function getGroupSettings(groupId) {
113
+ const row = q("SELECT settings FROM group_settings WHERE group_id = ?").get(
114
+ groupId
115
+ );
116
+ return row ? parseJson(row.settings, {}) : {};
117
+ }
118
+
119
+ function saveGroupSettings(groupId, settings) {
120
+ q(
121
+ `INSERT INTO group_settings (group_id, settings) VALUES (?, ?)
122
+ ON CONFLICT(group_id) DO UPDATE SET settings = excluded.settings`
123
+ ).run(groupId, JSON.stringify(settings || {}));
124
+ }
125
+
126
+ function getAllGroupSettings() {
127
+ return q("SELECT group_id, settings FROM group_settings")
128
+ .all()
129
+ .map((row) => ({
130
+ group_id: row.group_id,
131
+ settings: parseJson(row.settings, {}),
132
+ }));
133
+ }
134
+
135
+ function countGroups() {
136
+ return q("SELECT COUNT(*) AS n FROM group_settings").get().n;
137
+ }
138
+
139
+ // ===================================================================
140
+ // --- Warnings ---
141
+ // ===================================================================
142
+
143
+ function getUserWarnings(groupId, userId) {
144
+ const row = q(
145
+ "SELECT warnings FROM warnings WHERE group_id = ? AND user_id = ?"
146
+ ).get(groupId, userId);
147
+ return row ? parseJson(row.warnings, []) : [];
148
+ }
149
+
150
+ function saveUserWarnings(groupId, userId, warningsArray) {
151
+ q(
152
+ `INSERT INTO warnings (group_id, user_id, warnings) VALUES (?, ?, ?)
153
+ ON CONFLICT(group_id, user_id) DO UPDATE SET warnings = excluded.warnings`
154
+ ).run(groupId, userId, JSON.stringify(warningsArray || []));
155
+ }
156
+
157
+ function clearUserWarnings(groupId, userId) {
158
+ q("DELETE FROM warnings WHERE group_id = ? AND user_id = ?").run(
159
+ groupId,
160
+ userId
161
+ );
162
+ }
163
+
164
+ function getAllWarnings() {
165
+ return q("SELECT group_id, user_id, warnings FROM warnings")
166
+ .all()
167
+ .map((row) => ({
168
+ group_id: row.group_id,
169
+ user_id: row.user_id,
170
+ warnings: parseJson(row.warnings, []),
171
+ }));
172
+ }
173
+
174
+ function countWarnings() {
175
+ return q("SELECT COUNT(*) AS n FROM warnings").get().n;
176
+ }
177
+
178
+ // ===================================================================
179
+ // --- Todos ---
180
+ // ===================================================================
181
+
182
+ function getUserTodos(userId) {
183
+ const row = q("SELECT tasks FROM todos WHERE user_id = ?").get(userId);
184
+ return row ? parseJson(row.tasks, []) : [];
185
+ }
186
+
187
+ function saveUserTodos(userId, tasksArray) {
188
+ q(
189
+ `INSERT INTO todos (user_id, tasks) VALUES (?, ?)
190
+ ON CONFLICT(user_id) DO UPDATE SET tasks = excluded.tasks`
191
+ ).run(userId, JSON.stringify(tasksArray || []));
192
+ }
193
+
194
+ function getAllTodos() {
195
+ return q("SELECT user_id, tasks FROM todos")
196
+ .all()
197
+ .map((row) => ({ user_id: row.user_id, tasks: parseJson(row.tasks, []) }));
198
+ }
199
+
200
+ function countTodos() {
201
+ return q("SELECT COUNT(*) AS n FROM todos").get().n;
202
+ }
203
+
204
+ // ===================================================================
205
+ // --- Notes ---
206
+ // ===================================================================
207
+
208
+ function saveNote(groupId, keyword, text) {
209
+ q(
210
+ `INSERT INTO notes (group_id, keyword, note_text) VALUES (?, ?, ?)
211
+ ON CONFLICT(group_id, keyword) DO UPDATE SET note_text = excluded.note_text`
212
+ ).run(groupId, keyword, text);
213
+ }
214
+
215
+ function getNote(groupId, keyword) {
216
+ const row = q(
217
+ "SELECT note_text FROM notes WHERE group_id = ? AND keyword = ?"
218
+ ).get(groupId, keyword);
219
+ return row ? row.note_text : null;
220
+ }
221
+
222
+ function getAllNotes(groupId) {
223
+ return q("SELECT keyword FROM notes WHERE group_id = ? ORDER BY keyword")
224
+ .all(groupId)
225
+ .map((row) => row.keyword);
226
+ }
227
+
228
+ function deleteNote(groupId, keyword) {
229
+ const { changes } = q(
230
+ "DELETE FROM notes WHERE group_id = ? AND keyword = ?"
231
+ ).run(groupId, keyword);
232
+ return changes > 0;
233
+ }
234
+
235
+ function getAllNotesFlat(limit = 100) {
236
+ return q(
237
+ `SELECT group_id, keyword, note_text FROM notes
238
+ ORDER BY group_id, keyword LIMIT ?`
239
+ ).all(limit);
240
+ }
241
+
242
+ function countNotes() {
243
+ return q("SELECT COUNT(*) AS n FROM notes").get().n;
244
+ }
245
+
246
+ // ===================================================================
247
+ // --- Pairing QR ---
248
+ // ===================================================================
249
+
250
+ function saveQrCode(qr) {
251
+ q(
252
+ `INSERT INTO qr_codes (id, qr_string) VALUES (1, ?)
253
+ ON CONFLICT(id) DO UPDATE SET qr_string = excluded.qr_string`
254
+ ).run(qr);
255
+ }
256
+
257
+ function getQrCode() {
258
+ const row = q("SELECT qr_string FROM qr_codes WHERE id = 1").get();
259
+ return row ? row.qr_string : null;
260
+ }
261
+
262
+ function deleteQrCode() {
263
+ q("DELETE FROM qr_codes WHERE id = 1").run();
264
+ }
265
+
266
+ // ===================================================================
267
+ // --- LID <-> phone number (Baileys v7) ---
268
+ // ===================================================================
269
+
270
+ function storeLidPnMapping(lid, pn, deviceIndex = 0) {
271
+ q(
272
+ `INSERT INTO lid_mapping (lid, pn, device_index, updated_at) VALUES (?, ?, ?, ?)
273
+ ON CONFLICT(lid) DO UPDATE SET
274
+ pn = excluded.pn,
275
+ device_index = excluded.device_index,
276
+ updated_at = excluded.updated_at`
277
+ ).run(lid, pn, deviceIndex, Date.now());
278
+ logger.debug(`[LID] Stored mapping: ${lid} <-> ${pn}`);
279
+ }
280
+
281
+ function storeLidPnMappings(mappings) {
282
+ if (!mappings?.length) return;
283
+ for (const { lid, pn, deviceIndex = 0 } of mappings) {
284
+ storeLidPnMapping(lid, pn, deviceIndex);
285
+ }
286
+ logger.info(`[LID] Stored ${mappings.length} mappings`);
287
+ }
288
+
289
+ function getLidForPn(pn) {
290
+ const row = q("SELECT lid FROM lid_mapping WHERE pn = ?").get(pn);
291
+ return row ? row.lid : null;
292
+ }
293
+
294
+ function getLidsForPns(pns) {
295
+ const map = new Map();
296
+ if (!pns?.length) return map;
297
+ for (const pn of pns) {
298
+ const lid = getLidForPn(pn);
299
+ if (lid) map.set(pn, lid);
300
+ }
301
+ return map;
302
+ }
303
+
304
+ function getPnForLid(lid) {
305
+ const row = q("SELECT pn FROM lid_mapping WHERE lid = ?").get(lid);
306
+ return row ? row.pn : null;
307
+ }
308
+
309
+ function getAllLidMappings() {
310
+ return q("SELECT lid, pn, device_index FROM lid_mapping").all();
311
+ }
312
+
313
+ function countLidMappings() {
314
+ return q("SELECT COUNT(*) AS n FROM lid_mapping").get().n;
315
+ }
316
+
317
+ // ===================================================================
318
+ // --- User metadata & bot roles ---
319
+ // ===================================================================
320
+
321
+ function rowToUser(row) {
322
+ if (!row) return null;
323
+ return {
324
+ jid: row.user_jid,
325
+ lid: row.user_lid ?? null,
326
+ phone: row.phone_number ?? null,
327
+ isOwner: row.is_owner === 1,
328
+ isAdmin: row.is_admin === 1,
329
+ firstSeen: row.first_seen,
330
+ lastSeen: row.last_seen,
331
+ displayName: row.display_name ?? null,
332
+ };
333
+ }
334
+
335
+ function userRow(jid) {
336
+ return q("SELECT * FROM user_metadata WHERE user_jid = ?").get(jid);
337
+ }
338
+
339
+ /**
340
+ * Save or update user metadata.
341
+ *
342
+ * Role flags (`isOwner` / `isAdmin`) are written ONLY when the caller passes
343
+ * them. The message handler calls this for every incoming message without any
344
+ * role information, and resetting the flags there demoted real owners on their
345
+ * next message. Same story for `lid` / `phone` / `displayName`: a missing
346
+ * field keeps whatever we already knew instead of nulling it out.
347
+ *
348
+ * @param {object} userData - { jid, lid?, phone?, isOwner?, isAdmin?, displayName? }
349
+ */
350
+ function saveUserMetadata(userData) {
351
+ if (!userData?.jid) return;
352
+ const now = Date.now();
353
+ const existing = userRow(userData.jid);
354
+
355
+ const row = {
356
+ user_jid: userData.jid,
357
+ user_lid: userData.lid || existing?.user_lid || null,
358
+ phone_number: userData.phone || existing?.phone_number || null,
359
+ is_owner:
360
+ userData.isOwner === undefined
361
+ ? existing?.is_owner === 1
362
+ ? 1
363
+ : 0
364
+ : bool(userData.isOwner),
365
+ is_admin:
366
+ userData.isAdmin === undefined
367
+ ? existing?.is_admin === 1
368
+ ? 1
369
+ : 0
370
+ : bool(userData.isAdmin),
371
+ first_seen: existing?.first_seen ?? now,
372
+ last_seen: now,
373
+ display_name: userData.displayName || existing?.display_name || null,
374
+ };
375
+
376
+ q(
377
+ `INSERT INTO user_metadata
378
+ (user_jid, user_lid, phone_number, is_owner, is_admin, first_seen, last_seen, display_name)
379
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
380
+ ON CONFLICT(user_jid) DO UPDATE SET
381
+ user_lid = excluded.user_lid,
382
+ phone_number = excluded.phone_number,
383
+ is_owner = excluded.is_owner,
384
+ is_admin = excluded.is_admin,
385
+ last_seen = excluded.last_seen,
386
+ display_name = excluded.display_name`
387
+ ).run(
388
+ row.user_jid,
389
+ row.user_lid,
390
+ row.phone_number,
391
+ row.is_owner,
392
+ row.is_admin,
393
+ row.first_seen,
394
+ row.last_seen,
395
+ row.display_name
396
+ );
397
+ }
398
+
399
+ /**
400
+ * Get user metadata by JID, LID, or bare phone number. Four steps, in order:
401
+ * exact JID, then LID, then phone, then a digits-only match against either
402
+ * identifier (which is what handles the `:12` device suffixes).
403
+ */
404
+ function getUserMetadata(identifier) {
405
+ if (!identifier) return null;
406
+
407
+ let row = userRow(identifier);
408
+
409
+ if (!row) {
410
+ row = q("SELECT * FROM user_metadata WHERE user_lid = ?").get(identifier);
411
+ }
412
+
413
+ if (!row) {
414
+ row = q("SELECT * FROM user_metadata WHERE phone_number = ?").get(
415
+ identifier
416
+ );
417
+ }
418
+
419
+ if (!row) {
420
+ const phone = digitsOf(identifier);
421
+ if (phone) {
422
+ row =
423
+ q("SELECT * FROM user_metadata WHERE phone_number = ?").get(phone) ||
424
+ q(
425
+ `SELECT * FROM user_metadata
426
+ WHERE user_jid LIKE ? OR user_lid LIKE ?
427
+ LIMIT 1`
428
+ ).get(`${phone}@%`, `${phone}@%`);
429
+ }
430
+ }
431
+
432
+ return rowToUser(row);
433
+ }
434
+
435
+ function isUserOwner(identifier) {
436
+ return getUserMetadata(identifier)?.isOwner === true;
437
+ }
438
+
439
+ /**
440
+ * Check if user carries the bot-level admin role (not the same thing as being
441
+ * an admin of a WhatsApp group).
442
+ */
443
+ function isUserBotAdmin(identifier) {
444
+ return getUserMetadata(identifier)?.isAdmin === true;
445
+ }
446
+
447
+ function getAllOwners() {
448
+ return q("SELECT * FROM user_metadata WHERE is_owner = 1")
449
+ .all()
450
+ .map(rowToUser);
451
+ }
452
+
453
+ function getAllBotAdmins() {
454
+ return q("SELECT * FROM user_metadata WHERE is_admin = 1")
455
+ .all()
456
+ .map(rowToUser);
457
+ }
458
+
459
+ function getAllUsers() {
460
+ return q("SELECT * FROM user_metadata ORDER BY last_seen DESC")
461
+ .all()
462
+ .map(rowToUser);
463
+ }
464
+
465
+ function countUsers() {
466
+ return q("SELECT COUNT(*) AS n FROM user_metadata").get().n;
467
+ }
468
+
469
+ /**
470
+ * Grant or revoke a bot-level role, creating the user record when needed.
471
+ *
472
+ * @param {string} identifier - JID / LID / phone
473
+ * @param {"owner"|"admin"} role
474
+ * @param {boolean} enabled
475
+ * @returns {object|null} the stored user record
476
+ */
477
+ function setUserRole(identifier, role, enabled = true) {
478
+ if (!identifier) return null;
479
+
480
+ const wanted = String(role).toLowerCase() === "owner" ? "isOwner" : "isAdmin";
481
+ const existing = getUserMetadata(identifier);
482
+
483
+ // Keep LIDs as LIDs; anything else becomes a PN JID so the roster stays
484
+ // comparable with what the permission checks resolve senders to.
485
+ let jid = existing?.jid;
486
+ if (!jid) {
487
+ if (String(identifier).includes("@")) jid = identifier;
488
+ else {
489
+ const phone = digitsOf(identifier);
490
+ jid = phone ? `${phone}@s.whatsapp.net` : identifier;
491
+ }
492
+ }
493
+
494
+ saveUserMetadata({
495
+ jid,
496
+ lid:
497
+ existing?.lid || (String(identifier).endsWith("@lid") ? identifier : null),
498
+ phone: existing?.phone || digitsOf(identifier),
499
+ displayName: existing?.displayName || null,
500
+ [wanted]: !!enabled,
501
+ });
502
+
503
+ return getUserMetadata(jid);
504
+ }
505
+
506
+ function updateUserLastSeen(jid) {
507
+ q("UPDATE user_metadata SET last_seen = ? WHERE user_jid = ?").run(
508
+ Date.now(),
509
+ jid
510
+ );
511
+ }
512
+
513
+ // ===================================================================
514
+ // --- Forward scores ---
515
+ // ===================================================================
516
+
517
+ function incrementForwardScore(messageId, groupId, senderId) {
518
+ const now = Date.now();
519
+ q(
520
+ `INSERT INTO forward_scores
521
+ (message_id, group_id, original_sender, forward_count,
522
+ first_forwarded_at, last_forwarded_at, expires_at)
523
+ VALUES (?, ?, ?, 1, ?, ?, ?)
524
+ ON CONFLICT(message_id) DO UPDATE SET
525
+ forward_count = forward_count + 1,
526
+ last_forwarded_at = excluded.last_forwarded_at,
527
+ expires_at = excluded.expires_at`
528
+ ).run(messageId, groupId, senderId, now, now, forwardExpiry());
529
+
530
+ return (
531
+ q("SELECT forward_count FROM forward_scores WHERE message_id = ?").get(
532
+ messageId
533
+ )?.forward_count ?? 1
534
+ );
535
+ }
536
+
537
+ function getForwardScore(messageId) {
538
+ const row = q("SELECT * FROM forward_scores WHERE message_id = ?").get(
539
+ messageId
540
+ );
541
+ return row
542
+ ? {
543
+ count: row.forward_count,
544
+ sender: row.original_sender,
545
+ firstForwardedAt: row.first_forwarded_at,
546
+ lastForwardedAt: row.last_forwarded_at,
547
+ }
548
+ : null;
549
+ }
550
+
551
+ function getTopForwardedMessages(groupId, limit = 10) {
552
+ return q(
553
+ `SELECT message_id, original_sender, forward_count, first_forwarded_at
554
+ FROM forward_scores
555
+ WHERE group_id = ?
556
+ ORDER BY forward_count DESC
557
+ LIMIT ?`
558
+ ).all(groupId, limit);
559
+ }
560
+
561
+ // ===================================================================
562
+ // --- Debts ---
563
+ // ===================================================================
564
+
565
+ function debtRow(row) {
566
+ if (!row) return null;
567
+ return { ...row, settled: row.settled === 1 };
568
+ }
569
+
570
+ function addDebt({
571
+ groupId,
572
+ debtorId,
573
+ creditorId,
574
+ amount,
575
+ currency = "USD",
576
+ description = null,
577
+ }) {
578
+ const createdAt = Date.now();
579
+ const { lastInsertRowid } = q(
580
+ `INSERT INTO debts
581
+ (group_id, debtor_id, creditor_id, amount, currency, description, created_at, settled, settled_at)
582
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL)`
583
+ ).run(groupId, debtorId, creditorId, amount, currency, description, createdAt);
584
+
585
+ return getDebt(Number(lastInsertRowid));
586
+ }
587
+
588
+ function getDebt(id) {
589
+ return debtRow(q("SELECT * FROM debts WHERE id = ?").get(id));
590
+ }
591
+
592
+ function deleteDebt(id) {
593
+ const { changes } = q("DELETE FROM debts WHERE id = ?").run(id);
594
+ return changes > 0;
595
+ }
596
+
597
+ function listDebts(groupId, { settled = false } = {}) {
598
+ return q(
599
+ `SELECT * FROM debts WHERE group_id = ? AND settled = ?
600
+ ORDER BY created_at DESC`
601
+ )
602
+ .all(groupId, bool(settled))
603
+ .map(debtRow);
604
+ }
605
+
606
+ function getRecentDebts(limit = 50) {
607
+ return q("SELECT * FROM debts ORDER BY created_at DESC LIMIT ?")
608
+ .all(limit)
609
+ .map(debtRow);
610
+ }
611
+
612
+ function countDebts(settled = false) {
613
+ return q("SELECT COUNT(*) AS n FROM debts WHERE settled = ?").get(
614
+ bool(settled)
615
+ ).n;
616
+ }
617
+
618
+ // ===================================================================
619
+ // --- Scheduled messages ---
620
+ // ===================================================================
621
+ //
622
+ // Used to be config/schedule.json. In the database the dashboard can list
623
+ // them, and a crash in the middle of a write can't truncate the file.
624
+ //
625
+ // A job is `{ id, type, targetJid, message, cronString?, date?, status,
626
+ // creatorJid }` — the shape !schedule and !autoschedule have always built.
627
+
628
+ function scheduleRow(row) {
629
+ if (!row) return null;
630
+ return {
631
+ id: row.id,
632
+ type: row.type,
633
+ targetJid: row.target_jid,
634
+ message: row.message,
635
+ cronString: row.cron_string ?? undefined,
636
+ date: row.date ?? undefined,
637
+ status: row.status,
638
+ creatorJid: row.creator_jid ?? null,
639
+ createdAt: row.created_at,
640
+ };
641
+ }
642
+
643
+ function getSchedules() {
644
+ return q("SELECT * FROM schedules ORDER BY created_at").all().map(scheduleRow);
645
+ }
646
+
647
+ function getSchedule(id) {
648
+ return scheduleRow(q("SELECT * FROM schedules WHERE id = ?").get(String(id)));
649
+ }
650
+
651
+ function saveSchedule(job) {
652
+ q(
653
+ `INSERT INTO schedules
654
+ (id, type, target_jid, message, cron_string, date, status, creator_jid, created_at)
655
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
656
+ ON CONFLICT(id) DO UPDATE SET
657
+ type = excluded.type,
658
+ target_jid = excluded.target_jid,
659
+ message = excluded.message,
660
+ cron_string = excluded.cron_string,
661
+ date = excluded.date,
662
+ status = excluded.status,
663
+ creator_jid = excluded.creator_jid`
664
+ ).run(
665
+ String(job.id),
666
+ job.type,
667
+ job.targetJid,
668
+ job.message,
669
+ job.cronString ?? null,
670
+ job.date ?? null,
671
+ job.status || "pending",
672
+ job.creatorJid ?? null,
673
+ job.createdAt ?? Date.now()
674
+ );
675
+ return getSchedule(job.id);
676
+ }
677
+
678
+ function setScheduleStatus(id, status) {
679
+ q("UPDATE schedules SET status = ? WHERE id = ?").run(status, String(id));
680
+ }
681
+
682
+ function deleteSchedule(id) {
683
+ const { changes } = q("DELETE FROM schedules WHERE id = ?").run(String(id));
684
+ return changes > 0;
685
+ }
686
+
687
+ function countSchedules() {
688
+ return q("SELECT COUNT(*) AS n FROM schedules").get().n;
689
+ }
690
+
691
+ // ===================================================================
692
+ // --- AI conversation history ---
693
+ // ===================================================================
694
+ //
695
+ // One row per chat, no expiry. `!del` clears one chat, `!delall` clears the
696
+ // table. The callers are async (they always were, back when this was a
697
+ // separate database), so storage-hub keeps the async signatures.
698
+
699
+ function getChatHistory(chatId) {
700
+ const row = q("SELECT history FROM ai_history WHERE chat_id = ?").get(chatId);
701
+ return row ? parseJson(row.history, []) : [];
702
+ }
703
+
704
+ function saveChatHistory(chatId, historyArray) {
705
+ q(
706
+ `INSERT INTO ai_history (chat_id, history, updated_at) VALUES (?, ?, ?)
707
+ ON CONFLICT(chat_id) DO UPDATE SET
708
+ history = excluded.history,
709
+ updated_at = excluded.updated_at`
710
+ ).run(chatId, JSON.stringify(historyArray || []), Date.now());
711
+ }
712
+
713
+ function deleteChatHistory(chatId) {
714
+ const { changes } = q("DELETE FROM ai_history WHERE chat_id = ?").run(chatId);
715
+ return changes > 0;
716
+ }
717
+
718
+ function deleteAllChatHistories() {
719
+ q("DELETE FROM ai_history").run();
720
+ }
721
+
722
+ // ===================================================================
723
+ // --- Baileys auth ---
724
+ // ===================================================================
725
+ //
726
+ // Values arrive already serialized by BufferJSON (see auth-storage.cjs), so
727
+ // they are stored as text and handed back as text.
728
+
729
+ function authRead(key) {
730
+ const row = q("SELECT value FROM baileys_auth WHERE key = ?").get(key);
731
+ return row ? row.value : null;
732
+ }
733
+
734
+ function authWrite(key, serialized) {
735
+ q(
736
+ `INSERT INTO baileys_auth (key, value) VALUES (?, ?)
737
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
738
+ ).run(key, serialized);
739
+ }
740
+
741
+ function authRemove(key) {
742
+ q("DELETE FROM baileys_auth WHERE key = ?").run(key);
743
+ }
744
+
745
+ function authClearAll() {
746
+ q("DELETE FROM baileys_auth").run();
747
+ }
748
+
749
+ function hasCredentials() {
750
+ return !!q("SELECT 1 AS x FROM baileys_auth WHERE key = 'creds'").get();
751
+ }
752
+
753
+ module.exports = {
754
+ // Lifecycle
755
+ initStore,
756
+ flushStore,
757
+ isStoreReady,
758
+ pendingWrites,
759
+ // Bot settings
760
+ getBotSetting,
761
+ saveBotSetting,
762
+ deleteBotSetting,
763
+ // Group settings
764
+ getGroupSettings,
765
+ saveGroupSettings,
766
+ getAllGroupSettings,
767
+ countGroups,
768
+ // Warnings
769
+ getUserWarnings,
770
+ saveUserWarnings,
771
+ clearUserWarnings,
772
+ getAllWarnings,
773
+ countWarnings,
774
+ // Todos
775
+ getUserTodos,
776
+ saveUserTodos,
777
+ countTodos,
778
+ getAllTodos,
779
+ // Notes
780
+ saveNote,
781
+ getNote,
782
+ getAllNotes,
783
+ deleteNote,
784
+ getAllNotesFlat,
785
+ countNotes,
786
+ // QR
787
+ saveQrCode,
788
+ getQrCode,
789
+ deleteQrCode,
790
+ // LID mapping
791
+ storeLidPnMapping,
792
+ storeLidPnMappings,
793
+ getLidForPn,
794
+ getLidsForPns,
795
+ getPnForLid,
796
+ getAllLidMappings,
797
+ // User metadata & roles
798
+ saveUserMetadata,
799
+ getUserMetadata,
800
+ isUserOwner,
801
+ isUserBotAdmin,
802
+ getAllOwners,
803
+ getAllBotAdmins,
804
+ getAllUsers,
805
+ countUsers,
806
+ setUserRole,
807
+ updateUserLastSeen,
808
+ // Forward scores
809
+ incrementForwardScore,
810
+ getForwardScore,
811
+ getTopForwardedMessages,
812
+ // Debts
813
+ addDebt,
814
+ getDebt,
815
+ deleteDebt,
816
+ listDebts,
817
+ getRecentDebts,
818
+ countDebts,
819
+ // Schedules
820
+ getSchedules,
821
+ getSchedule,
822
+ saveSchedule,
823
+ setScheduleStatus,
824
+ deleteSchedule,
825
+ countSchedules,
826
+ // AI history
827
+ getChatHistory,
828
+ saveChatHistory,
829
+ deleteChatHistory,
830
+ deleteAllChatHistories,
831
+ // Baileys auth
832
+ authRead,
833
+ authWrite,
834
+ authRemove,
835
+ authClearAll,
836
+ hasCredentials,
837
+ };