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,129 @@
1
+ // Open the panel in a browser — but only when there is a person and a screen.
2
+ //
3
+ // Opening a browser is a nice touch on a laptop and pure noise on a server: it
4
+ // spawns something that will never render, on a machine nobody is looking at.
5
+ // So the question is not "can we" but "should we", and the answer comes from
6
+ // several signals rather than one variable, because any one of them lies.
7
+ //
8
+ // Whatever happens, this is best-effort. The URL is printed either way, and
9
+ // nothing in here is allowed to take the bot down.
10
+
11
+ const { spawn } = require("node:child_process");
12
+ const fs = require("node:fs");
13
+
14
+ const logger = require("./logger.cjs");
15
+
16
+ /**
17
+ * Why we would not open a browser. Returns null when we should.
18
+ *
19
+ * Split out from the opening itself so it can be tested against a synthetic
20
+ * environment instead of the machine running the tests.
21
+ *
22
+ * @param {object} [env] - defaults to the real environment
23
+ * @param {object} [options]
24
+ * @param {string} [options.platform] - defaults to the real platform
25
+ */
26
+ function browserBlockedBecause(env = process.env, { platform = process.platform } = {}) {
27
+ // An explicit answer beats every guess below, in both directions.
28
+ if (isFalsey(env.LEVIX_OPEN_BROWSER)) return "LEVIX_OPEN_BROWSER is off";
29
+ if (isTruthy(env.LEVIX_OPEN_BROWSER)) return null;
30
+
31
+ // Somebody else's automation is driving. Opening a browser there wedges
32
+ // pipelines and pops windows on developers' machines.
33
+ if (isTruthy(env.CI) || env.GITHUB_ACTIONS || env.GITLAB_CI || env.BUILDKITE) {
34
+ return "running in CI";
35
+ }
36
+
37
+ // A container has no session to open anything into, and the browser it
38
+ // spawned would be inside the container anyway.
39
+ if (inContainer(env)) return "running in a container";
40
+
41
+ // systemd starts services with neither a terminal nor a session.
42
+ if (env.INVOCATION_ID || env.JOURNAL_STREAM || env.NOTIFY_SOCKET) {
43
+ return "started by systemd";
44
+ }
45
+
46
+ // A remote shell: the display, if any, belongs to another machine.
47
+ if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return "connected over SSH";
48
+
49
+ // Nothing to render into. Windows and macOS always have a session when a
50
+ // user is logged in; on Linux and the BSDs it takes a display server.
51
+ if (platform !== "win32" && platform !== "darwin") {
52
+ const hasDisplay = Boolean(env.DISPLAY || env.WAYLAND_DISPLAY || env.MIR_SOCKET);
53
+ if (!hasDisplay) return "no graphical session";
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ function isTruthy(value) {
60
+ if (value === undefined || value === "") return false;
61
+ return !["0", "false", "off", "no"].includes(String(value).trim().toLowerCase());
62
+ }
63
+
64
+ function isFalsey(value) {
65
+ if (value === undefined || value === "") return false;
66
+ return ["0", "false", "off", "no"].includes(String(value).trim().toLowerCase());
67
+ }
68
+
69
+ function inContainer(env = process.env) {
70
+ if (isTruthy(env.LEVIX_IN_DOCKER) || isTruthy(env.KUBERNETES_SERVICE_HOST)) return true;
71
+ if (env.container) return true; // podman, systemd-nspawn, lxc
72
+ try {
73
+ if (fs.existsSync("/.dockerenv")) return true;
74
+ // The cgroup path names the container runtime on most setups.
75
+ const cgroup = fs.readFileSync("/proc/1/cgroup", "utf8");
76
+ if (/docker|kubepods|containerd|lxc|podman/i.test(cgroup)) return true;
77
+ } catch {
78
+ // Not Linux, or /proc is not readable. Fall through: the other signals
79
+ // above are what actually matter on macOS and Windows.
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /** The command that opens a URL on this platform. */
85
+ function openCommand(url, platform = process.platform) {
86
+ if (platform === "win32") {
87
+ // `start` is a cmd builtin; the empty string is the window title, which
88
+ // start would otherwise take from a quoted URL.
89
+ return { command: "cmd", args: ["/c", "start", "", url.replace(/&/g, "^&")] };
90
+ }
91
+ if (platform === "darwin") return { command: "open", args: [url] };
92
+ return { command: "xdg-open", args: [url] };
93
+ }
94
+
95
+ /**
96
+ * Open `url` if this looks like somebody's desktop.
97
+ *
98
+ * @returns {{ opened: boolean, reason: string|null }} — never throws.
99
+ */
100
+ function openBrowser(url, { env = process.env, platform = process.platform } = {}) {
101
+ const blocked = browserBlockedBecause(env, { platform });
102
+ if (blocked) return { opened: false, reason: blocked };
103
+
104
+ try {
105
+ const { command, args } = openCommand(url, platform);
106
+ const child = spawn(command, args, {
107
+ stdio: "ignore",
108
+ detached: true,
109
+ env,
110
+ });
111
+ // A missing xdg-open is an ordinary outcome on a minimal desktop, not an
112
+ // error worth interrupting anyone over.
113
+ child.on("error", (error) => {
114
+ logger.debug(`[browser] could not open a browser: ${error.message}`);
115
+ });
116
+ child.unref();
117
+ return { opened: true, reason: null };
118
+ } catch (error) {
119
+ logger.debug(`[browser] could not open a browser: ${error.message}`);
120
+ return { opened: false, reason: "the browser could not be launched" };
121
+ }
122
+ }
123
+
124
+ module.exports = {
125
+ openBrowser,
126
+ browserBlockedBecause,
127
+ openCommand,
128
+ inContainer,
129
+ };
@@ -0,0 +1,154 @@
1
+ // CommonJS shim around permissions.esm.js.
2
+ //
3
+ // Same gotcha as sendBotMessage.cjs: we can't `require` the ESM module
4
+ // directly, so we lazy-import on first call and cache the bindings.
5
+ //
6
+ // To avoid making every call site async (a lot of legacy .cjs commands are
7
+ // synchronous in their permission gates), we also expose **synchronous**
8
+ // variants `isOwnerJidSync` / `isAdminInGroupSync`. Those rely on a snapshot
9
+ // of the ESM module that we eagerly populate during connection bootstrap by
10
+ // calling `primePermissions()` once. If the snapshot isn't ready yet the sync
11
+ // variant falls back to the conservative `false` so we never grant a
12
+ // permission we can't verify.
13
+
14
+ const logger = require("./logger.cjs");
15
+
16
+ let mod = null;
17
+
18
+ async function getMod() {
19
+ if (mod) return mod;
20
+ mod = await import("./permissions.esm.js");
21
+ return mod;
22
+ }
23
+
24
+ /** Eagerly load the ESM module so the sync variants below can use it. */
25
+ async function primePermissions() {
26
+ try {
27
+ await getMod();
28
+ } catch (err) {
29
+ logger.error({ err }, "[permissions.cjs] prime failed");
30
+ }
31
+ }
32
+
33
+ function isOwnerJidSync(jid) {
34
+ if (!mod) return false;
35
+ try {
36
+ return mod.isOwnerJid(jid);
37
+ } catch (err) {
38
+ logger.error({ err }, "[permissions.cjs] isOwnerJidSync failed");
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function isBotAdminUserSync(jid) {
44
+ if (!mod) return false;
45
+ try {
46
+ return mod.isBotAdminUser(jid);
47
+ } catch (err) {
48
+ logger.error({ err }, "[permissions.cjs] isBotAdminUserSync failed");
49
+ return false;
50
+ }
51
+ }
52
+
53
+ function hasBotPrivilegesSync(jid) {
54
+ if (!mod) return false;
55
+ try {
56
+ return mod.hasBotPrivileges(jid);
57
+ } catch (err) {
58
+ logger.error({ err }, "[permissions.cjs] hasBotPrivilegesSync failed");
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function isAdminInGroupSync(groupMetadata, jid) {
64
+ if (!mod) return false;
65
+ try {
66
+ return mod.isAdminInGroup(groupMetadata, jid);
67
+ } catch (err) {
68
+ logger.error({ err }, "[permissions.cjs] isAdminInGroupSync failed");
69
+ return false;
70
+ }
71
+ }
72
+
73
+ function isBotAdminInGroupSync(groupMetadata, sock) {
74
+ if (!mod) return false;
75
+ try {
76
+ return mod.isBotAdminInGroup(groupMetadata, sock);
77
+ } catch (err) {
78
+ logger.error({ err }, "[permissions.cjs] isBotAdminInGroupSync failed");
79
+ return false;
80
+ }
81
+ }
82
+
83
+ function sameUserSync(a, b) {
84
+ if (!mod) return false;
85
+ try {
86
+ return mod.sameUser(a, b);
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ async function isOwnerJid(jid) {
93
+ const m = await getMod();
94
+ return m.isOwnerJid(jid);
95
+ }
96
+
97
+ async function isAdminInGroup(groupMetadata, jid) {
98
+ const m = await getMod();
99
+ return m.isAdminInGroup(groupMetadata, jid);
100
+ }
101
+
102
+ async function isBotAdminInGroup(groupMetadata, sock) {
103
+ const m = await getMod();
104
+ return m.isBotAdminInGroup(groupMetadata, sock);
105
+ }
106
+
107
+ async function bootstrapOwners(values) {
108
+ const m = await getMod();
109
+ return m.bootstrapOwners(values);
110
+ }
111
+
112
+ async function addBootstrapOwner(value) {
113
+ const m = await getMod();
114
+ return m.addBootstrapOwner(value);
115
+ }
116
+
117
+ async function isBotAdminUser(jid) {
118
+ const m = await getMod();
119
+ return m.isBotAdminUser(jid);
120
+ }
121
+
122
+ async function grantRole(target, role) {
123
+ const m = await getMod();
124
+ return m.grantRole(target, role);
125
+ }
126
+
127
+ async function revokeRole(target, role) {
128
+ const m = await getMod();
129
+ return m.revokeRole(target, role);
130
+ }
131
+
132
+ async function listRoles() {
133
+ const m = await getMod();
134
+ return m.listRoles();
135
+ }
136
+
137
+ module.exports = {
138
+ primePermissions,
139
+ isOwnerJid,
140
+ isAdminInGroup,
141
+ isBotAdminInGroup,
142
+ bootstrapOwners,
143
+ addBootstrapOwner,
144
+ isBotAdminUser,
145
+ grantRole,
146
+ revokeRole,
147
+ listRoles,
148
+ isOwnerJidSync,
149
+ isBotAdminUserSync,
150
+ hasBotPrivilegesSync,
151
+ isAdminInGroupSync,
152
+ isBotAdminInGroupSync,
153
+ sameUserSync,
154
+ };
@@ -0,0 +1,375 @@
1
+ // Centralized permissions module.
2
+ //
3
+ // Until now, owner detection was scattered across at least five places, each
4
+ // using a slightly different (and mutually inconsistent) check:
5
+ // * permissions.middleware.js — DB lookup + config.owners + fromMe
6
+ // * group.cjs — config.owners only (always empty)
7
+ // * group/media.cjs — config.owners on a `{}` parameter (throws!)
8
+ // * group/antilink.cjs — same as media.cjs
9
+ // * antispam.middleware.js — a legacy owner mirror (only the bot itself)
10
+ // * gemini.cjs — display-name match (fragile)
11
+ //
12
+ // This module is the single source of truth. It exposes:
13
+ //
14
+ // bootstrapOwners(list)
15
+ // Called once at connection time with the bot's own JID/LID plus every
16
+ // owner already recorded in user_metadata. Stored in a Set so subsequent
17
+ // checks are O(1) and don't hit the database on the hot path.
18
+ //
19
+ // isOwnerJid(jidOrLidOrPhone)
20
+ // Checks every direction we know about:
21
+ // - bootstrap set
22
+ // - user_metadata table (is_owner = 1) by jid, lid, or phone
23
+ // - cross-format: if given a LID, look up its PN and recheck; vice versa
24
+ // - phone-digit fallback (handles `:XX` device suffixes etc.)
25
+ //
26
+ // isAdminInGroup(groupMetadata, jid)
27
+ // LID/PN-aware admin check.
28
+ //
29
+ // isBotAdminInGroup(groupMetadata, sock)
30
+ // Same idea for the bot itself.
31
+ //
32
+ // sameUser(a, b)
33
+ // True if two identifiers refer to the same person (LID/PN/phone equiv).
34
+
35
+ import normalizeJid, {
36
+ isLidJid,
37
+ isPnJid,
38
+ } from "./normalizeJid.esm.js";
39
+ import {
40
+ getAllOwners,
41
+ getAllBotAdmins,
42
+ isUserOwner as dbIsOwner,
43
+ isUserBotAdmin as dbIsBotAdmin,
44
+ setUserRole,
45
+ getLidForPn,
46
+ getPnForLid,
47
+ } from "./storage.esm.js";
48
+
49
+ // Two rosters, same mechanics:
50
+ // owners — full access, including OWNER_ONLY commands
51
+ // bot admins — treated as an admin in every chat (group admin OR this role
52
+ // satisfies ADMINS_ONLY / ADMINS_OWNER), but not OWNER_ONLY
53
+ const bootstrap = new Set();
54
+ const bootstrapAdminSet = new Set();
55
+
56
+ function digitsOnly(value) {
57
+ if (!value) return null;
58
+ const m = String(value).match(/\d+/);
59
+ return m ? m[0] : null;
60
+ }
61
+
62
+ function pushInto(set, value) {
63
+ if (!value) return;
64
+ const norm = normalizeJid(value);
65
+ if (norm) set.add(norm);
66
+ const phone = digitsOnly(value);
67
+ if (phone) {
68
+ set.add(`${phone}@s.whatsapp.net`);
69
+ }
70
+ }
71
+
72
+ function pushBootstrap(value) {
73
+ pushInto(bootstrap, value);
74
+ }
75
+
76
+ /**
77
+ * Replace the bootstrap owner set. Pass an array of JIDs/LIDs/phones; we
78
+ * normalize and de-dupe them.
79
+ */
80
+ export function bootstrapOwners(values) {
81
+ bootstrap.clear();
82
+ if (!Array.isArray(values)) return;
83
+ values.forEach(pushBootstrap);
84
+ }
85
+
86
+ /** Add an owner to the bootstrap set (does not touch the DB). */
87
+ export function addBootstrapOwner(value) {
88
+ pushBootstrap(value);
89
+ }
90
+
91
+ export function getBootstrapOwners() {
92
+ return [...bootstrap];
93
+ }
94
+
95
+ /** Same as bootstrapOwners, for the bot-admin roster. */
96
+ export function bootstrapAdmins(values) {
97
+ bootstrapAdminSet.clear();
98
+ if (!Array.isArray(values)) return;
99
+ values.forEach((value) => pushInto(bootstrapAdminSet, value));
100
+ }
101
+
102
+ export function addBootstrapAdmin(value) {
103
+ pushInto(bootstrapAdminSet, value);
104
+ }
105
+
106
+ export function getBootstrapAdmins() {
107
+ return [...bootstrapAdminSet];
108
+ }
109
+
110
+ /**
111
+ * Every identifier that could stand for the same person: the normalized form,
112
+ * its LID/PN counterpart, and the bare-phone JID. Both role checks walk this
113
+ * list so a user granted a role by phone still matches when they message under
114
+ * a LID (and vice-versa).
115
+ */
116
+ function identityForms(jid) {
117
+ const forms = [];
118
+ const push = (value) => {
119
+ const norm = normalizeJid(value);
120
+ if (norm && !forms.includes(norm)) forms.push(norm);
121
+ };
122
+
123
+ const norm = normalizeJid(jid);
124
+ if (!norm) return forms;
125
+ push(norm);
126
+
127
+ if (isLidJid(norm)) {
128
+ try {
129
+ push(getPnForLid(norm));
130
+ } catch {}
131
+ }
132
+ if (isPnJid(norm)) {
133
+ try {
134
+ push(getLidForPn(norm));
135
+ } catch {}
136
+ }
137
+
138
+ const phone = digitsOnly(norm);
139
+ if (phone) push(`${phone}@s.whatsapp.net`);
140
+
141
+ return forms;
142
+ }
143
+
144
+ /**
145
+ * Shared role resolution: bootstrap set, then the DB, then a phone-digit walk
146
+ * over the stored roster (covers rows saved with only a phone or only a LID).
147
+ */
148
+ function hasRole(jid, { set, dbCheck, listAll }) {
149
+ if (!jid) return false;
150
+ const forms = identityForms(jid);
151
+ if (!forms.length) return false;
152
+
153
+ for (const form of forms) {
154
+ if (set.has(form)) return true;
155
+ try {
156
+ if (dbCheck(form)) return true;
157
+ } catch {}
158
+ }
159
+
160
+ const phone = digitsOnly(forms[0]);
161
+ if (phone) {
162
+ try {
163
+ for (const entry of listAll()) {
164
+ const entryPhone =
165
+ digitsOnly(entry.phone) ||
166
+ digitsOnly(entry.jid) ||
167
+ digitsOnly(entry.lid);
168
+ if (entryPhone && entryPhone === phone) return true;
169
+ }
170
+ } catch {}
171
+ }
172
+
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * Comprehensive owner check (bootstrap set + user_metadata + LID/PN
178
+ * cross-lookup + phone-digit fallback).
179
+ */
180
+ export function isOwnerJid(jid) {
181
+ return hasRole(jid, {
182
+ set: bootstrap,
183
+ dbCheck: dbIsOwner,
184
+ listAll: getAllOwners,
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Bot-level admin role. This is NOT "admin of this WhatsApp group" — it is a
190
+ * role the operator grants (`!perm add admin @user`, the dashboard, or by asking
191
+ * the AI) that makes the user an admin everywhere the bot works.
192
+ * Owners always pass.
193
+ */
194
+ export function isBotAdminUser(jid) {
195
+ if (isOwnerJid(jid)) return true;
196
+ return hasRole(jid, {
197
+ set: bootstrapAdminSet,
198
+ dbCheck: dbIsBotAdmin,
199
+ listAll: getAllBotAdmins,
200
+ });
201
+ }
202
+
203
+ /** Owner or bot-admin — "this person may drive the bot". */
204
+ export function hasBotPrivileges(jid) {
205
+ return isOwnerJid(jid) || isBotAdminUser(jid);
206
+ }
207
+
208
+ /**
209
+ * Grant a bot-level role and make it effective immediately (DB + in-memory
210
+ * roster, so the very next message already sees it).
211
+ *
212
+ * @param {string} target - JID / LID / bare phone
213
+ * @param {"owner"|"admin"} role
214
+ * @returns {object|null} the stored user record
215
+ */
216
+ export function grantRole(target, role = "admin") {
217
+ const wanted = String(role).toLowerCase() === "owner" ? "owner" : "admin";
218
+ const record = setUserRole(target, wanted, true);
219
+ if (wanted === "owner") addBootstrapOwner(record?.jid || target);
220
+ else addBootstrapAdmin(record?.jid || target);
221
+ return record;
222
+ }
223
+
224
+ /**
225
+ * Revoke a bot-level role. The in-memory roster is rebuilt from what's left so
226
+ * a revoked user stops matching without a restart.
227
+ *
228
+ * @returns {object|null} the stored user record
229
+ */
230
+ export function revokeRole(target, role = "admin") {
231
+ const wanted = String(role).toLowerCase() === "owner" ? "owner" : "admin";
232
+ const record = setUserRole(target, wanted, false);
233
+
234
+ const forms = identityForms(record?.jid || target);
235
+ const set = wanted === "owner" ? bootstrap : bootstrapAdminSet;
236
+ for (const form of forms) {
237
+ set.delete(form);
238
+ const phone = digitsOnly(form);
239
+ if (phone) set.delete(`${phone}@s.whatsapp.net`);
240
+ }
241
+ return record;
242
+ }
243
+
244
+ /** Everyone currently holding a bot-level role. */
245
+ export function listRoles() {
246
+ let owners = [];
247
+ let admins = [];
248
+ try {
249
+ owners = getAllOwners();
250
+ } catch {}
251
+ try {
252
+ admins = getAllBotAdmins();
253
+ } catch {}
254
+ return {
255
+ owners,
256
+ admins,
257
+ bootstrapOwners: getBootstrapOwners(),
258
+ bootstrapAdmins: getBootstrapAdmins(),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * True if the two identifiers refer to the same physical user — handles LID
264
+ * vs PN vs phone-with-device-suffix confusion.
265
+ */
266
+ export function sameUser(a, b) {
267
+ if (!a || !b) return false;
268
+ const na = normalizeJid(a);
269
+ const nb = normalizeJid(b);
270
+ if (na === nb) return true;
271
+ const da = digitsOnly(na);
272
+ const db = digitsOnly(nb);
273
+ if (da && db && da === db) return true;
274
+ // Cross-format lookup
275
+ try {
276
+ if (isLidJid(na)) {
277
+ const pn = getPnForLid(na);
278
+ if (pn && normalizeJid(pn) === nb) return true;
279
+ }
280
+ if (isLidJid(nb)) {
281
+ const pn = getPnForLid(nb);
282
+ if (pn && normalizeJid(pn) === na) return true;
283
+ }
284
+ if (isPnJid(na)) {
285
+ const lid = getLidForPn(na);
286
+ if (lid && normalizeJid(lid) === nb) return true;
287
+ }
288
+ if (isPnJid(nb)) {
289
+ const lid = getLidForPn(nb);
290
+ if (lid && normalizeJid(lid) === na) return true;
291
+ }
292
+ } catch {}
293
+ return false;
294
+ }
295
+
296
+ /**
297
+ * Check whether `jid` is admin in the given group metadata.
298
+ * Compares using sameUser so LID/PN mismatches don't fool us.
299
+ *
300
+ * v7/LID note: a group participant can expose the user under several fields —
301
+ * `id` (LID or PN, whichever is preferred), plus `lid` and `phoneNumber`. We
302
+ * match `jid` against ALL of them so admin detection works whether the caller
303
+ * hands us a LID or a PN, even before the lid_mapping table is populated.
304
+ */
305
+ export function isAdminInGroup(groupMetadata, jid) {
306
+ if (!groupMetadata?.participants || !jid) return false;
307
+ const target = normalizeJid(jid);
308
+ return groupMetadata.participants.some((p) => {
309
+ if (!["admin", "superadmin"].includes(p.admin)) return false;
310
+ const participantIds = [p.id, p.lid, p.phoneNumber, p.jid].filter(Boolean);
311
+ return participantIds.some(
312
+ (pid) => normalizeJid(pid) === target || sameUser(pid, jid)
313
+ );
314
+ });
315
+ }
316
+
317
+ /**
318
+ * Check whether the bot itself is admin in the group. We check both the
319
+ * bot's PN and its LID since either form may be stored on the participant.
320
+ */
321
+ export function isBotAdminInGroup(groupMetadata, sock) {
322
+ if (!groupMetadata?.participants || !sock) return false;
323
+ const candidates = [sock.user?.id, sock.user?.lid].filter(Boolean);
324
+ for (const candidate of candidates) {
325
+ if (isAdminInGroup(groupMetadata, candidate)) return true;
326
+ }
327
+ return false;
328
+ }
329
+
330
+ /**
331
+ * Resolve the sender's identifier from a Baileys message, normalized.
332
+ * Used by callers that previously did this themselves with subtle bugs.
333
+ */
334
+ export function getSenderId(msg, sock) {
335
+ if (!msg) return null;
336
+ if (msg.key?.fromMe && sock?.user?.id) return normalizeJid(sock.user.id);
337
+ if (msg.key?.remoteJid?.endsWith("@g.us")) {
338
+ return normalizeJid(msg.key.participant);
339
+ }
340
+ return normalizeJid(msg.key?.remoteJid);
341
+ }
342
+
343
+ /**
344
+ * Resolve EVERY identifier that could refer to the sender, normalized and
345
+ * de-duped. Baileys v7 delivers both a LID and its PN alternate on the message
346
+ * key (`participant` + `participantAlt` in groups, `remoteJid` + `remoteJidAlt`
347
+ * in DMs, plus the older `participantPn`/`senderPn` variants). Owner/admin
348
+ * checks should try all of them so a member listed by PN is still recognised
349
+ * when they message under a LID (and vice-versa) — without waiting for the
350
+ * lid_mapping table to fill in.
351
+ */
352
+ export function getSenderCandidates(msg, sock) {
353
+ if (!msg) return [];
354
+ const key = msg.key || {};
355
+
356
+ let raw;
357
+ if (key.fromMe) {
358
+ raw = [sock?.user?.id, sock?.user?.lid];
359
+ } else if (key.remoteJid?.endsWith("@g.us")) {
360
+ raw = [key.participant, key.participantAlt, key.participantPn, key.senderPn];
361
+ } else {
362
+ raw = [key.remoteJid, key.remoteJidAlt, key.senderPn];
363
+ }
364
+
365
+ const seen = new Set();
366
+ const out = [];
367
+ for (const value of raw) {
368
+ const norm = normalizeJid(value);
369
+ if (norm && !seen.has(norm)) {
370
+ seen.add(norm);
371
+ out.push(norm);
372
+ }
373
+ }
374
+ return out;
375
+ }