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,138 @@
1
+ // Adding one nginx site, and nothing else.
2
+ //
3
+ // The safety contract, in order:
4
+ //
5
+ // 1. write only a file named after Levix, in the directory this distro uses
6
+ // 2. enable it the way this distro enables sites
7
+ // 3. `nginx -t` — the whole configuration, not just ours
8
+ // 4. if that fails: put back exactly what was there, print nginx's own words,
9
+ // do NOT reload
10
+ // 5. only then reload (never restart: a restart drops every live connection
11
+ // on a server that may be serving somebody else's site)
12
+ //
13
+ // Running it twice with the same domain rewrites the same file. No timestamped
14
+ // piles of backups, no second server block.
15
+
16
+ import { nginxSitePath } from "./detect.js";
17
+
18
+ /** The site itself. WebSocket upgrade included — socket.io needs it. */
19
+ export function renderSite({ domain, port }) {
20
+ return `# Managed by Levix. Rewritten by \`levix domain ${domain}\`.
21
+ # Delete this file to remove the site; nothing else here is Levix's.
22
+ server {
23
+ listen 80;
24
+ listen [::]:80;
25
+ server_name ${domain};
26
+
27
+ location / {
28
+ proxy_pass http://127.0.0.1:${port};
29
+
30
+ proxy_http_version 1.1;
31
+
32
+ proxy_set_header Host $host;
33
+ proxy_set_header X-Real-IP $remote_addr;
34
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
35
+ proxy_set_header X-Forwarded-Proto $scheme;
36
+
37
+ # The control panel pushes the pairing QR over socket.io.
38
+ proxy_set_header Upgrade $http_upgrade;
39
+ proxy_set_header Connection "upgrade";
40
+
41
+ # A QR can sit open for a while before it is scanned.
42
+ proxy_read_timeout 300s;
43
+ }
44
+ }
45
+ `;
46
+ }
47
+
48
+ /**
49
+ * Write, validate, and only then reload.
50
+ *
51
+ * @returns {{ ok: boolean, path: string, error?: string, reloaded: boolean }}
52
+ */
53
+ export function applySite(system, { domain, port }) {
54
+ const path = nginxSitePath(domain, system);
55
+ const usesSitesAvailable = path.includes("/sites-available/");
56
+ const enabledPath = usesSitesAvailable
57
+ ? path.replace("/sites-available/", "/sites-enabled/")
58
+ : null;
59
+
60
+ // What was there before, so a failed `nginx -t` can be undone precisely.
61
+ const existed = system.exists(path);
62
+ const previous = existed ? system.readFile(path) : null;
63
+
64
+ const restore = () => {
65
+ if (existed) system.writeFile(path, previous);
66
+ else {
67
+ system.remove(path);
68
+ if (enabledPath) system.remove(enabledPath);
69
+ }
70
+ };
71
+
72
+ system.writeFile(path, renderSite({ domain, port }));
73
+ if (enabledPath) system.symlink(path, enabledPath);
74
+
75
+ const test = system.run("nginx", ["-t"]);
76
+ if (test.status !== 0) {
77
+ restore();
78
+ return {
79
+ ok: false,
80
+ path,
81
+ reloaded: false,
82
+ // nginx's own message names the file and the line. Ours would not.
83
+ error: (test.stderr || test.stdout || "nginx -t failed").trim(),
84
+ };
85
+ }
86
+
87
+ const reload = system.run("systemctl", ["reload", "nginx"]);
88
+ if (reload.status !== 0) {
89
+ // The configuration is valid, so leave it in place — but say plainly that
90
+ // it is not live yet.
91
+ return {
92
+ ok: false,
93
+ path,
94
+ reloaded: false,
95
+ error: `configuration is valid but nginx did not reload: ${(reload.stderr || "").trim()}`,
96
+ };
97
+ }
98
+
99
+ return { ok: true, path, reloaded: true };
100
+ }
101
+
102
+ /**
103
+ * Ask certbot for a certificate, if there isn't already a live one.
104
+ *
105
+ * @returns {{ ok: boolean, skipped?: string, error?: string }}
106
+ */
107
+ export function ensureCertificate(system, { domain, email = null }) {
108
+ if (hasCertificate(system, domain)) {
109
+ return { ok: true, skipped: "a certificate for this domain already exists" };
110
+ }
111
+
112
+ const args = [
113
+ "--nginx",
114
+ "-d",
115
+ domain,
116
+ "--non-interactive",
117
+ "--agree-tos",
118
+ "--redirect",
119
+ ...(email ? ["-m", email] : ["--register-unsafely-without-email"]),
120
+ ];
121
+
122
+ const result = system.run("certbot", args, { timeoutMs: 180000 });
123
+ if (result.status !== 0) {
124
+ return { ok: false, error: (result.stderr || result.stdout || "certbot failed").trim() };
125
+ }
126
+ return { ok: true };
127
+ }
128
+
129
+ /** Is there already a certificate covering this domain? */
130
+ export function hasCertificate(system, domain) {
131
+ if (system.exists(`/etc/letsencrypt/live/${domain}/fullchain.pem`)) return true;
132
+
133
+ // A certificate can live under a different lineage name while still covering
134
+ // the domain; certbot knows.
135
+ const listed = system.run("certbot", ["certificates"]);
136
+ if (listed.status !== 0) return false;
137
+ return new RegExp(`Domains:.*\\b${domain.replace(/\./g, "\\.")}\\b`).test(listed.stdout);
138
+ }
@@ -0,0 +1,148 @@
1
+ // Everything the domain command needs from the machine it's running on,
2
+ // behind one small interface.
3
+ //
4
+ // Two reasons it exists rather than calling child_process directly:
5
+ //
6
+ // 1. this code runs as root on servers with unrelated production sites on
7
+ // them, so every command it would run has to be visible in one place
8
+ // 2. the tests must be able to describe a server — "nginx and certbot, port
9
+ // 443 held by apache" — without that server existing
10
+ //
11
+ // The default implementation is the real one. Tests pass their own.
12
+
13
+ import { spawnSync } from "node:child_process";
14
+ import fs from "node:fs";
15
+ import dns from "node:dns/promises";
16
+
17
+ /**
18
+ * @typedef {object} System
19
+ * @property {(cmd: string, args?: string[], opts?: object) => {status: number, stdout: string, stderr: string}} run
20
+ * @property {(path: string) => boolean} exists
21
+ * @property {(path: string) => string} readFile
22
+ * @property {(path: string, content: string) => void} writeFile
23
+ * @property {(path: string) => void} mkdirp
24
+ * @property {(path: string) => void} remove
25
+ * @property {(from: string, to: string) => void} symlink
26
+ * @property {(command: string) => boolean} which
27
+ * @property {() => Array<{port: number, process: string}>} listeners
28
+ * @property {(hostname: string) => Promise<{v4: string[], v6: string[]}>} resolve
29
+ * @property {() => Promise<{v4: string|null, v6: string|null}>} publicIp
30
+ */
31
+
32
+ export function createSystem(overrides = {}) {
33
+ const real = {
34
+ run(command, args = [], options = {}) {
35
+ const result = spawnSync(command, args, {
36
+ encoding: "utf8",
37
+ timeout: options.timeoutMs ?? 120000,
38
+ ...options,
39
+ });
40
+ return {
41
+ status: result.status ?? 1,
42
+ stdout: result.stdout || "",
43
+ stderr: result.stderr || (result.error ? String(result.error.message) : ""),
44
+ };
45
+ },
46
+
47
+ exists: (path) => fs.existsSync(path),
48
+ readFile: (path) => fs.readFileSync(path, "utf8"),
49
+
50
+ writeFile(path, content) {
51
+ fs.writeFileSync(path, content, "utf8");
52
+ },
53
+
54
+ mkdirp(path) {
55
+ fs.mkdirSync(path, { recursive: true });
56
+ },
57
+
58
+ remove(path) {
59
+ fs.rmSync(path, { force: true });
60
+ },
61
+
62
+ symlink(from, to) {
63
+ try {
64
+ fs.unlinkSync(to);
65
+ } catch {}
66
+ fs.symlinkSync(from, to);
67
+ },
68
+
69
+ which(command) {
70
+ const result = spawnSync("sh", ["-c", `command -v ${command}`], { encoding: "utf8" });
71
+ return (result.status ?? 1) === 0 && Boolean((result.stdout || "").trim());
72
+ },
73
+
74
+ /** What is listening on 80 and 443, and which program owns it. */
75
+ listeners() {
76
+ // `ss` on anything modern; `netstat` for the stragglers. Both need the
77
+ // process column, which needs root — without it we still learn that the
78
+ // port is taken, just not by whom, and the command treats an unknown
79
+ // owner as a reason to stop rather than guess.
80
+ const attempts = [
81
+ ["ss", ["-ltnpH"]],
82
+ ["netstat", ["-ltnp"]],
83
+ ];
84
+
85
+ for (const [command, args] of attempts) {
86
+ const result = spawnSync(command, args, { encoding: "utf8" });
87
+ if ((result.status ?? 1) !== 0 || !result.stdout) continue;
88
+ return parseListeners(result.stdout);
89
+ }
90
+ return [];
91
+ },
92
+
93
+ async resolve(hostname) {
94
+ const out = { v4: [], v6: [] };
95
+ try {
96
+ out.v4 = await dns.resolve4(hostname);
97
+ } catch {}
98
+ try {
99
+ out.v6 = await dns.resolve6(hostname);
100
+ } catch {}
101
+ return out;
102
+ },
103
+
104
+ async publicIp() {
105
+ const out = { v4: null, v6: null };
106
+ // One short call to a plain-text endpoint. If there is no network, or it
107
+ // is slow, we carry on without it: the DNS check just becomes advisory.
108
+ try {
109
+ const controller = new AbortController();
110
+ const timer = setTimeout(() => controller.abort(), 4000);
111
+ const response = await fetch("https://api.ipify.org", { signal: controller.signal });
112
+ clearTimeout(timer);
113
+ if (response.ok) out.v4 = (await response.text()).trim() || null;
114
+ } catch {}
115
+ return out;
116
+ },
117
+ };
118
+
119
+ return { ...real, ...overrides };
120
+ }
121
+
122
+ /** Pull `{ port, process }` out of ss/netstat output. Exported to be tested. */
123
+ export function parseListeners(output) {
124
+ const found = [];
125
+ for (const rawLine of output.split("\n")) {
126
+ const line = rawLine.trim();
127
+ if (!line || /^(Netid|Proto|Active)/i.test(line)) continue;
128
+
129
+ // The local address column is the 4th on ss -ltnpH and netstat -ltnp.
130
+ const columns = line.split(/\s+/);
131
+ const local = columns.find((column) => /:\d+$/.test(column));
132
+ if (!local) continue;
133
+
134
+ const port = Number(local.slice(local.lastIndexOf(":") + 1));
135
+ if (!Number.isFinite(port)) continue;
136
+
137
+ // users:(("nginx",pid=123,fd=6)) | 123/nginx: master
138
+ const owner =
139
+ /users:\(\("([^"]+)"/.exec(line)?.[1] ||
140
+ /\d+\/([\w.-]+)/.exec(line)?.[1] ||
141
+ null;
142
+
143
+ found.push({ port, process: owner });
144
+ }
145
+ return found;
146
+ }
147
+
148
+ export default createSystem;
@@ -0,0 +1,414 @@
1
+ import { readdirSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { createRequire } from "module";
5
+ import {
6
+ checkCommandPermission,
7
+ checkBotAdmin,
8
+ } from "../middleware/permissions.middleware.js";
9
+ import { sendBotMessage } from "../utils/sendBotMessage.esm.js";
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+ const require = createRequire(import.meta.url);
14
+ const logger = require("../utils/logger.cjs");
15
+ const runtimeConfig = require("../config/runtime-config.cjs");
16
+
17
+ // Commands collection (keyed by both names and aliases)
18
+ const commands = new Map();
19
+ // Track aliases separately so help.cjs can group them.
20
+ const aliasIndex = new Map(); // alias -> canonicalName
21
+ // Public reverse view exposed for help command listing
22
+ const commandList = new Map(); // canonicalName -> command module
23
+
24
+ // Commands whose output should stay a SEPARATE message (these answer a
25
+ // question, generate content, or take free-form variables — editing the
26
+ // trigger into the answer would read badly). Everything else gets the
27
+ // "edit the original command message in place" behaviour requested by the
28
+ // operator (e.g. `!group all hello` rewrites the trigger into the announcement
29
+ // instead of posting a second message).
30
+ const EDIT_IN_PLACE_EXCLUDE = new Set([
31
+ "gemini",
32
+ "prayer",
33
+ "tts",
34
+ "stt",
35
+ "weather",
36
+ "qr",
37
+ "status",
38
+ "help",
39
+ "ping",
40
+ "shortlink",
41
+ "score",
42
+ "debt",
43
+ "todo",
44
+ "notes",
45
+ "listschedules",
46
+ "mod",
47
+ "calc",
48
+ "loop",
49
+ "rand",
50
+ "poll",
51
+ ]);
52
+
53
+ // Wrap a socket so commands reply nicely:
54
+ // * `editInPlace` commands: the FIRST plain-text send to the command's own
55
+ // chat edits the original command message (only possible when the message
56
+ // is from us — `fromMe`). Falls back to a normal send if the edit fails or
57
+ // isn't allowed. Subsequent sends behave normally.
58
+ // * everything else: `sock.sendMessage(jid, content)` automatically adds
59
+ // `quoted: msg` so the reply threads under the command.
60
+ // Every other method (groupMetadata, etc.) is forwarded untouched.
61
+ function wrapSockWithReply(sock, originalMsg, editInPlace = false) {
62
+ const chatJid = originalMsg.key.remoteJid;
63
+ let editUsed = false;
64
+
65
+ return new Proxy(sock, {
66
+ get(target, prop, receiver) {
67
+ if (prop === "sendMessage") {
68
+ return async function (jid, content, options) {
69
+ // Try to edit the trigger message in place for the first eligible
70
+ // text reply. We can only edit messages we sent ourselves.
71
+ const eligibleForEdit =
72
+ editInPlace &&
73
+ !editUsed &&
74
+ originalMsg.key.fromMe &&
75
+ jid === chatJid &&
76
+ content &&
77
+ typeof content === "object" &&
78
+ typeof content.text === "string" &&
79
+ !content.edit;
80
+
81
+ if (eligibleForEdit) {
82
+ editUsed = true;
83
+ try {
84
+ return await target.sendMessage.call(target, jid, {
85
+ ...content,
86
+ edit: originalMsg.key,
87
+ });
88
+ } catch {
89
+ // Editing failed (too old, not ours after all, …) — fall through
90
+ // to a normal quoted send so the user still gets a reply.
91
+ }
92
+ }
93
+
94
+ const opts = options ? { ...options } : {};
95
+ if (opts.quoted === undefined) {
96
+ opts.quoted = originalMsg;
97
+ }
98
+ return target.sendMessage.call(target, jid, content, opts);
99
+ };
100
+ }
101
+ const value = Reflect.get(target, prop, receiver);
102
+ return typeof value === "function" ? value.bind(target) : value;
103
+ },
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Point every alias of `command` at it. The effective list is the dashboard
109
+ * override when there is one, otherwise what the command file declared.
110
+ */
111
+ function indexAliases(command) {
112
+ const aliases = runtimeConfig.getAliases(
113
+ command.name,
114
+ command.__declaredAliases || []
115
+ );
116
+
117
+ for (const alias of aliases) {
118
+ const existing = commands.get(alias);
119
+ if (existing && existing.name !== command.name) {
120
+ logger.warn(
121
+ `[Commands] Alias conflict: ${alias} already maps to ${existing.name}, ignoring for ${command.name}`
122
+ );
123
+ continue;
124
+ }
125
+ commands.set(alias, command);
126
+ aliasIndex.set(alias, command.name);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Rebuild the name/alias lookup from the loaded commands. Call this after an
132
+ * alias override changes so the new trigger works on the next message instead
133
+ * of after a restart.
134
+ */
135
+ export function rebuildCommandIndex() {
136
+ commands.clear();
137
+ aliasIndex.clear();
138
+ for (const command of commandList.values()) {
139
+ commands.set(command.name, command);
140
+ }
141
+ for (const command of commandList.values()) {
142
+ indexAliases(command);
143
+ }
144
+ logger.info(
145
+ `[Commands] Index rebuilt: ${commandList.size} commands, ${commands.size} entries`
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Everything the dashboard needs to render (and edit) the command list.
151
+ *
152
+ * `permissionLocked` marks the commands whose level can't be lowered from the
153
+ * UI: they declare `userAdminRequired`, i.e. the code demands a real WhatsApp
154
+ * group admin no matter what the permission table says, so offering a
155
+ * "MEMBERS" switch for them would just be a lie.
156
+ */
157
+ export function getCommandCatalog() {
158
+ const out = [];
159
+
160
+ for (const command of commandList.values()) {
161
+ const isGroupSub = command.__category === "group";
162
+ const key = isGroupSub ? `group:${command.name}` : command.name;
163
+
164
+ out.push({
165
+ name: command.name,
166
+ key,
167
+ category: command.__category || "general",
168
+ description: command.description || "",
169
+ usage: command.usage || null,
170
+ chat: command.chat || "all",
171
+ declaredAliases: command.__declaredAliases || [],
172
+ aliases: runtimeConfig.getAliases(command.name, command.__declaredAliases || []),
173
+ aliasesOverridden: runtimeConfig.hasAliasOverride(command.name),
174
+ permission: runtimeConfig.getPermission(key),
175
+ defaultPermission: runtimeConfig.defaultPermission(key),
176
+ permissionLocked: !!command.userAdminRequired,
177
+ userAdminRequired: !!command.userAdminRequired,
178
+ botAdminRequired: !!command.botAdminRequired,
179
+ enabled: !runtimeConfig.isDisabled(command.name),
180
+ });
181
+ }
182
+
183
+ return out.sort((a, b) => a.name.localeCompare(b.name));
184
+ }
185
+
186
+ // Load all command files (CommonJS .cjs files)
187
+ export async function loadCommands() {
188
+ // A packaged build has no commands/ directory to scan — the build step writes
189
+ // a manifest that requires every command by name instead. When there is no
190
+ // manifest (a normal install) we read the directory, so dropping a new .cjs
191
+ // in there still Just Works.
192
+ const manifest = loadManifest();
193
+
194
+ if (manifest) {
195
+ for (const entry of manifest) {
196
+ registerCommand(entry.module, entry.label, entry.category);
197
+ }
198
+ } else {
199
+ const commandsPath = join(__dirname, "../commands");
200
+ for (const entry of readdirSync(commandsPath, { withFileTypes: true })) {
201
+ if (entry.isDirectory()) {
202
+ // Load commands from subdirectories (e.g., group/)
203
+ const subDirPath = join(commandsPath, entry.name);
204
+ const subFiles = readdirSync(subDirPath).filter((file) =>
205
+ file.endsWith(".cjs")
206
+ );
207
+
208
+ for (const file of subFiles) {
209
+ registerCommandFile(join(subDirPath, file), entry.name);
210
+ }
211
+ } else if (entry.name.endsWith(".cjs")) {
212
+ registerCommandFile(join(commandsPath, entry.name), null);
213
+ }
214
+ }
215
+ }
216
+
217
+ logger.info(
218
+ `[Commands] Loaded ${commandList.size} commands (${commands.size} entries with aliases)`
219
+ );
220
+ }
221
+
222
+ function loadManifest() {
223
+ try {
224
+ const manifest = require("../commands/_manifest.cjs");
225
+ return Array.isArray(manifest) ? manifest : null;
226
+ } catch (error) {
227
+ // MODULE_NOT_FOUND is the normal case: there is no manifest outside a
228
+ // packaged build. Anything else means the manifest exists and is broken,
229
+ // which is worth saying out loud before we fall back.
230
+ if (error?.code !== "MODULE_NOT_FOUND") {
231
+ logger.error({ err: error }, "[Commands] manifest failed to load");
232
+ }
233
+ return null;
234
+ }
235
+ }
236
+
237
+ function registerCommandFile(absPath, category) {
238
+ let command;
239
+ try {
240
+ command = require(absPath);
241
+ } catch (error) {
242
+ logger.error({ err: error, file: absPath }, "[Commands] failed to load");
243
+ return;
244
+ }
245
+ registerCommand(command, absPath, category);
246
+ }
247
+
248
+ function registerCommand(command, label, category) {
249
+ try {
250
+ if (!command?.name) return;
251
+
252
+ command.__category = category || "general";
253
+ // What the FILE declares, kept aside so a dashboard override can be
254
+ // cleared back to it later (command.aliases itself is what we index).
255
+ command.__declaredAliases = Array.isArray(command.aliases)
256
+ ? [...command.aliases]
257
+ : [];
258
+ commands.set(command.name, command);
259
+ commandList.set(command.name, command);
260
+ logger.info(
261
+ `[Commands] Loaded command: ${command.name}${category ? ` (from ${category}/)` : ""}`
262
+ );
263
+
264
+ indexAliases(command);
265
+ } catch (error) {
266
+ logger.error(`[Commands] Failed to load ${label}: ${error.message}`);
267
+ if (error.stack) logger.error(error.stack);
268
+ }
269
+ }
270
+
271
+ export function getLoadedCommands() {
272
+ return commandList;
273
+ }
274
+
275
+ export function getAliasIndex() {
276
+ return aliasIndex;
277
+ }
278
+
279
+ // Handle command execution
280
+ export async function handleCommand(sock, msg, body) {
281
+ const isGroup = msg.key.remoteJid.endsWith("@g.us");
282
+
283
+ const prefix = runtimeConfig.getPrefix();
284
+ if (!body.startsWith(prefix)) return false;
285
+
286
+ const args = body.slice(prefix.length).trim().split(/ +/);
287
+ const invokedName = args.shift().toLowerCase();
288
+ const command = commands.get(invokedName);
289
+ if (!command) return false;
290
+
291
+ // Turned off from the dashboard. Answered rather than ignored so the user
292
+ // knows the command exists and isn't broken.
293
+ if (runtimeConfig.isDisabled(command.name)) {
294
+ await sendBotMessage(
295
+ sock,
296
+ msg.key.remoteJid,
297
+ { text: "⛔ الأمر ده متوقف حاليًا." },
298
+ { replyTo: msg }
299
+ );
300
+ return true;
301
+ }
302
+
303
+ try {
304
+ const groupMetadata = isGroup
305
+ ? await sock.groupMetadata(msg.key.remoteJid).catch(() => null)
306
+ : null;
307
+
308
+ if (command.name !== "group") {
309
+ // Enforce the `chat` constraint declared by the command. Until now this
310
+ // was advisory-only and group-only commands could happily run in DMs.
311
+ const chatScope = (command.chat || "all").toLowerCase();
312
+ if (chatScope === "group" && !isGroup) {
313
+ await sendBotMessage(
314
+ sock,
315
+ msg.key.remoteJid,
316
+ { text: "⚠️ هذا الأمر يعمل في المجموعات فقط." },
317
+ { replyTo: msg }
318
+ );
319
+ return true;
320
+ }
321
+ if (chatScope === "private" && isGroup) {
322
+ await sendBotMessage(
323
+ sock,
324
+ msg.key.remoteJid,
325
+ { text: "⚠️ هذا الأمر يعمل في المحادثات الخاصة فقط." },
326
+ { replyTo: msg }
327
+ );
328
+ return true;
329
+ }
330
+
331
+ // Group sub-commands are keyed "group:<name>" everywhere — defaults.cjs,
332
+ // the dashboard, and `!group kick`. Use the same key here so running
333
+ // `!kick` directly is gated exactly like running `!group kick`.
334
+ const permissionKey =
335
+ command.__category === "group" ? `group:${command.name}` : command.name;
336
+
337
+ const permissionCheck = checkCommandPermission(
338
+ permissionKey,
339
+ msg,
340
+ groupMetadata,
341
+ sock
342
+ );
343
+
344
+ if (!permissionCheck.hasPermission) {
345
+ await sendBotMessage(
346
+ sock,
347
+ msg.key.remoteJid,
348
+ { text: permissionCheck.reason },
349
+ { replyTo: msg }
350
+ );
351
+ return true;
352
+ }
353
+
354
+ if (command.userAdminRequired && !permissionCheck.isSenderAdmin) {
355
+ await sendBotMessage(
356
+ sock,
357
+ msg.key.remoteJid,
358
+ { text: "⚠️ هذا الأمر يتطلب أن تكون مشرفًا." },
359
+ { replyTo: msg }
360
+ );
361
+ return true;
362
+ }
363
+
364
+ if (command.botAdminRequired && !checkBotAdmin(groupMetadata, sock)) {
365
+ await sendBotMessage(
366
+ sock,
367
+ msg.key.remoteJid,
368
+ { text: "⚠️ يجب أن أكون مشرفًا لتنفيذ هذا الأمر." },
369
+ { replyTo: msg }
370
+ );
371
+ return true;
372
+ }
373
+ }
374
+
375
+ // Pass the invoked name + canonical name so commands like gemini can
376
+ // detect alias-driven sub-commands (`!del`, `!resetai`, `!generate`).
377
+ const ctx = {
378
+ invokedName,
379
+ canonicalName: command.name,
380
+ isAlias: invokedName !== command.name,
381
+ groupMetadata,
382
+ };
383
+
384
+ // Wrap the socket so legacy commands that still call sock.sendMessage
385
+ // directly automatically reply-quote the original command message. This
386
+ // matches the operator's "every bot message should reply to the command"
387
+ // requirement without having to migrate every single .cjs file at once.
388
+ // Action/confirmation commands additionally edit the trigger message in
389
+ // place instead of posting a second message (see EDIT_IN_PLACE_EXCLUDE).
390
+ const editInPlace = !EDIT_IN_PLACE_EXCLUDE.has(command.name);
391
+ const proxiedSock = wrapSockWithReply(sock, msg, editInPlace);
392
+ await command.execute(proxiedSock, msg, args, body, groupMetadata, ctx);
393
+ return true;
394
+ } catch (error) {
395
+ logger.error(
396
+ { err: error, command: invokedName },
397
+ "Error executing command"
398
+ );
399
+ // Surface the error name to the user instead of silently re-throwing.
400
+ try {
401
+ await sendBotMessage(
402
+ sock,
403
+ msg.key.remoteJid,
404
+ {
405
+ text: `❌ *فشل تنفيذ الأمر*\n\n*النوع:* \`${error.name || "Error"}\`\n*التفاصيل:* ${
406
+ error.message || "غير معروف"
407
+ }`,
408
+ },
409
+ { replyTo: msg }
410
+ );
411
+ } catch {}
412
+ throw error;
413
+ }
414
+ }