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
|
@@ -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;
|