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.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/SETUP.md +242 -0
- package/app.cjs +337 -0
- package/bin/levix.js +7 -0
- package/deploy/install.sh +182 -0
- package/deploy/levix.service +56 -0
- package/deploy/nginx/levix.leviro.net.conf +79 -0
- package/package.json +78 -0
- package/public/brand/apple-touch-icon.png +0 -0
- package/public/brand/banner.jpg +0 -0
- package/public/brand/banner.webp +0 -0
- package/public/brand/icon-16.png +0 -0
- package/public/brand/icon-192.png +0 -0
- package/public/brand/icon-32.png +0 -0
- package/public/brand/icon-512.png +0 -0
- package/public/brand/icon-512.webp +0 -0
- package/public/brand/mark.png +0 -0
- package/public/brand/mark.svg +16 -0
- package/public/brand/mark.webp +0 -0
- package/public/brand/wordmark.png +0 -0
- package/public/brand/wordmark.webp +0 -0
- package/public/dashboard.css +881 -0
- package/public/dashboard.js +1057 -0
- package/public/logo.svg +16 -0
- package/public/qrcode.min.js +1 -0
- package/public/socket.io.min.js +7 -0
- package/scheduler.cjs +197 -0
- package/src/auth/auth-storage.cjs +74 -0
- package/src/auth/use-database-auth-state.js +130 -0
- package/src/bootstrap/core.js +74 -0
- package/src/bootstrap/events.cjs +43 -0
- package/src/bootstrap/panel.js +97 -0
- package/src/cli.js +153 -0
- package/src/commands/autoschedule.cjs +84 -0
- package/src/commands/blacklist.cjs +116 -0
- package/src/commands/block.cjs +70 -0
- package/src/commands/calc.cjs +387 -0
- package/src/commands/debt.cjs +175 -0
- package/src/commands/deleteschedule.cjs +36 -0
- package/src/commands/gemini.cjs +727 -0
- package/src/commands/group/add.cjs +136 -0
- package/src/commands/group/all.cjs +43 -0
- package/src/commands/group/antiSpam.cjs +96 -0
- package/src/commands/group/antilink.cjs +167 -0
- package/src/commands/group/approveall.cjs +54 -0
- package/src/commands/group/clearwarns.cjs +41 -0
- package/src/commands/group/deletenote.cjs +33 -0
- package/src/commands/group/demote.cjs +62 -0
- package/src/commands/group/kick.cjs +85 -0
- package/src/commands/group/media.cjs +120 -0
- package/src/commands/group/members.cjs +59 -0
- package/src/commands/group/note.cjs +32 -0
- package/src/commands/group/promote.cjs +60 -0
- package/src/commands/group/removeall.cjs +42 -0
- package/src/commands/group/rules.cjs +31 -0
- package/src/commands/group/save.cjs +36 -0
- package/src/commands/group/setname.cjs +41 -0
- package/src/commands/group/setpp.cjs +51 -0
- package/src/commands/group/setrules.cjs +46 -0
- package/src/commands/group/setwarn.cjs +58 -0
- package/src/commands/group/tagadmins.cjs +50 -0
- package/src/commands/group/warn.cjs +105 -0
- package/src/commands/group/warns.cjs +66 -0
- package/src/commands/group/welcome.cjs +109 -0
- package/src/commands/group.cjs +160 -0
- package/src/commands/help.cjs +230 -0
- package/src/commands/listschedules.cjs +47 -0
- package/src/commands/loop.cjs +163 -0
- package/src/commands/memory.cjs +201 -0
- package/src/commands/mod.cjs +155 -0
- package/src/commands/notes.cjs +28 -0
- package/src/commands/perm.cjs +236 -0
- package/src/commands/ping.cjs +17 -0
- package/src/commands/poll.cjs +80 -0
- package/src/commands/prayer.cjs +64 -0
- package/src/commands/qr.cjs +39 -0
- package/src/commands/rand.cjs +130 -0
- package/src/commands/restart.cjs +25 -0
- package/src/commands/schedule.cjs +86 -0
- package/src/commands/score.cjs +130 -0
- package/src/commands/setprefix.cjs +40 -0
- package/src/commands/shortlink.cjs +74 -0
- package/src/commands/shutdown.cjs +45 -0
- package/src/commands/status.cjs +75 -0
- package/src/commands/stt.cjs +150 -0
- package/src/commands/todo.cjs +88 -0
- package/src/commands/tts.cjs +170 -0
- package/src/commands/unblock.cjs +57 -0
- package/src/commands/weather.cjs +79 -0
- package/src/config/ai-persona.md +17 -0
- package/src/config/baileys.config.js +51 -0
- package/src/config/brand.cjs +36 -0
- package/src/config/brand.esm.js +17 -0
- package/src/config/constants.js +16 -0
- package/src/config/defaults.cjs +91 -0
- package/src/config/lock.cjs +110 -0
- package/src/config/paths.cjs +90 -0
- package/src/config/runtime-config.cjs +219 -0
- package/src/config/secrets.cjs +130 -0
- package/src/config/settings.cjs +386 -0
- package/src/core/connection.js +199 -0
- package/src/core/events.js +61 -0
- package/src/core/socket.js +64 -0
- package/src/db/db.cjs +282 -0
- package/src/db/store.cjs +837 -0
- package/src/db/store.esm.js +17 -0
- package/src/domain/caddy.js +120 -0
- package/src/domain/command.js +380 -0
- package/src/domain/detect.js +173 -0
- package/src/domain/nginx.js +138 -0
- package/src/domain/system.js +148 -0
- package/src/handlers/command.handler.js +414 -0
- package/src/handlers/group.handler.js +73 -0
- package/src/handlers/message.handler.js +149 -0
- package/src/index.js +217 -0
- package/src/middleware/antispam.middleware.js +68 -0
- package/src/middleware/blacklist.middleware.js +29 -0
- package/src/middleware/forward-tracking.middleware.js +49 -0
- package/src/middleware/permissions.middleware.js +114 -0
- package/src/routes/dashboard.api.esm.js +779 -0
- package/src/services/aiAgent.cjs +317 -0
- package/src/services/aiTools.cjs +635 -0
- package/src/utils/datetime.cjs +61 -0
- package/src/utils/lid-helper.esm.js +143 -0
- package/src/utils/logger.cjs +72 -0
- package/src/utils/memory.cjs +385 -0
- package/src/utils/normalizeJid.cjs +65 -0
- package/src/utils/normalizeJid.esm.js +37 -0
- package/src/utils/openBrowser.cjs +129 -0
- package/src/utils/permissions.cjs +154 -0
- package/src/utils/permissions.esm.js +375 -0
- package/src/utils/recentMessageCache.esm.js +70 -0
- package/src/utils/requestOrigin.cjs +102 -0
- package/src/utils/sendBotMessage.cjs +43 -0
- package/src/utils/sendBotMessage.esm.js +139 -0
- package/src/utils/statusMessage.cjs +179 -0
- package/src/utils/storage-hub.cjs +49 -0
- package/src/utils/storage-hub.esm.js +24 -0
- package/src/utils/storage.cjs +80 -0
- package/src/utils/storage.esm.js +88 -0
- package/src/utils/textDecode.cjs +197 -0
- package/src/utils/thumbnail.cjs +591 -0
- package/src/utils/typing.esm.js +102 -0
- package/views/dashboard.ejs +329 -0
- package/views/login.ejs +105 -0
- package/views/qr.ejs +108 -0
- 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
|
+
}
|