levix-bot 2.0.1 → 2.1.1

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.
@@ -45,20 +45,54 @@ function withThumbnailSupport(sock) {
45
45
  return sock;
46
46
  }
47
47
 
48
- // Create and configure WhatsApp socket
49
- export async function createWhatsAppSocket() {
50
- logger.info('[Socket] Initializing WhatsApp socket with database auth');
48
+ /**
49
+ * Create and configure a WhatsApp socket.
50
+ *
51
+ * @param {object} [options]
52
+ * @param {object|null} [options.proxy] - what createProxyAgents() returned, or
53
+ * null for a direct connection. Built by the session manager and passed in
54
+ * rather than read here, so this factory stays a pure function of its
55
+ * arguments and a test can hand it a fake.
56
+ */
57
+ export async function createWhatsAppSocket({ proxy = null } = {}) {
58
+ logger.info(
59
+ proxy
60
+ ? `[Socket] Initializing WhatsApp socket through ${proxy.label}`
61
+ : '[Socket] Initializing WhatsApp socket with database auth'
62
+ );
51
63
 
52
64
  const { state, saveCreds, clearAll } = await useDatabaseAuthState();
53
65
 
66
+ // Whether this install is already paired. NOT `store.hasCredentials()`: the
67
+ // auth state writes a `creds` row on its very first call, paired or not, so
68
+ // that row proves nothing. `creds.me.id` is only filled in by a successful
69
+ // pairing, which is exactly the question the session manager is asking —
70
+ // it decides whether a close is a failed pairing attempt or a dropped
71
+ // connection worth retrying.
72
+ const isPaired = !!state?.creds?.me?.id;
73
+
54
74
  const cachedGroupMetadata = (jid) => groupMetadataCache.get(jid);
55
75
 
76
+ const config = getBaileysConfig(cachedGroupMetadata);
77
+
78
+ // Three separate hooks, because Baileys has three separate network paths:
79
+ // agent -> the `ws` WebSocket (https.request, classic agent)
80
+ // fetchAgent -> media UPLOAD on Node (https.request, classic agent)
81
+ // options.dispatcher -> media DOWNLOAD (global fetch, undici Dispatcher)
82
+ // Setting only the first would leave every photo the bot sends or receives
83
+ // going out from the real IP. See src/core/proxy.js.
84
+ if (proxy) {
85
+ config.agent = proxy.agent;
86
+ config.fetchAgent = proxy.fetchAgent;
87
+ config.options = { ...(config.options || {}), dispatcher: proxy.dispatcher };
88
+ }
89
+
56
90
  const sock = withThumbnailSupport(
57
91
  makeWASocket({
58
92
  auth: state,
59
- ...getBaileysConfig(cachedGroupMetadata),
93
+ ...config,
60
94
  })
61
95
  );
62
96
 
63
- return { sock, saveCreds, clearAll };
97
+ return { sock, saveCreds, clearAll, isPaired };
64
98
  }
package/src/index.js CHANGED
@@ -3,10 +3,15 @@
3
3
  // Two shapes, one core:
4
4
  //
5
5
  // levix bootstrapCore() + bootstrapPanel(), then maybe a browser
6
- // levix headless bootstrapCore() only — nothing binds a port
6
+ // levix headless bootstrapCore({ autoStart: true }) — nothing binds a port
7
7
  //
8
8
  // The difference is which bootstrap runs, not a flag threaded through the
9
9
  // codebase. See src/bootstrap/.
10
+ //
11
+ // WhatsApp is not dialled by starting the process. In panel mode the session
12
+ // sits idle until somebody presses Start on the Connection screen, so a fresh
13
+ // install can be configured before it ever tries to pair. Headless has no
14
+ // screen and nobody to press anything, so it asks the core to connect for it.
10
15
 
11
16
  import os from "node:os";
12
17
  import { createRequire } from "module";
@@ -15,9 +20,9 @@ import { flushStore } from "./db/store.esm.js";
15
20
  import brand from "./config/brand.esm.js";
16
21
 
17
22
  const require = createRequire(import.meta.url);
23
+ const qrcodeTerminal = require("qrcode-terminal");
18
24
  const logger = require("./utils/logger.cjs");
19
25
  const secrets = require("./config/secrets.cjs");
20
- const settings = require("./config/settings.cjs");
21
26
  const { DATA_DIR } = require("./config/paths.cjs");
22
27
  const { release: releaseLock } = require("./config/lock.cjs");
23
28
  const { close: closeDatabase } = require("./db/db.cjs");
@@ -32,6 +37,10 @@ function short(path) {
32
37
  return home && path.startsWith(home) ? path.replace(home, "~") : path;
33
38
  }
34
39
 
40
+ // The session manager, so the shutdown handler can cancel its timers. Set as
41
+ // soon as the core is up.
42
+ let liveSession = null;
43
+
35
44
  /**
36
45
  * Start Levix.
37
46
  *
@@ -48,17 +57,66 @@ export async function start({ headless = false, open = true } = {}) {
48
57
  return headless ? startHeadless() : startWithPanel({ open });
49
58
  }
50
59
 
60
+ /**
61
+ * Print the QR in the terminal.
62
+ *
63
+ * A QR belongs there in both modes: the panel shows one too, but somebody on an
64
+ * SSH session has no browser to show it in. This used to live inside the
65
+ * connection handler, which meant src/core had to know a human might be
66
+ * watching. Attached after whatever else the mode wants to say about a QR, so
67
+ * the explanation comes before the block of glyphs rather than after it.
68
+ */
69
+ function attachTerminalQr() {
70
+ attach((event, payload) => {
71
+ if (event === "qr" && typeof payload === "string") {
72
+ qrcodeTerminal.generate(payload, { small: true });
73
+ }
74
+ });
75
+ }
76
+
51
77
  // ---------------------------------------------------------------------------
52
78
  // headless
53
79
  // ---------------------------------------------------------------------------
54
80
 
81
+ /**
82
+ * What headless does when the session reaches a state that needs a person.
83
+ *
84
+ * The panel answers this with a Start button. Headless has no button, no stdin
85
+ * and no UI, so a bot sitting in `disconnected` or `retry_exhausted` there is
86
+ * not "still running" in any useful sense — it is a silent process that will
87
+ * never answer another message. Handing the decision to the supervisor is the
88
+ * honest ending: systemd, pm2 and Docker all bring it back, and a genuinely
89
+ * persistent refusal trips their own restart limits and surfaces as a failed
90
+ * unit rather than as a bot that quietly stopped working.
91
+ *
92
+ * Panel mode never calls this. There, Levix stays up by design.
93
+ */
94
+ const HEADLESS_GRACE_MS = 5000;
95
+
96
+ function surrender(state) {
97
+ line();
98
+ line(" No panel here to start it again — stopping so the supervisor can.");
99
+ line(" (systemd / pm2 / docker restart it; without one, run levix again.)");
100
+ line();
101
+ logger.error(`[headless] session ended in ${state} — exiting for the supervisor`);
102
+ // Not unref'd: in a terminal state nothing else is holding the loop open, so
103
+ // an unref'd timer would let the process fall out with code 0 before this
104
+ // ever ran — the same restart, but with no signal that anything went wrong.
105
+ setTimeout(() => process.exit(1), HEADLESS_GRACE_MS);
106
+ }
107
+
55
108
  async function startHeadless() {
56
109
  line();
57
110
  line(` ${brand.name} — headless`);
58
111
 
59
112
  // Attached before the bot starts, because the QR can arrive during the very
60
113
  // first connection attempt.
114
+ //
115
+ // Headless has no Connection screen, so the terminal has to say what the
116
+ // panel would have shown — including the states that need a person, where
117
+ // "restart it" is the only instruction that exists here.
61
118
  let announcedPairing = false;
119
+ let lastState = null;
62
120
  attach((event, payload) => {
63
121
  if (event === "qr" && !announcedPairing) {
64
122
  announcedPairing = true;
@@ -69,16 +127,36 @@ async function startHeadless() {
69
127
  line(" WhatsApp -> Linked devices -> Link a device");
70
128
  line();
71
129
  }
72
- if (event === "status_update" && payload?.status === "Connected") {
73
- line();
74
- line(" ✓ WhatsApp connected");
75
- }
76
- if (event === "status_update" && payload?.status === "Disconnected") {
77
- line(" … WhatsApp disconnected, reconnecting");
130
+
131
+ if (event !== "session" || payload?.state === lastState) return;
132
+ lastState = payload.state;
133
+
134
+ switch (payload.state) {
135
+ case "connected":
136
+ line();
137
+ line(" ✓ WhatsApp connected");
138
+ break;
139
+ case "reconnecting":
140
+ line(` … WhatsApp disconnected, reconnecting (attempt ${payload.attempt}/${payload.maxAttempts})`);
141
+ break;
142
+ case "logged_out":
143
+ case "retry_exhausted":
144
+ case "disconnected":
145
+ case "error":
146
+ line();
147
+ line(` ✗ WhatsApp is not connected${payload.detail ? ` — ${payload.detail}` : ""}`);
148
+ surrender(payload.state);
149
+ break;
150
+ default:
151
+ break;
78
152
  }
79
153
  });
80
154
 
81
- const core = await bootstrapCore();
155
+ attachTerminalQr();
156
+
157
+ // Headless has no Start button, so it starts itself.
158
+ const core = await bootstrapCore({ autoStart: true });
159
+ liveSession = core.session;
82
160
 
83
161
  if (announcedPairing) line(" Waiting for connection...");
84
162
 
@@ -101,18 +179,12 @@ async function startHeadless() {
101
179
  async function startWithPanel({ open }) {
102
180
  const { bootstrapPanel, panelUrl } = await import("./bootstrap/panel.js");
103
181
 
104
- let setBotInstance = null;
105
- let setBotControls = null;
106
-
107
- // The panel needs the live socket, and a reconnect replaces it. Passing the
108
- // callback into the core keeps that true without the core knowing what a
109
- // panel is.
110
- const core = await bootstrapCore({
111
- onSocket(sock, controls) {
112
- if (setBotInstance) setBotInstance(sock);
113
- if (setBotControls) setBotControls({ clearAll: controls.clearAll });
114
- },
115
- });
182
+ attachTerminalQr();
183
+
184
+ // Nothing dials WhatsApp here. The panel comes up against an idle session and
185
+ // the Connection screen offers a Start button.
186
+ const core = await bootstrapCore({ autoStart: false });
187
+ liveSession = core.session;
116
188
 
117
189
  let panel;
118
190
  try {
@@ -126,9 +198,6 @@ async function startWithPanel({ open }) {
126
198
  throw error;
127
199
  }
128
200
 
129
- ({ setBotInstance, setBotControls } = panel);
130
- setBotInstance(core.sock);
131
-
132
201
  const firstRun = !secrets.hasDashboardPassword();
133
202
  const url = panelUrl({ port: panel.port, firstRun });
134
203
 
@@ -140,10 +209,15 @@ async function startWithPanel({ open }) {
140
209
  line(` Panel: ${url}`);
141
210
  line(` Data: ${short(DATA_DIR)}`);
142
211
 
143
- if (firstRun && !settings.get("public_domain")) {
212
+ // Only while the panel is unclaimed, and only once per process. Printed on
213
+ // every deployment shape, including one behind a domain: a proxied request
214
+ // can never count as local, so that is precisely the case that needs it.
215
+ if (firstRun) {
144
216
  line();
145
217
  line(" First run — that link asks you to pick a password.");
146
- line(` Opening it from another machine also needs this code: ${secrets.getSetupCode()}`);
218
+ line(" Opening it from another machine also needs this code:");
219
+ line();
220
+ line(` ${secrets.formatSetupCodeLine()}`);
147
221
  }
148
222
 
149
223
  if (open) {
@@ -156,6 +230,9 @@ async function startWithPanel({ open }) {
156
230
  );
157
231
  }
158
232
 
233
+ line();
234
+ line(" Not linked to WhatsApp yet? Open the panel, go to Connection and");
235
+ line(" press Start session.");
159
236
  line();
160
237
  line(" Press Ctrl+C to stop.");
161
238
  line();
@@ -188,10 +265,30 @@ function installShutdownHandlers() {
188
265
  shuttingDown = true;
189
266
  logger.info(`[shutdown] ${signal} received, closing down...`);
190
267
 
268
+ // Armed first, not last: everything below is awaited, and a socket close or
269
+ // a request that refuses to end would otherwise mean the watchdog is never
270
+ // reached at all. Not unref'd, for the same reason — this timer exists
271
+ // precisely for the case where nothing else is going to wake the loop.
272
+ const watchdog = setTimeout(() => {
273
+ logger.error("[shutdown] took too long — exiting anyway");
274
+ process.exit(1);
275
+ }, 10_000);
276
+
277
+ // Before anything else: no reconnect may fire while we are going down, and
278
+ // a pending retry timer must not outlive the process it was scheduled in.
279
+ if (liveSession) {
280
+ try {
281
+ await liveSession.shutdown();
282
+ } catch (error) {
283
+ logger.warn({ err: error }, "[shutdown] closing the WhatsApp session failed");
284
+ }
285
+ }
286
+
191
287
  const finish = async () => {
192
288
  await Promise.allSettled([flushStore()]);
193
289
  closeDatabase();
194
290
  releaseLock();
291
+ clearTimeout(watchdog);
195
292
  process.exit(0);
196
293
  };
197
294
 
@@ -199,16 +296,26 @@ function installShutdownHandlers() {
199
296
  // listening yet. Reaching into the module cache rather than requiring it
200
297
  // keeps this from constructing an Express app during shutdown.
201
298
  let server = null;
299
+ let io = null;
202
300
  try {
203
301
  const cached = require.cache[require.resolve("../app.cjs")];
204
302
  server = cached?.exports?.server ?? null;
303
+ io = cached?.exports?.io ?? null;
205
304
  } catch {}
206
305
 
306
+ // server.close() waits for every open connection, and a dashboard sitting
307
+ // on a socket.io websocket is an open connection that never ends by itself
308
+ // — so without this the shutdown ran to the 10s hard exit every time a tab
309
+ // was open, which is exactly what the panel's own Restart button does.
310
+ try {
311
+ io?.disconnectSockets(true);
312
+ server?.closeIdleConnections?.();
313
+ } catch (error) {
314
+ logger.debug({ err: error?.message }, "[shutdown] closing panel connections failed");
315
+ }
316
+
207
317
  if (server?.listening) server.close(finish);
208
318
  else await finish();
209
-
210
- // Don't wait forever on a request that refuses to end.
211
- setTimeout(() => process.exit(1), 10_000).unref();
212
319
  };
213
320
 
214
321
  ["SIGTERM", "SIGINT"].forEach((signal) => process.on(signal, () => shutdown(signal)));
@@ -7,9 +7,10 @@
7
7
  // Two rules the handlers follow:
8
8
  // * error text stays in the log. The internal message names collections and
9
9
  // server paths, and this response is rendered in a browser.
10
- // * nothing here can change the bot's identity. The name and the credit come
11
- // from src/config/brand.cjs, are injected into the AI prompt, and are not
12
- // exposed as an editable field anywhere below.
10
+ // * nothing here can change the bot's identity. What the UI renders comes
11
+ // from src/config/brand.cjs; what the model is told comes from
12
+ // src/config/ai-identity.cjs, which this file does not import and no route
13
+ // below returns. Neither is an editable field anywhere in the panel.
13
14
 
14
15
  import { Router } from "express";
15
16
  import { createRequire } from "module";
@@ -52,17 +53,44 @@ const { deleteScheduledJob } = require("../../scheduler.cjs");
52
53
 
53
54
  const router = Router();
54
55
 
55
- // The live socket, and the auth-clearing callback that comes with it.
56
- let botInstance = null;
57
- let botControls = { clearAll: null };
56
+ // The backend's WhatsApp session manager (src/core/session.js). The routes ask
57
+ // it questions and give it orders; they never create or destroy a socket
58
+ // themselves, and nothing a browser does to its websocket reaches it.
59
+ let session = null;
58
60
 
59
- export function setBotInstance(sock) {
60
- botInstance = sock;
61
+ export function setSession(manager) {
62
+ session = manager;
61
63
  }
62
64
 
63
- /** Handed the pieces of the current socket that /bot/* needs. */
64
- export function setBotControls(controls = {}) {
65
- botControls = { ...botControls, ...controls };
65
+ /** The live socket, or null. Re-read every time — a reconnect replaces it. */
66
+ function currentSocket() {
67
+ return session?.socket ?? null;
68
+ }
69
+
70
+ /** A safe snapshot even before the session manager has been handed over. */
71
+ function sessionState() {
72
+ return (
73
+ session?.getState() ?? {
74
+ state: "idle",
75
+ status: "Disconnected",
76
+ connected: false,
77
+ canStart: false,
78
+ canStop: false,
79
+ canUnlink: false,
80
+ proxy: null,
81
+ proxyChanged: false,
82
+ terminal: false,
83
+ hasQr: false,
84
+ attempt: 0,
85
+ maxAttempts: 0,
86
+ nextRetryAt: null,
87
+ reason: "no_session",
88
+ detail: null,
89
+ user: null,
90
+ lastDisconnect: null,
91
+ since: null,
92
+ }
93
+ );
66
94
  }
67
95
 
68
96
  // --- helpers --------------------------------------------------------------
@@ -91,9 +119,12 @@ function asyncRoute(handler) {
91
119
 
92
120
  router.get("/stats", (req, res) => {
93
121
  try {
122
+ const connection = sessionState();
123
+ const sock = currentSocket();
94
124
  res.json({
95
125
  success: true,
96
126
  stats: {
127
+ connection,
97
128
  totalGroups: countGroups(),
98
129
  totalWarnings: countWarnings(),
99
130
  totalTodos: countTodos(),
@@ -102,9 +133,9 @@ router.get("/stats", (req, res) => {
102
133
  totalSettledDebts: countDebts(true),
103
134
  totalSchedules: countSchedules(),
104
135
 
105
- isConnected: !!botInstance?.user,
106
- botNumber: botInstance?.user?.id || null,
107
- botName: botInstance?.user?.name || null,
136
+ isConnected: connection.connected && !!sock?.user,
137
+ botNumber: sock?.user?.id || null,
138
+ botName: sock?.user?.name || null,
108
139
 
109
140
  prefix: runtimeConfig.getPrefix(),
110
141
  commandCount: getCommandCatalog().length,
@@ -123,12 +154,16 @@ router.get("/stats", (req, res) => {
123
154
  });
124
155
 
125
156
  router.get("/health", (req, res) => {
157
+ const connection = sessionState();
126
158
  res.json({
127
159
  success: true,
128
160
  health: {
161
+ // Levix is healthy whether or not WhatsApp is linked: the panel is the
162
+ // only surface, and it answers long before a pairing exists.
129
163
  status: "healthy",
130
164
  database: true,
131
- bot: botInstance?.user ? "connected" : "disconnected",
165
+ bot: connection.connected ? "connected" : "disconnected",
166
+ session: connection.state,
132
167
  uptime: process.uptime(),
133
168
  timestamp: Date.now(),
134
169
  },
@@ -742,38 +777,100 @@ router.post("/security/password", (req, res) => {
742
777
  // Bot control
743
778
  // ===========================================================================
744
779
 
745
- // Unlink the WhatsApp account. The connection handler sees the logout, clears
746
- // the stored credentials and comes back with a fresh QR — no restart needed.
780
+ // The WhatsApp session. These four routes are the only way a browser can
781
+ // influence it, and each is a request to the backend's state machine rather
782
+ // than an instruction to make or break a socket. Everything under
783
+ // /dashboard/api is already behind the panel session and the cross-origin
784
+ // check in app.cjs, so no new surface is exposed here.
785
+
786
+ // The whole Connection screen in one request, including the QR already on
787
+ // screen. Without the code here a dashboard opened (or reloaded) while a
788
+ // pairing was already waiting showed an empty frame until Baileys happened to
789
+ // issue the next one.
790
+ router.get("/bot/session", (req, res) => {
791
+ res.json({ success: true, session: sessionState(), qr: session?.qr ?? null });
792
+ });
793
+
794
+ // Idempotent by construction: the manager returns the state it is already in
795
+ // when a socket exists or is being made, so a double-click, two operators and a
796
+ // retry firing at the same moment all end up with exactly one socket.
747
797
  router.post(
748
- "/bot/logout",
798
+ "/bot/session/start",
749
799
  asyncRoute(async (req, res) => {
750
- if (!botInstance) {
751
- return res.status(409).json({ success: false, error: "Bot is not connected" });
800
+ if (!session) {
801
+ return res.status(503).json({ success: false, error: "Session manager is not ready" });
752
802
  }
803
+ const state = await session.start({ reason: "dashboard" });
804
+ logger.info(`[Dashboard] Start session requested — now ${state.state}`);
805
+ res.json({ success: true, session: state });
806
+ })
807
+ );
753
808
 
754
- try {
755
- await botInstance.logout();
756
- } catch (error) {
757
- // A logout that fails still has to clear the local credentials, or the
758
- // bot reconnects with a session WhatsApp already dropped.
759
- logger.warn({ err: error }, "[Dashboard] logout call failed, clearing anyway");
760
- if (botControls.clearAll) await botControls.clearAll();
809
+ // Apply configuration that only a new socket can pick up — the proxy, today.
810
+ // It goes through the session's own stop()+start(), so this route creates
811
+ // nothing and the state machine stays the only thing that owns a socket.
812
+ router.post(
813
+ "/bot/session/reconnect",
814
+ asyncRoute(async (req, res) => {
815
+ if (!session) {
816
+ return res.status(503).json({ success: false, error: "Session manager is not ready" });
761
817
  }
818
+ const state = await session.reconnect({ reason: "dashboard" });
819
+ logger.info(`[Dashboard] Reconnect requested — now ${state.state}`);
820
+ res.json({ success: true, session: state });
821
+ })
822
+ );
762
823
 
824
+ // Stop talking to WhatsApp but keep the pairing. No retry follows a manual
825
+ // stop, and any pending one is cancelled.
826
+ router.post(
827
+ "/bot/session/stop",
828
+ asyncRoute(async (req, res) => {
829
+ if (!session) {
830
+ return res.status(503).json({ success: false, error: "Session manager is not ready" });
831
+ }
832
+ const state = await session.stop({ reason: "dashboard" });
833
+ logger.warn("[Dashboard] WhatsApp session stopped from the dashboard");
834
+ res.json({ success: true, session: state });
835
+ })
836
+ );
837
+
838
+ // Unlink the WhatsApp account: WhatsApp drops the companion device and the
839
+ // stored credentials go with it. Levix stays up, and the operator can start a
840
+ // fresh pairing whenever they want one.
841
+ router.post(
842
+ "/bot/logout",
843
+ asyncRoute(async (req, res) => {
844
+ if (!session) {
845
+ return res.status(503).json({ success: false, error: "Session manager is not ready" });
846
+ }
847
+ // Deliberately NOT gated on a live socket. A pairing WhatsApp has refused
848
+ // (403, 405) leaves dead credentials with no connection to unlink through,
849
+ // and that is precisely when the operator needs this.
850
+ if (!sessionState().canUnlink) {
851
+ return res.status(409).json({ success: false, error: "There is nothing linked to unlink" });
852
+ }
853
+
854
+ const state = await session.logout();
763
855
  logger.warn("[Dashboard] WhatsApp account unlinked from the dashboard");
764
- res.json({ success: true });
856
+ res.json({ success: true, session: state });
765
857
  })
766
858
  );
767
859
 
768
860
  // Only useful under a supervisor (pm2 / systemd / docker restart:always) —
769
- // this exits the process and something else has to bring it back.
861
+ // this stops the process and something else has to bring it back.
862
+ //
863
+ // SIGTERM to ourselves rather than process.exit(): that is the path in
864
+ // src/index.js that cancels the reconnect timers, closes the WhatsApp socket,
865
+ // flushes the store and closes the database. Exiting straight from here skipped
866
+ // all four.
770
867
  router.post("/bot/restart", (req, res) => {
771
868
  logger.warn("[Dashboard] restart requested");
772
869
  res.json({
773
870
  success: true,
774
871
  message: "Restarting. If the bot is not running under a supervisor, start it again yourself.",
775
872
  });
776
- setTimeout(() => process.exit(0), 500);
873
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 500).unref();
777
874
  });
778
875
 
779
876
  export default router;