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,91 @@
1
+ // Shipped defaults for the command table.
2
+ //
3
+ // This used to be config.json. It is JavaScript now for one reason: the
4
+ // database is the source of truth and the dashboard is how you change things,
5
+ // so a JSON file sitting next to the code invited the opposite — editing a
6
+ // file the operator would then lose on the next `git pull`.
7
+ //
8
+ // Nothing here is ever written to. Whatever the operator changes from the
9
+ // dashboard is stored as an override in `bot_settings` and layered on top by
10
+ // src/config/runtime-config.cjs, which means:
11
+ //
12
+ // * an update that adds a new command still gets its default permission,
13
+ // * nothing has to rewrite a file that is checked into the repo,
14
+ // * a change is live on the very next message.
15
+ //
16
+ // Group sub-commands are addressed as "group:<name>" everywhere else; the
17
+ // nesting here is only so the group entry can carry a default_permission.
18
+
19
+ const PREFIX = "!";
20
+
21
+ const COMMAND_PERMISSIONS = Object.freeze(
22
+ {
23
+ "ping": "MEMBERS",
24
+ "gemini": "MEMBERS",
25
+ "qr": "MEMBERS",
26
+ "shortlink": "MEMBERS",
27
+ "schedule": "ADMINS_OWNER",
28
+ "weather": "MEMBERS",
29
+ "prayer": "MEMBERS",
30
+ "tagadmins": "MEMBERS",
31
+ "help": "MEMBERS",
32
+ "score": "MEMBERS",
33
+ "stt": "MEMBERS",
34
+ "tts": "MEMBERS",
35
+ "todo": "MEMBERS",
36
+ "notes": "MEMBERS",
37
+ "debt": "MEMBERS",
38
+ "calc": "MEMBERS",
39
+ "loop": "MEMBERS",
40
+ "rand": "MEMBERS",
41
+ "poll": "MEMBERS",
42
+ "memory": "MEMBERS",
43
+ "mod": "ADMINS_OWNER",
44
+ "blacklist": "ADMINS_OWNER",
45
+ "listschedules": "ADMINS_OWNER",
46
+ "deleteschedule": "ADMINS_OWNER",
47
+ "autoschedule": "OWNER_ONLY",
48
+ "perm": "OWNER_ONLY",
49
+ "setprefix": "OWNER_ONLY",
50
+ "restart": "OWNER_ONLY",
51
+ "shutdown": "OWNER_ONLY",
52
+ "status": "OWNER_ONLY",
53
+ "block": "OWNER_ONLY",
54
+ "unblock": "OWNER_ONLY",
55
+ "group": {
56
+ "default_permission": "ADMINS_OWNER",
57
+ "sub_commands": {
58
+ "approveall": "ADMINS_OWNER",
59
+ "setname": "ADMINS_OWNER",
60
+ "setpp": "ADMINS_OWNER",
61
+ "kick": "ADMINS_OWNER",
62
+ "add": "ADMINS_OWNER",
63
+ "promote": "ADMINS_OWNER",
64
+ "demote": "ADMINS_OWNER",
65
+ "removeall": "OWNER_ONLY",
66
+ "setrules": "ADMINS_ONLY",
67
+ "antilink": "ADMINS_ONLY",
68
+ "media": "ADMINS_ONLY",
69
+ "setwarn": "ADMINS_ONLY",
70
+ "warn": "ADMINS_ONLY",
71
+ "warns": "ADMINS_OWNER",
72
+ "clearwarns": "ADMINS_ONLY",
73
+ "rules": "MEMBERS",
74
+ "notes": "MEMBERS",
75
+ "members": "MEMBERS",
76
+ "tagadmins": "MEMBERS",
77
+ "welcome": "ADMINS_OWNER",
78
+ "blacklist": "ADMINS_OWNER",
79
+ "save": "ADMINS_OWNER",
80
+ "deletenote": "ADMINS_OWNER",
81
+ "remove": "ADMINS_ONLY",
82
+ "all": "MEMBERS"
83
+ }
84
+ }
85
+ }
86
+ );
87
+
88
+ module.exports = {
89
+ prefix: PREFIX,
90
+ command_permissions: COMMAND_PERMISSIONS,
91
+ };
@@ -0,0 +1,110 @@
1
+ // One bot process per data directory.
2
+ //
3
+ // A WhatsApp pairing is a single session. Two Levix processes on one database
4
+ // would both hold the same Baileys credentials, overwrite each other's session
5
+ // keys, and answer every message twice — and the second one would only fail
6
+ // much later, when it tried to bind the panel's port, long after it had
7
+ // touched the auth rows.
8
+ //
9
+ // So the bot claims the data directory before it does anything else. This is a
10
+ // lock file with a pid in it, which is the simplest thing that works on a
11
+ // single machine — which is the only place a WhatsApp pairing can run anyway.
12
+ // No daemon, no distributed anything.
13
+ //
14
+ // The CLI's read-only and recovery commands (`where`, `reset-password`) do NOT
15
+ // take this lock: they have to work while the bot is running, or when it is
16
+ // wedged, which is the whole point of having them.
17
+
18
+ const fs = require("node:fs");
19
+
20
+ const { dataPath } = require("./paths.cjs");
21
+ const logger = require("../utils/logger.cjs");
22
+
23
+ const LOCK_FILE = dataPath("levix.lock");
24
+
25
+ /** Is that pid a process that still exists? */
26
+ function processAlive(pid) {
27
+ if (!Number.isInteger(pid) || pid <= 0) return false;
28
+ try {
29
+ // Signal 0 checks for existence without delivering anything.
30
+ process.kill(pid, 0);
31
+ return true;
32
+ } catch (err) {
33
+ // EPERM means it exists and belongs to somebody else — still alive.
34
+ return err.code === "EPERM";
35
+ }
36
+ }
37
+
38
+ function readLock() {
39
+ try {
40
+ const raw = fs.readFileSync(LOCK_FILE, "utf8");
41
+ const pid = Number.parseInt(String(raw).trim(), 10);
42
+ return Number.isFinite(pid) ? pid : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ let held = false;
49
+
50
+ function release() {
51
+ if (!held) return;
52
+ held = false;
53
+ try {
54
+ // Only remove it if it is still ours — a stale-lock takeover elsewhere
55
+ // could have replaced it.
56
+ if (readLock() === process.pid) fs.unlinkSync(LOCK_FILE);
57
+ } catch {}
58
+ }
59
+
60
+ /**
61
+ * Claim the data directory for this process.
62
+ *
63
+ * @throws {Error} when another live Levix already holds it. The message is
64
+ * written for whoever is reading the terminal, not for a stack trace.
65
+ */
66
+ function acquireSingleInstanceLock() {
67
+ if (held) return LOCK_FILE;
68
+
69
+ const claim = () => {
70
+ // "wx" fails if the file exists — the check and the create are one
71
+ // syscall, so two processes starting together can't both win.
72
+ const handle = fs.openSync(LOCK_FILE, "wx");
73
+ fs.writeFileSync(handle, String(process.pid));
74
+ fs.closeSync(handle);
75
+ };
76
+
77
+ try {
78
+ claim();
79
+ } catch (err) {
80
+ if (err.code !== "EEXIST") throw err;
81
+
82
+ const owner = readLock();
83
+ if (owner !== null && owner !== process.pid && processAlive(owner)) {
84
+ const error = new Error(
85
+ `Another Levix (pid ${owner}) is already using this data directory.\n` +
86
+ ` ${LOCK_FILE}\n\n` +
87
+ ` Only one Levix can run per data directory — a WhatsApp pairing is a\n` +
88
+ ` single session. Stop the other one, or start this one with --data\n` +
89
+ ` pointing somewhere else.`
90
+ );
91
+ error.code = "ELEVIXLOCKED";
92
+ throw error;
93
+ }
94
+
95
+ // The holder is gone (a crash, a kill -9, a reboot). Take it over.
96
+ logger.warn(
97
+ `[lock] Removing a stale lock from pid ${owner ?? "unknown"} — that process is gone`
98
+ );
99
+ fs.rmSync(LOCK_FILE, { force: true });
100
+ claim();
101
+ }
102
+
103
+ held = true;
104
+ // Best effort: a SIGKILL or a power cut leaves the file behind, which the
105
+ // stale check above handles on the next start.
106
+ process.once("exit", release);
107
+ return LOCK_FILE;
108
+ }
109
+
110
+ module.exports = { acquireSingleInstanceLock, release, LOCK_FILE, processAlive };
@@ -0,0 +1,90 @@
1
+ // The one directory the bot writes to.
2
+ //
3
+ // Database, long-term memory, the editable AI persona, logs, temp media —
4
+ // all of it lands under a single directory, so "back up the bot" means copying
5
+ // one folder and a packaged or globally installed copy never writes next to
6
+ // its own code.
7
+ //
8
+ // Resolution order:
9
+ //
10
+ // 1. `--data <dir>` on the command line
11
+ // 2. LEVIX_DATA_DIR — how a container is told where its volume is. It is the
12
+ // one environment variable the bot reads, because a Docker volume has no
13
+ // other way to say it.
14
+ // 3. `<repo>/data` when running from a clone (writable, not inside
15
+ // node_modules) — keeps a developer's data next to the code
16
+ // 4. `~/.levix` — global installs, packaged binaries, read-only images
17
+ //
18
+ // This module deliberately has no dependencies: the logger and the database
19
+ // both need it, and neither can wait for the other.
20
+
21
+ const fs = require("node:fs");
22
+ const os = require("node:os");
23
+ const path = require("node:path");
24
+
25
+ const ROOT = path.join(__dirname, "..", "..");
26
+
27
+ function argDataDir() {
28
+ const index = process.argv.indexOf("--data");
29
+ if (index !== -1 && process.argv[index + 1]) return process.argv[index + 1];
30
+ const inline = process.argv.find((arg) => arg.startsWith("--data="));
31
+ return inline ? inline.slice("--data=".length) : null;
32
+ }
33
+
34
+ function isWritable(dir) {
35
+ try {
36
+ fs.accessSync(dir, fs.constants.W_OK);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function resolveDataDir() {
44
+ const explicit = argDataDir() || process.env.LEVIX_DATA_DIR;
45
+ if (explicit) return path.resolve(explicit);
46
+
47
+ const installed = ROOT.includes(`${path.sep}node_modules${path.sep}`);
48
+ if (!installed && isWritable(ROOT)) return path.join(ROOT, "data");
49
+
50
+ return path.join(os.homedir(), ".levix");
51
+ }
52
+
53
+ const DATA_DIR = resolveDataDir();
54
+ fs.mkdirSync(DATA_DIR, { recursive: true });
55
+
56
+ // Where the read-only files that ship with the bot live: views/, public/, the
57
+ // persona template. Normally that is the package itself. A packaged build
58
+ // (a single executable has no package directory) unpacks them somewhere the
59
+ // filesystem can see and points this at that copy instead.
60
+ let assetRoot = ROOT;
61
+
62
+ function setAssetRoot(dir) {
63
+ assetRoot = dir;
64
+ }
65
+
66
+ /** A path to a file that ships with the bot and is never written to. */
67
+ function assetPath(...parts) {
68
+ return path.join(assetRoot, ...parts);
69
+ }
70
+
71
+ /** A path inside the data directory. Creates nothing — callers do that. */
72
+ function dataPath(...parts) {
73
+ return path.join(DATA_DIR, ...parts);
74
+ }
75
+
76
+ /** Same, but the directory is created first. */
77
+ function ensureDataDir(...parts) {
78
+ const dir = dataPath(...parts);
79
+ fs.mkdirSync(dir, { recursive: true });
80
+ return dir;
81
+ }
82
+
83
+ module.exports = {
84
+ DATA_DIR,
85
+ PACKAGE_ROOT: ROOT,
86
+ dataPath,
87
+ ensureDataDir,
88
+ assetPath,
89
+ setAssetRoot,
90
+ };
@@ -0,0 +1,219 @@
1
+ // The command table, changeable at runtime.
2
+ //
3
+ // src/config/defaults.cjs holds the DEFAULTS that ship with the bot. Whatever
4
+ // the operator changes from the dashboard is stored as an override in the
5
+ // `bot_settings` table and layered on top, so:
6
+ //
7
+ // * an update that adds a new command still gets its default permission,
8
+ // * nothing has to rewrite a file that is checked into the repo,
9
+ // * a change is live on the very next message.
10
+ //
11
+ // Override keys in bot_settings:
12
+ // prefix string
13
+ // command_permissions { "<command>": "<LEVEL>", "group:<sub>": "<LEVEL>" }
14
+ // command_aliases { "<command>": ["alias", ...] }
15
+ // disabled_commands ["<command>", ...]
16
+
17
+ const defaults = require("./defaults.cjs");
18
+ const logger = require("../utils/logger.cjs");
19
+
20
+ const PERMISSION_LEVELS = Object.freeze([
21
+ "MEMBERS",
22
+ "ADMINS_ONLY",
23
+ "ADMINS_OWNER",
24
+ "OWNER_ONLY",
25
+ ]);
26
+
27
+ const KEY_PERMISSIONS = "command_permissions";
28
+ const KEY_ALIASES = "command_aliases";
29
+ const KEY_DISABLED = "disabled_commands";
30
+ const KEY_PREFIX = "prefix";
31
+
32
+ // Lazy require: runtime-config is pulled in by modules that load very early,
33
+ // and this keeps the database open exactly once, from db.cjs.
34
+ function store() {
35
+ return require("../utils/storage.cjs");
36
+ }
37
+
38
+ // A read that somehow lands before the database is open falls back to the
39
+ // shipped defaults instead of throwing — the alternative is a boot-order crash
40
+ // for a value we have a perfectly good default for.
41
+ function readSetting(key, fallback) {
42
+ try {
43
+ const value = store().getBotSetting(key, undefined);
44
+ return value === undefined || value === null ? fallback : value;
45
+ } catch {
46
+ return fallback;
47
+ }
48
+ }
49
+
50
+ function writeSetting(key, value) {
51
+ store().saveBotSetting(key, value);
52
+ }
53
+
54
+ function isPlainObject(value) {
55
+ return !!value && typeof value === "object" && !Array.isArray(value);
56
+ }
57
+
58
+ // --- prefix ---------------------------------------------------------------
59
+
60
+ function getPrefix() {
61
+ const value = readSetting(KEY_PREFIX, defaults.prefix || "!");
62
+ return typeof value === "string" && value.trim() ? value : "!";
63
+ }
64
+
65
+ /** @throws {Error} when the prefix is empty, too long, or has whitespace. */
66
+ function setPrefix(value) {
67
+ const prefix = String(value ?? "").trim();
68
+ if (!prefix) throw new Error("Prefix can't be empty");
69
+ if (prefix.length > 3) throw new Error("Prefix can't be longer than 3 characters");
70
+ if (/\s/.test(prefix)) throw new Error("Prefix can't contain whitespace");
71
+ writeSetting(KEY_PREFIX, prefix);
72
+ logger.info(`[config] prefix -> ${prefix}`);
73
+ return prefix;
74
+ }
75
+
76
+ // --- permissions ----------------------------------------------------------
77
+
78
+ // Sub-commands of `group` are addressed as "group:kick" so one flat map can
79
+ // hold both levels without nesting.
80
+ function defaultPermission(commandKey) {
81
+ const [head, sub] = String(commandKey).split(":");
82
+ const table = defaults.command_permissions || {};
83
+
84
+ if (sub) {
85
+ const groupEntry = table[head];
86
+ if (isPlainObject(groupEntry)) {
87
+ return (
88
+ groupEntry.sub_commands?.[sub] ||
89
+ groupEntry.default_permission ||
90
+ "MEMBERS"
91
+ );
92
+ }
93
+ return "MEMBERS";
94
+ }
95
+
96
+ const entry = table[head];
97
+ if (typeof entry === "string") return entry;
98
+ if (isPlainObject(entry)) return entry.default_permission || "MEMBERS";
99
+ return "MEMBERS";
100
+ }
101
+
102
+ function permissionOverrides() {
103
+ const raw = readSetting(KEY_PERMISSIONS, {});
104
+ return isPlainObject(raw) ? raw : {};
105
+ }
106
+
107
+ function getPermission(commandKey) {
108
+ const override = permissionOverrides()[commandKey];
109
+ if (typeof override === "string" && PERMISSION_LEVELS.includes(override)) {
110
+ return override;
111
+ }
112
+ return defaultPermission(commandKey);
113
+ }
114
+
115
+ /**
116
+ * Set (or clear, with `level === null`) the permission of one command.
117
+ * @throws {Error} on an unknown level.
118
+ */
119
+ function setPermission(commandKey, level) {
120
+ const overrides = { ...permissionOverrides() };
121
+
122
+ if (level === null || level === undefined || level === "") {
123
+ delete overrides[commandKey];
124
+ } else {
125
+ if (!PERMISSION_LEVELS.includes(level)) {
126
+ throw new Error(`Unknown permission level: ${level}`);
127
+ }
128
+ // Storing a value identical to the default would pin the command to
129
+ // today's default forever; drop it instead so it keeps following defaults.cjs.
130
+ if (level === defaultPermission(commandKey)) delete overrides[commandKey];
131
+ else overrides[commandKey] = level;
132
+ }
133
+
134
+ writeSetting(KEY_PERMISSIONS, overrides);
135
+ logger.info(`[config] permission ${commandKey} -> ${level || "(default)"}`);
136
+ return getPermission(commandKey);
137
+ }
138
+
139
+ // --- aliases --------------------------------------------------------------
140
+
141
+ function aliasOverrides() {
142
+ const raw = readSetting(KEY_ALIASES, {});
143
+ return isPlainObject(raw) ? raw : {};
144
+ }
145
+
146
+ /** The effective aliases: the override if there is one, else what the command file declares. */
147
+ function getAliases(commandName, declared = []) {
148
+ const override = aliasOverrides()[commandName];
149
+ return Array.isArray(override) ? override : declared;
150
+ }
151
+
152
+ function hasAliasOverride(commandName) {
153
+ return Array.isArray(aliasOverrides()[commandName]);
154
+ }
155
+
156
+ /**
157
+ * Replace a command's aliases. `null` restores whatever the command file declares.
158
+ * @throws {Error} on a malformed alias.
159
+ */
160
+ function setAliases(commandName, aliases) {
161
+ const overrides = { ...aliasOverrides() };
162
+
163
+ if (aliases === null || aliases === undefined) {
164
+ delete overrides[commandName];
165
+ } else {
166
+ if (!Array.isArray(aliases)) throw new Error("Aliases must be a list");
167
+ const cleaned = [];
168
+ for (const raw of aliases) {
169
+ const alias = String(raw ?? "").trim().toLowerCase();
170
+ if (!alias) continue;
171
+ if (/\s/.test(alias)) throw new Error(`Alias "${alias}" can't contain spaces`);
172
+ if (alias.length > 20) throw new Error(`Alias "${alias}" is too long`);
173
+ if (!cleaned.includes(alias)) cleaned.push(alias);
174
+ }
175
+ if (cleaned.length > 10) throw new Error("At most 10 aliases per command");
176
+ overrides[commandName] = cleaned;
177
+ }
178
+
179
+ writeSetting(KEY_ALIASES, overrides);
180
+ logger.info(`[config] aliases ${commandName} -> ${JSON.stringify(overrides[commandName] ?? "(default)")}`);
181
+ return getAliases(commandName);
182
+ }
183
+
184
+ // --- enable / disable -----------------------------------------------------
185
+
186
+ function disabledCommands() {
187
+ const raw = readSetting(KEY_DISABLED, []);
188
+ return Array.isArray(raw) ? raw.map(String) : [];
189
+ }
190
+
191
+ function isDisabled(commandName) {
192
+ return disabledCommands().includes(commandName);
193
+ }
194
+
195
+ function setEnabled(commandName, enabled) {
196
+ const current = new Set(disabledCommands());
197
+ if (enabled) current.delete(commandName);
198
+ else current.add(commandName);
199
+ writeSetting(KEY_DISABLED, [...current]);
200
+ logger.info(`[config] command ${commandName} -> ${enabled ? "enabled" : "disabled"}`);
201
+ return !current.has(commandName);
202
+ }
203
+
204
+ module.exports = {
205
+ PERMISSION_LEVELS,
206
+ getPrefix,
207
+ setPrefix,
208
+ defaultPermission,
209
+ permissionOverrides,
210
+ getPermission,
211
+ setPermission,
212
+ aliasOverrides,
213
+ getAliases,
214
+ hasAliasOverride,
215
+ setAliases,
216
+ disabledCommands,
217
+ isDisabled,
218
+ setEnabled,
219
+ };
@@ -0,0 +1,130 @@
1
+ // Secrets, generated rather than configured.
2
+ //
3
+ // There is no .env file. Anything that used to be a secret an operator had to
4
+ // invent and paste — the session signing key, the dashboard password — is
5
+ // either generated here on first start or set once from the browser.
6
+ //
7
+ // What is stored, and how:
8
+ //
9
+ // secret:session the express-session signing key. 48 random bytes, made on
10
+ // first start and reused forever after. Nobody ever sees it.
11
+ // auth:password the dashboard password as an scrypt hash + salt. The
12
+ // password itself is never written anywhere.
13
+ //
14
+ // Both live in `bot_settings`, i.e. inside the same SQLite file as the rest of
15
+ // the bot's state — so a backup of that one file is a backup of the login too.
16
+
17
+ const crypto = require("node:crypto");
18
+
19
+ const store = require("../db/store.cjs");
20
+ const logger = require("../utils/logger.cjs");
21
+
22
+ const SESSION_KEY = "secret:session";
23
+ const PASSWORD_KEY = "auth:password";
24
+
25
+ const MIN_PASSWORD_LENGTH = 8;
26
+ const SCRYPT_KEYLEN = 64;
27
+
28
+ // ===================================================================
29
+ // --- Session signing key ---
30
+ // ===================================================================
31
+
32
+ /**
33
+ * The express-session secret. Generated on first call and persisted, so
34
+ * sessions survive a restart but nothing has to be configured by hand.
35
+ */
36
+ function getSessionSecret() {
37
+ const existing = store.getBotSetting(SESSION_KEY, null);
38
+ if (typeof existing === "string" && existing.length >= 32) return existing;
39
+
40
+ const generated = crypto.randomBytes(48).toString("hex");
41
+ store.saveBotSetting(SESSION_KEY, generated);
42
+ logger.info("[secrets] Generated a new session signing key");
43
+ return generated;
44
+ }
45
+
46
+ // ===================================================================
47
+ // --- Dashboard password ---
48
+ // ===================================================================
49
+
50
+ function hashPassword(plain, salt = crypto.randomBytes(16)) {
51
+ const hash = crypto.scryptSync(String(plain), salt, SCRYPT_KEYLEN);
52
+ return { salt: salt.toString("hex"), hash: hash.toString("hex") };
53
+ }
54
+
55
+ function hasDashboardPassword() {
56
+ const record = store.getBotSetting(PASSWORD_KEY, null);
57
+ return !!(record && record.hash && record.salt);
58
+ }
59
+
60
+ /**
61
+ * @throws {Error} when the password is too short — the dashboard holds a full
62
+ * WhatsApp account, so this is not a place for a 3-character password.
63
+ */
64
+ function setDashboardPassword(plain) {
65
+ const password = String(plain ?? "");
66
+ if (password.length < MIN_PASSWORD_LENGTH) {
67
+ throw new Error(
68
+ `Password must be at least ${MIN_PASSWORD_LENGTH} characters`
69
+ );
70
+ }
71
+
72
+ const { salt, hash } = hashPassword(password);
73
+ store.saveBotSetting(PASSWORD_KEY, { alg: "scrypt", salt, hash, updatedAt: Date.now() });
74
+ logger.info("[secrets] Dashboard password updated");
75
+ }
76
+
77
+ /** Constant-time check. False (never a throw) when no password is set yet. */
78
+ function verifyDashboardPassword(candidate) {
79
+ const record = store.getBotSetting(PASSWORD_KEY, null);
80
+ if (!record?.hash || !record?.salt) return false;
81
+ if (typeof candidate !== "string") return false;
82
+
83
+ const expected = Buffer.from(record.hash, "hex");
84
+ const actual = crypto.scryptSync(
85
+ candidate,
86
+ Buffer.from(record.salt, "hex"),
87
+ expected.length
88
+ );
89
+ return crypto.timingSafeEqual(expected, actual);
90
+ }
91
+
92
+ // ===================================================================
93
+ // --- First-run setup code ---
94
+ // ===================================================================
95
+ //
96
+ // Until a password exists the dashboard shows a "choose your password" page.
97
+ // From localhost that is all it asks — whoever is at the machine is the
98
+ // operator. From anywhere else it also asks for this code, which is printed in
99
+ // the terminal at startup, so a bot that comes up on a public IP can't be
100
+ // claimed by the first stranger who finds the port.
101
+ //
102
+ // It is generated per process and never stored: restart the bot and you get a
103
+ // new one.
104
+
105
+ let setupCode = null;
106
+
107
+ function getSetupCode() {
108
+ if (!setupCode) {
109
+ setupCode = crypto.randomBytes(4).toString("hex").toUpperCase();
110
+ }
111
+ return setupCode;
112
+ }
113
+
114
+ function setupCodeMatches(candidate) {
115
+ if (typeof candidate !== "string") return false;
116
+ const expected = Buffer.from(getSetupCode());
117
+ const actual = Buffer.from(candidate.trim().toUpperCase());
118
+ if (expected.length !== actual.length) return false;
119
+ return crypto.timingSafeEqual(expected, actual);
120
+ }
121
+
122
+ module.exports = {
123
+ MIN_PASSWORD_LENGTH,
124
+ getSessionSecret,
125
+ hasDashboardPassword,
126
+ setDashboardPassword,
127
+ verifyDashboardPassword,
128
+ getSetupCode,
129
+ setupCodeMatches,
130
+ };