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.
@@ -0,0 +1,828 @@
1
+ // The WhatsApp session, owned by the backend.
2
+ //
3
+ // WHY THIS FILE EXISTS
4
+ // --------------------
5
+ // The bot used to create a Baileys socket as an unavoidable part of starting
6
+ // the process, and the reconnect logic lived inside the `connection.update`
7
+ // handler of whichever socket happened to be alive. Two problems came out of
8
+ // that:
9
+ //
10
+ // * `levix` could not boot without dialling WhatsApp. A fresh install with no
11
+ // pairing spent its whole life regenerating QR codes nobody was looking at.
12
+ // * "who decides to reconnect" was answered by an event handler on a socket
13
+ // that had already been replaced, so a slow close could resurrect a dead
14
+ // connection on top of a live one.
15
+ //
16
+ // So the lifecycle is a small state machine here, in the backend, and it is the
17
+ // only thing allowed to create or destroy a socket. The panel *displays* this
18
+ // state and asks it to change; a browser opening, closing or refreshing has no
19
+ // effect on it whatsoever. Headless has no browser and simply calls start()
20
+ // itself.
21
+ //
22
+ // THE STATES
23
+ // ----------
24
+ // idle nothing is running. Panel mode boots here.
25
+ // starting a socket is being created right now.
26
+ // waiting_for_qr unpaired: a QR is out and we are waiting for a phone.
27
+ // linking the QR was scanned; WhatsApp wants the socket restarted.
28
+ // connected open.
29
+ // reconnecting a recoverable close; a retry is scheduled.
30
+ // disconnected closed and not retrying. Startable again by hand.
31
+ // retry_exhausted the retry schedule ran out. Levix keeps running.
32
+ // logged_out WhatsApp dropped the pairing; credentials were cleared.
33
+ // error starting the socket threw.
34
+ //
35
+ // THE RECONNECT POLICY
36
+ // --------------------
37
+ // Staged linear backoff — NOT exponential: 5s, 10s, 15s, 20s, 25s, then stop.
38
+ // The counter resets to zero on every successful `open`. Exactly one retry
39
+ // timer can exist at a time, and every socket carries a generation number: an
40
+ // event from a socket that is no longer the current one is dropped on the
41
+ // floor, which is what stops a late close from scheduling a reconnect on top
42
+ // of a live connection.
43
+ //
44
+ // A pairing attempt (unpaired install, QR on screen) that closes before it ever
45
+ // opened is NOT retried. The QR is deleted and the session goes back to idle.
46
+ // Regenerating one forever is how the old code burned a socket a minute on
47
+ // installs nobody was pairing.
48
+
49
+ import { createRequire } from "module";
50
+ import { DisconnectReason } from "@whiskeysockets/baileys";
51
+
52
+ import { createWhatsAppSocket } from "./socket.js";
53
+ import { setupEventListeners } from "./events.js";
54
+ import { handleConnectionOpen, classifyDisconnect } from "./connection.js";
55
+ import { RETRY_SCHEDULE_MS } from "../config/constants.js";
56
+ import { clearAuthState, deleteQrCode, saveQrCode } from "../utils/storage.esm.js";
57
+ import {
58
+ createProxyAgents,
59
+ describeProxyFailure,
60
+ proxyFingerprint,
61
+ readProxyConfig,
62
+ redactProxy,
63
+ } from "./proxy.js";
64
+
65
+ const require = createRequire(import.meta.url);
66
+ const logger = require("../utils/logger.cjs");
67
+
68
+ // Baileys' `end()` awaits `ws.close()`, which waits for the socket's own
69
+ // 'close' event with no timeout of its own. A half-dead TCP connection can
70
+ // therefore hang it forever — and this is on the SIGTERM path, so "forever"
71
+ // would mean systemd eventually SIGKILLing a bot mid-write.
72
+ const SOCKET_CLOSE_TIMEOUT_MS = 5000;
73
+
74
+ function withTimeout(promise, ms) {
75
+ return Promise.race([
76
+ Promise.resolve(promise),
77
+ new Promise((resolve) => setTimeout(resolve, ms).unref?.()),
78
+ ]);
79
+ }
80
+
81
+ export const SESSION_STATES = Object.freeze({
82
+ IDLE: "idle",
83
+ STARTING: "starting",
84
+ WAITING_FOR_QR: "waiting_for_qr",
85
+ LINKING: "linking",
86
+ CONNECTED: "connected",
87
+ RECONNECTING: "reconnecting",
88
+ DISCONNECTED: "disconnected",
89
+ RETRY_EXHAUSTED: "retry_exhausted",
90
+ LOGGED_OUT: "logged_out",
91
+ ERROR: "error",
92
+ });
93
+
94
+ const S = SESSION_STATES;
95
+
96
+ /** States in which a socket already exists or is on its way. */
97
+ const BUSY_STATES = new Set([
98
+ S.STARTING,
99
+ S.WAITING_FOR_QR,
100
+ S.LINKING,
101
+ S.CONNECTED,
102
+ S.RECONNECTING,
103
+ ]);
104
+
105
+ /** What the dashboard's status pill has always been told. Kept stable. */
106
+ function legacyStatus(state, hasQr) {
107
+ if (state === S.CONNECTED) return "Connected";
108
+ if (state === S.WAITING_FOR_QR) return hasQr ? "QR Code Received" : "Connecting";
109
+ if (state === S.STARTING) return "Connecting";
110
+ if (state === S.LINKING) return "Linking";
111
+ if (state === S.RECONNECTING) return "Reconnecting";
112
+ return "Disconnected";
113
+ }
114
+
115
+ /**
116
+ * The one WhatsApp session.
117
+ *
118
+ * Everything is injectable so the state machine can be tested without a network
119
+ * or a phone; the defaults wire the real Baileys socket.
120
+ */
121
+ export class WhatsAppSession {
122
+ #state = S.IDLE;
123
+ #since = Date.now();
124
+ #socket = null;
125
+ #clearAll = null;
126
+
127
+ // Bumped for every socket. Events carrying an older number are ignored, which
128
+ // is how a replaced socket stops being able to affect anything.
129
+ #generation = 0;
130
+
131
+ #attempt = 0;
132
+ #retryTimer = null;
133
+ #nextRetryAt = null;
134
+
135
+ // A start() already in flight. Concurrent callers await it instead of
136
+ // creating a second socket.
137
+ #starting = null;
138
+
139
+ // True while this attempt is an unpaired pairing attempt that has not yet
140
+ // been scanned. Its closes are terminal by design.
141
+ #pairing = false;
142
+
143
+ #shuttingDown = false;
144
+
145
+ // The proxy the live socket was actually built with, and how it reads. The
146
+ // fingerprint contains the password, so it stays here — only the boolean
147
+ // "would a reconnect change anything" reaches a browser.
148
+ #proxyFingerprint = null;
149
+ #proxyLabel = null;
150
+ #proxyConfig = null;
151
+
152
+ #qr = null;
153
+ #reason = null;
154
+ #detail = null;
155
+ #lastDisconnect = null;
156
+
157
+ constructor({
158
+ createSocket = createWhatsAppSocket,
159
+ attachListeners = setupEventListeners,
160
+ onOpen = handleConnectionOpen,
161
+ // The wipe of last resort. A socket hands us its own clearAll(), but a
162
+ // session that is disconnected — 403, 405, retries exhausted, or simply
163
+ // restarted — has no socket to ask, and that is exactly when an operator
164
+ // wants to unlink and pair a different account.
165
+ clearCredentials = clearAuthState,
166
+ initializeScheduledJobs = null,
167
+ stopScheduledJobs = null,
168
+ // Read at socket-creation time, never cached at import: the operator can
169
+ // change the proxy from the panel and the next connection must use it.
170
+ // Injectable so a test can drive the whole state machine without a proxy.
171
+ loadProxy = readProxyConfig,
172
+ buildProxyAgents = createProxyAgents,
173
+ emit = () => {},
174
+ retryDelaysMs = RETRY_SCHEDULE_MS,
175
+ log = logger,
176
+ } = {}) {
177
+ this.createSocket = createSocket;
178
+ this.attachListeners = attachListeners;
179
+ this.onOpen = onOpen;
180
+ this.initializeScheduledJobs = initializeScheduledJobs;
181
+ this.stopScheduledJobs = stopScheduledJobs;
182
+ this.loadProxy = loadProxy;
183
+ this.buildProxyAgents = buildProxyAgents;
184
+ this.emitEvent = emit;
185
+ this.clearCredentials = clearCredentials;
186
+ this.retryDelaysMs = retryDelaysMs;
187
+ this.log = log;
188
+ }
189
+
190
+ // -------------------------------------------------------------------------
191
+ // what the outside world can see
192
+ // -------------------------------------------------------------------------
193
+
194
+ /** The live socket, or null. Callers must not cache it across a reconnect. */
195
+ get socket() {
196
+ return this.#socket;
197
+ }
198
+
199
+ get state() {
200
+ return this.#state;
201
+ }
202
+
203
+ /**
204
+ * The QR waiting to be scanned, or null.
205
+ *
206
+ * Deliberately not part of getState(): that snapshot is broadcast to every
207
+ * open panel on every transition, and there is no reason to put a pairing
208
+ * code through it more than once. A dashboard that loads while a code is
209
+ * already up asks for it here instead.
210
+ */
211
+ get qr() {
212
+ return this.#qr;
213
+ }
214
+
215
+ /** Everything the dashboard needs to render the Connection screen. */
216
+ getState() {
217
+ const terminal =
218
+ this.#state === S.LOGGED_OUT ||
219
+ this.#state === S.RETRY_EXHAUSTED ||
220
+ this.#state === S.ERROR;
221
+
222
+ return {
223
+ state: this.#state,
224
+ status: legacyStatus(this.#state, !!this.#qr),
225
+ since: this.#since,
226
+ attempt: this.#attempt,
227
+ maxAttempts: this.retryDelaysMs.length,
228
+ nextRetryAt: this.#nextRetryAt,
229
+ canStart: !BUSY_STATES.has(this.#state) && !this.#shuttingDown,
230
+ canStop: BUSY_STATES.has(this.#state),
231
+ // Not "is it connected": a pairing that WhatsApp has refused (403, 405)
232
+ // leaves dead credentials behind, and unlinking is the only way out of
233
+ // it. The one state with nothing to unlink is the one that just did.
234
+ canUnlink: this.#state !== S.LOGGED_OUT && !this.#shuttingDown,
235
+ // Redacted — never the password. Null when the socket is direct.
236
+ proxy: this.#proxyLabel,
237
+ // True when the saved proxy settings differ from what the live socket was
238
+ // built with, i.e. a reconnect would actually change something. Only the
239
+ // answer crosses the wire, never the fingerprint it was computed from.
240
+ proxyChanged: this.#proxyIsStale(),
241
+ connected: this.#state === S.CONNECTED,
242
+ terminal,
243
+ hasQr: !!this.#qr,
244
+ reason: this.#reason,
245
+ detail: this.#detail,
246
+ lastDisconnect: this.#lastDisconnect,
247
+ user: this.#socket?.user
248
+ ? { id: this.#socket.user.id || null, name: this.#socket.user.name || null }
249
+ : null,
250
+ };
251
+ }
252
+
253
+ // -------------------------------------------------------------------------
254
+ // commands
255
+ // -------------------------------------------------------------------------
256
+
257
+ /**
258
+ * Bring the session up. Idempotent: if a socket exists or is being made, this
259
+ * returns the state it is already in and creates nothing.
260
+ */
261
+ async start({ reason = "manual" } = {}) {
262
+ if (this.#shuttingDown) return this.getState();
263
+
264
+ if (this.#starting) {
265
+ await this.#starting.catch(() => {});
266
+ return this.getState();
267
+ }
268
+ if (BUSY_STATES.has(this.#state)) return this.getState();
269
+
270
+ // A manual start clears whatever the last failure left behind.
271
+ this.#cancelRetry();
272
+ this.#attempt = 0;
273
+
274
+ this.#starting = this.#open({ reason });
275
+ try {
276
+ await this.#starting;
277
+ } finally {
278
+ this.#starting = null;
279
+ }
280
+ return this.getState();
281
+ }
282
+
283
+ /**
284
+ * Take the session down on purpose. No retry follows, because the generation
285
+ * is bumped before the socket is told to end — its close event is already
286
+ * stale by the time it fires.
287
+ */
288
+ async stop({ reason = "manual", state = S.IDLE, detail = null } = {}) {
289
+ this.#cancelRetry();
290
+ this.#attempt = 0;
291
+
292
+ // Wait for an in-flight start, or we would tear down a socket that has not
293
+ // been assigned yet and then have it appear behind us.
294
+ if (this.#starting) await this.#starting.catch(() => {});
295
+
296
+ // Again after the await: a close that landed while we were waiting is
297
+ // entitled to schedule a retry, and it would outlive this stop.
298
+ this.#cancelRetry();
299
+ this.#attempt = 0;
300
+
301
+ await this.#destroySocket();
302
+ this.#clearQr();
303
+ this.#pairing = false;
304
+ this.#transition(state, { reason, detail });
305
+ return this.getState();
306
+ }
307
+
308
+ /**
309
+ * Unlink the WhatsApp account: ask WhatsApp to drop the companion device,
310
+ * then clear the credentials whether or not that call got through.
311
+ */
312
+ async logout() {
313
+ // A start that is still creating its socket has to finish first: otherwise
314
+ // #open() assigns a live socket after this method has already declared the
315
+ // session logged out, and the account is unlinked with a connection still
316
+ // running.
317
+ this.#cancelRetry();
318
+ if (this.#starting) await this.#starting.catch(() => {});
319
+
320
+ const sock = this.#socket;
321
+ const clearAll = this.#clearAll;
322
+
323
+ // Stale from here on: whatever the socket emits while logging out is not
324
+ // allowed to schedule anything.
325
+ this.#generation += 1;
326
+ this.#cancelRetry();
327
+ this.#attempt = 0;
328
+
329
+ if (sock) {
330
+ try {
331
+ await withTimeout(sock.logout(), SOCKET_CLOSE_TIMEOUT_MS);
332
+ } catch (error) {
333
+ this.log.warn({ err: error }, "[Session] logout call failed, clearing credentials anyway");
334
+ }
335
+ // Before the wipe, not after. The auth state's clearAll() empties the
336
+ // table but leaves `state.creds` populated in memory, so one more
337
+ // creds.update from a socket still finishing its teardown would write
338
+ // the dead credentials straight back in.
339
+ this.#detach(sock);
340
+ try {
341
+ await withTimeout(sock.end?.(undefined), SOCKET_CLOSE_TIMEOUT_MS);
342
+ } catch {}
343
+ }
344
+
345
+ // clearAll() belongs to the socket that was alive; when there wasn't one,
346
+ // the store-level wipe does the same job.
347
+ try {
348
+ await (clearAll ? clearAll() : this.clearCredentials?.());
349
+ } catch (error) {
350
+ this.log.error({ err: error }, "[Session] failed to clear credentials");
351
+ }
352
+
353
+ this.#socket = null;
354
+ this.#clearAll = null;
355
+ this.#pairing = false;
356
+ this.#clearQr();
357
+ this.#transition(S.LOGGED_OUT, {
358
+ reason: "unlinked",
359
+ detail: "The WhatsApp account was unlinked. Start a session to pair a new one.",
360
+ });
361
+ return this.getState();
362
+ }
363
+
364
+ /**
365
+ * Take the session down and bring it straight back up.
366
+ *
367
+ * This is what "Reconnect to apply" runs. It is composed from the two
368
+ * lifecycle methods that already exist rather than being a second path to a
369
+ * socket: stop() cancels the retry and ends the current connection, start()
370
+ * re-reads the settings — including the proxy — and opens a new one. Nothing
371
+ * outside this class ever creates a socket.
372
+ */
373
+ async reconnect({ reason = "reconnect" } = {}) {
374
+ if (this.#shuttingDown) return this.getState();
375
+ await this.stop({ reason, state: S.IDLE });
376
+ return this.start({ reason });
377
+ }
378
+
379
+ /** Process shutdown: cancel everything and never reconnect again. */
380
+ async shutdown() {
381
+ this.#shuttingDown = true;
382
+ this.#cancelRetry();
383
+ if (this.#starting) await this.#starting.catch(() => {});
384
+ this.#cancelRetry();
385
+ await this.#destroySocket();
386
+ // A code left in the database would be served by GET /qr on the next boot,
387
+ // long after the socket that issued it died.
388
+ this.#clearQr();
389
+ this.#transition(S.IDLE, { reason: "shutdown" });
390
+ }
391
+
392
+ // -------------------------------------------------------------------------
393
+ // the socket
394
+ // -------------------------------------------------------------------------
395
+
396
+ async #open({ reason }) {
397
+ // Whatever code was on offer belonged to the previous attempt.
398
+ this.#clearQr();
399
+ this.#transition(S.STARTING, { reason });
400
+
401
+ // Anything that invalidates this attempt — stop, logout, shutdown — bumps
402
+ // the generation. Creating a socket is asynchronous, so the attempt has to
403
+ // recheck that it is still the current one when it comes back.
404
+ const startedAt = this.#generation;
405
+
406
+ // Read per attempt, so changing the proxy and pressing Reconnect is all it
407
+ // takes. A configuration that does not validate fails the attempt with the
408
+ // operator's own mistake rather than dialling out wrongly.
409
+ let proxyConfig;
410
+ let proxy = null;
411
+ try {
412
+ proxyConfig = this.loadProxy();
413
+ proxy = this.buildProxyAgents(proxyConfig);
414
+ } catch (error) {
415
+ this.log.error(
416
+ { err: error?.message },
417
+ "[Session] the proxy configuration is not usable"
418
+ );
419
+ this.#proxyFingerprint = null;
420
+ this.#proxyLabel = null;
421
+ this.#transition(S.ERROR, {
422
+ reason: "proxy_invalid",
423
+ // normalizeProxyConfig() only ever throws messages about the shape of
424
+ // the settings, never about their values, so this is safe to show.
425
+ detail: error?.message || "The proxy configuration is not usable",
426
+ });
427
+ return;
428
+ }
429
+
430
+ this.#proxyFingerprint = proxyFingerprint(proxyConfig);
431
+ this.#proxyLabel = redactProxy(proxyConfig);
432
+ this.#proxyConfig = proxyConfig;
433
+
434
+ let created;
435
+ try {
436
+ created = await this.createSocket({ proxy });
437
+ } catch (error) {
438
+ this.log.error({ err: error }, "[Session] failed to create the WhatsApp socket");
439
+ this.#transition(S.ERROR, {
440
+ reason: "socket_failed",
441
+ detail:
442
+ describeProxyFailure(error, proxyConfig) ||
443
+ error?.message ||
444
+ "Could not create the WhatsApp socket",
445
+ });
446
+ return;
447
+ }
448
+
449
+ if (this.#shuttingDown || this.#generation !== startedAt) {
450
+ // Overtaken. Throw the socket away rather than adopt it, or we would end
451
+ // up with a live connection nobody asked for and no state pointing at it.
452
+ this.log.warn("[Session] discarding a socket whose start was overtaken");
453
+ this.#detach(created.sock);
454
+ try {
455
+ await withTimeout(created.sock?.end?.(undefined), SOCKET_CLOSE_TIMEOUT_MS);
456
+ } catch {}
457
+ return;
458
+ }
459
+
460
+ const { sock, saveCreds, clearAll, isPaired } = created;
461
+
462
+ const generation = (this.#generation += 1);
463
+ this.#socket = sock;
464
+ this.#clearAll = clearAll;
465
+ // An install with no pairing yet is on a pairing attempt: its closes are
466
+ // terminal until a phone has actually scanned the code.
467
+ this.#pairing = !isPaired;
468
+
469
+ this.attachListeners(sock, {
470
+ saveCreds,
471
+ onConnectionUpdate: (update) => this.#onConnectionUpdate(generation, update),
472
+ onHandshakeRejected: (response) =>
473
+ this.#onHandshakeRejected(generation, sock, response),
474
+ });
475
+
476
+ this.#transition(this.#pairing ? S.WAITING_FOR_QR : S.STARTING, {
477
+ reason,
478
+ detail: this.#pairing ? "Waiting for a QR code" : "Connecting to WhatsApp",
479
+ });
480
+
481
+ this.log.info("[Session] WhatsApp socket created");
482
+ }
483
+
484
+ /**
485
+ * The server answered the WebSocket handshake with an HTTP status.
486
+ *
487
+ * In practice this is the proxy: 407 for bad credentials, 403 for a proxy
488
+ * that will not tunnel to WhatsApp. Nothing else in Baileys reacts to it, so
489
+ * without this the attempt hangs in `starting` forever.
490
+ *
491
+ * The response is turned into a normal close on the socket itself, so the
492
+ * ordinary state machine decides what happens next — this does not classify
493
+ * anything or schedule anything of its own.
494
+ */
495
+ #onHandshakeRejected(generation, sock, response) {
496
+ if (generation !== this.#generation) return;
497
+
498
+ const status = response?.statusCode ?? 0;
499
+ const viaProxy = !!this.#proxyConfig?.enabled;
500
+ const detail =
501
+ viaProxy && (status === 407 || status === 401)
502
+ ? `The proxy at ${this.#proxyLabel} rejected the username or password (HTTP ${status}).`
503
+ : viaProxy
504
+ ? `The proxy at ${this.#proxyLabel} refused to connect to WhatsApp (HTTP ${status}).`
505
+ : `WhatsApp refused the connection (HTTP ${status}).`;
506
+
507
+ this.log.error(`[Session] handshake rejected with HTTP ${status} — ${detail}`);
508
+
509
+ // Baileys' end() is idempotent and emits the close through the normal
510
+ // path, so the retry policy and the terminal classification stay exactly
511
+ // where they already live.
512
+ const error = new Error(detail);
513
+ error.output = { statusCode: status || 500, payload: { error: detail } };
514
+ Promise.resolve(sock?.end?.(error)).catch(() => {});
515
+ }
516
+
517
+ /** Cut every wire back from a socket we are done with. */
518
+ #detach(sock) {
519
+ for (const event of ["connection.update", "creds.update"]) {
520
+ try {
521
+ sock?.ev?.removeAllListeners?.(event);
522
+ } catch {}
523
+ }
524
+ }
525
+
526
+ /** End the current socket without letting its close event mean anything. */
527
+ async #destroySocket() {
528
+ const sock = this.#socket;
529
+ if (!sock) return;
530
+
531
+ this.#generation += 1;
532
+ this.#socket = null;
533
+ this.#clearAll = null;
534
+
535
+ this.#detach(sock);
536
+ this.#stopJobs();
537
+ this.#proxyFingerprint = null;
538
+ this.#proxyLabel = null;
539
+ this.#proxyConfig = null;
540
+ try {
541
+ await withTimeout(sock.end?.(undefined), SOCKET_CLOSE_TIMEOUT_MS);
542
+ } catch (error) {
543
+ this.log.debug({ err: error?.message }, "[Session] socket end threw");
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Would reconnecting pick up a different proxy than the one in use?
549
+ *
550
+ * False whenever nothing is connected — there is no socket to be stale, and
551
+ * the next Start reads the settings fresh anyway.
552
+ */
553
+ #proxyIsStale() {
554
+ if (this.#proxyFingerprint === null) return false;
555
+ try {
556
+ return proxyFingerprint(this.loadProxy()) !== this.#proxyFingerprint;
557
+ } catch {
558
+ // Settings that no longer validate (half-edited host, say) are not a
559
+ // reason to nag; the next Start will report the real error.
560
+ return false;
561
+ }
562
+ }
563
+
564
+ /** Scheduled jobs live exactly as long as the socket they send through. */
565
+ #stopJobs() {
566
+ try {
567
+ this.stopScheduledJobs?.();
568
+ } catch (error) {
569
+ this.log.warn({ err: error }, "[Session] stopping the scheduled jobs failed");
570
+ }
571
+ }
572
+
573
+ // -------------------------------------------------------------------------
574
+ // events from Baileys
575
+ // -------------------------------------------------------------------------
576
+
577
+ async #onConnectionUpdate(generation, update) {
578
+ // A socket we have already replaced. Nothing it says can matter.
579
+ if (generation !== this.#generation) return;
580
+
581
+ const { connection, lastDisconnect, qr, isNewLogin } = update || {};
582
+
583
+ if (qr) this.#onQr(qr);
584
+
585
+ // The phone scanned the code. Pairing succeeded even though the connection
586
+ // is about to be torn down and restarted — this is WhatsApp's own login
587
+ // handshake, not a failed attempt.
588
+ if (isNewLogin) {
589
+ this.#pairing = false;
590
+ this.#clearQr();
591
+ this.#transition(S.LINKING, {
592
+ reason: "paired",
593
+ detail: "Paired. Finishing the handshake…",
594
+ });
595
+ }
596
+
597
+ if (connection === "open") await this.#onOpen();
598
+ else if (connection === "close") await this.#onClose(lastDisconnect);
599
+ }
600
+
601
+ #onQr(qr) {
602
+ this.#qr = qr;
603
+ try {
604
+ saveQrCode(qr);
605
+ } catch (error) {
606
+ this.log.warn({ err: error }, "[Session] failed to store the QR code");
607
+ }
608
+ this.#transition(S.WAITING_FOR_QR, {
609
+ reason: "qr",
610
+ detail: "Scan the code from WhatsApp → Linked devices.",
611
+ });
612
+ this.emitEvent("qr", qr);
613
+ }
614
+
615
+ async #onOpen() {
616
+ this.#attempt = 0;
617
+ this.#nextRetryAt = null;
618
+ this.#pairing = false;
619
+ this.#clearQr();
620
+ this.#lastDisconnect = null;
621
+ this.#transition(S.CONNECTED, { reason: "open", detail: null });
622
+
623
+ try {
624
+ await this.onOpen(this.#socket, this.initializeScheduledJobs);
625
+ } catch (error) {
626
+ this.log.error({ err: error }, "[Session] post-connect setup failed");
627
+ }
628
+ }
629
+
630
+ async #onClose(lastDisconnect) {
631
+ const statusCode = lastDisconnect?.error?.output?.statusCode;
632
+ const reasonText =
633
+ lastDisconnect?.error?.output?.payload?.error ||
634
+ lastDisconnect?.error?.message ||
635
+ "Unknown";
636
+
637
+ this.#lastDisconnect = { statusCode: statusCode ?? null, reason: reasonText, at: Date.now() };
638
+ const closedSocket = this.#socket;
639
+ this.#socket = null;
640
+ // This socket is finished. Bump past it here rather than waiting for
641
+ // #destroySocket(), which no-ops once #socket is null — otherwise a manual
642
+ // stop from `reconnecting` would leave the dead socket's generation current
643
+ // and anything it emitted afterwards would still be believed.
644
+ this.#generation += 1;
645
+ this.#detach(closedSocket);
646
+ // The scheduled jobs were armed with that socket. Leaving them running
647
+ // means a daily message firing into a closed connection, failing, and
648
+ // being lost with nothing to show for it. The next open re-arms them.
649
+ this.#stopJobs();
650
+
651
+ // classifyDisconnect stays the only thing that decides terminal vs
652
+ // recoverable. A proxy only ever enriches the sentence next to it.
653
+ const verdict = classifyDisconnect(statusCode);
654
+ const proxyNote = describeProxyFailure(lastDisconnect?.error, this.#proxyConfig);
655
+ if (proxyNote && !verdict.terminal) {
656
+ verdict.detail = verdict.detail ? `${proxyNote} ${verdict.detail}` : proxyNote;
657
+ }
658
+
659
+ this.log.warn(
660
+ `[Session] Connection closed (${statusCode ?? "no status"}: ${reasonText}) — ${verdict.label}` +
661
+ (this.#proxyLabel ? ` via ${this.#proxyLabel}` : "")
662
+ );
663
+
664
+ if (this.#shuttingDown) return;
665
+
666
+ if (verdict.loggedOut) return this.#handleLoggedOut(verdict, closedSocket);
667
+ if (verdict.terminal) {
668
+ this.#clearQr();
669
+ this.#pairing = false;
670
+ return this.#transition(S.DISCONNECTED, {
671
+ reason: verdict.reason,
672
+ detail: verdict.detail,
673
+ });
674
+ }
675
+
676
+ // A pairing attempt that closed before anyone scanned. Do not hand out
677
+ // another code nobody asked for; drop the stale one and wait for a person.
678
+ if (this.#pairing && !verdict.restartRequired) {
679
+ this.#pairing = false;
680
+ this.#clearQr();
681
+ return this.#transition(S.DISCONNECTED, {
682
+ reason: "pairing_cancelled",
683
+ detail: "The pairing attempt ended before the code was scanned. Start a session to try again.",
684
+ });
685
+ }
686
+
687
+ this.#scheduleRetry(verdict);
688
+ }
689
+
690
+ async #handleLoggedOut(verdict, closedSocket) {
691
+ this.log.error("[Session] WhatsApp logged this device out. Clearing the stored credentials.");
692
+
693
+ // Baileys destroys its emitter inside end() before this close is reported,
694
+ // so there is normally nothing left to hear from. Being explicit costs
695
+ // nothing and closes the one window where a late creds.update could write
696
+ // the dead credentials back over the wipe below.
697
+ this.#detach(closedSocket);
698
+
699
+ const clearAll = this.#clearAll;
700
+ this.#clearAll = null;
701
+ if (clearAll) {
702
+ try {
703
+ await clearAll();
704
+ this.log.info("[Session] Credentials cleared");
705
+ } catch (error) {
706
+ this.log.error({ err: error }, "[Session] failed to clear credentials");
707
+ }
708
+ }
709
+
710
+ this.#pairing = false;
711
+ this.#clearQr();
712
+ this.#cancelRetry();
713
+ this.#attempt = 0;
714
+ this.#transition(S.LOGGED_OUT, { reason: verdict.reason, detail: verdict.detail });
715
+ }
716
+
717
+ // -------------------------------------------------------------------------
718
+ // retries
719
+ // -------------------------------------------------------------------------
720
+
721
+ #scheduleRetry(verdict) {
722
+ // Never two timers. A close that arrives while one is pending replaces it
723
+ // rather than adding to it.
724
+ this.#cancelRetry();
725
+
726
+ if (this.#attempt >= this.retryDelaysMs.length) {
727
+ this.log.error(
728
+ `[Session] ${this.retryDelaysMs.length} reconnect attempts failed. Levix stays up; start the session again from the panel.`
729
+ );
730
+ this.#clearQr();
731
+ return this.#transition(S.RETRY_EXHAUSTED, {
732
+ reason: "retry_exhausted",
733
+ detail: `Gave up after ${this.retryDelaysMs.length} attempts. Start the session to try again.`,
734
+ });
735
+ }
736
+
737
+ const delayMs = this.retryDelaysMs[this.#attempt];
738
+ this.#attempt += 1;
739
+ this.#nextRetryAt = Date.now() + delayMs;
740
+
741
+ this.log.warn(
742
+ `[Session] Reconnecting in ${Math.round(delayMs / 1000)}s (attempt ${this.#attempt}/${this.retryDelaysMs.length})`
743
+ );
744
+
745
+ this.#transition(S.RECONNECTING, {
746
+ reason: verdict.reason,
747
+ // The close's own explanation first: a 515 straight after a scan is
748
+ // WhatsApp asking for a fresh connection, not a failed attempt, and
749
+ // "attempt 1 of 5" on its own reads like something went wrong.
750
+ // No countdown in here: this string is baked once, at schedule time, and
751
+ // would go on claiming "in 5s" for the whole wait. The panel renders the
752
+ // remaining seconds from nextRetryAt, which actually ticks.
753
+ detail:
754
+ `${verdict.detail ? `${verdict.detail} ` : ""}` +
755
+ `Reconnecting (attempt ${this.#attempt} of ${this.retryDelaysMs.length}).`,
756
+ });
757
+
758
+ this.#retryTimer = setTimeout(() => {
759
+ this.#retryTimer = null;
760
+ this.#nextRetryAt = null;
761
+ if (this.#shuttingDown) return;
762
+ if (this.#starting) return;
763
+
764
+ this.#starting = this.#open({ reason: "reconnect" })
765
+ .catch((error) => this.log.error({ err: error }, "[Session] reconnect failed"))
766
+ .finally(() => {
767
+ this.#starting = null;
768
+ });
769
+ }, delayMs);
770
+
771
+ // Deliberately NOT unref'd. Once the WhatsApp socket is gone, a headless
772
+ // Levix has nothing else holding the event loop open — verified: the
773
+ // database is synchronous and the log transport does not hold a ref — so an
774
+ // unref'd timer would let the process exit during the wait instead of
775
+ // reconnecting. Shutdown cancels this timer explicitly, so keeping it
776
+ // ref'd cannot delay a SIGTERM.
777
+ }
778
+
779
+ #cancelRetry() {
780
+ if (this.#retryTimer) {
781
+ clearTimeout(this.#retryTimer);
782
+ this.#retryTimer = null;
783
+ }
784
+ this.#nextRetryAt = null;
785
+ }
786
+
787
+ // -------------------------------------------------------------------------
788
+ // bookkeeping
789
+ // -------------------------------------------------------------------------
790
+
791
+ /**
792
+ * Drop the pairing code, in memory and on disk.
793
+ *
794
+ * The row is deleted unconditionally rather than only when this process is
795
+ * holding a code: a QR written by a previous process is unscannable, and
796
+ * guarding on the in-memory copy left it in the database forever, where
797
+ * GET /qr would happily serve it to somebody.
798
+ */
799
+ #clearQr() {
800
+ const had = this.#qr !== null;
801
+ this.#qr = null;
802
+ try {
803
+ deleteQrCode();
804
+ } catch (error) {
805
+ this.log.debug({ err: error?.message }, "[Session] failed to delete the stored QR");
806
+ }
807
+ if (had) this.emitEvent("qr_cleared", {});
808
+ }
809
+
810
+ #transition(state, { reason = null, detail = null } = {}) {
811
+ const changed = state !== this.#state;
812
+ this.#state = state;
813
+ this.#reason = reason;
814
+ this.#detail = detail;
815
+ if (changed) this.#since = Date.now();
816
+
817
+ const snapshot = this.getState();
818
+ // `session` carries everything; `status_update` is the shape the dashboard
819
+ // and the headless printer have always listened for.
820
+ this.emitEvent("session", snapshot);
821
+ this.emitEvent("status_update", { status: snapshot.status, state: snapshot.state });
822
+ }
823
+ }
824
+
825
+ /** The numbers Baileys hands back, for anything that needs to name them. */
826
+ export { DisconnectReason };
827
+
828
+ export default WhatsAppSession;