@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,480 @@
1
+ import { Mutex } from 'async-mutex';
2
+ import { config, logger } from '#internals.js';
3
+ import { watch } from 'chokidar';
4
+ import fs from 'fs/promises';
5
+ import path from 'path';
6
+ import { pathToFileURL } from 'url';
7
+ import { formatter } from '#formatter.js';
8
+
9
+ const {
10
+ dir,
11
+ defaultEvent,
12
+ prefixes: PREFIXES,
13
+ hmr: {
14
+ enable: HMREnabled,
15
+ debounce: debounceMs,
16
+ debug: isDebug
17
+ }
18
+ } = config.plugins;
19
+
20
+ const PLUGIN_DIR = path.resolve(import.meta.dirname ?? '.', dir);
21
+
22
+ const EVENTS = new Set([
23
+ 'messaging-history.set', 'chats.upsert', 'chats.update', 'chats.delete',
24
+ 'contacts.upsert', 'contacts.update', 'messages.upsert', 'messages.update',
25
+ 'messages.delete', 'messages.reaction', 'message-receipt.update',
26
+ 'groups.update', 'group-participants.update', 'connection.update',
27
+ 'creds.update', 'presence.update', 'blocklist.set', 'blocklist.update', 'call',
28
+ ]);
29
+
30
+ const createBuckets = () => Object.fromEntries(
31
+ [...EVENTS].map(e => [e, { auto: new Map(), match: new Map() }])
32
+ );
33
+
34
+ export class PluginManager {
35
+ static #plugins = new Map();
36
+ static #watcher = null;
37
+ static #ready = false;
38
+ static #debounceTimers = new Map();
39
+ static #instances = new Set();
40
+ static #fileLocks = new Map();
41
+ static #buckets = createBuckets();
42
+ static #eventCounts = new Map([...EVENTS].map(e => [e, 0]));
43
+
44
+ #sock;
45
+ #handlers = new Map();
46
+ #destroyed = false;
47
+
48
+ constructor(sock) {
49
+ if (!sock?.ev) throw new TypeError('Invalid socket: missing ev property');
50
+ this.#sock = sock;
51
+ }
52
+
53
+ static #getLock(filePath) {
54
+ return PluginManager.#fileLocks.get(filePath)
55
+ ?? PluginManager.#fileLocks.set(filePath, new Mutex()).get(filePath);
56
+ }
57
+
58
+ static #handleError(context, err, level = 'error') {
59
+ if (isDebug) {
60
+ logger.error(err, context);
61
+ } else if (level === 'error') {
62
+ logger.warn(`${context} ${err?.message ?? 'Unknown error'}`);
63
+ }
64
+ }
65
+
66
+ async init() {
67
+ if (this.#destroyed) throw new Error('Cannot reinitialize destroyed instance');
68
+
69
+ PluginManager.#instances.add(this);
70
+
71
+ if (!PluginManager.#ready) {
72
+ await fs.mkdir(PLUGIN_DIR, { recursive: true }).catch(() => {});
73
+ await PluginManager.#loadAll();
74
+ if (HMREnabled) PluginManager.#initWatcher();
75
+ PluginManager.#ready = true;
76
+ }
77
+
78
+ this.#syncListeners();
79
+ if (isDebug) {
80
+ logger.debug(`[PluginManager] Init (sockets: ${PluginManager.#instances.size}, plugins: ${PluginManager.#plugins.size})`);
81
+ }
82
+ return this;
83
+ }
84
+
85
+ destroy() {
86
+ if (this.#destroyed) return;
87
+ this.#destroyed = true;
88
+
89
+ for (const [event, handler] of this.#handlers) {
90
+ this.#sock.ev.off(event, handler);
91
+ }
92
+ this.#handlers.clear();
93
+ PluginManager.#instances.delete(this);
94
+
95
+ if (PluginManager.#instances.size === 0) {
96
+ PluginManager.#cleanup();
97
+ }
98
+ }
99
+
100
+ static #cleanup() {
101
+ PluginManager.#watcher?.close();
102
+ PluginManager.#watcher = null;
103
+ PluginManager.#ready = false;
104
+ PluginManager.#debounceTimers.forEach(clearTimeout);
105
+ PluginManager.#debounceTimers.clear();
106
+ PluginManager.#fileLocks.clear();
107
+ PluginManager.#plugins.clear();
108
+
109
+ for (const bucket of Object.values(PluginManager.#buckets)) {
110
+ bucket.auto.clear();
111
+ bucket.match.clear();
112
+ }
113
+
114
+ for (const event of EVENTS) {
115
+ PluginManager.#eventCounts.set(event, 0);
116
+ }
117
+ }
118
+
119
+ static #getParent(dirPath) {
120
+ return path.relative(PLUGIN_DIR, dirPath).split(path.sep)[0] || null;
121
+ }
122
+
123
+ static async #loadAll() {
124
+ const entries = await fs.readdir(PLUGIN_DIR, { withFileTypes: true, recursive: true }).catch(() => []);
125
+
126
+ const files = entries
127
+ .filter(e => e.isFile() && /(?<!\.d)\.[jt]s$/.test(e.name) && !e.name.startsWith('_'))
128
+ .map(e => {
129
+ const dirPath = e.parentPath ?? e.path;
130
+ const parent = PluginManager.#getParent(dirPath);
131
+ const filePath = path.join(dirPath, e.name);
132
+ return { path: filePath, parent };
133
+ });
134
+
135
+ const results = await Promise.allSettled(
136
+ files.map(({ path: p, parent }) => PluginManager.#loadFile(p, parent))
137
+ );
138
+
139
+ let loaded = 0;
140
+ let failed = 0;
141
+
142
+ for (let i = 0; i < results.length; i++) {
143
+ const result = results[i];
144
+ if (result.status === 'fulfilled') {
145
+ loaded += result.value?.size ?? 0;
146
+ } else {
147
+ failed++;
148
+ PluginManager.#handleError(`[PluginManager] Failed to load ${files[i].path}:`, result.reason, 'warn');
149
+ }
150
+ }
151
+
152
+ logger.info(`[PluginManager] Loaded ${loaded} plugins${failed ? ` (${failed} failed)` : ''}`);
153
+ }
154
+
155
+ static async #loadFile(filePath, parent, register = true) {
156
+ const exec = async () => {
157
+ const { mtimeMs } = await fs.stat(filePath);
158
+ const mod = await import(`${pathToFileURL(filePath)}?v=${mtimeMs | 0}`);
159
+ const loaded = new Map();
160
+
161
+ for (const [name, value] of Object.entries(mod)) {
162
+ const plugin = PluginManager.#normalize(value);
163
+ if (!plugin || plugin.enabled === false) continue;
164
+
165
+ const id = path.relative(PLUGIN_DIR, filePath)
166
+ .replace(/\.[jt]s$/, '')
167
+ .replaceAll(path.sep, '/') + ':' + name;
168
+
169
+ const events = (Array.isArray(plugin.events) ? plugin.events : [])
170
+ .filter(e => EVENTS.has(e));
171
+
172
+ plugin._meta = {
173
+ parent,
174
+ filePath,
175
+ id,
176
+ events: events.length ? events : [defaultEvent],
177
+ matchers: PluginManager.#compile(plugin.match, plugin.prefix),
178
+ };
179
+
180
+ if (register) {
181
+ PluginManager.#plugins.set(id, plugin);
182
+ PluginManager.#register(id, plugin);
183
+ }
184
+ loaded.set(id, plugin);
185
+ }
186
+ return loaded;
187
+ };
188
+
189
+ return register ? PluginManager.#getLock(filePath).runExclusive(exec) : exec();
190
+ }
191
+
192
+ static #normalize(value) {
193
+ if (typeof value === 'function') return value;
194
+ if (typeof value?.default === 'function') {
195
+ const { default: fn, ...rest } = value;
196
+ return Object.assign(fn, rest);
197
+ }
198
+ return null;
199
+ }
200
+
201
+ static #compile(match, prefixOpt) {
202
+ if (!Array.isArray(match) || !match.length) return null;
203
+
204
+ const strings = match.filter(m => typeof m === 'string').map(s => s.toLowerCase());
205
+ const regexes = match.filter(m => m instanceof RegExp);
206
+
207
+ const prefixes = prefixOpt === false
208
+ ? null
209
+ : prefixOpt
210
+ ? new Set([prefixOpt].flat())
211
+ : new Set(PREFIXES);
212
+
213
+ return {
214
+ strings,
215
+ set: strings.length ? new Set(strings) : null,
216
+ regexes,
217
+ prefixes,
218
+ };
219
+ }
220
+
221
+ static #test(matchers, body) {
222
+ if (!body || typeof body !== 'string') return null;
223
+
224
+ const text = body.toLowerCase();
225
+
226
+ if (matchers.set) {
227
+ const prefix = text[0];
228
+ const prefixValid = !matchers.prefixes || matchers.prefixes.has(prefix);
229
+
230
+ if (prefixValid) {
231
+ const rest = text.slice(1);
232
+ const idx = rest.indexOf(' ');
233
+ const cmd = idx < 0 ? rest : rest.slice(0, idx);
234
+
235
+ if (cmd) {
236
+ if (matchers.set.has(cmd)) {
237
+ return { value: cmd, prefix, captures: null };
238
+ }
239
+
240
+ for (const s of matchers.strings) {
241
+ if (cmd.length > s.length && cmd.startsWith(s)) {
242
+ return { value: s, prefix, captures: null };
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ for (const re of matchers.regexes) {
250
+ re.lastIndex = 0;
251
+ const m = re.exec(body);
252
+ if (m) return { value: re, prefix: null, captures: m };
253
+ }
254
+
255
+ return null;
256
+ }
257
+
258
+ static #register(id, plugin) {
259
+ const key = plugin._meta.matchers ? 'match' : 'auto';
260
+ for (const e of plugin._meta.events) {
261
+ const bucket = PluginManager.#buckets[e]?.[key];
262
+ if (bucket && !bucket.has(id)) {
263
+ bucket.set(id, plugin);
264
+ PluginManager.#eventCounts.set(e, (PluginManager.#eventCounts.get(e) ?? 0) + 1);
265
+ }
266
+ }
267
+ }
268
+
269
+ static #unregister(id) {
270
+ const events = PluginManager.#plugins.get(id)?._meta?.events ?? [];
271
+ for (const e of events) {
272
+ const bucket = PluginManager.#buckets[e];
273
+ if (bucket?.auto.delete(id) || bucket?.match.delete(id)) {
274
+ PluginManager.#eventCounts.set(e, Math.max(0, (PluginManager.#eventCounts.get(e) ?? 1) - 1));
275
+ }
276
+ }
277
+ }
278
+
279
+ #syncListeners() {
280
+ if (this.#destroyed) return;
281
+
282
+ const active = new Set();
283
+ for (const [event, count] of PluginManager.#eventCounts) {
284
+ if (count > 0) active.add(event);
285
+ }
286
+
287
+ for (const [event, handler] of this.#handlers) {
288
+ if (!active.has(event)) {
289
+ this.#sock.ev.off(event, handler);
290
+ this.#handlers.delete(event);
291
+ if (isDebug) logger.debug(`[Events] ⬇ ${event}`);
292
+ }
293
+ }
294
+
295
+ for (const event of active) {
296
+ if (!this.#handlers.has(event)) {
297
+ const handler = this.#createHandler(event);
298
+ this.#sock.ev.on(event, handler);
299
+ this.#handlers.set(event, handler);
300
+ if (isDebug) logger.debug(`[Events] ⬆ ${event}`);
301
+ }
302
+ }
303
+ }
304
+
305
+ static #syncAll() {
306
+ for (const instance of PluginManager.#instances) {
307
+ instance.#syncListeners();
308
+ }
309
+ }
310
+
311
+ #createHandler(event) {
312
+ const bucket = PluginManager.#buckets[event];
313
+ const sock = this.#sock;
314
+ const dispatch = (ctx) => this.#dispatch(sock, ctx, bucket);
315
+
316
+ const handlers = {
317
+ 'messages.upsert': ({ messages, type }) => {
318
+ if (type !== 'notify') return;
319
+ for (const msg of messages) {
320
+ if (!msg?.key?.remoteJid || msg.key.remoteJid === 'status@broadcast' || msg.key.fromMe) continue;
321
+ try {
322
+ dispatch(formatter(sock, msg, event));
323
+ } catch (err) {
324
+ PluginManager.#handleError('[PluginManager] Format error:', err, 'warn');
325
+ }
326
+ }
327
+ },
328
+
329
+ 'messages.update': (updates) => {
330
+ for (const { key, update } of updates) {
331
+ if (key?.remoteJid) dispatch({ event, key, update, jid: key.remoteJid });
332
+ }
333
+ },
334
+
335
+ 'messages.reaction': (reactions) => {
336
+ for (const { key, reaction } of reactions) {
337
+ if (key?.remoteJid) dispatch({ event, key, reaction, jid: key.remoteJid, emoji: reaction?.text });
338
+ }
339
+ },
340
+
341
+ 'group-participants.update': (u) => dispatch({ event, ...u }),
342
+ 'connection.update': (u) => dispatch({ event, ...u }),
343
+ 'creds.update': (creds) => dispatch({ event, creds }),
344
+ 'call': (calls) => calls.forEach(c => dispatch({ event, ...c })),
345
+ };
346
+
347
+ return handlers[event] ?? ((data) => dispatch({ event, data }));
348
+ }
349
+
350
+ #dispatch(sock, ctx, bucket) {
351
+ if (this.#destroyed) return;
352
+
353
+ for (const [id, plugin] of bucket.auto) {
354
+ this.#execute(id, plugin, sock, ctx, null);
355
+ }
356
+
357
+ if (bucket.match.size && ctx.body) {
358
+ for (const [id, plugin] of bucket.match) {
359
+ const matchers = plugin._meta?.matchers;
360
+ if (!matchers) continue;
361
+
362
+ const result = PluginManager.#test(matchers, ctx.body);
363
+ if (result) this.#execute(id, plugin, sock, ctx, result);
364
+ }
365
+ }
366
+ }
367
+
368
+ async #execute(id, plugin, sock, ctx, match) {
369
+ if (this.#destroyed) return;
370
+ try {
371
+ await plugin(sock, match ? { ...ctx, _match: match } : ctx, ctx.event);
372
+ } catch (err) {
373
+ PluginManager.#handleError(`[Plugin:${id}]`, err);
374
+ }
375
+ }
376
+
377
+ static #initWatcher() {
378
+ if (PluginManager.#watcher) return;
379
+
380
+ PluginManager.#watcher = watch(PLUGIN_DIR, {
381
+ persistent: true,
382
+ ignoreInitial: true,
383
+ ignored: [/(^|[/\\])_/, /\.d\.[jt]s$/, /node_modules/, /(^|[/\\])\../],
384
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 },
385
+ })
386
+ .on('add', p => PluginManager.#debounce(p, 'add'))
387
+ .on('change', p => PluginManager.#debounce(p, 'change'))
388
+ .on('unlink', p => PluginManager.#debounce(p, 'unlink'))
389
+ .on('error', e => PluginManager.#handleError('[Watcher]', e, 'warn'));
390
+ }
391
+
392
+ static #debounce(filePath, type) {
393
+ clearTimeout(PluginManager.#debounceTimers.get(filePath));
394
+ PluginManager.#debounceTimers.set(
395
+ filePath,
396
+ setTimeout(() => {
397
+ PluginManager.#debounceTimers.delete(filePath);
398
+ PluginManager.#hmr(filePath, type);
399
+ }, debounceMs)
400
+ );
401
+ }
402
+
403
+ static async #hmr(filePath, type) {
404
+ const rel = path.relative(PLUGIN_DIR, filePath);
405
+ const ts = isDebug ? new Date().toISOString().slice(11, 19) : '';
406
+ const tag = isDebug ? `[HMR:${ts}]` : '[HMR]';
407
+
408
+ try {
409
+ await PluginManager.#getLock(filePath).runExclusive(async () => {
410
+ if (type === 'unlink') {
411
+ const n = PluginManager.#unloadFile(filePath);
412
+ logger.info(`${tag} Unloaded: ${rel} (${n})`);
413
+ } else {
414
+ const parent = PluginManager.#getParent(path.dirname(filePath));
415
+ const plugins = await PluginManager.#loadFile(filePath, parent, false);
416
+
417
+ PluginManager.#unloadFile(filePath);
418
+
419
+ for (const [id, plugin] of plugins) {
420
+ PluginManager.#plugins.set(id, plugin);
421
+ PluginManager.#register(id, plugin);
422
+ }
423
+
424
+ logger.info(`${tag} ${type === 'add' ? 'Added' : 'Reloaded'}: ${rel} (${plugins.size})`);
425
+ }
426
+
427
+ PluginManager.#syncAll();
428
+ });
429
+ } catch (err) {
430
+ PluginManager.#handleError(`${tag} Failed: ${rel}`, err);
431
+ } finally {
432
+ if (type === 'unlink') {
433
+ const lock = PluginManager.#fileLocks.get(filePath);
434
+ if (lock && !lock.isLocked()) PluginManager.#fileLocks.delete(filePath);
435
+ }
436
+ }
437
+ }
438
+
439
+ static #unloadFile(filePath) {
440
+ let count = 0;
441
+ for (const [id, plugin] of PluginManager.#plugins) {
442
+ if (plugin._meta?.filePath === filePath) {
443
+ PluginManager.#unregister(id);
444
+ PluginManager.#plugins.delete(id);
445
+ count++;
446
+ }
447
+ }
448
+ return count;
449
+ }
450
+
451
+ get(id) {
452
+ return PluginManager.#plugins.get(id);
453
+ }
454
+
455
+ get all() {
456
+ return new Map(PluginManager.#plugins);
457
+ }
458
+
459
+ get events() {
460
+ return [...this.#handlers.keys()];
461
+ }
462
+
463
+ get destroyed() {
464
+ return this.#destroyed;
465
+ }
466
+
467
+ static get instances() {
468
+ return PluginManager.#instances.size;
469
+ }
470
+
471
+ static get count() {
472
+ return PluginManager.#plugins.size;
473
+ }
474
+ }
475
+
476
+ export const pluginManager = async (sock) => {
477
+ const manager = new PluginManager(sock);
478
+ await manager.init();
479
+ return manager;
480
+ }
@@ -0,0 +1,3 @@
1
+ import { spindle } from "@shoru/spindle";
2
+
3
+ export const spinner = spindle();
@@ -0,0 +1,11 @@
1
+ import { BufferJSON } from 'baileys';
2
+
3
+ export const serialize = (data) => {
4
+ if (data == null) return null;
5
+ return JSON.stringify(data, BufferJSON.replacer);
6
+ }
7
+
8
+ export const deserialize = (json) => {
9
+ if (json == null) return null;
10
+ return JSON.parse(json, BufferJSON.reviver);
11
+ };
@@ -0,0 +1,9 @@
1
+ import { isPnUser } from "baileys";
2
+
3
+ const extractPN = (jid) => jid.split("@")[0].split(":")[0]
4
+
5
+ export const getPN = async (sock, jid) => {
6
+ if (isPnUser(jid)) return extractPN(jid);
7
+ const pn = await sock.signalRepository.lidMapping.getPNForLID(jid);
8
+ return extractPN(pn);
9
+ }
@@ -0,0 +1,5 @@
1
+ export * from './buffer-json.js';
2
+ export * from './pause-spinner.js';
3
+ export * from './time-string.js';
4
+ export * from './type-conversions.js';
5
+ export * from './get-pn.js';
@@ -0,0 +1,12 @@
1
+ import { spinner } from "#internals.js";
2
+
3
+ export const pauseSpinner = async (action) => {
4
+ if (!spinner.isSpinning) return action();
5
+
6
+ spinner.stop();
7
+ try {
8
+ return await action();
9
+ } finally {
10
+ spinner.start();
11
+ }
12
+ };
@@ -0,0 +1,19 @@
1
+ import { loadConfig } from '#internals.js';
2
+
3
+ const { timeZone } = await loadConfig();
4
+
5
+ export const getTimeString = (timestamp, TIME_ZONE = timeZone) => {
6
+ const date = new Date(timestamp * 1000);
7
+ const options = {
8
+ year: 'numeric',
9
+ month: 'long',
10
+ day: 'numeric',
11
+ hour: '2-digit',
12
+ minute: '2-digit',
13
+ second: '2-digit',
14
+ hour12: false,
15
+ TIME_ZONE
16
+ };
17
+ const result = date.toLocaleDateString('en-US', options)
18
+ return result.split(' at ')
19
+ }
@@ -0,0 +1,5 @@
1
+ export const isString = x => typeof x === "string";
2
+
3
+ export const toNumber = x => (x && typeof x.toNumber === "function") ? x.toNumber() : x;
4
+
5
+ export const toBase64 = x => x ? Buffer.from(x).toString("base64") : x;