@shoru/kitten 0.0.4 → 0.0.5

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/package.json CHANGED
@@ -1,41 +1,42 @@
1
- {
2
- "name": "@shoru/kitten",
3
- "type": "module",
4
- "version": "0.0.4",
5
- "description": "A powerful Node.js framework to simplify making WhatsApp Bots",
6
- "main": "src/index.js",
7
- "repository": {
8
- "url": "git+https://github.com/Mangaka-bot/kitten-wa.git"
9
- },
10
- "scripts": {
11
- "start": "node --no-warnings src/index.js",
12
- "test": "echo \"Error: no test specified\" && exit 1"
13
- },
14
- "imports": {
15
- "#utils.js": "./src/utils/index.js",
16
- "#auth.js": "./src/auth/index.js",
17
- "#internals.js": "./src/internals/index.js",
18
- "#client.js": "./src/client/index.js",
19
- "#formatter.js": "./src/formatter/index.js"
20
- },
21
- "exports": {
22
- ".": "./src/index.js"
23
- },
24
- "author": "Aymane Shoru",
25
- "license": "MIT",
26
- "dependencies": {
27
- "@hapi/boom": "^10.0.1",
28
- "@inquirer/prompts": "^8.1.0",
29
- "@shoru/spindle": "^1.0.6",
30
- "async-mutex": "^0.5.0",
31
- "baileys": "^7.0.0-rc.9",
32
- "chalk": "^5.6.2",
33
- "chokidar": "^5.0.0",
34
- "cosmiconfig": "^9.0.0",
35
- "defu": "^6.1.4",
36
- "lmdb": "^3.4.4",
37
- "pino": "^10.1.0",
38
- "pino-pretty": "^13.1.3",
39
- "qrcode-terminal": "^0.12.0"
40
- }
41
- }
1
+ {
2
+ "name": "@shoru/kitten",
3
+ "type": "module",
4
+ "version": "0.0.5",
5
+ "description": "A powerful Node.js framework to simplify making WhatsApp Bots",
6
+ "main": "src/index.js",
7
+ "repository": {
8
+ "url": "git+https://github.com/Mangaka-bot/kitten-wa.git"
9
+ },
10
+ "scripts": {
11
+ "start": "node --no-warnings src/index.js",
12
+ "test": "echo \"Error: no test specified\" && exit 1"
13
+ },
14
+ "imports": {
15
+ "#utils.js": "./src/utils/index.js",
16
+ "#auth.js": "./src/auth/index.js",
17
+ "#internals.js": "./src/internals/index.js",
18
+ "#client.js": "./src/client/index.js",
19
+ "#formatter.js": "./src/formatter/index.js",
20
+ "#plugins.js": "./src/plugins/index.js"
21
+ },
22
+ "exports": {
23
+ ".": "./src/index.js"
24
+ },
25
+ "author": "Aymane Shoru",
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "@hapi/boom": "^10.0.1",
29
+ "@inquirer/prompts": "^8.1.0",
30
+ "@shoru/spindle": "^1.0.6",
31
+ "async-mutex": "^0.5.0",
32
+ "baileys": "^7.0.0-rc.9",
33
+ "chalk": "^5.6.2",
34
+ "chokidar": "^5.0.0",
35
+ "cosmiconfig": "^9.0.0",
36
+ "defu": "^6.1.4",
37
+ "lmdb": "^3.4.4",
38
+ "pino": "^10.1.0",
39
+ "pino-pretty": "^13.1.3",
40
+ "qrcode-terminal": "^0.12.0"
41
+ }
42
+ }
@@ -2,7 +2,8 @@ import { DisconnectReason } from 'baileys';
2
2
  import { Boom } from '@hapi/boom';
3
3
  import qrcode from 'qrcode-terminal';
4
4
  import chalk from 'chalk';
5
- import { logger, pino, pluginManager } from '#internals.js';
5
+ import { pluginManager } from '#plugins.js';
6
+ import { logger, pino } from '#internals.js';
6
7
  import { initSession, listSessions } from '#auth.js';
7
8
  import { getConnectionConfig } from './getConnectionConfig.js';
8
9
 
