levix-bot 2.0.1 → 2.1.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.
@@ -59,9 +59,10 @@ const SETTINGS = [
59
59
  {
60
60
  key: "gemini_model",
61
61
  type: "string",
62
- default: "gemini-2.5-flash",
62
+ default: "gemini-3.7-flash",
63
63
  group: "ai",
64
64
  label: "Gemini model",
65
+ hint: "The model that answers. Flash is the default because a WhatsApp reply is judged on how fast it arrives; it still takes Google Search and Levix's own function tools in the same request, which is what the agent depends on. A Gemini 3 Pro model works here too if you would rather trade latency for depth.",
65
66
  },
66
67
  {
67
68
  key: "ai_agent",
@@ -108,6 +109,30 @@ const SETTINGS = [
108
109
  label: "Memory injected (chars)",
109
110
  hint: "How much of the long-term memory files goes into every prompt.",
110
111
  },
112
+ {
113
+ key: "gemini_stt_model",
114
+ type: "string",
115
+ default: "gemini-3.7-flash",
116
+ group: "ai",
117
+ label: "Gemini transcription model",
118
+ hint: "Used by !stt only. Transcription is high-volume and latency-bound, so it stays on Flash whatever the chat model is set to.",
119
+ },
120
+ {
121
+ key: "gemini_image_model",
122
+ type: "string",
123
+ default: "gemini-3.1-flash-image",
124
+ group: "ai",
125
+ label: "Gemini image model",
126
+ hint: "Used by !generate only. Image output needs a model that can return images, which is not the same one that answers chat — gemini-3.1-flash-image is the dedicated stable one.",
127
+ },
128
+ {
129
+ key: "ai_google_search",
130
+ type: "bool",
131
+ default: true,
132
+ group: "ai",
133
+ label: "Gemini Google Search",
134
+ hint: "Lets Gemini use Google's own search grounding when a question needs current information. Gemini only — the Groq fallback is unaffected.",
135
+ },
111
136
  {
112
137
  key: "groq_api_key",
113
138
  type: "secret",
@@ -185,6 +210,65 @@ const SETTINGS = [
185
210
  hint: "Only if the bundled ffmpeg-static binary can't run on this machine.",
186
211
  },
187
212
 
213
+ // --- WhatsApp proxy ----------------------------------------------------
214
+ //
215
+ // Applies to the WhatsApp connection ONLY: the session manager hands these
216
+ // to the one makeWASocket() call (src/core/proxy.js). The control panel, the
217
+ // AI providers and every other outbound request are untouched.
218
+ //
219
+ // None of these apply to a socket that is already open — the Connection
220
+ // screen offers "Reconnect to apply" instead of dropping a healthy session
221
+ // the moment somebody opens Settings.
222
+ {
223
+ key: "whatsapp_proxy_enabled",
224
+ type: "bool",
225
+ default: false,
226
+ group: "whatsapp",
227
+ label: "Route WhatsApp through a proxy",
228
+ hint: "Off means WhatsApp connects directly, exactly as before.",
229
+ },
230
+ {
231
+ key: "whatsapp_proxy_protocol",
232
+ type: "string",
233
+ default: "http",
234
+ choices: ["http", "https", "socks5"],
235
+ group: "whatsapp",
236
+ label: "Proxy protocol",
237
+ },
238
+ {
239
+ key: "whatsapp_proxy_host",
240
+ type: "string",
241
+ default: "",
242
+ group: "whatsapp",
243
+ label: "Proxy host",
244
+ hint: "Hostname or IP of the proxy server. Required when the proxy is on.",
245
+ },
246
+ {
247
+ key: "whatsapp_proxy_port",
248
+ type: "int",
249
+ default: 0,
250
+ min: 0,
251
+ max: 65535,
252
+ group: "whatsapp",
253
+ label: "Proxy port",
254
+ },
255
+ {
256
+ key: "whatsapp_proxy_username",
257
+ type: "string",
258
+ default: "",
259
+ group: "whatsapp",
260
+ label: "Proxy username",
261
+ hint: "Leave empty for a proxy that needs no authentication.",
262
+ },
263
+ {
264
+ key: "whatsapp_proxy_password",
265
+ type: "secret",
266
+ default: "",
267
+ group: "whatsapp",
268
+ label: "Proxy password",
269
+ hint: "Stored like an API key: never sent back to this page, never logged.",
270
+ },
271
+
188
272
  // --- Server ------------------------------------------------------------
189
273
  {
190
274
  key: "port",
@@ -251,6 +335,7 @@ const GROUP_LABELS = {
251
335
  integrations: "Integrations",
252
336
  media: "Media",
253
337
  server: "Server",
338
+ whatsapp: "WhatsApp proxy",
254
339
  };
255
340
 
256
341
  // This module is required by files that load before the database is opened in
@@ -319,7 +404,15 @@ function validate(definition, value) {
319
404
 
320
405
  if (definition.type === "bool") return coerce(definition, value);
321
406
 
322
- const text = String(value ?? "");
407
+ const text = String(value ?? "").trim();
408
+
409
+ // A fixed set of values (the proxy protocol) is still a string setting; the
410
+ // list is what makes the dashboard render a dropdown and what stops a
411
+ // hand-crafted PATCH from storing something the code cannot act on.
412
+ if (definition.choices && text && !definition.choices.includes(text)) {
413
+ throw new Error(`${definition.label} must be one of: ${definition.choices.join(", ")}`);
414
+ }
415
+
323
416
  if (text.length > 500) throw new Error(`${definition.label} is too long`);
324
417
  if (definition.key === "bot_timezone" && text) {
325
418
  try {
@@ -372,6 +465,7 @@ function describe() {
372
465
  hint: definition.hint || null,
373
466
  min: definition.min ?? null,
374
467
  max: definition.max ?? null,
468
+ choices: definition.choices ?? null,
375
469
  restart: definition.restart === true,
376
470
  source: sourceOf(definition.key),
377
471
  };
@@ -1,152 +1,158 @@
1
- import { delay, DisconnectReason } from "@whiskeysockets/baileys";
2
- import qrcode from "qrcode-terminal";
1
+ // What a WhatsApp connection means, minus the decision of when to make one.
2
+ //
3
+ // The retry schedule, the socket's lifetime and the state machine live in
4
+ // src/core/session.js. This file answers two narrower questions:
5
+ //
6
+ // classifyDisconnect(statusCode) — is this close terminal, and why?
7
+ // handleConnectionOpen(sock, …) — what has to happen once one is open?
8
+ //
9
+ // Keeping them apart is what lets the state machine be tested without Baileys
10
+ // and without a database.
11
+
12
+ import { DisconnectReason } from "@whiskeysockets/baileys";
3
13
  import { createRequire } from "module";
4
- import { MAX_RETRIES, RETRY_DELAY_MS } from "../config/constants.js";
5
14
  import { groupMetadataCache } from "./socket.js";
6
15
  import {
7
- saveQrCode,
8
- deleteQrCode,
9
16
  saveUserMetadata,
10
17
  getAllOwners,
11
18
  getAllBotAdmins,
12
19
  } from "../utils/storage.esm.js";
13
- import {
14
- bootstrapOwners,
15
- bootstrapAdmins,
16
- } from "../utils/permissions.esm.js";
20
+ import { bootstrapOwners, bootstrapAdmins } from "../utils/permissions.esm.js";
17
21
 
18
22
  const require = createRequire(import.meta.url);
19
23
  const logger = require("../utils/logger.cjs");
20
24
  const { primePermissions } = require("../utils/permissions.cjs");
21
25
 
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 });
26
+ /**
27
+ * WhatsApp answers a `<failure reason="405">` when it refuses the connection
28
+ * outright — the number is not in Baileys' DisconnectReason enum, so it is
29
+ * named here rather than left as a bare literal in a comparison. Retrying it is
30
+ * how an install ends up hammering a server that has already said no, so Levix
31
+ * treats it as terminal and waits for a person.
32
+ */
33
+ export const CONNECTION_FAILURE_405 = 405;
34
+
35
+ /**
36
+ * Read a close and say what should happen next.
37
+ *
38
+ * Terminal means: do not reconnect on our own. Everything else gets the staged
39
+ * retry schedule in session.js.
40
+ *
41
+ * @param {number|undefined} statusCode - `lastDisconnect.error.output.statusCode`
42
+ */
43
+ export function classifyDisconnect(statusCode) {
44
+ // Baileys reports an intentional logout and a server-side unlink with the
45
+ // same code (DisconnectReason.loggedOut === 401), and it is the only case
46
+ // where the stored credentials are genuinely dead.
47
+ if (statusCode === DisconnectReason.loggedOut) {
48
+ return {
49
+ terminal: true,
50
+ loggedOut: true,
51
+ restartRequired: false,
52
+ reason: "logged_out",
53
+ label: "logged out",
54
+ detail: "WhatsApp unlinked this device. Start a session to pair again.",
55
+ };
40
56
  }
41
57
 
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);
58
+ if (statusCode === CONNECTION_FAILURE_405) {
59
+ return {
60
+ terminal: true,
61
+ loggedOut: false,
62
+ restartRequired: false,
63
+ reason: "connection_failure",
64
+ label: "refused by WhatsApp (405)",
65
+ // Deliberately not a credential wipe: a 405 says WhatsApp would not take
66
+ // this connection, not that the pairing is gone.
67
+ detail:
68
+ "WhatsApp refused the connection (405). The pairing was left alone — try starting the session again later.",
69
+ };
70
+ }
114
71
 
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;
72
+ if (statusCode === DisconnectReason.forbidden) {
73
+ return {
74
+ terminal: true,
75
+ loggedOut: false,
76
+ restartRequired: false,
77
+ reason: "forbidden",
78
+ label: "forbidden",
79
+ detail: "WhatsApp rejected this account (403). Reconnecting will not help.",
80
+ };
81
+ }
121
82
 
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);
83
+ // Part of the pairing handshake: WhatsApp asks for the socket to be made
84
+ // again once a phone has scanned the code. Recoverable, and never the end of
85
+ // a pairing attempt.
86
+ if (statusCode === DisconnectReason.restartRequired) {
87
+ return {
88
+ terminal: false,
89
+ loggedOut: false,
90
+ restartRequired: true,
91
+ reason: "restart_required",
92
+ label: "restart required",
93
+ detail: "WhatsApp asked for a fresh connection.",
94
+ };
147
95
  }
148
96
 
149
- return { shouldReconnect: false };
97
+ return {
98
+ terminal: false,
99
+ loggedOut: false,
100
+ restartRequired: false,
101
+ reason: "connection_closed",
102
+ label: "recoverable",
103
+ detail: null,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Everything that has to happen the moment a connection is open: record the
109
+ * paired account as an owner, seed the permission rosters, (re)start the
110
+ * scheduled jobs against the new socket, and cache the group metadata.
111
+ *
112
+ * @param {object} sock
113
+ * @param {(sock: object) => void} [initializeScheduledJobs]
114
+ */
115
+ export async function handleConnectionOpen(sock, initializeScheduledJobs) {
116
+ logger.info("✅ Connection opened successfully!");
117
+
118
+ // The paired account is always an owner — that's the whole bootstrap.
119
+ // Everyone else is granted from the dashboard (Roles) or with `!perm`, and
120
+ // is already a row in user_metadata by the time we get here.
121
+ await saveBotOwnerMetadata(sock);
122
+
123
+ const botPn = sock?.user?.id
124
+ ? `${sock.user.id.split(":")[0].split("@")[0]}@s.whatsapp.net`
125
+ : null;
126
+ const botLid = sock?.user?.lid
127
+ ? `${sock.user.lid.split(":")[0].split("@")[0]}@lid`
128
+ : null;
129
+
130
+ // Both rosters are seeded from the database so a permission check never has
131
+ // to hit it on the hot path.
132
+ const ownersRoster = [
133
+ botPn,
134
+ botLid,
135
+ ...getAllOwners().flatMap((user) => [user.jid, user.lid]),
136
+ ].filter(Boolean);
137
+ const adminsRoster = getAllBotAdmins()
138
+ .flatMap((user) => [user.jid, user.lid])
139
+ .filter(Boolean);
140
+
141
+ bootstrapOwners(ownersRoster);
142
+ bootstrapAdmins(adminsRoster);
143
+
144
+ await primePermissions();
145
+ logger.info(
146
+ `[Permissions] Owner roster: ${ownersRoster.length} entries; ` +
147
+ `admin roster: ${adminsRoster.length} entries`
148
+ );
149
+
150
+ // Called on every open, including a reconnect: scheduleNewJob() stops the
151
+ // previous timer for the same id first, so a weekly message is not sent twice
152
+ // after the first reconnect.
153
+ if (initializeScheduledJobs) initializeScheduledJobs(sock);
154
+
155
+ await cacheAllGroups(sock);
150
156
  }
151
157
 
152
158
  // Record the paired WhatsApp account as the bot's first owner. Whoever scanned
@@ -155,12 +161,12 @@ async function saveBotOwnerMetadata(sock) {
155
161
  try {
156
162
  logger.info("[User Metadata] Saving bot owner metadata...");
157
163
 
158
- const phoneNumber = sock.user?.id?.split(":")[0]?.split("@")[0];
164
+ const phoneNumber = sock?.user?.id?.split(":")[0]?.split("@")[0];
159
165
  const userJid = phoneNumber ? `${phoneNumber}@s.whatsapp.net` : null;
160
- const userLid = sock.user?.lid
166
+ const userLid = sock?.user?.lid
161
167
  ? `${sock.user.lid.split(":")[0].split("@")[0]}@lid`
162
168
  : null;
163
- const displayName = sock.user?.name || sock.user?.verifiedName || null;
169
+ const displayName = sock?.user?.name || sock?.user?.verifiedName || null;
164
170
 
165
171
  if (userJid) {
166
172
  saveUserMetadata({
@@ -1,26 +1,63 @@
1
1
  import { createRequire } from 'module';
2
- import { handleConnectionUpdate } from './connection.js';
3
2
  import { handleIncomingMessage } from '../handlers/message.handler.js';
4
3
  import { handleGroupParticipantsUpdate, handleGroupJoinRequests } from '../handlers/group.handler.js';
5
4
  import { groupMetadataCache } from './socket.js';
6
- import { storeLidPnMapping, storeLidPnMappings } from '../utils/storage.esm.js';
5
+ import { storeLidPnMappings } from '../utils/storage.esm.js';
7
6
 
8
7
  const require = createRequire(import.meta.url);
9
8
  const logger = require('../utils/logger.cjs');
10
9
 
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
- });
10
+ /**
11
+ * Wire one socket up.
12
+ *
13
+ * `connection.update` is handed straight to the session manager
14
+ * (src/core/session.js) — it owns the state machine and the retry schedule, so
15
+ * nothing here decides whether to reconnect.
16
+ *
17
+ * @param {object} sock
18
+ * @param {object} handlers
19
+ * @param {() => Promise<void>} handlers.saveCreds
20
+ * @param {(update: object) => void} handlers.onConnectionUpdate
21
+ * @param {(response: object) => void} [handlers.onHandshakeRejected]
22
+ */
23
+ export function setupEventListeners(
24
+ sock,
25
+ { saveCreds, onConnectionUpdate, onHandshakeRejected } = {}
26
+ ) {
27
+ // Every listener below goes through this. A bare `async` listener hands its
28
+ // promise to Baileys' emitter, which drops it — so one rejection anywhere in
29
+ // the message pipeline becomes an unhandledRejection, and src/index.js turns
30
+ // that into a process exit. A group lookup failing mid-disconnect is exactly
31
+ // the case the reconnect ladder exists to survive, so it must not be fatal.
32
+ const contained = (label, handler) => (payload) => {
33
+ Promise.resolve()
34
+ .then(() => handler(payload))
35
+ .catch((err) => logger.error({ err }, `[Events] ${label} handler failed`));
36
+ };
37
+
38
+ sock.ev.on(
39
+ 'connection.update',
40
+ contained('connection.update', (update) => onConnectionUpdate?.(update))
41
+ );
42
+
43
+ // A handshake the server answered with an HTTP status instead of an upgrade.
44
+ //
45
+ // This has to be listened for or the connection hangs silently forever: ws
46
+ // only calls abortHandshake() when NOTHING is listening for
47
+ // 'unexpected-response' (ws/lib/websocket.js:928), and Baileys' own
48
+ // WebSocketClient registers a re-emitting listener for it — which satisfies
49
+ // that guard and then does nothing with the event. A proxy answering 407
50
+ // therefore produced no error, no close and no connection.update at all: the
51
+ // session simply sat in `starting` until somebody noticed.
52
+ sock.ws?.on?.(
53
+ 'unexpected-response',
54
+ contained('unexpected-response', (_request, response) => {
55
+ onHandshakeRejected?.(response);
56
+ })
57
+ );
21
58
 
22
59
  // Save credentials
23
- sock.ev.on('creds.update', saveCreds);
60
+ if (saveCreds) sock.ev.on('creds.update', saveCreds);
24
61
 
25
62
  // V7: LID mapping updates
26
63
  sock.ev.on('lid-mapping.update', (updates) => {
@@ -45,17 +82,22 @@ export function setupEventListeners(sock, saveCreds, clearAll, io, initializeSch
45
82
  });
46
83
 
47
84
  // Incoming messages
48
- sock.ev.on('messages.upsert', async (m) => {
49
- await handleIncomingMessage(sock, m);
50
- });
85
+ sock.ev.on(
86
+ 'messages.upsert',
87
+ contained('messages.upsert', (m) => handleIncomingMessage(sock, m))
88
+ );
51
89
 
52
90
  // Group participant updates
53
- sock.ev.on('group-participants.update', async (update) => {
54
- await handleGroupParticipantsUpdate(sock, update);
55
- });
91
+ sock.ev.on(
92
+ 'group-participants.update',
93
+ contained('group-participants.update', (update) =>
94
+ handleGroupParticipantsUpdate(sock, update)
95
+ )
96
+ );
56
97
 
57
98
  // Group join requests
58
- sock.ev.on('group-requests.update', async (events) => {
59
- await handleGroupJoinRequests(sock, events);
60
- });
99
+ sock.ev.on(
100
+ 'group-requests.update',
101
+ contained('group-requests.update', (events) => handleGroupJoinRequests(sock, events))
102
+ );
61
103
  }