@shoru/kitten 0.0.6 → 0.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.
@@ -1,604 +1,553 @@
1
- import { DisconnectReason } from 'baileys';
2
- import { Boom } from '@hapi/boom';
3
- import qrcode from 'qrcode-terminal';
4
- import chalk from 'chalk';
5
- import { pluginManager } from '#plugins.js';
6
- import { logger, pino } from '#internals.js';
7
- import { initSession, listSessions } from '#auth.js';
8
- import { getConnectionConfig } from './getConnectionConfig.js';
9
-
10
- export const ConnectionState = Object.freeze({
11
- DISCONNECTED: 'disconnected',
12
- CONNECTING: 'connecting',
13
- CONNECTED: 'connected',
14
- RECONNECTING: 'reconnecting',
15
- });
16
-
17
- class ConnectionError extends Error {
18
- constructor(message, { statusCode, recoverable = true } = {}) {
19
- super(message);
20
- this.name = 'ConnectionError';
21
- this.statusCode = statusCode;
22
- this.recoverable = recoverable;
23
- }
24
- }
25
-
26
- const DISCONNECT_HANDLERS = new Map([
27
- [DisconnectReason.connectionClosed, { message: 'Connection closed', recoverable: true }],
28
- [DisconnectReason.restartRequired, { message: 'QR Scanned', recoverable: true }],
29
- [DisconnectReason.timedOut, { message: 'Connection timed out', recoverable: true }],
30
- [DisconnectReason.connectionLost, { message: 'Connection lost', recoverable: true }],
31
- [DisconnectReason.unavailableService, { message: 'Service unavailable', recoverable: true }],
32
- [DisconnectReason.loggedOut, { message: 'Session logged out', recoverable: true, deleteSession: true }],
33
- [DisconnectReason.forbidden, { message: 'Account banned', recoverable: false, deleteSession: true }],
34
- [405, { message: 'Not logged in', recoverable: true, deleteSession: true }],
35
- ]);
36
-
37
- const silentLogger = Object.freeze({
38
- trace: () => {},
39
- debug: () => {},
40
- info: () => {},
41
- warn: () => {},
42
- error: () => {},
43
- fatal: () => {},
44
- prompt: () => {},
45
- child: () => silentLogger,
46
- });
47
-
48
- const silentPino = pino({ level: 'silent' });
49
-
50
- export class Client {
51
- static #registry = new Map();
52
-
53
- static #isSyncing = false;
54
- static #isConfiguring = false;
55
-
56
- // Static Registry API
57
-
58
- static get(id) {
59
- return Client.#registry.get(id);
60
- }
61
-
62
- static has(id) {
63
- return Client.#registry.has(id);
64
- }
65
-
66
- static get size() {
67
- return Client.#registry.size;
68
- }
69
-
70
- static keys() {
71
- return Client.#registry.keys();
72
- }
73
-
74
- static values() {
75
- return Client.#registry.values();
76
- }
77
-
78
- static entries() {
79
- return Client.#registry.entries();
80
- }
81
-
82
- static [Symbol.iterator]() {
83
- return Client.#registry.values();
84
- }
85
-
86
- // Instance Properties
87
-
88
- sock = null;
89
- session = null;
90
- id = null;
91
-
92
- #flag = '';
93
- #plugins = null;
94
- #qr = null;
95
- #state = ConnectionState.DISCONNECTED;
96
- #cancelWait = null;
97
- #hasConnectedOnce = false;
98
-
99
- #socketConfig = null;
100
- #authConfig = null;
101
-
102
- #reconnectAttempts = 0;
103
- #reconnectTimer = null;
104
- #isShuttingDown = false;
105
-
106
- #pendingConnect = null;
107
-
108
- #maxRetries;
109
- #backoff;
110
-
111
- // Options
112
- #silent;
113
- #sync;
114
- #logger;
115
-
116
- // Callbacks
117
- #onPairing;
118
- #onConnect;
119
- #onReconnect;
120
- #onDisconnect;
121
- #onStateChange;
122
-
123
- constructor(options = {}) {
124
- const {
125
- id,
126
- maxRetries = 30,
127
- backoff = (attempt) => Math.min(1000 * 2 ** (attempt - 1), 60_000),
128
- silent = false,
129
- sync = false,
130
- onPairing = null,
131
- onConnect = null,
132
- onReconnect = null,
133
- onDisconnect = null,
134
- onStateChange = null,
135
- socketConfig = {},
136
- } = options;
137
-
138
- this.id = id;
139
- this.#socketConfig = socketConfig;
140
- this.#maxRetries = maxRetries;
141
- this.#backoff = backoff;
142
- this.#silent = silent;
143
- this.#sync = sync;
144
- this.#logger = silent ? silentLogger : logger;
145
- this.#onPairing = onPairing;
146
- this.#onConnect = onConnect;
147
- this.#onReconnect = onReconnect;
148
- this.#onDisconnect = onDisconnect;
149
- this.#onStateChange = onStateChange;
150
- }
151
-
152
- get state() {
153
- return this.#state;
154
- }
155
-
156
- get isConnected() {
157
- return this.#state === ConnectionState.CONNECTED;
158
- }
159
-
160
- get reconnectAttempts() {
161
- return this.#reconnectAttempts;
162
- }
163
-
164
- // Registry Management
165
-
166
- #register() {
167
- if (this.id != null) {
168
- Client.#registry.set(this.id, this);
169
- }
170
- }
171
-
172
- #unregister() {
173
- if (this.id != null) {
174
- Client.#registry.delete(this.id);
175
- }
176
- }
177
-
178
- // State Management
179
-
180
- #setState(newState) {
181
- const oldState = this.#state;
182
- if (oldState === newState) return;
183
-
184
- this.#state = newState;
185
- this.#emit('stateChange', { oldState, newState });
186
- }
187
-
188
- #emit(event, data = {}) {
189
- const callbacks = {
190
- connect: this.#onConnect,
191
- reconnect: this.#onReconnect,
192
- disconnect: this.#onDisconnect,
193
- stateChange: this.#onStateChange,
194
- };
195
-
196
- const callback = callbacks[event];
197
- if (typeof callback !== 'function') return;
198
-
199
- queueMicrotask(() => {
200
- try {
201
- callback({ ...data, client: this });
202
- } catch (err) {
203
- this.#logger.error(err, `[${this.#flag}] Error in ${event} callback`);
204
- }
205
- });
206
- }
207
-
208
- // Connection Management
209
-
210
- async connect() {
211
- if (this.#isShuttingDown) {
212
- throw new Error(`[${this.#flag}] Client is shutting down`);
213
- }
214
-
215
- if (this.#state === ConnectionState.CONNECTED) {
216
- return { sock: this.sock, session: this.session, id: this.id };
217
- }
218
-
219
- if (this.#pendingConnect) {
220
- return this.#pendingConnect.promise;
221
- }
222
-
223
- return this.#initConnection();
224
- }
225
-
226
- async #initConnection() {
227
- this.#setState(ConnectionState.CONNECTING);
228
- this.#reconnectAttempts = 0;
229
- this.#pendingConnect = this.#createDeferred();
230
-
231
- try {
232
- await this.#createSocket();
233
- } catch (err) {
234
- this.#setState(ConnectionState.DISCONNECTED);
235
- this.#resolvePending(null, err);
236
- }
237
-
238
- return this.#pendingConnect.promise;
239
- }
240
-
241
- #createDeferred() {
242
- let resolve, reject;
243
- const promise = new Promise((res, rej) => {
244
- resolve = res;
245
- reject = rej;
246
- });
247
- return { promise, resolve, reject };
248
- }
249
-
250
- #resolvePending(value, error = null) {
251
- if (!this.#pendingConnect) return;
252
-
253
- const { resolve, reject } = this.#pendingConnect;
254
- this.#pendingConnect = null;
255
-
256
- if (error) {
257
- reject(error);
258
- } else {
259
- resolve(value);
260
- }
261
- }
262
-
263
- async #createSocket() {
264
- this.#cleanupSocket();
265
-
266
- const socketConfig = this.#silent
267
- ? { ...this.#socketConfig, logger: silentPino }
268
- : this.#socketConfig;
269
-
270
- const { sock, session } = await initSession({
271
- socketConfig,
272
- id: this.id,
273
- });
274
-
275
- this.sock = sock;
276
- this.session = session;
277
- this.id = session.id;
278
- this.#flag = `CLIENT-${session.id}`;
279
-
280
- this.sock.ev.on('connection.update', (update) => {
281
- this.#handleConnectionUpdate(update);
282
- });
283
- }
284
-
285
- async #handleConnectionUpdate({ connection, lastDisconnect, qr }) {
286
- if (this.#isShuttingDown) return;
287
-
288
- try {
289
- if (qr) {
290
- await this.#handleAuth(qr);
291
- } else if (connection === 'open') {
292
- await this.#onConnectionOpen();
293
- } else if (connection === 'close') {
294
- await this.#onConnectionClose(lastDisconnect);
295
- }
296
- } catch (err) {
297
- this.#logger.error(err, `[${this.#flag}] Error in connection update handler`);
298
- this.#resolvePending(null, err);
299
- }
300
- }
301
-
302
- async #onConnectionOpen() {
303
- const wasReconnecting = this.#state === ConnectionState.RECONNECTING;
304
- this.#setState(ConnectionState.CONNECTED);
305
-
306
- this.#register();
307
-
308
- if (!this.#plugins || this.#plugins.destroyed) {
309
- try {
310
- this.#plugins = await pluginManager(this.sock);
311
- } catch (err) {
312
- this.#logger.error(err, `[${this.#flag}] Failed to initialize plugins`);
313
- }
314
- }
315
-
316
- const attempts = this.#reconnectAttempts;
317
- this.#reconnectAttempts = 0;
318
-
319
- if (wasReconnecting) {
320
- this.#emit('reconnect', { attempts });
321
- this.#logger.debug(`[${this.#flag}] Reconnected after ${attempts} attempt(s)`);
322
- } else {
323
- this.#hasConnectedOnce = true;
324
- this.#emit('connect');
325
- this.#logger.debug(`[${this.#flag}] Connected successfully`);
326
- this.#resolvePending({ sock: this.sock, session: this.session, id: this.id });
327
-
328
- if (!this.#sync) {
329
- this.#syncOtherSessions();
330
- }
331
- }
332
- }
333
-
334
- // Automatic Session Synchronization
335
-
336
- async #syncOtherSessions() {
337
- if (Client.#isSyncing) return;
338
- Client.#isSyncing = true;
339
-
340
- try {
341
- const allSessionIds = listSessions();
342
- const otherSessionIds = allSessionIds.filter((id) => id !== this.id);
343
-
344
- if (otherSessionIds.length === 0) {
345
- this.#logger.debug(`[${this.#flag}] No other sessions to sync`);
346
- return;
347
- }
348
-
349
- this.#logger.debug(
350
- `[${this.#flag}] Syncing ${otherSessionIds.length} other session(s) in background`
351
- );
352
-
353
- const results = await Promise.allSettled(
354
- otherSessionIds.map((sessionId) => this.#restoreSession(sessionId))
355
- );
356
-
357
- let successCount = 0;
358
- let skipCount = 0;
359
- let failCount = 0;
360
-
361
- for (const result of results) {
362
- if (result.status === 'fulfilled') {
363
- if (result.value === true) successCount++;
364
- else skipCount++;
365
- } else {
366
- failCount++;
367
- }
368
- }
369
-
370
- this.#logger.debug(
371
- `[${this.#flag}] Session sync complete: ${successCount} restored, ${skipCount} skipped, ${failCount} failed`
372
- );
373
- } catch (err) {
374
- this.#logger.error(err, `[${this.#flag}] Error during session sync`);
375
- } finally {
376
- Client.#isSyncing = false;
377
- }
378
- }
379
-
380
- async #restoreSession(sessionId) {
381
- if (Client.has(sessionId)) {
382
- return false;
383
- }
384
-
385
- const client = new Client({
386
- id: sessionId,
387
- silent: true,
388
- sync: true,
389
- maxRetries: 3,
390
- });
391
-
392
- await client.connect();
393
- return true;
394
- }
395
-
396
- async #onConnectionClose(lastDisconnect) {
397
- const disconnectInfo = this.#parseDisconnectReason(lastDisconnect);
398
- const { message, statusCode, recoverable, deleteSession } = disconnectInfo;
399
-
400
- if (message === 'QR Scanned' && !this.#onPairing && !this.#silent) {
401
- console.clear();
402
- }
403
-
404
- const level = recoverable ? 'debug' : 'warn';
405
- this.#logger[level](`[${this.#flag}] Disconnected: ${message} (code: ${statusCode})`);
406
-
407
- if (this.#hasConnectedOnce) {
408
- this.#emit('disconnect', { message, statusCode, recoverable });
409
- }
410
-
411
- this.#unregister();
412
-
413
- if (deleteSession) {
414
- await this.session?.delete().catch((err) => {
415
- this.#logger.error(err, `[${this.#flag}] Failed to delete session`);
416
- });
417
- }
418
-
419
- if (!recoverable || this.#isShuttingDown) {
420
- this.#setState(ConnectionState.DISCONNECTED);
421
- this.#resolvePending(null, new ConnectionError(message, { statusCode, recoverable }));
422
- return;
423
- }
424
-
425
- await this.#scheduleReconnect(message);
426
- }
427
-
428
- // Reconnection Logic
429
-
430
- async #scheduleReconnect(reason) {
431
- this.#reconnectAttempts++;
432
-
433
- if (this.#reconnectAttempts > this.#maxRetries) {
434
- const err = new ConnectionError(
435
- `Max reconnection attempts (${this.#maxRetries}) exceeded`,
436
- { recoverable: false }
437
- );
438
- this.#setState(ConnectionState.DISCONNECTED);
439
- this.#resolvePending(null, err);
440
- this.#logger.error(err, `[${this.#flag}] ${err.message}`);
441
- return;
442
- }
443
-
444
- if (this.#hasConnectedOnce) {
445
- this.#setState(ConnectionState.RECONNECTING);
446
- }
447
-
448
- const delay = this.#backoff(this.#reconnectAttempts);
449
- const retriesInfo =
450
- this.#maxRetries !== Infinity
451
- ? `(${this.#reconnectAttempts}/${this.#maxRetries})`
452
- : '';
453
-
454
- this.#logger.debug(`[${this.#flag}] ${reason}. Reconnecting in ${delay}ms`);
455
-
456
- const cancelled = await this.#wait(delay);
457
- if (cancelled || this.#isShuttingDown) return;
458
-
459
- this.#logger.debug(`[${this.#flag}] Executing reconnect attempt ${retriesInfo}`);
460
-
461
- try {
462
- await this.#createSocket();
463
- } catch (err) {
464
- this.#logger.error(err, `[${this.#flag}] Socket creation failed during reconnect`);
465
- }
466
- }
467
-
468
- #wait(ms) {
469
- return new Promise((resolve) => {
470
- this.#reconnectTimer = setTimeout(() => {
471
- this.#reconnectTimer = null;
472
- resolve(false);
473
- }, ms);
474
-
475
- this.#cancelWait = () => resolve(true);
476
- });
477
- }
478
-
479
- #parseDisconnectReason(lastDisconnect) {
480
- const boom = new Boom(lastDisconnect?.error);
481
- const statusCode = boom?.output?.statusCode;
482
- const handler = DISCONNECT_HANDLERS.get(statusCode);
483
-
484
- if (!handler) {
485
- return {
486
- message: `Unknown disconnect reason (code: ${statusCode ?? 'unknown'})`,
487
- statusCode,
488
- recoverable: true,
489
- deleteSession: false,
490
- };
491
- }
492
-
493
- return { ...handler, statusCode };
494
- }
495
-
496
- // Authentication
497
-
498
- async #handleAuth(qr) {
499
- this.#qr = qr;
500
-
501
- if (this.#sync) {
502
- const err = new ConnectionError('Authentication required for sync connection', {
503
- recoverable: false,
504
- });
505
- this.#logger.debug(`[${this.#flag}] Sync connection requires auth, aborting`);
506
- this.#cleanupSocket();
507
- this.#setState(ConnectionState.DISCONNECTED);
508
- this.#resolvePending(null, err);
509
- return;
510
- }
511
-
512
- if (Client.#isConfiguring) return;
513
-
514
- if (typeof this.#onPairing === 'function') {
515
- const requestPairingCode = this.sock?.requestPairingCode?.bind(this.sock);
516
- await this.#onPairing({ qr: this.#qr, requestPairingCode });
517
- return;
518
- }
519
-
520
- if (this.#silent) {
521
- return;
522
- }
523
-
524
- Client.#isConfiguring = true;
525
- try {
526
- this.#authConfig ??= await getConnectionConfig();
527
- } finally {
528
- Client.#isConfiguring = false;
529
- }
530
-
531
- if (this.#authConfig.type === 'pn') {
532
- const code = await this.sock.requestPairingCode(this.#authConfig.pn);
533
- this.#logger.prompt(this.#formatPairingCode(code));
534
- } else {
535
- qrcode.generate(this.#qr, { small: true });
536
- process.stdout.write('\n');
537
- }
538
- }
539
-
540
- #formatPairingCode(code) {
541
- const formatted = code.match(/.{1,4}/g)?.join(' ') ?? code;
542
- return `\n${chalk.green('> Your OTP Code: ')}${chalk.bold(formatted)}`;
543
- }
544
-
545
- // Cleanup & Shutdown
546
-
547
- #cleanupSocket() {
548
- if (this.#plugins && !this.#plugins.destroyed) {
549
- this.#plugins.destroy();
550
- this.#plugins = null;
551
- }
552
-
553
- if (!this.sock) return;
554
-
555
- try {
556
- this.sock.ev.removeAllListeners();
557
- } catch {
558
- /* noop */
559
- }
560
-
561
- this.sock = null;
562
- }
563
-
564
- #clearReconnectTimer() {
565
- if (this.#reconnectTimer) {
566
- clearTimeout(this.#reconnectTimer);
567
- this.#reconnectTimer = null;
568
- this.#cancelWait?.();
569
- }
570
- }
571
-
572
- async disconnect() {
573
- if (this.#isShuttingDown) return;
574
- this.#isShuttingDown = true;
575
-
576
- this.#clearReconnectTimer();
577
- this.#resolvePending(null, new Error('Client disconnected'));
578
-
579
- // Unregister from registry
580
- this.#unregister();
581
-
582
- this.#cleanupSocket();
583
- this.#setState(ConnectionState.DISCONNECTED);
584
-
585
- this.#isShuttingDown = false;
586
- this.#hasConnectedOnce = false;
587
- }
588
-
589
- async logout() {
590
- try {
591
- await this.sock?.logout();
592
- await this.disconnect();
593
- await this.session?.delete();
594
- } catch (err) {
595
- this.#logger.error(err, `[${this.#flag}] Logging out failed`);
596
- }
597
- }
598
- }
599
-
600
- export const getClient = async (options) => {
601
- const client = new Client(options);
602
- await client.connect();
603
- return client;
1
+ import { pluginManager } from '#plugins.js';
2
+ import { logger } from '#internals.js';
3
+ import { initSession, listSessions } from '#auth.js';
4
+ import {
5
+ ConnectionState,
6
+ DEFAULT_MAX_RETRIES,
7
+ defaultBackoff,
8
+ silentLogger,
9
+ silentPino,
10
+ } from './constants.js';
11
+ import { ConnectionError, parseDisconnectReason } from './errors.js';
12
+ import { ClientRegistry } from './registry.js';
13
+ import { createSocketProxy } from './socket-proxy.js';
14
+ import { createPersistentEventBus } from './event-bus.js';
15
+ import { handleClientAuth } from './auth-handler.js';
16
+ import { syncOtherSessions } from './sync-manager.js';
17
+
18
+ export { ConnectionState, ConnectionError };
19
+
20
+ export class Client {
21
+ // Static Registry API
22
+
23
+ static get(id) {
24
+ return ClientRegistry.get(id);
25
+ }
26
+
27
+ static has(id) {
28
+ return ClientRegistry.has(id);
29
+ }
30
+
31
+ static get size() {
32
+ return ClientRegistry.size;
33
+ }
34
+
35
+ static keys() {
36
+ return ClientRegistry.keys();
37
+ }
38
+
39
+ static values() {
40
+ return ClientRegistry.values();
41
+ }
42
+
43
+ static entries() {
44
+ return ClientRegistry.entries();
45
+ }
46
+
47
+ static [Symbol.iterator]() {
48
+ return ClientRegistry.values();
49
+ }
50
+
51
+ // Instance Properties
52
+
53
+ sock = null;
54
+ session = null;
55
+ id = null;
56
+
57
+ #rawSock = null;
58
+ #ev = null;
59
+ #sockProxy = null;
60
+
61
+ #flag = '';
62
+ #plugins = null;
63
+ #qr = null;
64
+ #state = ConnectionState.DISCONNECTED;
65
+ #cancelWait = null;
66
+ #hasConnectedOnce = false;
67
+ #hasEverConnected = false;
68
+ #isFirstConnection = false;
69
+ #isNewSession = null;
70
+
71
+ #socketConfig = null;
72
+ #authConfig = null;
73
+ #pairingCodeRequested = false;
74
+
75
+ #reconnectAttempts = 0;
76
+ #reconnectTimer = null;
77
+ #isShuttingDown = false;
78
+
79
+ #pendingConnect = null;
80
+
81
+ #maxRetries;
82
+ #backoff;
83
+
84
+ // Options
85
+ #silent;
86
+ #sync;
87
+ #logger;
88
+
89
+ // Callbacks
90
+ #onPairing;
91
+ #onConnect;
92
+ #onReconnect;
93
+ #onDisconnect;
94
+ #onStateChange;
95
+
96
+ constructor(options = {}) {
97
+ const {
98
+ id,
99
+ maxRetries = DEFAULT_MAX_RETRIES,
100
+ backoff = defaultBackoff,
101
+ silent = false,
102
+ sync = false,
103
+ onPairing = null,
104
+ onConnect = null,
105
+ onReconnect = null,
106
+ onDisconnect = null,
107
+ onStateChange = null,
108
+ socketConfig = {},
109
+ } = options;
110
+
111
+ this.id = id;
112
+ this.#socketConfig = socketConfig;
113
+ this.#maxRetries = maxRetries;
114
+ this.#backoff = backoff;
115
+ this.#silent = silent;
116
+ this.#sync = sync;
117
+ this.#logger = silent ? silentLogger : logger;
118
+ this.#onPairing = onPairing;
119
+ this.#onConnect = onConnect;
120
+ this.#onReconnect = onReconnect;
121
+ this.#onDisconnect = onDisconnect;
122
+ this.#onStateChange = onStateChange;
123
+
124
+ this.#ev = createPersistentEventBus();
125
+ this.#sockProxy = createSocketProxy(
126
+ () => this.#rawSock,
127
+ () => this.#ev
128
+ );
129
+ this.sock = this.#sockProxy;
130
+ }
131
+
132
+ get state() {
133
+ return this.#state;
134
+ }
135
+
136
+ get isConnected() {
137
+ return this.#state === ConnectionState.CONNECTED;
138
+ }
139
+
140
+ get isFirstConnection() {
141
+ return this.#isFirstConnection;
142
+ }
143
+
144
+ get reconnectAttempts() {
145
+ return this.#reconnectAttempts;
146
+ }
147
+
148
+ get ev() {
149
+ return this.#ev;
150
+ }
151
+
152
+ get rawSocket() {
153
+ return this.#rawSock;
154
+ }
155
+
156
+ // Registry Management
157
+
158
+ #register() {
159
+ if (this.id != null) {
160
+ ClientRegistry.set(this.id, this);
161
+ }
162
+ }
163
+
164
+ #unregister() {
165
+ if (this.id != null) {
166
+ ClientRegistry.delete(this.id);
167
+ }
168
+ }
169
+
170
+ // State Management
171
+
172
+ #setState(newState) {
173
+ const oldState = this.#state;
174
+ if (oldState === newState) return;
175
+
176
+ this.#state = newState;
177
+ this.#emit('stateChange', { oldState, newState });
178
+ }
179
+
180
+ #emit(event, data = {}) {
181
+ const callbacks = {
182
+ connect: this.#onConnect,
183
+ reconnect: this.#onReconnect,
184
+ disconnect: this.#onDisconnect,
185
+ stateChange: this.#onStateChange,
186
+ };
187
+
188
+ const callback = callbacks[event];
189
+ if (typeof callback !== 'function') return;
190
+
191
+ queueMicrotask(() => {
192
+ try {
193
+ callback({ ...data, client: this });
194
+ } catch (err) {
195
+ this.#logger.error(err, `[${this.#flag}] Error in ${event} callback`);
196
+ }
197
+ });
198
+ }
199
+
200
+ // Connection Management
201
+
202
+ async connect() {
203
+ if (this.#isShuttingDown) {
204
+ throw new Error(`[${this.#flag}] Client is shutting down`);
205
+ }
206
+
207
+ if (this.#state === ConnectionState.CONNECTED) {
208
+ return { sock: this.sock, session: this.session, id: this.id };
209
+ }
210
+
211
+ if (this.#pendingConnect) {
212
+ return this.#pendingConnect.promise;
213
+ }
214
+
215
+ return this.#initConnection();
216
+ }
217
+
218
+ async #initConnection() {
219
+ this.#setState(ConnectionState.CONNECTING);
220
+ this.#reconnectAttempts = 0;
221
+ this.#pendingConnect = this.#createDeferred();
222
+
223
+ try {
224
+ await this.#createSocket();
225
+ } catch (err) {
226
+ this.#setState(ConnectionState.DISCONNECTED);
227
+ this.#resolvePending(null, err);
228
+ }
229
+
230
+ return this.#pendingConnect.promise;
231
+ }
232
+
233
+ #createDeferred() {
234
+ let resolve, reject;
235
+ const promise = new Promise((res, rej) => {
236
+ resolve = res;
237
+ reject = rej;
238
+ });
239
+ return { promise, resolve, reject };
240
+ }
241
+
242
+ #resolvePending(value, error = null) {
243
+ if (!this.#pendingConnect) return;
244
+
245
+ const { resolve, reject } = this.#pendingConnect;
246
+ this.#pendingConnect = null;
247
+
248
+ if (error) {
249
+ reject(error);
250
+ } else {
251
+ resolve(value);
252
+ }
253
+ }
254
+
255
+ async #createSocket() {
256
+ this.#cleanupSocket();
257
+
258
+ let targetId = this.id;
259
+ if (targetId == null) {
260
+ const existingSessions = listSessions();
261
+ const available = existingSessions.find((id) => !ClientRegistry.has(id));
262
+ if (available != null) {
263
+ targetId = available;
264
+ }
265
+ }
266
+
267
+ const socketConfig = this.#silent
268
+ ? { ...this.#socketConfig, logger: silentPino }
269
+ : this.#socketConfig;
270
+
271
+ const { sock, session } = await initSession({
272
+ socketConfig,
273
+ id: targetId,
274
+ });
275
+
276
+ this.#rawSock = sock;
277
+ this.session = session;
278
+ this.id = session.id;
279
+ this.#flag = `CLIENT-${session.id}`;
280
+
281
+ if (this.#isNewSession === null) {
282
+ this.#isNewSession = Boolean(session.isNew);
283
+ }
284
+
285
+ // Pipe all events from the active raw socket into the persistent event bus
286
+ this.#rawSock.ev.process(async (events) => {
287
+ for (const [event, data] of Object.entries(events)) {
288
+ this.#ev.emit(event, data);
289
+ }
290
+ });
291
+
292
+ this.#rawSock.ev.on('connection.update', (update) => {
293
+ this.#handleConnectionUpdate(update);
294
+ });
295
+ }
296
+
297
+ async #handleConnectionUpdate({ connection, lastDisconnect, qr }) {
298
+ if (this.#isShuttingDown) return;
299
+
300
+ try {
301
+ if (qr) {
302
+ this.#qr = qr;
303
+ await handleClientAuth({
304
+ qr: this.#qr,
305
+ rawSock: this.#rawSock,
306
+ flag: this.#flag,
307
+ isSync: this.#sync,
308
+ isSilent: this.#silent,
309
+ onPairing: this.#onPairing,
310
+ logger: this.#logger,
311
+ authConfig: this.#authConfig,
312
+ setAuthConfig: (cfg) => {
313
+ this.#authConfig = cfg;
314
+ },
315
+ pairingCodeRequested: this.#pairingCodeRequested,
316
+ setPairingCodeRequested: (req) => {
317
+ this.#pairingCodeRequested = req;
318
+ },
319
+ onSyncAuthAbort: (err) => {
320
+ this.#cleanupSocket();
321
+ this.#setState(ConnectionState.DISCONNECTED);
322
+ this.#resolvePending(null, err);
323
+ },
324
+ });
325
+ }
326
+
327
+ if (connection === 'open') {
328
+ await this.#onConnectionOpen();
329
+ } else if (connection === 'close') {
330
+ await this.#onConnectionClose(lastDisconnect);
331
+ }
332
+ } catch (err) {
333
+ this.#logger.error(err, `[${this.#flag}] Error in connection update handler`);
334
+ this.#resolvePending(null, err);
335
+ }
336
+ }
337
+
338
+ async #onConnectionOpen() {
339
+ const wasReconnecting = this.#state === ConnectionState.RECONNECTING;
340
+ this.#setState(ConnectionState.CONNECTED);
341
+
342
+ this.#register();
343
+ this.#pairingCodeRequested = false;
344
+
345
+ if (!this.#plugins || this.#plugins.destroyed) {
346
+ try {
347
+ this.#plugins = await pluginManager(this.sock);
348
+ } catch (err) {
349
+ this.#logger.error(err, `[${this.#flag}] Failed to initialize plugins`);
350
+ }
351
+ }
352
+
353
+ const attempts = this.#reconnectAttempts;
354
+ this.#reconnectAttempts = 0;
355
+
356
+ const isFirstConnection = Boolean(this.#isNewSession && !this.#hasEverConnected);
357
+ this.#isFirstConnection = isFirstConnection;
358
+
359
+ if (wasReconnecting) {
360
+ this.#isFirstConnection = false;
361
+ this.#emit('reconnect', { attempts });
362
+ this.#logger.debug(`[${this.#flag}] Reconnected after ${attempts} attempt(s)`);
363
+ } else {
364
+ this.#hasConnectedOnce = true;
365
+ this.#emit('connect', { isFirstConnection });
366
+ this.#hasEverConnected = true;
367
+ this.#isNewSession = false;
368
+ this.#logger.debug(`[${this.#flag}] Connected successfully`);
369
+ this.#resolvePending({ sock: this.sock, session: this.session, id: this.id });
370
+
371
+ if (!this.#sync) {
372
+ syncOtherSessions({
373
+ currentId: this.id,
374
+ flag: this.#flag,
375
+ logger: this.#logger,
376
+ ClientClass: Client,
377
+ });
378
+ }
379
+ }
380
+ }
381
+
382
+ async #onConnectionClose(lastDisconnect) {
383
+ const disconnectInfo = parseDisconnectReason(lastDisconnect);
384
+ const { message, statusCode, recoverable, deleteSession, isRestart } = disconnectInfo;
385
+
386
+ const level = recoverable ? 'debug' : 'warn';
387
+ this.#logger[level](`[${this.#flag}] Disconnected: ${message} (code: ${statusCode})`);
388
+
389
+ if (this.#hasConnectedOnce && !isRestart) {
390
+ this.#emit('disconnect', { message, statusCode, recoverable });
391
+ }
392
+
393
+ this.#unregister();
394
+
395
+ if (deleteSession) {
396
+ await this.session?.delete().catch((err) => {
397
+ this.#logger.error(err, `[${this.#flag}] Failed to delete session`);
398
+ });
399
+ this.#authConfig = null;
400
+ this.#pairingCodeRequested = false;
401
+ this.#isNewSession = true;
402
+ this.#hasEverConnected = false;
403
+ this.#isFirstConnection = false;
404
+ this.#hasConnectedOnce = false;
405
+ }
406
+
407
+ // If session was deleted, invalid, or logged out, and client is not a background sync connection
408
+ if (deleteSession && !this.#sync) {
409
+ this.#logger.warn(`[${this.#flag}] Session invalid or deleted, initiating new connection dialogue`);
410
+ this.id = null; // Reset so that a fresh clean session can be allocated
411
+ await this.#scheduleReconnect('Session reset for re-authentication');
412
+ return;
413
+ }
414
+
415
+ if (!recoverable || this.#isShuttingDown) {
416
+ this.#setState(ConnectionState.DISCONNECTED);
417
+ this.#resolvePending(null, new ConnectionError(message, { statusCode, recoverable }));
418
+ return;
419
+ }
420
+
421
+ if (isRestart) {
422
+ this.#logger.debug(`[${this.#flag}] restarting session`);
423
+ try {
424
+ await this.#createSocket();
425
+ } catch (err) {
426
+ this.#logger.error(err, `[${this.#flag}] Socket creation failed during restart`);
427
+ await this.#scheduleReconnect(err.message);
428
+ }
429
+ return;
430
+ }
431
+
432
+ await this.#scheduleReconnect(message);
433
+ }
434
+
435
+ // Reconnection Logic
436
+
437
+ async #scheduleReconnect(reason) {
438
+ this.#reconnectAttempts++;
439
+
440
+ if (this.#reconnectAttempts > this.#maxRetries) {
441
+ const err = new ConnectionError(
442
+ `Max reconnection attempts (${this.#maxRetries}) exceeded`,
443
+ { recoverable: false }
444
+ );
445
+ this.#setState(ConnectionState.DISCONNECTED);
446
+ this.#resolvePending(null, err);
447
+ this.#logger.error(err, `[${this.#flag}] ${err.message}`);
448
+ return;
449
+ }
450
+
451
+ if (this.#hasConnectedOnce) {
452
+ this.#setState(ConnectionState.RECONNECTING);
453
+ }
454
+
455
+ const delay = this.#backoff(this.#reconnectAttempts);
456
+ const retriesInfo =
457
+ this.#maxRetries !== Infinity
458
+ ? `(${this.#reconnectAttempts}/${this.#maxRetries})`
459
+ : '';
460
+
461
+ this.#logger.debug(`[${this.#flag}] ${reason}. Reconnecting in ${delay}ms`);
462
+
463
+ const cancelled = await this.#wait(delay);
464
+ if (cancelled || this.#isShuttingDown) return;
465
+
466
+ this.#logger.debug(`[${this.#flag}] Executing reconnect attempt ${retriesInfo}`);
467
+
468
+ try {
469
+ await this.#createSocket();
470
+ } catch (err) {
471
+ this.#logger.error(err, `[${this.#flag}] Socket creation failed during reconnect`);
472
+ await this.#scheduleReconnect(err.message);
473
+ }
474
+ }
475
+
476
+ #wait(ms) {
477
+ return new Promise((resolve) => {
478
+ this.#reconnectTimer = setTimeout(() => {
479
+ this.#reconnectTimer = null;
480
+ resolve(false);
481
+ }, ms);
482
+
483
+ this.#cancelWait = () => resolve(true);
484
+ });
485
+ }
486
+
487
+ // Cleanup & Shutdown
488
+
489
+ #cleanupSocket() {
490
+ if (this.#plugins && !this.#plugins.destroyed) {
491
+ this.#plugins.destroy();
492
+ this.#plugins = null;
493
+ }
494
+
495
+ this.#pairingCodeRequested = false;
496
+
497
+ if (!this.#rawSock) return;
498
+
499
+ try {
500
+ this.#rawSock.ev.removeAllListeners();
501
+ this.#rawSock.ws?.close?.();
502
+ } catch {
503
+ /* noop */
504
+ }
505
+
506
+ this.#rawSock = null;
507
+ }
508
+
509
+ #clearReconnectTimer() {
510
+ if (this.#reconnectTimer) {
511
+ clearTimeout(this.#reconnectTimer);
512
+ this.#reconnectTimer = null;
513
+ this.#cancelWait?.();
514
+ }
515
+ }
516
+
517
+ async disconnect() {
518
+ if (this.#isShuttingDown) return;
519
+ this.#isShuttingDown = true;
520
+
521
+ this.#clearReconnectTimer();
522
+ this.#resolvePending(null, new Error('Client disconnected'));
523
+
524
+ // Unregister from registry
525
+ this.#unregister();
526
+
527
+ this.#cleanupSocket();
528
+ this.#setState(ConnectionState.DISCONNECTED);
529
+
530
+ this.#isShuttingDown = false;
531
+ this.#hasConnectedOnce = false;
532
+ this.#pairingCodeRequested = false;
533
+ }
534
+
535
+ async logout() {
536
+ try {
537
+ await this.#rawSock?.logout?.();
538
+ await this.disconnect();
539
+ await this.session?.delete();
540
+ this.#isNewSession = null;
541
+ this.#hasEverConnected = false;
542
+ this.#isFirstConnection = false;
543
+ } catch (err) {
544
+ this.#logger.error(err, `[${this.#flag}] Logging out failed`);
545
+ }
546
+ }
547
+ }
548
+
549
+ export const getClient = async (options) => {
550
+ const client = new Client(options);
551
+ await client.connect();
552
+ return client;
604
553
  };