levix-bot 3.1.1-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "levix-bot",
3
- "version": "3.1.1-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.
@@ -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
  *
@@ -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.
@@ -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