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.
- package/README.md +23 -5
- package/SETUP.md +24 -4
- package/deploy/install.sh +106 -11
- package/deploy/levix.service +3 -0
- package/package.json +11 -6
- package/public/dashboard.css +9 -0
- package/public/dashboard.js +233 -43
- package/scheduler.cjs +15 -0
- package/src/bootstrap/core.js +36 -32
- package/src/bootstrap/events.cjs +4 -2
- package/src/bootstrap/panel.js +9 -7
- package/src/commands/gemini.cjs +45 -28
- package/src/commands/restart.cjs +6 -2
- package/src/commands/stt.cjs +43 -26
- package/src/config/ai-identity.cjs +61 -0
- package/src/config/ai-persona.md +92 -17
- package/src/config/brand.cjs +8 -18
- package/src/config/constants.js +11 -2
- package/src/config/secrets.cjs +19 -0
- package/src/config/settings.cjs +96 -2
- package/src/core/connection.js +140 -134
- package/src/core/events.js +64 -22
- package/src/core/proxy.js +266 -0
- package/src/core/session.js +828 -0
- package/src/core/socket.js +39 -5
- package/src/index.js +136 -29
- package/src/routes/dashboard.api.esm.js +127 -30
- package/src/services/aiAgent.cjs +278 -48
- package/src/services/aiTools.cjs +14 -12
- package/src/utils/storage.esm.js +5 -0
- package/views/dashboard.ejs +20 -11
- package/views/qr.ejs +23 -3
package/src/core/socket.js
CHANGED
|
@@ -45,20 +45,54 @@ function withThumbnailSupport(sock) {
|
|
|
45
45
|
return sock;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
...
|
|
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()
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
11
|
-
// from src/config/brand.cjs
|
|
12
|
-
//
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
60
|
-
|
|
61
|
+
export function setSession(manager) {
|
|
62
|
+
session = manager;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
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: !!
|
|
106
|
-
botNumber:
|
|
107
|
-
botName:
|
|
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:
|
|
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
|
-
//
|
|
746
|
-
//
|
|
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/
|
|
798
|
+
"/bot/session/start",
|
|
749
799
|
asyncRoute(async (req, res) => {
|
|
750
|
-
if (!
|
|
751
|
-
return res.status(
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
|
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.
|
|
873
|
+
setTimeout(() => process.kill(process.pid, "SIGTERM"), 500).unref();
|
|
777
874
|
});
|
|
778
875
|
|
|
779
876
|
export default router;
|