@shoru/kitten 0.0.1-beta

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