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,386 @@
|
|
|
1
|
+
// Runtime settings — everything that used to live in a .env file.
|
|
2
|
+
//
|
|
3
|
+
// There is no .env file any more. A setting resolves in exactly two steps:
|
|
4
|
+
//
|
|
5
|
+
// 1. the value the operator saved from the dashboard (`setting:*` in the
|
|
6
|
+
// bot_settings table)
|
|
7
|
+
// 2. the default baked in here
|
|
8
|
+
//
|
|
9
|
+
// So a fresh install runs with no configuration at all, and the control panel
|
|
10
|
+
// is the only place a value is ever changed. Consumers must read through
|
|
11
|
+
// `get()` at CALL time (not once at import) or a change wouldn't take effect
|
|
12
|
+
// until a restart — the few settings that genuinely can't apply live are
|
|
13
|
+
// marked `restart: true` and the dashboard says so next to the field.
|
|
14
|
+
//
|
|
15
|
+
// Secrets (API keys) are stored the same way but never leave the server: the
|
|
16
|
+
// dashboard only ever learns whether one is configured.
|
|
17
|
+
|
|
18
|
+
const logger = require("../utils/logger.cjs");
|
|
19
|
+
|
|
20
|
+
const SETTINGS = [
|
|
21
|
+
// --- General -----------------------------------------------------------
|
|
22
|
+
{
|
|
23
|
+
key: "bot_timezone",
|
|
24
|
+
type: "string",
|
|
25
|
+
default: "Africa/Cairo",
|
|
26
|
+
group: "general",
|
|
27
|
+
label: "Timezone",
|
|
28
|
+
hint: "IANA name, e.g. Africa/Cairo. Used by the AI, !prayer and the scheduler.",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
key: "bot_min_delay_ms",
|
|
32
|
+
type: "int",
|
|
33
|
+
default: 400,
|
|
34
|
+
min: 0,
|
|
35
|
+
max: 10000,
|
|
36
|
+
group: "general",
|
|
37
|
+
label: "Min reply delay (ms)",
|
|
38
|
+
hint: "Small human-looking pause before the bot answers.",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: "bot_max_delay_ms",
|
|
42
|
+
type: "int",
|
|
43
|
+
default: 900,
|
|
44
|
+
min: 0,
|
|
45
|
+
max: 30000,
|
|
46
|
+
group: "general",
|
|
47
|
+
label: "Max reply delay (ms)",
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// --- AI ----------------------------------------------------------------
|
|
51
|
+
{
|
|
52
|
+
key: "gemini_api_key",
|
|
53
|
+
type: "secret",
|
|
54
|
+
default: "",
|
|
55
|
+
group: "ai",
|
|
56
|
+
label: "Gemini API key",
|
|
57
|
+
hint: "Free key from aistudio.google.com/apikey — without it the AI commands stay off.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: "gemini_model",
|
|
61
|
+
type: "string",
|
|
62
|
+
default: "gemini-2.5-flash",
|
|
63
|
+
group: "ai",
|
|
64
|
+
label: "Gemini model",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
key: "ai_agent",
|
|
68
|
+
type: "bool",
|
|
69
|
+
default: true,
|
|
70
|
+
group: "ai",
|
|
71
|
+
label: "Tool use (agent mode)",
|
|
72
|
+
hint: "Off = the AI answers from the prompt only, no search / page reading / memory writes.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: "ai_max_tool_steps",
|
|
76
|
+
type: "int",
|
|
77
|
+
default: 6,
|
|
78
|
+
min: 1,
|
|
79
|
+
max: 20,
|
|
80
|
+
group: "ai",
|
|
81
|
+
label: "Max tool rounds",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: "ai_tool_timeout_ms",
|
|
85
|
+
type: "int",
|
|
86
|
+
default: 30000,
|
|
87
|
+
min: 1000,
|
|
88
|
+
max: 120000,
|
|
89
|
+
group: "ai",
|
|
90
|
+
label: "Tool timeout (ms)",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: "ai_max_history",
|
|
94
|
+
type: "int",
|
|
95
|
+
default: 24,
|
|
96
|
+
min: 2,
|
|
97
|
+
max: 200,
|
|
98
|
+
group: "ai",
|
|
99
|
+
label: "Conversation turns kept",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
key: "memory_context_chars",
|
|
103
|
+
type: "int",
|
|
104
|
+
default: 6000,
|
|
105
|
+
min: 500,
|
|
106
|
+
max: 40000,
|
|
107
|
+
group: "ai",
|
|
108
|
+
label: "Memory injected (chars)",
|
|
109
|
+
hint: "How much of the long-term memory files goes into every prompt.",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
key: "groq_api_key",
|
|
113
|
+
type: "secret",
|
|
114
|
+
default: "",
|
|
115
|
+
group: "ai",
|
|
116
|
+
label: "Groq API key (fallback)",
|
|
117
|
+
hint: "Used only when Gemini is out of quota.",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: "groq_model",
|
|
121
|
+
type: "string",
|
|
122
|
+
default: "llama-3.3-70b-versatile",
|
|
123
|
+
group: "ai",
|
|
124
|
+
label: "Groq model",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
key: "google_search_api_key",
|
|
128
|
+
type: "secret",
|
|
129
|
+
default: "",
|
|
130
|
+
group: "ai",
|
|
131
|
+
label: "Google Search API key",
|
|
132
|
+
hint: "Optional. Without it the search tool uses DuckDuckGo.",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
key: "google_search_cx",
|
|
136
|
+
type: "string",
|
|
137
|
+
default: "",
|
|
138
|
+
group: "ai",
|
|
139
|
+
label: "Google Search engine ID (cx)",
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
// --- Integrations ------------------------------------------------------
|
|
143
|
+
{
|
|
144
|
+
key: "openweathermap_api_key",
|
|
145
|
+
type: "secret",
|
|
146
|
+
default: "",
|
|
147
|
+
group: "integrations",
|
|
148
|
+
label: "OpenWeatherMap API key",
|
|
149
|
+
hint: "Needed by !weather.",
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
// --- Media -------------------------------------------------------------
|
|
153
|
+
{
|
|
154
|
+
key: "thumbnail_remote",
|
|
155
|
+
type: "bool",
|
|
156
|
+
default: true,
|
|
157
|
+
group: "media",
|
|
158
|
+
label: "Previews for remote media",
|
|
159
|
+
hint: "Download media sent by URL to build a thumbnail before forwarding it.",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
key: "thumbnail_remote_max_mb",
|
|
163
|
+
type: "int",
|
|
164
|
+
default: 20,
|
|
165
|
+
min: 1,
|
|
166
|
+
max: 200,
|
|
167
|
+
group: "media",
|
|
168
|
+
label: "Max remote media (MB)",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
key: "thumbnail_remote_timeout_ms",
|
|
172
|
+
type: "int",
|
|
173
|
+
default: 20000,
|
|
174
|
+
min: 1000,
|
|
175
|
+
max: 120000,
|
|
176
|
+
group: "media",
|
|
177
|
+
label: "Remote media timeout (ms)",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
key: "ffmpeg_path",
|
|
181
|
+
type: "string",
|
|
182
|
+
default: "",
|
|
183
|
+
group: "media",
|
|
184
|
+
label: "ffmpeg path",
|
|
185
|
+
hint: "Only if the bundled ffmpeg-static binary can't run on this machine.",
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// --- Server ------------------------------------------------------------
|
|
189
|
+
{
|
|
190
|
+
key: "port",
|
|
191
|
+
type: "int",
|
|
192
|
+
default: 3001,
|
|
193
|
+
min: 1,
|
|
194
|
+
max: 65535,
|
|
195
|
+
group: "server",
|
|
196
|
+
label: "Control panel port",
|
|
197
|
+
hint: "Takes effect on the next start — and only inside the bot. Under Docker or behind a reverse proxy, change the mapping there instead.",
|
|
198
|
+
restart: true,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
key: "trust_proxy",
|
|
202
|
+
type: "string",
|
|
203
|
+
default: "",
|
|
204
|
+
group: "server",
|
|
205
|
+
label: "Proxy hops in front",
|
|
206
|
+
hint: 'Number of proxies (nginx = "1"). Leave empty when the panel is reachable directly — a wrong value lets a client forge its address.',
|
|
207
|
+
restart: true,
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
key: "public_domain",
|
|
211
|
+
type: "string",
|
|
212
|
+
default: "",
|
|
213
|
+
group: "server",
|
|
214
|
+
label: "Public domain",
|
|
215
|
+
hint: "Set by `levix domain`. When present it is the address the bot prints and the panel calls home.",
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
key: "bind_address",
|
|
219
|
+
type: "string",
|
|
220
|
+
default: "",
|
|
221
|
+
group: "server",
|
|
222
|
+
label: "Bind address",
|
|
223
|
+
hint: "Empty = every interface. `levix domain` sets 127.0.0.1 once a reverse proxy is in front, so nobody can reach the panel past the certificate.",
|
|
224
|
+
restart: true,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
key: "dashboard_origin",
|
|
228
|
+
type: "string",
|
|
229
|
+
default: "",
|
|
230
|
+
group: "server",
|
|
231
|
+
label: "Extra allowed origin",
|
|
232
|
+
hint: "Only if you open the panel from a different domain, e.g. https://bot.example.com.",
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
key: "forward_score_ttl_days",
|
|
236
|
+
type: "int",
|
|
237
|
+
default: 30,
|
|
238
|
+
min: 1,
|
|
239
|
+
max: 365,
|
|
240
|
+
group: "server",
|
|
241
|
+
label: "Forward counters kept (days)",
|
|
242
|
+
hint: "How long the !score counters survive. They gain a row per forwarded message.",
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const BY_KEY = new Map(SETTINGS.map((definition) => [definition.key, definition]));
|
|
247
|
+
|
|
248
|
+
const GROUP_LABELS = {
|
|
249
|
+
general: "General",
|
|
250
|
+
ai: "AI",
|
|
251
|
+
integrations: "Integrations",
|
|
252
|
+
media: "Media",
|
|
253
|
+
server: "Server",
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// This module is required by files that load before the database is opened in
|
|
257
|
+
// a few edge paths (a command file pulled in by a script, say), so a read that
|
|
258
|
+
// comes too early falls back to the default instead of throwing.
|
|
259
|
+
function storedValue(key) {
|
|
260
|
+
try {
|
|
261
|
+
const value = require("../utils/storage.cjs").getBotSetting(
|
|
262
|
+
`setting:${key}`,
|
|
263
|
+
undefined
|
|
264
|
+
);
|
|
265
|
+
return value === undefined || value === null ? undefined : value;
|
|
266
|
+
} catch {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function coerce(definition, raw) {
|
|
272
|
+
switch (definition.type) {
|
|
273
|
+
case "int": {
|
|
274
|
+
const number = Number(raw);
|
|
275
|
+
if (!Number.isFinite(number)) return definition.default;
|
|
276
|
+
return Math.trunc(number);
|
|
277
|
+
}
|
|
278
|
+
case "bool": {
|
|
279
|
+
if (typeof raw === "boolean") return raw;
|
|
280
|
+
const text = String(raw).trim().toLowerCase();
|
|
281
|
+
if (["false", "off", "0", "no"].includes(text)) return false;
|
|
282
|
+
if (["true", "on", "1", "yes"].includes(text)) return true;
|
|
283
|
+
return definition.default;
|
|
284
|
+
}
|
|
285
|
+
default:
|
|
286
|
+
return String(raw);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The value in force for `key`: what the dashboard saved, else the default. */
|
|
291
|
+
function get(key) {
|
|
292
|
+
const definition = BY_KEY.get(key);
|
|
293
|
+
if (!definition) throw new Error(`Unknown setting: ${key}`);
|
|
294
|
+
|
|
295
|
+
const stored = storedValue(key);
|
|
296
|
+
if (stored !== undefined && stored !== "") return coerce(definition, stored);
|
|
297
|
+
|
|
298
|
+
return definition.default;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Where the value in force came from — shown next to each field in the UI. */
|
|
302
|
+
function sourceOf(key) {
|
|
303
|
+
const stored = storedValue(key);
|
|
304
|
+
return stored !== undefined && stored !== "" ? "dashboard" : "default";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function validate(definition, value) {
|
|
308
|
+
if (definition.type === "int") {
|
|
309
|
+
const number = Number(value);
|
|
310
|
+
if (!Number.isFinite(number)) throw new Error(`${definition.label} must be a number`);
|
|
311
|
+
if (definition.min !== undefined && number < definition.min) {
|
|
312
|
+
throw new Error(`${definition.label} must be ${definition.min} or more`);
|
|
313
|
+
}
|
|
314
|
+
if (definition.max !== undefined && number > definition.max) {
|
|
315
|
+
throw new Error(`${definition.label} must be ${definition.max} or less`);
|
|
316
|
+
}
|
|
317
|
+
return Math.trunc(number);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (definition.type === "bool") return coerce(definition, value);
|
|
321
|
+
|
|
322
|
+
const text = String(value ?? "");
|
|
323
|
+
if (text.length > 500) throw new Error(`${definition.label} is too long`);
|
|
324
|
+
if (definition.key === "bot_timezone" && text) {
|
|
325
|
+
try {
|
|
326
|
+
new Intl.DateTimeFormat("en", { timeZone: text });
|
|
327
|
+
} catch {
|
|
328
|
+
throw new Error(`"${text}" is not a valid timezone`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return text;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Save one setting. An empty string clears the override, so the value falls
|
|
336
|
+
* back to the default again.
|
|
337
|
+
* @throws {Error} on an unknown key or a value that fails validation.
|
|
338
|
+
*/
|
|
339
|
+
function set(key, value) {
|
|
340
|
+
const definition = BY_KEY.get(key);
|
|
341
|
+
if (!definition) throw new Error(`Unknown setting: ${key}`);
|
|
342
|
+
|
|
343
|
+
const storage = require("../utils/storage.cjs");
|
|
344
|
+
|
|
345
|
+
if (value === null || value === "" || value === undefined) {
|
|
346
|
+
storage.deleteBotSetting(`setting:${key}`);
|
|
347
|
+
logger.info(`[settings] ${key} cleared (back to the default)`);
|
|
348
|
+
return get(key);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const clean = validate(definition, value);
|
|
352
|
+
storage.saveBotSetting(`setting:${key}`, clean);
|
|
353
|
+
// Never log the value of a secret.
|
|
354
|
+
logger.info(
|
|
355
|
+
`[settings] ${key} -> ${definition.type === "secret" ? "(updated)" : clean}`
|
|
356
|
+
);
|
|
357
|
+
return get(key);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Everything the settings screen renders. Secrets report only whether they are
|
|
362
|
+
* configured and where they came from — the value itself never goes out.
|
|
363
|
+
*/
|
|
364
|
+
function describe() {
|
|
365
|
+
return SETTINGS.map((definition) => {
|
|
366
|
+
const base = {
|
|
367
|
+
key: definition.key,
|
|
368
|
+
type: definition.type,
|
|
369
|
+
group: definition.group,
|
|
370
|
+
groupLabel: GROUP_LABELS[definition.group] || definition.group,
|
|
371
|
+
label: definition.label,
|
|
372
|
+
hint: definition.hint || null,
|
|
373
|
+
min: definition.min ?? null,
|
|
374
|
+
max: definition.max ?? null,
|
|
375
|
+
restart: definition.restart === true,
|
|
376
|
+
source: sourceOf(definition.key),
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
if (definition.type === "secret") {
|
|
380
|
+
return { ...base, configured: !!get(definition.key), value: null };
|
|
381
|
+
}
|
|
382
|
+
return { ...base, value: get(definition.key), default: definition.default };
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
module.exports = { get, set, describe, sourceOf, SETTING_KEYS: [...BY_KEY.keys()] };
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { delay, DisconnectReason } from "@whiskeysockets/baileys";
|
|
2
|
+
import qrcode from "qrcode-terminal";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import { MAX_RETRIES, RETRY_DELAY_MS } from "../config/constants.js";
|
|
5
|
+
import { groupMetadataCache } from "./socket.js";
|
|
6
|
+
import {
|
|
7
|
+
saveQrCode,
|
|
8
|
+
deleteQrCode,
|
|
9
|
+
saveUserMetadata,
|
|
10
|
+
getAllOwners,
|
|
11
|
+
getAllBotAdmins,
|
|
12
|
+
} from "../utils/storage.esm.js";
|
|
13
|
+
import {
|
|
14
|
+
bootstrapOwners,
|
|
15
|
+
bootstrapAdmins,
|
|
16
|
+
} from "../utils/permissions.esm.js";
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const logger = require("../utils/logger.cjs");
|
|
20
|
+
const { primePermissions } = require("../utils/permissions.cjs");
|
|
21
|
+
|
|
22
|
+
let retryCount = 0;
|
|
23
|
+
|
|
24
|
+
// Handle connection updates
|
|
25
|
+
export async function handleConnectionUpdate(
|
|
26
|
+
update,
|
|
27
|
+
sock,
|
|
28
|
+
clearAll,
|
|
29
|
+
io,
|
|
30
|
+
initializeScheduledJobs
|
|
31
|
+
) {
|
|
32
|
+
const { connection, lastDisconnect, qr } = update;
|
|
33
|
+
|
|
34
|
+
if (qr) {
|
|
35
|
+
logger.info("Scan the QR code below:");
|
|
36
|
+
saveQrCode(qr);
|
|
37
|
+
io.emit("qr", qr);
|
|
38
|
+
io.emit("status_update", { status: "QR Code Received" });
|
|
39
|
+
qrcode.generate(qr, { small: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (connection === "close") {
|
|
43
|
+
const statusCode = lastDisconnect?.error?.output?.statusCode;
|
|
44
|
+
const reason =
|
|
45
|
+
lastDisconnect?.error?.output?.payload?.error ||
|
|
46
|
+
lastDisconnect?.error?.message ||
|
|
47
|
+
"Unknown";
|
|
48
|
+
|
|
49
|
+
// Define which status codes should trigger reconnection
|
|
50
|
+
// 515: QR timeout, 500/503: Server errors, 404: Not found, etc.
|
|
51
|
+
const noReconnectCodes = [401, 403, DisconnectReason.loggedOut];
|
|
52
|
+
const shouldReconnect = !noReconnectCodes.includes(statusCode);
|
|
53
|
+
|
|
54
|
+
io.emit("status_update", { status: "Disconnected" });
|
|
55
|
+
|
|
56
|
+
// Log full disconnect info for debugging
|
|
57
|
+
logger.debug(
|
|
58
|
+
{ lastDisconnect, statusCode, reason },
|
|
59
|
+
"[Connection] Disconnect details"
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (shouldReconnect && retryCount < MAX_RETRIES) {
|
|
63
|
+
retryCount++;
|
|
64
|
+
const delayMs = RETRY_DELAY_MS * retryCount; // Exponential backoff: 5s, 10s, 15s, 20s, 25s
|
|
65
|
+
|
|
66
|
+
// Special message for QR timeout
|
|
67
|
+
const messagePrefix =
|
|
68
|
+
statusCode === 515 ? "🔄 QR code expired" : "🔌 Connection closed";
|
|
69
|
+
|
|
70
|
+
logger.warn(
|
|
71
|
+
`${messagePrefix}. Reason: ${statusCode || reason}. Retrying in ${
|
|
72
|
+
delayMs / 1000
|
|
73
|
+
}s... (Attempt ${retryCount}/${MAX_RETRIES})`
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
await delay(delayMs);
|
|
77
|
+
return { shouldReconnect: true };
|
|
78
|
+
} else if (!shouldReconnect) {
|
|
79
|
+
if (statusCode === DisconnectReason.loggedOut) {
|
|
80
|
+
logger.error(
|
|
81
|
+
"❌ Connection closed permanently. Logged out. Clearing auth data from database."
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Clear authentication data from database
|
|
85
|
+
if (clearAll) {
|
|
86
|
+
await clearAll();
|
|
87
|
+
logger.info("[Auth] Authentication data cleared from database");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Reset retry count and trigger reconnection for new QR
|
|
91
|
+
retryCount = 0;
|
|
92
|
+
return { shouldReconnect: true };
|
|
93
|
+
} else {
|
|
94
|
+
logger.error(
|
|
95
|
+
`❌ Connection closed with non-recoverable error: ${statusCode}. Not retrying.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
logger.error(`❌ Max retries (${MAX_RETRIES}) reached. Exiting.`);
|
|
100
|
+
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
} else if (connection === "open") {
|
|
104
|
+
logger.info("✅ Connection opened successfully!");
|
|
105
|
+
deleteQrCode();
|
|
106
|
+
|
|
107
|
+
retryCount = 0;
|
|
108
|
+
io.emit("status_update", { status: "Connected" });
|
|
109
|
+
|
|
110
|
+
// The paired account is always an owner — that's the whole bootstrap.
|
|
111
|
+
// Everyone else is granted from the dashboard (Roles) or with `!perm`, and
|
|
112
|
+
// is already a row in user_metadata by the time we get here.
|
|
113
|
+
await saveBotOwnerMetadata(sock);
|
|
114
|
+
|
|
115
|
+
const botPn = sock.user?.id
|
|
116
|
+
? `${sock.user.id.split(":")[0].split("@")[0]}@s.whatsapp.net`
|
|
117
|
+
: null;
|
|
118
|
+
const botLid = sock.user?.lid
|
|
119
|
+
? `${sock.user.lid.split(":")[0].split("@")[0]}@lid`
|
|
120
|
+
: null;
|
|
121
|
+
|
|
122
|
+
// Both rosters are seeded from the database so a permission check never
|
|
123
|
+
// has to hit it on the hot path.
|
|
124
|
+
const ownersRoster = [
|
|
125
|
+
botPn,
|
|
126
|
+
botLid,
|
|
127
|
+
...getAllOwners().flatMap((user) => [user.jid, user.lid]),
|
|
128
|
+
].filter(Boolean);
|
|
129
|
+
const adminsRoster = getAllBotAdmins()
|
|
130
|
+
.flatMap((user) => [user.jid, user.lid])
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
|
|
133
|
+
bootstrapOwners(ownersRoster);
|
|
134
|
+
bootstrapAdmins(adminsRoster);
|
|
135
|
+
|
|
136
|
+
await primePermissions();
|
|
137
|
+
logger.info(
|
|
138
|
+
`[Permissions] Owner roster: ${ownersRoster.length} entries; ` +
|
|
139
|
+
`admin roster: ${adminsRoster.length} entries`
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Initialize scheduled jobs
|
|
143
|
+
initializeScheduledJobs(sock);
|
|
144
|
+
|
|
145
|
+
// Cache all groups
|
|
146
|
+
await cacheAllGroups(sock);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { shouldReconnect: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Record the paired WhatsApp account as the bot's first owner. Whoever scanned
|
|
153
|
+
// the QR owns the bot — there is no owner list to configure anywhere.
|
|
154
|
+
async function saveBotOwnerMetadata(sock) {
|
|
155
|
+
try {
|
|
156
|
+
logger.info("[User Metadata] Saving bot owner metadata...");
|
|
157
|
+
|
|
158
|
+
const phoneNumber = sock.user?.id?.split(":")[0]?.split("@")[0];
|
|
159
|
+
const userJid = phoneNumber ? `${phoneNumber}@s.whatsapp.net` : null;
|
|
160
|
+
const userLid = sock.user?.lid
|
|
161
|
+
? `${sock.user.lid.split(":")[0].split("@")[0]}@lid`
|
|
162
|
+
: null;
|
|
163
|
+
const displayName = sock.user?.name || sock.user?.verifiedName || null;
|
|
164
|
+
|
|
165
|
+
if (userJid) {
|
|
166
|
+
saveUserMetadata({
|
|
167
|
+
jid: userJid,
|
|
168
|
+
lid: userLid,
|
|
169
|
+
phone: phoneNumber,
|
|
170
|
+
isOwner: true,
|
|
171
|
+
displayName,
|
|
172
|
+
});
|
|
173
|
+
logger.info(
|
|
174
|
+
`[User Metadata] Saved bot owner: ${phoneNumber} (JID: ${userJid}, LID: ${userLid})`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
logger.error({ err }, "[Error] Failed to save bot owner metadata");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Cache all group metadata
|
|
183
|
+
async function cacheAllGroups(sock) {
|
|
184
|
+
logger.info("[Cache] Fetching and caching metadata for all groups...");
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const groups = await sock.groupFetchAllParticipating();
|
|
188
|
+
let cachedCount = 0;
|
|
189
|
+
|
|
190
|
+
for (const jid in groups) {
|
|
191
|
+
groupMetadataCache.set(jid, groups[jid]);
|
|
192
|
+
cachedCount++;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
logger.info(`[Cache] Successfully cached ${cachedCount} groups.`);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
logger.error("[Error] Failed to fetch and cache groups:", err);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { handleConnectionUpdate } from './connection.js';
|
|
3
|
+
import { handleIncomingMessage } from '../handlers/message.handler.js';
|
|
4
|
+
import { handleGroupParticipantsUpdate, handleGroupJoinRequests } from '../handlers/group.handler.js';
|
|
5
|
+
import { groupMetadataCache } from './socket.js';
|
|
6
|
+
import { storeLidPnMapping, storeLidPnMappings } from '../utils/storage.esm.js';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const logger = require('../utils/logger.cjs');
|
|
10
|
+
|
|
11
|
+
// Setup all event listeners for WhatsApp socket
|
|
12
|
+
export function setupEventListeners(sock, saveCreds, clearAll, io, initializeScheduledJobs, reconnectCallback) {
|
|
13
|
+
// Connection updates
|
|
14
|
+
sock.ev.on('connection.update', async (update) => {
|
|
15
|
+
const result = await handleConnectionUpdate(update, sock, clearAll, io, initializeScheduledJobs);
|
|
16
|
+
if (result?.shouldReconnect && reconnectCallback) {
|
|
17
|
+
// Trigger reconnection
|
|
18
|
+
await reconnectCallback();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Save credentials
|
|
23
|
+
sock.ev.on('creds.update', saveCreds);
|
|
24
|
+
|
|
25
|
+
// V7: LID mapping updates
|
|
26
|
+
sock.ev.on('lid-mapping.update', (updates) => {
|
|
27
|
+
logger.info(`[LID] Received ${Object.keys(updates).length} LID mapping updates`);
|
|
28
|
+
const mappings = [];
|
|
29
|
+
for (const [lid, pn] of Object.entries(updates)) {
|
|
30
|
+
if (lid && pn) {
|
|
31
|
+
mappings.push({ lid, pn, deviceIndex: 0 });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (mappings.length > 0) {
|
|
35
|
+
storeLidPnMappings(mappings);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Group metadata updates
|
|
40
|
+
sock.ev.on('groups.upsert', (updates) => {
|
|
41
|
+
for (const group of updates) {
|
|
42
|
+
logger.info(`[Cache] Caching metadata for group: ${group.id}`);
|
|
43
|
+
groupMetadataCache.set(group.id, group);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Incoming messages
|
|
48
|
+
sock.ev.on('messages.upsert', async (m) => {
|
|
49
|
+
await handleIncomingMessage(sock, m);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Group participant updates
|
|
53
|
+
sock.ev.on('group-participants.update', async (update) => {
|
|
54
|
+
await handleGroupParticipantsUpdate(sock, update);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Group join requests
|
|
58
|
+
sock.ev.on('group-requests.update', async (events) => {
|
|
59
|
+
await handleGroupJoinRequests(sock, events);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { makeWASocket } from '@whiskeysockets/baileys';
|
|
2
|
+
import NodeCache from 'node-cache';
|
|
3
|
+
import {
|
|
4
|
+
getBaileysConfig,
|
|
5
|
+
setRecentMessageGetter,
|
|
6
|
+
} from '../config/baileys.config.js';
|
|
7
|
+
import { CACHE_CONFIG } from '../config/constants.js';
|
|
8
|
+
import { useDatabaseAuthState } from '../auth/use-database-auth-state.js';
|
|
9
|
+
import { getMessageFromRecent } from '../utils/recentMessageCache.esm.js';
|
|
10
|
+
import { createRequire } from 'module';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const logger = require('../utils/logger.cjs');
|
|
14
|
+
const { withMediaThumbnail } = require('../utils/thumbnail.cjs');
|
|
15
|
+
|
|
16
|
+
// Group metadata cache
|
|
17
|
+
export const groupMetadataCache = new NodeCache(CACHE_CONFIG);
|
|
18
|
+
|
|
19
|
+
// Wire the recent-message cache into Baileys' getMessage hook so retries can
|
|
20
|
+
// recover (rc10 enableAutoSessionRecreation needs this).
|
|
21
|
+
setRecentMessageGetter(getMessageFromRecent);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Baileys can't generate media previews in this install (no sharp/jimp, and its
|
|
25
|
+
* video path shells out to a bare `ffmpeg` that isn't on the PATH), so every
|
|
26
|
+
* image/video we sent went out with a blank thumbnail. Patch sendMessage once,
|
|
27
|
+
* at the socket level, so EVERY caller gets a preview — commands that use
|
|
28
|
+
* sendBotMessage and the handful of legacy commands still calling
|
|
29
|
+
* sock.sendMessage directly.
|
|
30
|
+
*/
|
|
31
|
+
function withThumbnailSupport(sock) {
|
|
32
|
+
const original = sock.sendMessage.bind(sock);
|
|
33
|
+
|
|
34
|
+
sock.sendMessage = async function sendMessageWithThumbnail(jid, content, options) {
|
|
35
|
+
let payload = content;
|
|
36
|
+
try {
|
|
37
|
+
payload = await withMediaThumbnail(content);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
logger.debug({ err: err?.message }, '[Socket] thumbnail step skipped');
|
|
40
|
+
payload = content;
|
|
41
|
+
}
|
|
42
|
+
return original(jid, payload, options);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return sock;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Create and configure WhatsApp socket
|
|
49
|
+
export async function createWhatsAppSocket() {
|
|
50
|
+
logger.info('[Socket] Initializing WhatsApp socket with database auth');
|
|
51
|
+
|
|
52
|
+
const { state, saveCreds, clearAll } = await useDatabaseAuthState();
|
|
53
|
+
|
|
54
|
+
const cachedGroupMetadata = (jid) => groupMetadataCache.get(jid);
|
|
55
|
+
|
|
56
|
+
const sock = withThumbnailSupport(
|
|
57
|
+
makeWASocket({
|
|
58
|
+
auth: state,
|
|
59
|
+
...getBaileysConfig(cachedGroupMetadata),
|
|
60
|
+
})
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return { sock, saveCreds, clearAll };
|
|
64
|
+
}
|