levix-bot 2.0.0 → 2.1.0
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/app.cjs +22 -1
- package/deploy/install.sh +97 -11
- package/deploy/levix.service +3 -0
- package/package.json +18 -9
- 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/autoschedule.cjs +2 -3
- package/src/commands/gemini.cjs +45 -28
- package/src/commands/restart.cjs +6 -2
- package/src/commands/stt.cjs +43 -26
- package/src/commands/tts.cjs +55 -35
- 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/public/dashboard.js
CHANGED
|
@@ -174,11 +174,46 @@
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
// --- connection status --------------------------------------------------
|
|
177
|
+
//
|
|
178
|
+
// The backend owns the session (src/core/session.js); this file only draws
|
|
179
|
+
// what it reports. Nothing here starts, stops or retries a connection on its
|
|
180
|
+
// own, and closing this tab does not touch the bot.
|
|
181
|
+
|
|
182
|
+
// What each backend state looks like: the pill class, the label, and the
|
|
183
|
+
// sentence under the Connection card's heading.
|
|
184
|
+
const SESSION_LABELS = {
|
|
185
|
+
idle: { tone: "off", label: "Not started", tag: "danger", tagText: "Not started" },
|
|
186
|
+
starting: { tone: "warn", label: "Connecting", tag: "warn", tagText: "Connecting" },
|
|
187
|
+
waiting_for_qr: { tone: "warn", label: "Waiting for scan", tag: "warn", tagText: "Waiting for scan" },
|
|
188
|
+
linking: { tone: "warn", label: "Linking", tag: "warn", tagText: "Linking" },
|
|
189
|
+
connected: { tone: "on", label: "Connected", tag: "ok", tagText: "Linked" },
|
|
190
|
+
reconnecting: { tone: "warn", label: "Reconnecting", tag: "warn", tagText: "Reconnecting" },
|
|
191
|
+
disconnected: { tone: "off", label: "Disconnected", tag: "danger", tagText: "Disconnected" },
|
|
192
|
+
retry_exhausted: { tone: "off", label: "Disconnected", tag: "danger", tagText: "Gave up reconnecting" },
|
|
193
|
+
logged_out: { tone: "off", label: "Not linked", tag: "danger", tagText: "Not linked" },
|
|
194
|
+
error: { tone: "off", label: "Error", tag: "danger", tagText: "Error" },
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const describeSession = (state) =>
|
|
198
|
+
SESSION_LABELS[state] || { tone: "off", label: "Disconnected", tag: "danger", tagText: "Disconnected" };
|
|
177
199
|
|
|
178
|
-
|
|
200
|
+
// The last snapshot the backend sent. Every renderer reads this rather than
|
|
201
|
+
// its own idea of what is going on, so the pill and the Connection card can
|
|
202
|
+
// never disagree.
|
|
203
|
+
let sessionSnapshot = null;
|
|
204
|
+
|
|
205
|
+
function setStatus(snapshot) {
|
|
206
|
+
if (!snapshot) return;
|
|
207
|
+
sessionSnapshot = snapshot;
|
|
208
|
+
const look = describeSession(snapshot.state);
|
|
179
209
|
const pill = $("#status-pill");
|
|
180
|
-
pill.className = `pill ${
|
|
181
|
-
pill.innerHTML = `<span class="dot"></span>${esc(label)}`;
|
|
210
|
+
pill.className = `pill ${look.tone}`;
|
|
211
|
+
pill.innerHTML = `<span class="dot"></span>${esc(look.label)}`;
|
|
212
|
+
// Outside the `currentView` check: leaving the Connection screen while a
|
|
213
|
+
// reconnect was pending used to leave the ticker running for the life of
|
|
214
|
+
// the page, because only renderConnection() could ever stop it.
|
|
215
|
+
syncRetryTicker(snapshot);
|
|
216
|
+
if (currentView === "connection") renderConnection();
|
|
182
217
|
}
|
|
183
218
|
|
|
184
219
|
// --- overview -----------------------------------------------------------
|
|
@@ -186,7 +221,10 @@
|
|
|
186
221
|
async function loadStats() {
|
|
187
222
|
const { stats } = await api("/stats");
|
|
188
223
|
|
|
189
|
-
|
|
224
|
+
// /stats carries the same session snapshot the socket pushes, so the poll
|
|
225
|
+
// that runs every 15s refreshes the pill instead of flattening whatever the
|
|
226
|
+
// last live event put there.
|
|
227
|
+
setStatus(stats.connection);
|
|
190
228
|
|
|
191
229
|
const cards = [
|
|
192
230
|
["Groups", stats.totalGroups],
|
|
@@ -253,55 +291,174 @@
|
|
|
253
291
|
$("#qr-note").textContent = "Scan it from WhatsApp → Linked devices.";
|
|
254
292
|
}
|
|
255
293
|
|
|
294
|
+
function clearQr() {
|
|
295
|
+
qrRendered = null;
|
|
296
|
+
$("#qr-frame").innerHTML = "";
|
|
297
|
+
$("#qr-note").textContent = "Waiting for a QR code…";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function retryHint(snapshot) {
|
|
301
|
+
if (snapshot.state !== "reconnecting" || !snapshot.nextRetryAt) return "";
|
|
302
|
+
const seconds = Math.max(0, Math.round((snapshot.nextRetryAt - Date.now()) / 1000));
|
|
303
|
+
return ` Next attempt in ${seconds}s.`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// A countdown that only ticks while there is one to show. Rendering it once
|
|
307
|
+
// and leaving it would put a number on screen that stops being true a second
|
|
308
|
+
// later.
|
|
309
|
+
let retryTicker = null;
|
|
310
|
+
|
|
311
|
+
function syncRetryTicker(snapshot) {
|
|
312
|
+
const counting = snapshot.state === "reconnecting" && snapshot.nextRetryAt;
|
|
313
|
+
if (counting && !retryTicker) {
|
|
314
|
+
retryTicker = setInterval(() => {
|
|
315
|
+
if (currentView === "connection") renderConnection();
|
|
316
|
+
}, 1000);
|
|
317
|
+
} else if (!counting && retryTicker) {
|
|
318
|
+
clearInterval(retryTicker);
|
|
319
|
+
retryTicker = null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Draw the Connection card from the last snapshot. No network call. */
|
|
324
|
+
function renderConnection() {
|
|
325
|
+
const snapshot = sessionSnapshot;
|
|
326
|
+
if (!snapshot) return;
|
|
327
|
+
|
|
328
|
+
const look = describeSession(snapshot.state);
|
|
329
|
+
const who = snapshot.user?.id ? shortJid(snapshot.user.id) : "";
|
|
330
|
+
|
|
331
|
+
$("#conn-state").innerHTML =
|
|
332
|
+
`<span class="tag ${esc(look.tag)}">${esc(look.tagText)}</span>` +
|
|
333
|
+
(who ? ` <span class="hint">${esc(who)}</span>` : "");
|
|
334
|
+
|
|
335
|
+
$("#conn-detail").textContent = (snapshot.detail || "") + retryHint(snapshot);
|
|
336
|
+
|
|
337
|
+
// The proxy line, and the one action that can apply a changed one. The
|
|
338
|
+
// label is already redacted server-side — there is no password to leak
|
|
339
|
+
// here because the browser was never told it.
|
|
340
|
+
const proxyLine = $("#conn-proxy");
|
|
341
|
+
if (snapshot.proxy) {
|
|
342
|
+
proxyLine.textContent = `Connecting through ${snapshot.proxy}.`;
|
|
343
|
+
} else if (snapshot.connected || snapshot.state === "waiting_for_qr") {
|
|
344
|
+
proxyLine.textContent = "Connecting directly, with no proxy.";
|
|
345
|
+
} else {
|
|
346
|
+
proxyLine.textContent = "";
|
|
347
|
+
}
|
|
348
|
+
if (snapshot.proxyChanged) {
|
|
349
|
+
proxyLine.textContent +=
|
|
350
|
+
" The saved proxy settings differ from the connection in use — reconnect to apply them.";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Only offered when it would do something: there has to be a live session
|
|
354
|
+
// to replace, and a reason to replace it.
|
|
355
|
+
const canReconnect = snapshot.canStop && snapshot.proxyChanged;
|
|
356
|
+
$("#reconnect-btn").style.display = canReconnect ? "" : "none";
|
|
357
|
+
$("#reconnect-btn").disabled = !canReconnect;
|
|
358
|
+
|
|
359
|
+
// Buttons follow the backend, so a state that cannot accept an action
|
|
360
|
+
// cannot be asked for one.
|
|
361
|
+
$("#start-btn").disabled = !snapshot.canStart;
|
|
362
|
+
$("#start-btn").textContent =
|
|
363
|
+
snapshot.state === "logged_out" || snapshot.state === "idle"
|
|
364
|
+
? "Start session"
|
|
365
|
+
: "Retry connection";
|
|
366
|
+
$("#stop-btn").disabled = !snapshot.canStop;
|
|
367
|
+
// Not "only while connected": a refused pairing leaves dead credentials
|
|
368
|
+
// that can only be cleared by unlinking.
|
|
369
|
+
$("#unlink-btn").disabled = !snapshot.canUnlink;
|
|
370
|
+
|
|
371
|
+
// The QR frame is only up while a code can actually be scanned.
|
|
372
|
+
const pairing = snapshot.state === "waiting_for_qr";
|
|
373
|
+
$("#qr-panel").style.display = pairing ? "block" : "none";
|
|
374
|
+
if (!pairing) clearQr();
|
|
375
|
+
}
|
|
376
|
+
|
|
256
377
|
VIEWS.connection = {
|
|
257
378
|
title: "Connection",
|
|
379
|
+
// One request for the whole screen, including a QR that was already on
|
|
380
|
+
// offer before this tab existed — a reload has to show the real backend
|
|
381
|
+
// state, not an empty frame.
|
|
258
382
|
load: guard(async () => {
|
|
259
|
-
const {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
? `<span class="tag ok">Linked</span> <span class="hint">${esc(shortJid(stats.botNumber))}</span>`
|
|
264
|
-
: `<span class="tag danger">Not linked</span>`;
|
|
265
|
-
|
|
266
|
-
$("#qr-panel").style.display = connected ? "none" : "block";
|
|
267
|
-
if (connected) {
|
|
268
|
-
qrRendered = null;
|
|
269
|
-
$("#qr-frame").innerHTML = "";
|
|
270
|
-
$("#qr-note").textContent = "";
|
|
271
|
-
}
|
|
383
|
+
const { session, qr } = await api("/bot/session");
|
|
384
|
+
setStatus(session);
|
|
385
|
+
renderConnection();
|
|
386
|
+
if (qr && session.state === "waiting_for_qr") renderQr(qr);
|
|
272
387
|
}),
|
|
273
388
|
};
|
|
274
389
|
|
|
390
|
+
function confirmed(question, fn) {
|
|
391
|
+
return guard(async () => {
|
|
392
|
+
if (!confirm(question)) return;
|
|
393
|
+
await fn();
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
275
397
|
function initConnectionActions() {
|
|
276
|
-
|
|
398
|
+
// Starting is idempotent on the server: a second click while a socket is
|
|
399
|
+
// being made is answered with the state it is already in.
|
|
400
|
+
$("#start-btn").addEventListener(
|
|
277
401
|
"click",
|
|
278
402
|
guard(async () => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
403
|
+
// Disabled straight away so an impatient second click cannot even be
|
|
404
|
+
// made, and put back from the backend's answer either way — a failed
|
|
405
|
+
// request must not leave a dead button behind.
|
|
406
|
+
$("#start-btn").disabled = true;
|
|
407
|
+
try {
|
|
408
|
+
const { session } = await api("/bot/session/start", { method: "POST" });
|
|
409
|
+
setStatus(session);
|
|
410
|
+
toast("Session starting…", "ok");
|
|
411
|
+
} finally {
|
|
412
|
+
renderConnection();
|
|
285
413
|
}
|
|
286
|
-
await api("/bot/logout", { method: "POST" });
|
|
287
|
-
toast("Unlinked. A new QR code will appear here shortly.", "ok");
|
|
288
|
-
setStatus(false, "Disconnected");
|
|
289
414
|
})
|
|
290
415
|
);
|
|
291
416
|
|
|
417
|
+
$("#reconnect-btn").addEventListener(
|
|
418
|
+
"click",
|
|
419
|
+
confirmed(
|
|
420
|
+
"Reconnect WhatsApp now to apply the saved settings? The bot stops answering for a few seconds.",
|
|
421
|
+
async () => {
|
|
422
|
+
const { session } = await api("/bot/session/reconnect", { method: "POST" });
|
|
423
|
+
setStatus(session);
|
|
424
|
+
toast("Reconnecting with the new settings…", "ok");
|
|
425
|
+
}
|
|
426
|
+
)
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
$("#stop-btn").addEventListener(
|
|
430
|
+
"click",
|
|
431
|
+
confirmed(
|
|
432
|
+
"Stop the WhatsApp session? The bot stops answering until you start it again. The pairing is kept.",
|
|
433
|
+
async () => {
|
|
434
|
+
const { session } = await api("/bot/session/stop", { method: "POST" });
|
|
435
|
+
setStatus(session);
|
|
436
|
+
toast("Session stopped.", "ok");
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
$("#unlink-btn").addEventListener(
|
|
442
|
+
"click",
|
|
443
|
+
confirmed(
|
|
444
|
+
"Unlink this WhatsApp account? The bot stops answering until you start a new session and scan a QR code.",
|
|
445
|
+
async () => {
|
|
446
|
+
const { session } = await api("/bot/logout", { method: "POST" });
|
|
447
|
+
setStatus(session);
|
|
448
|
+
toast("Unlinked. Press Start session to pair again.", "ok");
|
|
449
|
+
}
|
|
450
|
+
)
|
|
451
|
+
);
|
|
452
|
+
|
|
292
453
|
$("#restart-btn").addEventListener(
|
|
293
454
|
"click",
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
)
|
|
299
|
-
) {
|
|
300
|
-
return;
|
|
455
|
+
confirmed(
|
|
456
|
+
"Restart the bot? It only comes back on its own if it runs under a supervisor (pm2, systemd, Docker).",
|
|
457
|
+
async () => {
|
|
458
|
+
const data = await api("/bot/restart", { method: "POST" });
|
|
459
|
+
toast(data.message || "Restarting…", "ok");
|
|
301
460
|
}
|
|
302
|
-
|
|
303
|
-
toast(data.message || "Restarting…", "ok");
|
|
304
|
-
})
|
|
461
|
+
)
|
|
305
462
|
);
|
|
306
463
|
}
|
|
307
464
|
|
|
@@ -925,7 +1082,8 @@
|
|
|
925
1082
|
if (setting.type === "secret") {
|
|
926
1083
|
control = `
|
|
927
1084
|
<div class="inline">
|
|
928
|
-
<input type="password" id="${id}" placeholder="${setting.configured ? "•••••••• (set)" : "not set"}"
|
|
1085
|
+
<input type="password" id="${id}" placeholder="${setting.configured ? "•••••••• (set)" : "not set"}"
|
|
1086
|
+
autocomplete="new-password"${setting.configured ? ' data-configured="1"' : ""} />
|
|
929
1087
|
<button class="btn btn-sm" data-save-setting="${esc(setting.key)}">Save</button>
|
|
930
1088
|
${setting.configured ? `<button class="btn btn-sm btn-danger" data-clear-setting="${esc(setting.key)}">Clear</button>` : ""}
|
|
931
1089
|
</div>`;
|
|
@@ -938,6 +1096,19 @@
|
|
|
938
1096
|
</label>
|
|
939
1097
|
<button class="btn btn-sm" data-save-setting="${esc(setting.key)}">Save</button>
|
|
940
1098
|
</div>`;
|
|
1099
|
+
} else if (Array.isArray(setting.choices) && setting.choices.length) {
|
|
1100
|
+
control = `
|
|
1101
|
+
<div class="inline">
|
|
1102
|
+
<select id="${id}">
|
|
1103
|
+
${setting.choices
|
|
1104
|
+
.map(
|
|
1105
|
+
(choice) =>
|
|
1106
|
+
`<option value="${esc(choice)}"${choice === setting.value ? " selected" : ""}>${esc(choice)}</option>`
|
|
1107
|
+
)
|
|
1108
|
+
.join("")}
|
|
1109
|
+
</select>
|
|
1110
|
+
<button class="btn btn-sm" data-save-setting="${esc(setting.key)}">Save</button>
|
|
1111
|
+
</div>`;
|
|
941
1112
|
} else {
|
|
942
1113
|
control = `
|
|
943
1114
|
<div class="inline">
|
|
@@ -1005,6 +1176,16 @@
|
|
|
1005
1176
|
|
|
1006
1177
|
const key = save ? save.dataset.saveSetting : clear.dataset.clearSetting;
|
|
1007
1178
|
const input = document.getElementById(`set-${key}`);
|
|
1179
|
+
|
|
1180
|
+
// An already-set secret with an empty box means "leave it alone", not
|
|
1181
|
+
// "erase it". The box is always empty — the server never sends the value
|
|
1182
|
+
// back — so saving the field as-is would wipe a working password every
|
|
1183
|
+
// time somebody edited the host next to it. Clear is the explicit way.
|
|
1184
|
+
if (save && input.dataset.configured === "1" && input.value === "") {
|
|
1185
|
+
toast("Left unchanged. Use Clear to remove it.");
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1008
1189
|
const value = clear
|
|
1009
1190
|
? ""
|
|
1010
1191
|
: input.type === "checkbox"
|
|
@@ -1015,28 +1196,37 @@
|
|
|
1015
1196
|
await api("/settings", { method: "PATCH", body: { key, value } });
|
|
1016
1197
|
toast(clear ? "Cleared" : "Saved", "ok");
|
|
1017
1198
|
VIEWS.settings.load();
|
|
1199
|
+
// A proxy change cannot reach a socket that is already open; the
|
|
1200
|
+
// Connection screen is where that is offered, so refresh its state.
|
|
1201
|
+
if (key.startsWith("whatsapp_proxy_")) loadStats().catch(() => {});
|
|
1018
1202
|
})();
|
|
1019
1203
|
});
|
|
1020
1204
|
}
|
|
1021
1205
|
|
|
1022
1206
|
// --- boot ---------------------------------------------------------------
|
|
1023
1207
|
|
|
1208
|
+
// The socket is a one-way feed of what the backend is already doing. Opening
|
|
1209
|
+
// it, losing it or reloading the page changes nothing about the WhatsApp
|
|
1210
|
+
// session; a reconnect happens in the backend with every tab shut.
|
|
1024
1211
|
function initSocket() {
|
|
1025
1212
|
// eslint-disable-next-line no-undef
|
|
1026
1213
|
const socket = io();
|
|
1027
1214
|
|
|
1215
|
+
socket.on("session", (snapshot) => setStatus(snapshot));
|
|
1216
|
+
|
|
1028
1217
|
socket.on("qr", (qr) => {
|
|
1029
1218
|
renderQr(qr);
|
|
1030
|
-
setStatus(false, "Waiting for scan");
|
|
1031
1219
|
if (currentView !== "connection") {
|
|
1032
1220
|
toast("A new QR code is ready — open Connection to scan it.");
|
|
1033
1221
|
}
|
|
1034
1222
|
});
|
|
1035
1223
|
|
|
1036
|
-
socket.on("
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1224
|
+
socket.on("qr_cleared", () => clearQr());
|
|
1225
|
+
|
|
1226
|
+
// A socket that dropped may have missed transitions while it was away.
|
|
1227
|
+
socket.on("connect", () => {
|
|
1228
|
+
if (currentView === "connection") VIEWS.connection.load();
|
|
1229
|
+
else loadStats().catch(() => {});
|
|
1040
1230
|
});
|
|
1041
1231
|
}
|
|
1042
1232
|
|
package/scheduler.cjs
CHANGED
|
@@ -103,6 +103,20 @@ function scheduleAt(whenMs, run) {
|
|
|
103
103
|
return { stop: () => clearTimeout(timer) };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Stop every running task.
|
|
108
|
+
*
|
|
109
|
+
* Each task closes over the socket it was armed with (see scheduleNewJob), so
|
|
110
|
+
* once that socket is gone the task can only fail. The connection handler calls
|
|
111
|
+
* initializeScheduledJobs() again on the next open, which re-arms every job
|
|
112
|
+
* against the socket that is actually alive.
|
|
113
|
+
*/
|
|
114
|
+
function stopAllScheduledJobs() {
|
|
115
|
+
const count = runningTasks.size;
|
|
116
|
+
for (const jobId of [...runningTasks.keys()]) stopTask(jobId);
|
|
117
|
+
if (count) logger.info(`[Scheduler] Stopped ${count} job(s) — no live connection`);
|
|
118
|
+
}
|
|
119
|
+
|
|
106
120
|
function stopTask(jobId) {
|
|
107
121
|
const existing = runningTasks.get(jobId);
|
|
108
122
|
if (!existing) return;
|
|
@@ -189,6 +203,7 @@ function updateJobStatus(jobId, status) {
|
|
|
189
203
|
|
|
190
204
|
module.exports = {
|
|
191
205
|
initializeScheduledJobs,
|
|
206
|
+
stopAllScheduledJobs,
|
|
192
207
|
scheduleNewJob,
|
|
193
208
|
getScheduledJobs,
|
|
194
209
|
saveScheduledJob,
|
package/src/bootstrap/core.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
// Everything Levix needs to be a WhatsApp bot.
|
|
2
2
|
//
|
|
3
|
-
// Database, settings, commands, the WhatsApp
|
|
4
|
-
// no Express, no socket.io — those live in bootstrap/panel.js and are
|
|
5
|
-
// optional. `levix headless` runs this file and nothing else, which is
|
|
6
|
-
// makes "no panel port is opened" a fact rather than a promise.
|
|
3
|
+
// Database, settings, commands, the WhatsApp session manager, the scheduler.
|
|
4
|
+
// No HTTP, no Express, no socket.io — those live in bootstrap/panel.js and are
|
|
5
|
+
// entirely optional. `levix headless` runs this file and nothing else, which is
|
|
6
|
+
// what makes "no panel port is opened" a fact rather than a promise.
|
|
7
|
+
//
|
|
8
|
+
// Note what this does NOT do: it does not dial WhatsApp. It builds the session
|
|
9
|
+
// manager and hands it back. Panel mode leaves it idle until somebody presses
|
|
10
|
+
// Start on the Connection screen; headless has nobody to press anything, so it
|
|
11
|
+
// asks for `autoStart`. That is the whole of the difference between the two
|
|
12
|
+
// modes — there is no `if (headless)` anywhere below this line.
|
|
7
13
|
|
|
8
14
|
import { createRequire } from "module";
|
|
9
|
-
import {
|
|
10
|
-
import { setupEventListeners } from "../core/events.js";
|
|
15
|
+
import { WhatsAppSession } from "../core/session.js";
|
|
11
16
|
import { loadCommands, getLoadedCommands } from "../handlers/command.handler.js";
|
|
12
17
|
import { initStore } from "../db/store.esm.js";
|
|
18
|
+
import { deleteQrCode } from "../utils/storage.esm.js";
|
|
13
19
|
|
|
14
20
|
const require = createRequire(import.meta.url);
|
|
15
21
|
const logger = require("../utils/logger.cjs");
|
|
@@ -17,18 +23,17 @@ const { sink } = require("./events.cjs");
|
|
|
17
23
|
const { acquireSingleInstanceLock } = require("../config/lock.cjs");
|
|
18
24
|
const { ensureDataDir } = require("../config/paths.cjs");
|
|
19
25
|
const store = require("../db/store.cjs");
|
|
20
|
-
const { initializeScheduledJobs } = require("../../scheduler.cjs");
|
|
26
|
+
const { initializeScheduledJobs, stopAllScheduledJobs } = require("../../scheduler.cjs");
|
|
21
27
|
|
|
22
28
|
/**
|
|
23
29
|
* Start the bot.
|
|
24
30
|
*
|
|
25
31
|
* @param {object} [options]
|
|
26
|
-
* @param {
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* @returns {Promise<{ sock: object, commandCount: number, wasPaired: boolean }>}
|
|
32
|
+
* @param {boolean} [options.autoStart] - connect to WhatsApp straight away.
|
|
33
|
+
* Headless does; the panel waits to be asked.
|
|
34
|
+
* @returns {Promise<{ session: object, commandCount: number, wasPaired: boolean }>}
|
|
30
35
|
*/
|
|
31
|
-
export async function bootstrapCore({
|
|
36
|
+
export async function bootstrapCore({ autoStart = false } = {}) {
|
|
32
37
|
// Claim the data directory before anything touches the WhatsApp session. A
|
|
33
38
|
// second copy has to fail here, not after it has written to the auth rows
|
|
34
39
|
// the first one is using.
|
|
@@ -45,30 +50,29 @@ export async function bootstrapCore({ onSocket } = {}) {
|
|
|
45
50
|
await initStore();
|
|
46
51
|
await loadCommands();
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
async function connect({ onSocket, isReconnect = false } = {}) {
|
|
54
|
-
const { sock, saveCreds, clearAll } = await createWhatsAppSocket();
|
|
53
|
+
// A pairing code belongs to the socket that issued it. One left in the table
|
|
54
|
+
// by a process that was killed rather than stopped can never be scanned, and
|
|
55
|
+
// GET /qr would happily serve it to somebody who then waits for nothing.
|
|
56
|
+
deleteQrCode();
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
sock,
|
|
58
|
-
saveCreds,
|
|
59
|
-
clearAll,
|
|
58
|
+
const session = new WhatsAppSession({
|
|
60
59
|
// The bot reports into the hub; whoever is listening decides what that
|
|
61
60
|
// means — a socket.io broadcast, a line in the terminal, or nothing.
|
|
62
|
-
sink,
|
|
61
|
+
emit: sink.emit,
|
|
63
62
|
initializeScheduledJobs,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
);
|
|
63
|
+
stopScheduledJobs: stopAllScheduledJobs,
|
|
64
|
+
log: logger,
|
|
65
|
+
});
|
|
69
66
|
|
|
70
|
-
if (
|
|
67
|
+
if (autoStart) await session.start({ reason: "autostart" });
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
return {
|
|
70
|
+
session,
|
|
71
|
+
commandCount: getLoadedCommands().size,
|
|
72
|
+
wasPaired,
|
|
73
|
+
/** The live socket, or null. Re-read it; a reconnect replaces it. */
|
|
74
|
+
get sock() {
|
|
75
|
+
return session.socket;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
74
78
|
}
|
package/src/bootstrap/events.cjs
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
// panel -> forwards every event to socket.io
|
|
11
11
|
// headless -> prints the ones a person waiting at a terminal cares about
|
|
12
12
|
//
|
|
13
|
-
// `sink` is shaped like the socket.io server object because that is what
|
|
14
|
-
//
|
|
13
|
+
// `sink` is shaped like the socket.io server object because that is what the
|
|
14
|
+
// panel forwards from. The WhatsApp session manager (src/core/session.js) emits
|
|
15
|
+
// into it; nothing that listens here can emit back into the bot, which is what
|
|
16
|
+
// keeps a browser attaching or leaving from touching the connection.
|
|
15
17
|
|
|
16
18
|
const listeners = new Set();
|
|
17
19
|
|
package/src/bootstrap/panel.js
CHANGED
|
@@ -31,8 +31,9 @@ export async function bootstrapPanel({ core } = {}) {
|
|
|
31
31
|
installFinalHandlers,
|
|
32
32
|
} = require("../../app.cjs");
|
|
33
33
|
|
|
34
|
-
const { default: dashboardApiRoutes,
|
|
35
|
-
|
|
34
|
+
const { default: dashboardApiRoutes, setSession } = await import(
|
|
35
|
+
"../routes/dashboard.api.esm.js"
|
|
36
|
+
);
|
|
36
37
|
|
|
37
38
|
// Same session as the control panel: these routes hand back groups, debts
|
|
38
39
|
// and warnings with people's numbers in them.
|
|
@@ -40,12 +41,13 @@ export async function bootstrapPanel({ core } = {}) {
|
|
|
40
41
|
logger.info("Dashboard API routes registered");
|
|
41
42
|
|
|
42
43
|
// Everything the bot reports goes out to the browsers watching the panel.
|
|
44
|
+
// One direction only: a browser attaching or leaving cannot reach back and
|
|
45
|
+
// change the WhatsApp session's lifecycle. See src/core/session.js.
|
|
43
46
|
attach((event, payload) => io.emit(event, payload));
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
48
|
+
// The routes read the live socket off the session manager, so a reconnect
|
|
49
|
+
// needs no re-wiring here.
|
|
50
|
+
if (core?.session) setSession(core.session);
|
|
49
51
|
|
|
50
52
|
// After every route is registered — the 404 and the error handler have to be
|
|
51
53
|
// last, or they swallow what comes after them.
|
|
@@ -73,7 +75,7 @@ export async function bootstrapPanel({ core } = {}) {
|
|
|
73
75
|
server.listen(port, host, resolve);
|
|
74
76
|
});
|
|
75
77
|
|
|
76
|
-
return { port, host, server
|
|
78
|
+
return { port, host, server };
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
/**
|
|
@@ -2,7 +2,7 @@ const {
|
|
|
2
2
|
scheduleNewJob,
|
|
3
3
|
saveScheduledJob,
|
|
4
4
|
} = require("../../scheduler.cjs");
|
|
5
|
-
const {
|
|
5
|
+
const { randomUUID } = require("node:crypto");
|
|
6
6
|
const { defaultTimezone } = require("../utils/datetime.cjs");
|
|
7
7
|
|
|
8
8
|
module.exports = {
|
|
@@ -58,7 +58,7 @@ module.exports = {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const newJob = {
|
|
61
|
-
id:
|
|
61
|
+
id: randomUUID(),
|
|
62
62
|
type: "recurring",
|
|
63
63
|
cronString: cronString,
|
|
64
64
|
message: message,
|
|
@@ -81,4 +81,3 @@ module.exports = {
|
|
|
81
81
|
});
|
|
82
82
|
},
|
|
83
83
|
};
|
|
84
|
-
// Note: You need to run `npm install uuid` for this command.
|