@@ -0,0 +1,25 @@
1
+ import { getConfig } from '#internals.js';
2
+ import path from 'path';
3
+
4
+ const config = await getConfig();
5
+
6
+ export const {
7
+ dir,
8
+ defaultEvent,
9
+ prefixes: PREFIXES,
10
+ hmr: {
11
+ enable: HMREnabled,
12
+ debounce: debounceMs,
13
+ debug: isDebug
14
+ }
15
+ } = config.plugins;
16
+
17
+ export const PLUGIN_DIR = path.join(process.cwd(), dir);
18
+
19
+ export const EVENTS = Object.freeze(new Set([
20
+ 'messaging-history.set', 'chats.upsert', 'chats.update', 'chats.delete',
21
+ 'contacts.upsert', 'contacts.update', 'messages.upsert', 'messages.update',
22
+ 'messages.delete', 'messages.reaction', 'message-receipt.update',
23
+ 'groups.update', 'group-participants.update', 'connection.update',
24
+ 'creds.update', 'presence.update', 'blocklist.set', 'blocklist.update', 'call',
25
+ ]));
@@ -0,0 +1,8 @@
1
+ import { logger } from '#internals.js';
2
+ import { isDebug } from './config.js';
3
+
4
+ export function handleError(context, err) {
5
+ if (isDebug) {
6
+ logger.warn(`${context} ${err?.message ?? 'Unknown error'}`);
7
+ }
8
+ }
@@ -0,0 +1,85 @@
1
+ import { formatter } from '#formatter.js';
2
+ import { getBucket } from './registry.js';
3
+ import { test } from './matcher.js';
4
+ import { handleError } from './handle-error.js';
5
+
6
+ export function createHandler(event, sock, isDestroyed, execute) {
7
+ const bucket = getBucket(event);
8
+
9
+ const dispatch = (ctx) => {
10
+ if (isDestroyed() || !ctx) return;
11
+
12
+ // Execute auto-triggered plugins
13
+ for (const [id, plugin] of bucket.auto) {
14
+ execute(id, plugin, sock, ctx, event, null);
15
+ }
16
+
17
+ // Execute pattern-matched plugins
18
+ if (bucket.match.size && ctx.body) {
19
+ for (const [id, plugin] of bucket.match) {
20
+ const matchers = plugin._meta?.matchers;
21
+ if (!matchers) continue;
22
+
23
+ const result = test(matchers, ctx.body);
24
+ if (result) {
25
+ execute(id, plugin, sock, ctx, event, result);
26
+ }
27
+ }
28
+ }
29
+ };
30
+
31
+ switch (event) {
32
+ case 'messages.upsert':
33
+ return ({ messages, type }) => {
34
+ if (type !== 'notify') return;
35
+
36
+ for (const msg of messages) {
37
+ if (!msg?.key?.remoteJid || msg.key.remoteJid === 'status@broadcast') {
38
+ continue;
39
+ }
40
+
41
+ try {
42
+ dispatch(formatter(sock, msg, event));
43
+ } catch (err) {
44
+ handleError('[PluginManager] Format error:', err);
45
+ }
46
+ }
47
+ };
48
+
49
+ case 'messages.update':
50
+ return (updates) => {
51
+ for (const { key, update } of updates) {
52
+ if (key?.remoteJid) {
53
+ dispatch({ key, update, jid: key.remoteJid });
54
+ }
55
+ }
56
+ };
57
+
58
+ case 'messages.reaction':
59
+ return (reactions) => {
60
+ for (const { key, reaction } of reactions) {
61
+ if (key?.remoteJid) {
62
+ dispatch({
63
+ key,
64
+ reaction,
65
+ jid: key.remoteJid,
66
+ emoji: reaction?.text
67
+ });
68
+ }
69
+ }
70
+ };
71
+
72
+ case 'group-participants.update':
73
+ case 'connection.update':
74
+ return (update) => dispatch(update);
75
+
76
+ case 'creds.update':
77
+ return (creds) => dispatch({ creds });
78
+
79
+ case 'call':
80
+ return (calls) => calls.forEach(c => dispatch(c));
81
+
82
+ default:
83
+ return (data) => dispatch({ data });
84
+ }
85
+ }
@@ -0,0 +1,166 @@
1
+ import { logger } from '#internals.js';
2
+ import { HMREnabled, isDebug } from './config.js';
3
+ import { handleError } from './handle-error.js';
4
+ import {
5
+ getPlugin,
6
+ getAllPlugins,
7
+ getPluginCount,
8
+ getEventCounts,
9
+ clear as clearRegistry
10
+ } from './registry.js';
11
+ import { loadAll, clearLocks } from './loader.js';
12
+ import { initWatcher, closeWatcher, clearTimers, onSync } from './watcher.js';
13
+ import { createHandler } from './handlers.js';
14
+
15
+ const instances = new Set();
16
+
17
+ let ready = false;
18
+
19
+ function syncAllInstances() {
20
+ for (const instance of instances) {
21
+ instance._syncListeners();
22
+ }
23
+ }
24
+
25
+ function cleanup() {
26
+ closeWatcher();
27
+ clearTimers();
28
+ clearLocks();
29
+ clearRegistry();
30
+ ready = false;
31
+ }
32
+
33
+ export class PluginManager {
34
+ #sock;
35
+ #handlers = new Map();
36
+ #destroyed = false;
37
+
38
+ constructor(sock) {
39
+ if (!sock?.ev) {
40
+ throw new TypeError('Invalid socket: missing ev property');
41
+ }
42
+ this.#sock = sock;
43
+ }
44
+
45
+ async init() {
46
+ if (this.#destroyed) {
47
+ throw new Error('Cannot reinitialize destroyed instance');
48
+ }
49
+
50
+ instances.add(this);
51
+
52
+ if (!ready) {
53
+ await loadAll();
54
+
55
+ if (HMREnabled) {
56
+ initWatcher();
57
+ onSync(syncAllInstances);
58
+ }
59
+
60
+ ready = true;
61
+ }
62
+
63
+ this._syncListeners();
64
+
65
+ if (isDebug) {
66
+ logger.debug(
67
+ `[PluginManager] Init (sockets: ${instances.size}, plugins: ${getPluginCount()})`
68
+ );
69
+ }
70
+
71
+ return this;
72
+ }
73
+
74
+ destroy() {
75
+ if (this.#destroyed) return;
76
+ this.#destroyed = true;
77
+
78
+ for (const [event, handler] of this.#handlers) {
79
+ this.#sock.ev.off(event, handler);
80
+ }
81
+
82
+ this.#handlers.clear();
83
+ instances.delete(this);
84
+
85
+ if (instances.size === 0) {
86
+ cleanup();
87
+ }
88
+ }
89
+
90
+ _syncListeners() {
91
+ if (this.#destroyed) return;
92
+
93
+ const eventCounts = getEventCounts();
94
+ const activeEvents = new Set();
95
+
96
+ for (const [event, count] of eventCounts) {
97
+ if (count > 0) activeEvents.add(event);
98
+ }
99
+
100
+ // Remove inactive events
101
+ for (const [event, handler] of this.#handlers) {
102
+ if (!activeEvents.has(event)) {
103
+ this.#sock.ev.off(event, handler);
104
+ this.#handlers.delete(event);
105
+ if (isDebug) logger.debug(`[Events] (-) ${event}`);
106
+ }
107
+ }
108
+
109
+ // Add active events
110
+ for (const event of activeEvents) {
111
+ if (!this.#handlers.has(event)) {
112
+ const handler = createHandler(
113
+ event,
114
+ this.#sock,
115
+ () => this.#destroyed,
116
+ this.#execute.bind(this)
117
+ );
118
+
119
+ this.#sock.ev.on(event, handler);
120
+ this.#handlers.set(event, handler);
121
+ if (isDebug) logger.debug(`[Events] (+) ${event}`);
122
+ }
123
+ }
124
+ }
125
+
126
+ async #execute(id, plugin, sock, ctx, event, match) {
127
+ if (this.#destroyed) return;
128
+
129
+ try {
130
+ const context = match ? { ...ctx, _match: match } : ctx;
131
+ await plugin(sock, context, event);
132
+ } catch (err) {
133
+ handleError(`[Plugin:${id}]`, err);
134
+ }
135
+ }
136
+
137
+ get(id) {
138
+ return getPlugin(id);
139
+ }
140
+
141
+ get all() {
142
+ return new Map(getAllPlugins());
143
+ }
144
+
145
+ get events() {
146
+ return [...this.#handlers.keys()];
147
+ }
148
+
149
+ get destroyed() {
150
+ return this.#destroyed;
151
+ }
152
+
153
+ static get instances() {
154
+ return instances.size;
155
+ }
156
+
157
+ static get count() {
158
+ return getPluginCount();
159
+ }
160
+ }
161
+
162
+ export async function pluginManager(sock) {
163
+ const manager = new PluginManager(sock);
164
+ await manager.init();
165
+ return manager;
166
+ }
@@ -0,0 +1,132 @@
1
+ import { Mutex } from 'async-mutex';
2
+ import { logger } from '#internals.js';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import { pathToFileURL } from 'url';
6
+ import { PLUGIN_DIR, EVENTS, defaultEvent } from './config.js';
7
+ import { handleError } from './handle-error.js';
8
+ import { compile } from './matcher.js';
9
+ import { setPlugin, registerToBuckets } from './registry.js';
10
+
11
+ const fileLocks = new Map();
12
+
13
+ export function getLock(filePath) {
14
+ let lock = fileLocks.get(filePath);
15
+ if (!lock) {
16
+ lock = new Mutex();
17
+ fileLocks.set(filePath, lock);
18
+ }
19
+ return lock;
20
+ }
21
+
22
+ export function deleteLockIfUnused(filePath) {
23
+ const lock = fileLocks.get(filePath);
24
+ if (lock && !lock.isLocked()) {
25
+ fileLocks.delete(filePath);
26
+ }
27
+ }
28
+
29
+ export function clearLocks() {
30
+ fileLocks.clear();
31
+ }
32
+
33
+ export function getParentFolder(dirPath) {
34
+ return path.relative(PLUGIN_DIR, dirPath).split(path.sep)[0] || null;
35
+ }
36
+
37
+ function normalize(value) {
38
+ if (typeof value === 'function') return value;
39
+
40
+ if (typeof value?.default === 'function') {
41
+ const { default: fn, ...rest } = value;
42
+ return Object.assign(fn, rest);
43
+ }
44
+
45
+ return null;
46
+ }
47
+
48
+ export async function loadFile(filePath, parent, shouldRegister = true) {
49
+ const execute = async () => {
50
+ const { mtimeMs } = await fs.stat(filePath);
51
+ const mod = await import(`${pathToFileURL(filePath)}?v=${Math.trunc(mtimeMs)}`);
52
+ const loaded = new Map();
53
+
54
+ for (const [name, value] of Object.entries(mod)) {
55
+ const plugin = normalize(value);
56
+ if (!plugin || plugin.enabled === false) continue;
57
+
58
+ const id = path.relative(PLUGIN_DIR, filePath)
59
+ .replace(/\.[jt]s$/, '')
60
+ .replaceAll(path.sep, '/') + ':' + name;
61
+
62
+ const events = (Array.isArray(plugin.events) ? plugin.events : [])
63
+ .filter(e => EVENTS.has(e));
64
+
65
+ plugin._meta = {
66
+ parent,
67
+ filePath,
68
+ id,
69
+ events: events.length ? events : [defaultEvent],
70
+ matchers: compile(plugin.match, plugin.prefix),
71
+ };
72
+
73
+ if (shouldRegister) {
74
+ setPlugin(id, plugin);
75
+ registerToBuckets(id, plugin);
76
+ }
77
+
78
+ loaded.set(id, plugin);
79
+ }
80
+
81
+ return loaded;
82
+ };
83
+
84
+ return shouldRegister
85
+ ? getLock(filePath).runExclusive(execute)
86
+ : execute();
87
+ }
88
+
89
+ export async function loadAll() {
90
+ await fs.mkdir(PLUGIN_DIR, { recursive: true }).catch(() => {});
91
+
92
+ const entries = await fs.readdir(PLUGIN_DIR, {
93
+ withFileTypes: true,
94
+ recursive: true
95
+ }).catch(() => []);
96
+
97
+ const files = entries
98
+ .filter(e =>
99
+ e.isFile() &&
100
+ /(?<!\.d)\.[jt]s$/.test(e.name) &&
101
+ !e.name.startsWith('_')
102
+ )
103
+ .map(e => {
104
+ const dirPath = e.parentPath ?? e.path;
105
+ return {
106
+ path: path.join(dirPath, e.name),
107
+ parent: getParentFolder(dirPath)
108
+ };
109
+ });
110
+
111
+ const results = await Promise.allSettled(
112
+ files.map(({ path: p, parent }) => loadFile(p, parent))
113
+ );
114
+
115
+ let loaded = 0;
116
+ let failed = 0;
117
+
118
+ for (let i = 0; i < results.length; i++) {
119
+ const result = results[i];
120
+ if (result.status === 'fulfilled') {
121
+ loaded += result.value?.size ?? 0;
122
+ } else {
123
+ failed++;
124
+ const rel = path.relative(PLUGIN_DIR, files[i].path);
125
+ handleError(`[PluginManager:${rel}] Failed to load:`, result.reason);
126
+ }
127
+ }
128
+
129
+ logger.info(
130
+ `[PluginManager] Loaded ${loaded} plugins${failed ? ` (${failed} failed)` : ''}`
131
+ );
132
+ }
@@ -0,0 +1,59 @@
1
+ import { PREFIXES } from './config.js';
2
+
3
+ export function compile(match, prefixOpt = PREFIXES) {
4
+ if (!Array.isArray(match) || !match.length) return null;
5
+
6
+ const strings = match
7
+ .filter(m => typeof m === 'string')
8
+ .map(s => s.toLowerCase());
9
+
10
+ const regexes = match.filter(m => m instanceof RegExp);
11
+
12
+ const prefixes = prefixOpt === false
13
+ ? null
14
+ : new Set([prefixOpt ?? PREFIXES].flat());
15
+
16
+ return {
17
+ strings,
18
+ set: strings.length ? new Set(strings) : null,
19
+ regexes,
20
+ prefixes,
21
+ };
22
+ }
23
+
24
+ export function test(matchers, body) {
25
+ if (!body || typeof body !== 'string') return null;
26
+
27
+ const text = body.toLowerCase();
28
+
29
+ if (matchers.set) {
30
+ const prefix = text[0];
31
+ const prefixValid = !matchers.prefixes || matchers.prefixes.has(prefix);
32
+
33
+ if (prefixValid) {
34
+ const rest = text.slice(1);
35
+ const idx = rest.indexOf(' ');
36
+ const cmd = idx < 0 ? rest : rest.slice(0, idx);
37
+
38
+ if (cmd) {
39
+ if (matchers.set.has(cmd)) {
40
+ return { match: cmd, prefix };
41
+ }
42
+
43
+ for (const s of matchers.strings) {
44
+ if (cmd.length > s.length && cmd.startsWith(s)) {
45
+ return { match: s, prefix };
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ for (const re of matchers.regexes) {
53
+ re.lastIndex = 0;
54
+ const m = re.exec(body);
55
+ if (m) return { match: m, prefix: null };
56
+ }
57
+
58
+ return null;
59
+ }
@@ -0,0 +1,95 @@
1
+ import { EVENTS } from './config.js';
2
+
3
+ const plugins = new Map();
4
+
5
+ const eventCounts = new Map([...EVENTS].map(e => [e, 0]));
6
+
7
+ const buckets = Object.fromEntries(
8
+ [...EVENTS].map(e => [e, { auto: new Map(), match: new Map() }])
9
+ );
10
+
11
+ // Plugin Map Operations
12
+
13
+ export function getPlugin(id) {
14
+ return plugins.get(id);
15
+ }
16
+
17
+ export function setPlugin(id, plugin) {
18
+ plugins.set(id, plugin);
19
+ }
20
+
21
+ export function deletePlugin(id) {
22
+ plugins.delete(id);
23
+ }
24
+
25
+ export function getAllPlugins() {
26
+ return plugins;
27
+ }
28
+
29
+ export function getPluginCount() {
30
+ return plugins.size;
31
+ }
32
+
33
+ // Bucket Operations
34
+
35
+ export function getBucket(event) {
36
+ return buckets[event];
37
+ }
38
+
39
+ export function getEventCounts() {
40
+ return eventCounts;
41
+ }
42
+
43
+ export function registerToBuckets(id, plugin) {
44
+ const key = plugin._meta.matchers ? 'match' : 'auto';
45
+
46
+ for (const event of plugin._meta.events) {
47
+ const bucket = buckets[event]?.[key];
48
+ if (bucket && !bucket.has(id)) {
49
+ bucket.set(id, plugin);
50
+ eventCounts.set(event, (eventCounts.get(event) ?? 0) + 1);
51
+ }
52
+ }
53
+ }
54
+
55
+ export function unregisterFromBuckets(id) {
56
+ const plugin = plugins.get(id);
57
+ const events = plugin?._meta?.events ?? [];
58
+
59
+ for (const event of events) {
60
+ const bucket = buckets[event];
61
+ if (bucket?.auto.delete(id) || bucket?.match.delete(id)) {
62
+ eventCounts.set(event, Math.max(0, (eventCounts.get(event) ?? 1) - 1));
63
+ }
64
+ }
65
+ }
66
+
67
+ export function unloadByFilePath(filePath) {
68
+ const idsToUnload = [];
69
+
70
+ for (const [id, plugin] of plugins) {
71
+ if (plugin._meta?.filePath === filePath) {
72
+ idsToUnload.push(id);
73
+ }
74
+ }
75
+
76
+ for (const id of idsToUnload) {
77
+ unregisterFromBuckets(id);
78
+ plugins.delete(id);
79
+ }
80
+
81
+ return idsToUnload.length;
82
+ }
83
+
84
+ export function clear() {
85
+ plugins.clear();
86
+
87
+ for (const bucket of Object.values(buckets)) {
88
+ bucket.auto.clear();
89
+ bucket.match.clear();
90
+ }
91
+
92
+ for (const event of EVENTS) {
93
+ eventCounts.set(event, 0);
94
+ }
95
+ }
@@ -0,0 +1,99 @@
1
+ import { watch } from 'chokidar';
2
+ import { logger } from '#internals.js';
3
+ import path from 'path';
4
+ import { PLUGIN_DIR, debounceMs } from './config.js';
5
+ import { handleError } from './handle-error.js';
6
+ import { loadFile, getLock, getParentFolder, deleteLockIfUnused } from './loader.js';
7
+ import { setPlugin, registerToBuckets, unloadByFilePath } from './registry.js';
8
+
9
+ let watcher = null;
10
+
11
+ const debounceTimers = new Map();
12
+
13
+ let syncCallback = null;
14
+
15
+ export function onSync(callback) {
16
+ syncCallback = callback;
17
+ }
18
+
19
+ export function initWatcher() {
20
+ if (watcher) return;
21
+
22
+ watcher = watch(PLUGIN_DIR, {
23
+ persistent: true,
24
+ ignoreInitial: true,
25
+ ignored: [
26
+ /(^|[/\\])_/,
27
+ /\.d\.[jt]s$/,
28
+ /node_modules/,
29
+ /(^|[/\\])\../
30
+ ],
31
+ awaitWriteFinish: {
32
+ stabilityThreshold: 100,
33
+ pollInterval: 20
34
+ },
35
+ })
36
+ .on('add', p => scheduleHMR(p, 'add'))
37
+ .on('change', p => scheduleHMR(p, 'change'))
38
+ .on('unlink', p => scheduleHMR(p, 'unlink'))
39
+ .on('error', err => handleError('[Watcher]', err));
40
+ }
41
+
42
+ export function closeWatcher() {
43
+ watcher?.close();
44
+ watcher = null;
45
+ }
46
+
47
+ export function clearTimers() {
48
+ debounceTimers.forEach(clearTimeout);
49
+ debounceTimers.clear();
50
+ }
51
+
52
+ function scheduleHMR(filePath, type) {
53
+ clearTimeout(debounceTimers.get(filePath));
54
+
55
+ debounceTimers.set(
56
+ filePath,
57
+ setTimeout(() => {
58
+ debounceTimers.delete(filePath);
59
+ executeHMR(filePath, type);
60
+ }, debounceMs)
61
+ );
62
+ }
63
+
64
+ async function executeHMR(filePath, type) {
65
+ const rel = path.relative(PLUGIN_DIR, filePath);
66
+
67
+ try {
68
+ await getLock(filePath).runExclusive(async () => {
69
+ if (type === 'unlink') {
70
+ const count = unloadByFilePath(filePath);
71
+ logger.info(`[HMR] Unloaded: ${rel} (${count})`);
72
+ } else {
73
+ // Load new plugins without registering
74
+ const parent = getParentFolder(path.dirname(filePath));
75
+ const loaded = await loadFile(filePath, parent, false);
76
+
77
+ // Remove old plugins for this file
78
+ unloadByFilePath(filePath);
79
+
80
+ // Register newly loaded plugins
81
+ for (const [id, plugin] of loaded) {
82
+ setPlugin(id, plugin);
83
+ registerToBuckets(id, plugin);
84
+ }
85
+
86
+ const action = type === 'add' ? 'Added' : 'Reloaded';
87
+ logger.info(`[HMR] ${action}: ${rel} (${loaded.size})`);
88
+ }
89
+
90
+ syncCallback?.();
91
+ });
92
+ } catch (err) {
93
+ handleError(`[HMR:${rel}] Failed:`, err);
94
+ } finally {
95
+ if (type === 'unlink') {
96
+ deleteLockIfUnused(filePath);
97
+ }
98
+ }
99
+ }
@@ -1,477 +0,0 @@
1
- import { Mutex } from 'async-mutex';
2
- import { getConfig, 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 config = await getConfig();
10
-
11
- const {
12
- dir,
13
- defaultEvent,
14
- prefixes: PREFIXES,
15
- hmr: {
16
- enable: HMREnabled,
17
- debounce: debounceMs,
18
- debug: isDebug
19
- }
20
- } = config.plugins;
21
-
22
- const PLUGIN_DIR = path.join(process.cwd(), dir);
23
-
24
- const EVENTS = new Set([
25
- 'messaging-history.set', 'chats.upsert', 'chats.update', 'chats.delete',
26
- 'contacts.upsert', 'contacts.update', 'messages.upsert', 'messages.update',
27
- 'messages.delete', 'messages.reaction', 'message-receipt.update',
28
- 'groups.update', 'group-participants.update', 'connection.update',
29
- 'creds.update', 'presence.update', 'blocklist.set', 'blocklist.update', 'call',
30
- ]);
31
-
32
- const createBuckets = () => Object.fromEntries(
33
- [...EVENTS].map(e => [e, { auto: new Map(), match: new Map() }])
34
- );
35
-
36
- export class PluginManager {
37
- static #plugins = new Map();
38
- static #watcher = null;
39
- static #ready = false;
40
- static #debounceTimers = new Map();
41
- static #instances = new Set();
42
- static #fileLocks = new Map();
43
- static #buckets = createBuckets();
44
- static #eventCounts = new Map([...EVENTS].map(e => [e, 0]));
45
-
46
- #sock;
47
- #handlers = new Map();
48
- #destroyed = false;
49
-
50
- constructor(sock) {
51
- if (!sock?.ev) throw new TypeError('Invalid socket: missing ev property');
52
- this.#sock = sock;
53
- }
54
-
55
- static #getLock(filePath) {
56
- return PluginManager.#fileLocks.get(filePath)
57
- ?? PluginManager.#fileLocks.set(filePath, new Mutex()).get(filePath);
58
- }
59
-
60
- static #handleError(context, err) {
61
- if (isDebug) {
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
- const rel = path.relative(PLUGIN_DIR, files[i].path);
149
- PluginManager.#handleError(`[PluginManager:${rel}] Failed to load:`, result.reason);
150
- }
151
- }
152
-
153
- logger.info(`[PluginManager] Loaded ${loaded} plugins${failed ? ` (${failed} failed)` : ''}`);
154
- }
155
-
156
- static async #loadFile(filePath, parent, register = true) {
157
- const exec = async () => {
158
- const { mtimeMs } = await fs.stat(filePath);
159
- const mod = await import(`${pathToFileURL(filePath)}?v=${Math.trunc(mtimeMs)}`);
160
- const loaded = new Map();
161
-
162
- for (const [name, value] of Object.entries(mod)) {
163
- const plugin = PluginManager.#normalize(value);
164
- if (!plugin || plugin.enabled === false) continue;
165
-
166
- const id = path.relative(PLUGIN_DIR, filePath)
167
- .replace(/\.[jt]s$/, '')
168
- .replaceAll(path.sep, '/') + ':' + name;
169
-
170
- const events = (Array.isArray(plugin.events) ? plugin.events : [])
171
- .filter(e => EVENTS.has(e));
172
-
173
- plugin._meta = {
174
- parent,
175
- filePath,
176
- id,
177
- events: events.length ? events : [defaultEvent],
178
- matchers: PluginManager.#compile(plugin.match, plugin.prefix),
179
- };
180
-
181
- if (register) {
182
- PluginManager.#plugins.set(id, plugin);
183
- PluginManager.#register(id, plugin);
184
- }
185
- loaded.set(id, plugin);
186
- }
187
- return loaded;
188
- };
189
-
190
- return register ? PluginManager.#getLock(filePath).runExclusive(exec) : exec();
191
- }
192
-
193
- static #normalize(value) {
194
- if (typeof value === 'function') return value;
195
- if (typeof value?.default === 'function') {
196
- const { default: fn, ...rest } = value;
197
- return Object.assign(fn, rest);
198
- }
199
- return null;
200
- }
201
-
202
- static #compile(match, prefixOpt = PREFIXES) {
203
- if (!Array.isArray(match) || !match.length) return null;
204
-
205
- const strings = match.filter(m => typeof m === 'string').map(s => s.toLowerCase());
206
- const regexes = match.filter(m => m instanceof RegExp);
207
-
208
- const prefixes = prefixOpt === false
209
- ? null
210
- : new Set([prefixOpt ?? PREFIXES].flat());
211
-
212
- return {
213
- strings,
214
- set: strings.length ? new Set(strings) : null,
215
- regexes,
216
- prefixes,
217
- };
218
- }
219
-
220
- static #test(matchers, body) {
221
- if (!body || typeof body !== 'string') return null;
222
-
223
- const text = body.toLowerCase();
224
-
225
- if (matchers.set) {
226
- const prefix = text[0];
227
- const prefixValid = !matchers.prefixes || matchers.prefixes.has(prefix);
228
-
229
- if (prefixValid) {
230
- const rest = text.slice(1);
231
- const idx = rest.indexOf(' ');
232
- const cmd = idx < 0 ? rest : rest.slice(0, idx);
233
-
234
- if (cmd) {
235
- if (matchers.set.has(cmd)) {
236
- return { match: cmd, prefix };
237
- }
238
-
239
- for (const s of matchers.strings) {
240
- if (cmd.length > s.length && cmd.startsWith(s)) {
241
- return { match: s, prefix };
242
- }
243
- }
244
- }
245
- }
246
- }
247
-
248
- for (const re of matchers.regexes) {
249
- re.lastIndex = 0;
250
- const m = re.exec(body);
251
- if (m) return { match: m, prefix: null };
252
- }
253
-
254
- return null;
255
- }
256
-
257
- static #register(id, plugin) {
258
- const key = plugin._meta.matchers ? 'match' : 'auto';
259
- for (const e of plugin._meta.events) {
260
- const bucket = PluginManager.#buckets[e]?.[key];
261
- if (bucket && !bucket.has(id)) {
262
- bucket.set(id, plugin);
263
- PluginManager.#eventCounts.set(e, (PluginManager.#eventCounts.get(e) ?? 0) + 1);
264
- }
265
- }
266
- }
267
-
268
- static #unregister(id) {
269
- const events = PluginManager.#plugins.get(id)?._meta?.events ?? [];
270
- for (const e of events) {
271
- const bucket = PluginManager.#buckets[e];
272
- if (bucket?.auto.delete(id) || bucket?.match.delete(id)) {
273
- PluginManager.#eventCounts.set(e, Math.max(0, (PluginManager.#eventCounts.get(e) ?? 1) - 1));
274
- }
275
- }
276
- }
277
-
278
- #syncListeners() {
279
- if (this.#destroyed) return;
280
-
281
- const active = new Set();
282
- for (const [event, count] of PluginManager.#eventCounts) {
283
- if (count > 0) active.add(event);
284
- }
285
-
286
- for (const [event, handler] of this.#handlers) {
287
- if (!active.has(event)) {
288
- this.#sock.ev.off(event, handler);
289
- this.#handlers.delete(event);
290
- if (isDebug) logger.debug(`[Events] (-) ${event}`);
291
- }
292
- }
293
-
294
- for (const event of active) {
295
- if (!this.#handlers.has(event)) {
296
- const handler = this.#createHandler(event);
297
- this.#sock.ev.on(event, handler);
298
- this.#handlers.set(event, handler);
299
- if (isDebug) logger.debug(`[Events] (+) ${event}`);
300
- }
301
- }
302
- }
303
-
304
- static #syncAll() {
305
- for (const instance of PluginManager.#instances) {
306
- instance.#syncListeners();
307
- }
308
- }
309
-
310
- #createHandler(event) {
311
- const bucket = PluginManager.#buckets[event];
312
- const sock = this.#sock;
313
- const dispatch = (ctx, event) => this.#dispatch(sock, ctx, bucket, event);
314
-
315
- const handlers = {
316
- 'messages.upsert': ({ messages, type }) => {
317
- if (type !== 'notify') return;
318
- for (const msg of messages) {
319
- if (!msg?.key?.remoteJid || msg.key.remoteJid === 'status@broadcast') continue;
320
- try {
321
- dispatch(formatter(sock, msg, event));
322
- } catch (err) {
323
- PluginManager.#handleError('[PluginManager] Format error:', err);
324
- }
325
- }
326
- },
327
-
328
- 'messages.update': (updates) => {
329
- for (const { key, update } of updates) {
330
- if (key?.remoteJid) dispatch({ key, update, jid: key.remoteJid }, event);
331
- }
332
- },
333
-
334
- 'messages.reaction': (reactions) => {
335
- for (const { key, reaction } of reactions) {
336
- if (key?.remoteJid) dispatch({ key, reaction, jid: key.remoteJid, emoji: reaction?.text }, event);
337
- }
338
- },
339
-
340
- 'group-participants.update': (u) => dispatch(u, event),
341
- 'connection.update': (u) => dispatch(u, event),
342
- 'creds.update': (creds) => dispatch({ creds }, event),
343
- 'call': (calls) => calls.forEach(c => dispatch(c, event)),
344
- };
345
-
346
- return handlers[event] ?? ((data) => dispatch({ data }, event));
347
- }
348
-
349
- #dispatch(sock, ctx, bucket, event) {
350
- if (this.#destroyed || !ctx) return;
351
-
352
- for (const [id, plugin] of bucket.auto) {
353
- this.#execute(id, plugin, sock, ctx, event);
354
- }
355
-
356
- if (bucket.match.size && ctx.body) {
357
- for (const [id, plugin] of bucket.match) {
358
- const matchers = plugin._meta?.matchers;
359
- if (!matchers) continue;
360
-
361
- const result = PluginManager.#test(matchers, ctx.body);
362
- if (result) this.#execute(id, plugin, sock, ctx, event, result);
363
- }
364
- }
365
- }
366
-
367
- async #execute(id, plugin, sock, ctx, event, match) {
368
- if (this.#destroyed) return;
369
- try {
370
- await plugin(sock, match ? { ...ctx, _match: match } : ctx, event);
371
- } catch (err) {
372
- PluginManager.#handleError(`[Plugin:${id}]`, err);
373
- }
374
- }
375
-
376
- static #initWatcher() {
377
- if (PluginManager.#watcher) return;
378
-
379
- PluginManager.#watcher = watch(PLUGIN_DIR, {
380
- persistent: true,
381
- ignoreInitial: true,
382
- ignored: [/(^|[/\\])_/, /\.d\.[jt]s$/, /node_modules/, /(^|[/\\])\../],
383
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 },
384
- })
385
- .on('add', p => PluginManager.#debounce(p, 'add'))
386
- .on('change', p => PluginManager.#debounce(p, 'change'))
387
- .on('unlink', p => PluginManager.#debounce(p, 'unlink'))
388
- .on('error', e => PluginManager.#handleError('[Watcher]', e));
389
- }
390
-
391
- static #debounce(filePath, type) {
392
- clearTimeout(PluginManager.#debounceTimers.get(filePath));
393
- PluginManager.#debounceTimers.set(
394
- filePath,
395
- setTimeout(() => {
396
- PluginManager.#debounceTimers.delete(filePath);
397
- PluginManager.#hmr(filePath, type);
398
- }, debounceMs)
399
- );
400
- }
401
-
402
- static async #hmr(filePath, type) {
403
- const rel = path.relative(PLUGIN_DIR, filePath);
404
-
405
- try {
406
- await PluginManager.#getLock(filePath).runExclusive(async () => {
407
- if (type === 'unlink') {
408
- const n = PluginManager.#unloadFile(filePath);
409
- logger.info(`[HMR] Unloaded: ${rel} (${n})`);
410
- } else {
411
- const parent = PluginManager.#getParent(path.dirname(filePath));
412
- const plugins = await PluginManager.#loadFile(filePath, parent, false);
413
-
414
- PluginManager.#unloadFile(filePath);
415
-
416
- for (const [id, plugin] of plugins) {
417
- PluginManager.#plugins.set(id, plugin);
418
- PluginManager.#register(id, plugin);
419
- }
420
-
421
- logger.info(`[HMR] ${type === 'add' ? 'Added' : 'Reloaded'}: ${rel} (${plugins.size})`);
422
- }
423
-
424
- PluginManager.#syncAll();
425
- });
426
- } catch (err) {
427
- PluginManager.#handleError(`[HMR:${rel}] Failed:`, err);
428
- } finally {
429
- if (type === 'unlink') {
430
- const lock = PluginManager.#fileLocks.get(filePath);
431
- if (lock && !lock.isLocked()) PluginManager.#fileLocks.delete(filePath);
432
- }
433
- }
434
- }
435
-
436
- static #unloadFile(filePath) {
437
- let count = 0;
438
- for (const [id, plugin] of PluginManager.#plugins) {
439
- if (plugin._meta?.filePath === filePath) {
440
- PluginManager.#unregister(id);
441
- PluginManager.#plugins.delete(id);
442
- count++;
443
- }
444
- }
445
- return count;
446
- }
447
-
448
- get(id) {
449
- return PluginManager.#plugins.get(id);
450
- }
451
-
452
- get all() {
453
- return new Map(PluginManager.#plugins);
454
- }
455
-
456
- get events() {
457
- return [...this.#handlers.keys()];
458
- }
459
-
460
- get destroyed() {
461
- return this.#destroyed;
462
- }
463
-
464
- static get instances() {
465
- return PluginManager.#instances.size;
466
- }
467
-
468
- static get count() {
469
- return PluginManager.#plugins.size;
470
- }
471
- }
472
-
473
- export const pluginManager = async (sock) => {
474
- const manager = new PluginManager(sock);
475
- await manager.init();
476
- return manager;
477
- }