levix-bot 3.0.0-beta → 3.2.0-beta

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/app.cjs CHANGED
@@ -292,6 +292,12 @@ app.get("/", noStore, (req, res) => {
292
292
  return res.render("login", { error: null });
293
293
  });
294
294
 
295
+ app.get("/login", noStore, (req, res) => {
296
+ if (!secrets.hasDashboardPassword()) return res.redirect("/setup");
297
+ if (req.session.loggedIn) return res.redirect("/");
298
+ return res.render("login", { error: null });
299
+ });
300
+
295
301
  app.post("/login", (req, res, next) => {
296
302
  if (!secrets.hasDashboardPassword()) return res.redirect("/setup");
297
303
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "levix-bot",
3
- "version": "3.0.0-beta",
3
+ "version": "3.2.0-beta",
4
4
  "description": "A self-hosted personal WhatsApp bot with an AI agent, group moderation and a web control panel.",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -391,6 +391,19 @@
391
391
 
392
392
  $("#conn-detail").textContent = (snapshot.detail || "") + retryHint(snapshot);
393
393
 
394
+ const alertBox = $("#conn-disconnect-alert");
395
+ const alertText = $("#conn-disconnect-text");
396
+ if (alertBox && alertText) {
397
+ if (snapshot.lastDisconnect?.statusCode && snapshot.state !== "connected" && snapshot.state !== "idle") {
398
+ const code = snapshot.lastDisconnect.statusCode;
399
+ const reason = snapshot.lastDisconnect.reason || "Unknown";
400
+ alertText.innerHTML = `⚠️ <strong>Connection failure (${esc(code)}: ${esc(reason)})</strong> — check network connectivity or proxy settings.`;
401
+ alertBox.style.display = "block";
402
+ } else {
403
+ alertBox.style.display = "none";
404
+ }
405
+ }
406
+
394
407
  // The proxy line, and the one action that can apply a changed one. The
395
408
  // label is already redacted server-side — there is no password to leak
396
409
  // here because the browser was never told it.
@@ -7,10 +7,21 @@
7
7
  import { createRequire } from "module";
8
8
 
9
9
  const require = createRequire(import.meta.url);
10
+ const fs = require("node:fs");
10
11
  const logger = require("../utils/logger.cjs");
11
12
  const settings = require("../config/settings.cjs");
13
+ const paths = require("../config/paths.cjs");
12
14
  const { attach } = require("./events.cjs");
13
15
 
16
+ /** Unix socket path for the Android host. Bionic Node cannot bind TCP. */
17
+ export function androidPanelSocketPath() {
18
+ return paths.dataPath("panel.sock");
19
+ }
20
+
21
+ export function isAndroidHost() {
22
+ return process.env.LEVIX_ANDROID === "1";
23
+ }
24
+
14
25
  /**
15
26
  * Start the panel and bind its port.
16
27
  *
@@ -55,6 +66,7 @@ export async function bootstrapPanel({ core } = {}) {
55
66
 
56
67
  const port = settings.get("port");
57
68
  const host = resolveBindAddress();
69
+ const androidSock = isAndroidHost() ? androidPanelSocketPath() : null;
58
70
 
59
71
  await new Promise((resolve, reject) => {
60
72
  // "EADDRINUSE" plus a stack trace is not an answer anyone can act on, and
@@ -72,10 +84,19 @@ export async function bootstrapPanel({ core } = {}) {
72
84
  }
73
85
  reject(error);
74
86
  });
75
- server.listen(port, host, resolve);
87
+ if (androidSock) {
88
+ try {
89
+ fs.unlinkSync(androidSock);
90
+ } catch {
91
+ // first run has no leftover socket
92
+ }
93
+ server.listen(androidSock, resolve);
94
+ } else {
95
+ server.listen(port, host, resolve);
96
+ }
76
97
  });
77
98
 
78
- return { port, host, server };
99
+ return { port, host, server, socketPath: androidSock };
79
100
  }
80
101
 
81
102
  /**
@@ -89,15 +110,18 @@ export async function bootstrapPanel({ core } = {}) {
89
110
  export function resolveBindAddress() {
90
111
  const configured = settings.get("bind_address");
91
112
  if (configured) return configured;
92
- // The phone is the host: keep the panel on loopback until a later phase
93
- // opens it in a WebView. LAN bind stays an explicit setting.
94
- if (process.env.LEVIX_ANDROID === "1") return "127.0.0.1";
113
+ // Desktop / Docker: every interface. Android never reaches this — it
114
+ // binds a unix socket instead (see androidPanelSocketPath). The WebView
115
+ // still opens http://127.0.0.1:port and the host serves that over the socket.
95
116
  return "0.0.0.0";
96
117
  }
97
118
 
98
119
  /** The address a person should type, given how this install is reachable. */
99
120
  export function panelUrl({ port, firstRun = false } = {}) {
100
121
  const domain = settings.get("public_domain");
101
- const base = domain ? `https://${domain}` : `http://localhost:${port}`;
122
+ const host =
123
+ domain ||
124
+ (isAndroidHost() ? "127.0.0.1" : "localhost");
125
+ const base = domain ? `https://${host}` : `http://${host}:${port}`;
102
126
  return firstRun ? `${base}/setup` : base;
103
127
  }
@@ -22,15 +22,7 @@ const { sendBotMessage } = require("../utils/sendBotMessage.cjs");
22
22
 
23
23
  const execFileAsync = promisify(execFile);
24
24
 
25
- let ffmpegStatic = null;
26
- try {
27
- ffmpegStatic = require("ffmpeg-static");
28
- } catch (err) {
29
- logger.warn(
30
- { err: err?.message },
31
- "[TTS] ffmpeg unavailable — will fall back to raw MP3"
32
- );
33
- }
25
+ const { ffmpegPath } = require("../utils/thumbnail.cjs");
34
26
 
35
27
  function tmpPath(ext) {
36
28
  return path.join(
@@ -56,37 +48,45 @@ async function synthesizeMp3(text, mp3Path) {
56
48
  }
57
49
 
58
50
  async function transcodeToOpus(mp3Path) {
59
- if (!ffmpegStatic) return null;
51
+ const bin = ffmpegPath();
52
+ if (!bin) return null;
60
53
 
61
54
  const oggPath = tmpPath("ogg");
62
- await execFileAsync(
63
- ffmpegStatic,
64
- [
65
- "-hide_banner",
66
- "-loglevel",
67
- "error",
68
- "-y",
69
- "-i",
70
- mp3Path,
71
- "-vn",
72
- "-c:a",
73
- "libopus",
74
- "-ac",
75
- "1",
76
- "-ar",
77
- "48000",
78
- "-b:a",
79
- "48k",
80
- "-application",
81
- "voip",
82
- "-f",
83
- "ogg",
84
- oggPath,
85
- ],
86
- { windowsHide: true, timeout: 30_000 },
87
- );
88
-
89
- return oggPath;
55
+ try {
56
+ await execFileAsync(
57
+ bin,
58
+ [
59
+ "-hide_banner",
60
+ "-loglevel",
61
+ "error",
62
+ "-y",
63
+ "-i",
64
+ mp3Path,
65
+ "-vn",
66
+ "-c:a",
67
+ "libopus",
68
+ "-ac",
69
+ "1",
70
+ "-ar",
71
+ "48000",
72
+ "-b:a",
73
+ "48k",
74
+ "-application",
75
+ "voip",
76
+ "-f",
77
+ "ogg",
78
+ oggPath,
79
+ ],
80
+ { windowsHide: true, timeout: 30_000 },
81
+ );
82
+ return oggPath;
83
+ } catch (err) {
84
+ logger.warn(
85
+ { err: err?.message, bin },
86
+ "[TTS] ffmpeg transcode failed — will fall back to raw MP3"
87
+ );
88
+ return null;
89
+ }
90
90
  }
91
91
 
92
92
  module.exports = {
@@ -124,6 +124,7 @@ export const SESSION_STATES = Object.freeze({
124
124
  LINKING: "linking",
125
125
  CONNECTED: "connected",
126
126
  RECONNECTING: "reconnecting",
127
+ PAUSED: "paused",
127
128
  DISCONNECTED: "disconnected",
128
129
  RETRY_EXHAUSTED: "retry_exhausted",
129
130
  LOGGED_OUT: "logged_out",
@@ -139,6 +140,7 @@ const BUSY_STATES = new Set([
139
140
  S.LINKING,
140
141
  S.CONNECTED,
141
142
  S.RECONNECTING,
143
+ S.PAUSED,
142
144
  ]);
143
145
 
144
146
  /** What the dashboard's status pill has always been told. Kept stable. */
@@ -148,6 +150,7 @@ function legacyStatus(state, hasQr) {
148
150
  if (state === S.STARTING) return "Connecting";
149
151
  if (state === S.LINKING) return "Linking";
150
152
  if (state === S.RECONNECTING) return "Reconnecting";
153
+ if (state === S.PAUSED) return "Paused (Offline)";
151
154
  return "Disconnected";
152
155
  }
153
156
 
@@ -195,6 +198,8 @@ export class WhatsAppSession {
195
198
  #reason = null;
196
199
  #detail = null;
197
200
  #lastDisconnect = null;
201
+ #isOnline = true;
202
+ #wasActiveBeforeOffline = false;
198
203
 
199
204
  constructor({
200
205
  createSocket = createWhatsAppSocket,
@@ -290,6 +295,7 @@ export class WhatsAppSession {
290
295
  // answer crosses the wire, never the fingerprint it was computed from.
291
296
  proxyChanged: this.#proxyIsStale(),
292
297
  connected: this.#state === S.CONNECTED,
298
+ isOnline: this.#isOnline,
293
299
  terminal,
294
300
  hasQr: !!this.#qr,
295
301
  hasPairingCode: !!this.#pairingCode,
@@ -309,6 +315,52 @@ export class WhatsAppSession {
309
315
  // commands
310
316
  // -------------------------------------------------------------------------
311
317
 
318
+ /**
319
+ * Set device internet connectivity state.
320
+ *
321
+ * When offline (false):
322
+ * - Pauses active connection attempts and cancels pending retry timers.
323
+ * - Transitions to S.PAUSED without exhausting retries.
324
+ *
325
+ * When online (true):
326
+ * - Restores connection if it was active before going offline or currently paused.
327
+ * - Resets the retry attempt counter so a fresh retry ladder is available.
328
+ */
329
+ async setInternetOnline(online, { reason = "network" } = {}) {
330
+ const isOnline = !!online;
331
+ if (this.#isOnline === isOnline) return this.getState();
332
+ this.#isOnline = isOnline;
333
+
334
+ if (!isOnline) {
335
+ this.log.warn("[Session] Device is offline — pausing WhatsApp connection and retries");
336
+ this.#wasActiveBeforeOffline =
337
+ BUSY_STATES.has(this.#state) || this.#state === S.PAUSED;
338
+ this.#cancelRetry();
339
+
340
+ if (this.#socket) {
341
+ await this.#destroySocket();
342
+ }
343
+ this.#transition(S.PAUSED, {
344
+ reason: "offline",
345
+ detail: "Device is offline. Connection and retries are paused until internet is restored.",
346
+ });
347
+ return this.getState();
348
+ }
349
+
350
+ this.log.info("[Session] Device is back online — restoring WhatsApp connection");
351
+ if (this.#wasActiveBeforeOffline || this.#state === S.PAUSED) {
352
+ this.#wasActiveBeforeOffline = false;
353
+ this.#attempt = 0;
354
+ this.#cancelRetry();
355
+ await this.start({ reason: "network_restored" });
356
+ }
357
+ return this.getState();
358
+ }
359
+
360
+ get isOnline() {
361
+ return this.#isOnline;
362
+ }
363
+
312
364
  /**
313
365
  * Bring the session up. Idempotent: if a socket exists or is being made, this
314
366
  * returns the state it is already in and creates nothing.
@@ -316,11 +368,15 @@ export class WhatsAppSession {
316
368
  async start({ reason = "manual", method, phone } = {}) {
317
369
  if (this.#shuttingDown) return this.getState();
318
370
 
371
+ if (this.#state === S.PAUSED && !this.#isOnline) {
372
+ return this.getState();
373
+ }
374
+
319
375
  if (this.#starting) {
320
376
  await this.#starting.catch(() => {});
321
377
  return this.getState();
322
378
  }
323
- if (BUSY_STATES.has(this.#state)) return this.getState();
379
+ if (BUSY_STATES.has(this.#state) && this.#state !== S.PAUSED) return this.getState();
324
380
 
325
381
  this.#pairingIntent = parseStartOptions({ method, phone });
326
382
 
@@ -451,6 +507,15 @@ export class WhatsAppSession {
451
507
  // -------------------------------------------------------------------------
452
508
 
453
509
  async #open({ reason }) {
510
+ if (!this.#isOnline) {
511
+ this.#wasActiveBeforeOffline = true;
512
+ this.#transition(S.PAUSED, {
513
+ reason: "offline",
514
+ detail: "Device is offline. Connection and retries are paused until internet is restored.",
515
+ });
516
+ return;
517
+ }
518
+
454
519
  // Whatever code was on offer belonged to the previous attempt.
455
520
  this.#clearQr();
456
521
  this.#transition(S.STARTING, { reason });
@@ -790,13 +855,23 @@ export class WhatsAppSession {
790
855
 
791
856
  if (this.#shuttingDown) return;
792
857
 
858
+ if (!this.#isOnline) {
859
+ this.#wasActiveBeforeOffline = true;
860
+ this.#cancelRetry();
861
+ return this.#transition(S.PAUSED, {
862
+ reason: "offline",
863
+ detail: `Connection closed (${statusCode ?? "no status"}: ${reasonText}). Paused until internet is restored.`,
864
+ });
865
+ }
866
+
793
867
  if (verdict.loggedOut) return this.#handleLoggedOut(verdict, closedSocket);
794
868
  if (verdict.terminal) {
795
869
  this.#clearQr();
796
870
  this.#pairing = false;
871
+ const codeInfo = this.#lastDisconnect?.statusCode ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})` : "";
797
872
  return this.#transition(S.DISCONNECTED, {
798
873
  reason: verdict.reason,
799
- detail: verdict.detail,
874
+ detail: `${verdict.detail || ""}${codeInfo}`.trim(),
800
875
  });
801
876
  }
802
877
 
@@ -805,9 +880,10 @@ export class WhatsAppSession {
805
880
  if (this.#pairing && !verdict.restartRequired) {
806
881
  this.#pairing = false;
807
882
  this.#clearQr();
883
+ const codeInfo = this.#lastDisconnect?.statusCode ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})` : "";
808
884
  return this.#transition(S.DISCONNECTED, {
809
885
  reason: "pairing_cancelled",
810
- detail: "The pairing attempt ended before the code was scanned. Start a session to try again.",
886
+ detail: `The pairing attempt ended before the code was scanned${codeInfo}. Start a session to try again.`,
811
887
  });
812
888
  }
813
889
 
@@ -846,6 +922,15 @@ export class WhatsAppSession {
846
922
  // -------------------------------------------------------------------------
847
923
 
848
924
  #scheduleRetry(verdict) {
925
+ if (!this.#isOnline) {
926
+ this.#wasActiveBeforeOffline = true;
927
+ this.#cancelRetry();
928
+ return this.#transition(S.PAUSED, {
929
+ reason: "offline",
930
+ detail: "Device is offline. Connection and retries are paused until internet is restored.",
931
+ });
932
+ }
933
+
849
934
  // Never two timers. A close that arrives while one is pending replaces it
850
935
  // rather than adding to it.
851
936
  this.#cancelRetry();
@@ -855,9 +940,10 @@ export class WhatsAppSession {
855
940
  `[Session] ${this.retryDelaysMs.length} reconnect attempts failed. Levix stays up; start the session again from the panel.`
856
941
  );
857
942
  this.#clearQr();
943
+ const codeInfo = this.#lastDisconnect?.statusCode ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})` : "";
858
944
  return this.#transition(S.RETRY_EXHAUSTED, {
859
945
  reason: "retry_exhausted",
860
- detail: `Gave up after ${this.retryDelaysMs.length} attempts. Start the session to try again.`,
946
+ detail: `Gave up after ${this.retryDelaysMs.length} attempts${codeInfo}. Start the session to try again.`,
861
947
  });
862
948
  }
863
949
 
@@ -869,17 +955,12 @@ export class WhatsAppSession {
869
955
  `[Session] Reconnecting in ${Math.round(delayMs / 1000)}s (attempt ${this.#attempt}/${this.retryDelaysMs.length})`
870
956
  );
871
957
 
958
+ const codeInfo = this.#lastDisconnect?.statusCode ? ` (${this.#lastDisconnect.statusCode}: ${this.#lastDisconnect.reason})` : "";
872
959
  this.#transition(S.RECONNECTING, {
873
960
  reason: verdict.reason,
874
- // The close's own explanation first: a 515 straight after a scan is
875
- // WhatsApp asking for a fresh connection, not a failed attempt, and
876
- // "attempt 1 of 5" on its own reads like something went wrong.
877
- // No countdown in here: this string is baked once, at schedule time, and
878
- // would go on claiming "in 5s" for the whole wait. The panel renders the
879
- // remaining seconds from nextRetryAt, which actually ticks.
880
961
  detail:
881
962
  `${verdict.detail ? `${verdict.detail} ` : ""}` +
882
- `Reconnecting (attempt ${this.#attempt} of ${this.retryDelaysMs.length}).`,
963
+ `Reconnecting (attempt ${this.#attempt} of ${this.retryDelaysMs.length})${codeInfo}.`,
883
964
  });
884
965
 
885
966
  this.#retryTimer = setTimeout(() => {
package/src/index.js CHANGED
@@ -41,6 +41,10 @@ function short(path) {
41
41
  // soon as the core is up.
42
42
  let liveSession = null;
43
43
 
44
+ export function getLiveSession() {
45
+ return liveSession;
46
+ }
47
+
44
48
  /**
45
49
  * Start Levix.
46
50
  *
@@ -194,9 +198,16 @@ async function startWithPanel({ open }) {
194
198
  try {
195
199
  panel = await bootstrapPanel({ core });
196
200
  } catch (error) {
197
- if (error.code === "EADDRINUSE") {
201
+ if (error.code === "EADDRINUSE" || error.code === "ECONNREFUSED") {
198
202
  console.error(`\n ${error.message}\n`);
199
203
  logger.error(error.message);
204
+ if (process.env.LEVIX_ANDROID === "1") {
205
+ // WhatsApp still works without the panel. process.exit here also
206
+ // trips pino's sonic-boom ("not ready yet") as a second crash.
207
+ console.log("Panel failed " + (error.code || error.message));
208
+ console.log("Levix ready");
209
+ return { ...core, panel: null, url: null };
210
+ }
200
211
  process.exit(1);
201
212
  }
202
213
  throw error;
@@ -912,6 +912,21 @@ router.post(
912
912
  })
913
913
  );
914
914
 
915
+ router.post(
916
+ "/bot/session/network",
917
+ asyncRoute(async (req, res) => {
918
+ if (!session) {
919
+ return res.status(503).json({ success: false, error: "Session manager is not ready" });
920
+ }
921
+ const { online } = req.body || {};
922
+ if (typeof online !== "boolean") {
923
+ return badRequest(res, "online (boolean) is required");
924
+ }
925
+ const state = await session.setInternetOnline(online);
926
+ res.json({ success: true, session: state });
927
+ })
928
+ );
929
+
915
930
  // Unlink the WhatsApp account: WhatsApp drops the companion device and the
916
931
  // stored credentials go with it. Levix stays up, and the operator can start a
917
932
  // fresh pairing whenever they want one.
@@ -78,7 +78,10 @@ function hasForwardingHeader(headers = {}) {
78
78
  * the right key for rate limiting a password or setup-code guess.
79
79
  */
80
80
  function clientAddress(req) {
81
- return req?.socket?.remoteAddress || req?.connection?.remoteAddress || "";
81
+ const addr = req?.socket?.remoteAddress || req?.connection?.remoteAddress;
82
+ if (addr) return addr;
83
+ if (process.env.LEVIX_ANDROID === "1") return "127.0.0.1";
84
+ return "";
82
85
  }
83
86
 
84
87
  /**
@@ -48,9 +48,22 @@ const ffmpegStatic = (() => {
48
48
  })();
49
49
 
50
50
  // Read at call time so the dashboard can point at another binary without a
51
- // restart; ffmpeg-static is what ships with the bot and works nearly everywhere.
51
+ // restart; ffmpeg-static is what ships with the bot on desktop/docker.
52
+ // On Android, check LEVIX_FFMPEG_PATH or libffmpeg.so in nativeLibraryDir (LD_LIBRARY_PATH).
52
53
  function ffmpegBin() {
53
- return settings.get("ffmpeg_path") || ffmpegStatic || "ffmpeg";
54
+ const configured = settings.get("ffmpeg_path");
55
+ if (configured) return configured;
56
+ if (process.env.LEVIX_FFMPEG_PATH && fs.existsSync(process.env.LEVIX_FFMPEG_PATH)) {
57
+ return process.env.LEVIX_FFMPEG_PATH;
58
+ }
59
+ if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
60
+ return process.env.FFMPEG_PATH;
61
+ }
62
+ if (process.env.LEVIX_ANDROID === "1" && process.env.LD_LIBRARY_PATH) {
63
+ const androidBin = path.join(process.env.LD_LIBRARY_PATH, "libffmpeg.so");
64
+ if (fs.existsSync(androidBin)) return androidBin;
65
+ }
66
+ return ffmpegStatic || "ffmpeg";
54
67
  }
55
68
  const FFMPEG_TIMEOUT_MS = 20000;
56
69
 
@@ -316,6 +316,9 @@
316
316
  </div>
317
317
 
318
318
  <div class="hint session-detail" id="conn-detail"></div>
319
+ <div id="conn-disconnect-alert" class="session-error-callout" style="display: none; margin-top: 10px; padding: 10px 14px; border-radius: 8px; background: rgba(239, 68, 68, 0.12); color: #ef4444; font-size: 13px; font-weight: 500; border: 1px solid rgba(239, 68, 68, 0.25);">
320
+ <span id="conn-disconnect-text"></span>
321
+ </div>
319
322
 
320
323
  <div class="hint" id="conn-proxy"></div>
321
324