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 +1 -1
- package/public/dashboard.js +13 -0
- package/src/commands/tts.cjs +38 -38
- package/src/core/session.js +92 -11
- package/src/index.js +4 -0
- package/src/routes/dashboard.api.esm.js +15 -0
- package/src/utils/thumbnail.cjs +15 -2
- package/views/dashboard.ejs +3 -0
package/package.json
CHANGED
package/public/dashboard.js
CHANGED
|
@@ -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.
|
package/src/commands/tts.cjs
CHANGED
|
@@ -22,15 +22,7 @@ const { sendBotMessage } = require("../utils/sendBotMessage.cjs");
|
|
|
22
22
|
|
|
23
23
|
const execFileAsync = promisify(execFile);
|
|
24
24
|
|
|
25
|
-
|
|
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
|
-
|
|
51
|
+
const bin = ffmpegPath();
|
|
52
|
+
if (!bin) return null;
|
|
60
53
|
|
|
61
54
|
const oggPath = tmpPath("ogg");
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 = {
|
package/src/core/session.js
CHANGED
|
@@ -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:
|
|
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
|
@@ -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.
|
package/src/utils/thumbnail.cjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
package/views/dashboard.ejs
CHANGED
|
@@ -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
|
|