@manybot/manybot 4.4.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/client/whatsappClient.js +11 -4
- package/src/config.js +187 -89
- package/src/kernel/messageHandler.js +65 -12
- package/src/kernel/pluginApi.js +180 -74
- package/src/kernel/pluginGuard.js +53 -12
- package/src/kernel/pluginLoader.js +62 -19
- package/src/kernel/sendGuard.js +166 -0
- package/src/logger/logger.js +1 -0
- package/src/main.js +42 -11
package/src/kernel/pluginApi.js
CHANGED
|
@@ -17,26 +17,51 @@ import { emptyFolder } from "#utils/file";
|
|
|
17
17
|
import { getChatId } from "#utils/getChatId";
|
|
18
18
|
import pkg from "whatsapp-web.js";
|
|
19
19
|
import { mkdirSync } from "fs";
|
|
20
|
+
import path from "path";
|
|
21
|
+
import { waitForSendSlot, simulateState,
|
|
22
|
+
typingDuration, mediaDuration } from "#sendguard";
|
|
20
23
|
|
|
21
24
|
const { MessageMedia } = pkg;
|
|
22
25
|
|
|
23
26
|
// ── Storage API ──────────────────────────────────────────────────────────────
|
|
24
27
|
|
|
25
28
|
export function buildStorageApi(pluginName) {
|
|
29
|
+
if (typeof pluginName !== "string" || pluginName.trim() === "") {
|
|
30
|
+
throw new Error("[storage] buildStorageApi: pluginName must be a non-empty string");
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
const dir = path.join(CONFIG_DIR, "data", pluginName);
|
|
27
34
|
mkdirSync(dir, { recursive: true });
|
|
28
|
-
|
|
35
|
+
|
|
29
36
|
return {
|
|
30
37
|
dir,
|
|
31
38
|
|
|
32
39
|
/**
|
|
33
|
-
* Resolves a path inside data directory.
|
|
34
|
-
*
|
|
40
|
+
* Resolves a path inside the plugin's data directory.
|
|
41
|
+
* Creates subdirectories automatically.
|
|
35
42
|
* @param {string} relativePath
|
|
36
43
|
* @returns {string}
|
|
37
44
|
*/
|
|
38
|
-
|
|
45
|
+
resolve(relativePath) {
|
|
46
|
+
if (!relativePath || typeof relativePath !== "string") {
|
|
47
|
+
throw new Error(`[storage] resolve() requires a non-empty string, got: ${typeof relativePath}`);
|
|
48
|
+
}
|
|
49
|
+
if (relativePath.includes("..")) {
|
|
50
|
+
throw new Error(`[storage] path traversal detected in: "${relativePath}"`);
|
|
51
|
+
}
|
|
52
|
+
if (path.isAbsolute(relativePath)) {
|
|
53
|
+
throw new Error(`[storage] absolute paths are not allowed: "${relativePath}"`);
|
|
54
|
+
}
|
|
55
|
+
if (relativePath.includes("\\")) {
|
|
56
|
+
throw new Error(`[storage] Windows-style paths are not allowed: "${relativePath}"`);
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
const resolved = path.join(dir, relativePath);
|
|
60
|
+
|
|
61
|
+
if (!resolved.startsWith(path.resolve(dir) + path.sep)) {
|
|
62
|
+
throw new Error(`[storage] resolved path escapes plugin data dir: "${resolved}"`);
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
mkdirSync(path.dirname(resolved), { recursive: true });
|
|
41
66
|
return resolved;
|
|
42
67
|
},
|
|
@@ -55,9 +80,6 @@ function buildConfigApi() {
|
|
|
55
80
|
get(key, defaultValue = null) {
|
|
56
81
|
return CONFIG[key] ?? defaultValue;
|
|
57
82
|
},
|
|
58
|
-
|
|
59
|
-
/** Full config object — read only. */
|
|
60
|
-
all: CONFIG,
|
|
61
83
|
};
|
|
62
84
|
}
|
|
63
85
|
|
|
@@ -135,7 +157,7 @@ function buildPluginsApi(pluginRegistry) {
|
|
|
135
157
|
require(name) {
|
|
136
158
|
const plugin = pluginRegistry.get(name);
|
|
137
159
|
if (!plugin || plugin.status !== "active") {
|
|
138
|
-
throw new Error(`
|
|
160
|
+
throw new Error(`[plugins] dependency "${name}" does not exist or is not active`);
|
|
139
161
|
}
|
|
140
162
|
return plugin.exports;
|
|
141
163
|
},
|
|
@@ -249,28 +271,70 @@ function mediaFromSource(source, mimetype) {
|
|
|
249
271
|
|
|
250
272
|
/**
|
|
251
273
|
* Returns send methods bound to a target that exposes `.sendMessage()`.
|
|
274
|
+
*
|
|
252
275
|
* @param {{ sendMessage: Function }} target
|
|
276
|
+
* @param {object} [extraOpts] — merged into every sendMessage call (e.g. { quoted: msg })
|
|
277
|
+
* @param {string|null} [chatId] — serialized chat ID; enables sendGuard when set
|
|
278
|
+
* @param {object|null} [chatObj] — real Chat object; enables typing indicator when set
|
|
253
279
|
*/
|
|
254
|
-
function makeSender(target) {
|
|
280
|
+
function makeSender(target, extraOpts = {}, chatId = null, chatObj = null, { cooldown = true, jitter = true } = {}) {
|
|
255
281
|
return {
|
|
256
282
|
async text(content, opts = {}) {
|
|
257
|
-
|
|
283
|
+
if (chatId) {
|
|
284
|
+
await waitForSendSlot(chatId, { cooldown, jitter });
|
|
285
|
+
await simulateState(chatObj, typingDuration(content), "typing");
|
|
286
|
+
}
|
|
287
|
+
return target.sendMessage(content, { ...extraOpts, ...opts });
|
|
258
288
|
},
|
|
289
|
+
|
|
259
290
|
async image(filePath, caption = "") {
|
|
291
|
+
if (chatId) {
|
|
292
|
+
await waitForSendSlot(chatId, { cooldown, jitter });
|
|
293
|
+
await simulateState(chatObj, mediaDuration(), "typing");
|
|
294
|
+
}
|
|
260
295
|
const media = MessageMedia.fromFilePath(filePath);
|
|
261
|
-
return target.sendMessage(media, { caption });
|
|
296
|
+
return target.sendMessage(media, { caption, ...extraOpts });
|
|
262
297
|
},
|
|
298
|
+
|
|
263
299
|
async video(filePath, caption = "") {
|
|
300
|
+
if (chatId) {
|
|
301
|
+
await waitForSendSlot(chatId, { cooldown, jitter });
|
|
302
|
+
await simulateState(chatObj, mediaDuration(), "typing");
|
|
303
|
+
}
|
|
264
304
|
const media = MessageMedia.fromFilePath(filePath);
|
|
265
|
-
return target.sendMessage(media, { caption });
|
|
305
|
+
return target.sendMessage(media, { caption, ...extraOpts });
|
|
266
306
|
},
|
|
307
|
+
|
|
267
308
|
async audio(filePath, { asVoice = true } = {}) {
|
|
309
|
+
if (chatId) {
|
|
310
|
+
await waitForSendSlot(chatId, { cooldown, jitter });
|
|
311
|
+
// "gravando áudio…" é mais convincente que "digitando…" para áudio
|
|
312
|
+
await simulateState(chatObj, mediaDuration(), "recording");
|
|
313
|
+
}
|
|
268
314
|
const media = MessageMedia.fromFilePath(filePath);
|
|
269
|
-
return target.sendMessage(media, { sendAudioAsVoice: asVoice });
|
|
315
|
+
return target.sendMessage(media, { sendAudioAsVoice: asVoice, ...extraOpts });
|
|
270
316
|
},
|
|
317
|
+
|
|
271
318
|
async sticker(source) {
|
|
319
|
+
if (chatId) {
|
|
320
|
+
await waitForSendSlot(chatId, { cooldown, jitter });
|
|
321
|
+
await simulateState(chatObj, mediaDuration(), "typing");
|
|
322
|
+
}
|
|
272
323
|
const media = mediaFromSource(source, "image/webp");
|
|
273
|
-
return target.sendMessage(media, { sendMediaAsSticker: true });
|
|
324
|
+
return target.sendMessage(media, { sendMediaAsSticker: true, ...extraOpts });
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
async file(filePath, filename) {
|
|
328
|
+
if (chatId) {
|
|
329
|
+
await waitForSendSlot(chatId, { cooldown, jitter });
|
|
330
|
+
await simulateState(chatObj, mediaDuration(), "typing");
|
|
331
|
+
}
|
|
332
|
+
const media = MessageMedia.fromFilePath(filePath);
|
|
333
|
+
return target.sendMessage(media, {
|
|
334
|
+
sendMediaAsDocument: true,
|
|
335
|
+
filename: filename ?? path.basename(filePath),
|
|
336
|
+
...extraOpts,
|
|
337
|
+
});
|
|
274
338
|
},
|
|
275
339
|
};
|
|
276
340
|
}
|
|
@@ -291,27 +355,28 @@ function chatIdTarget(client, chatId) {
|
|
|
291
355
|
* ctx.send.image("./foto.jpg", "legenda")
|
|
292
356
|
* ctx.send.to("5511@c.us").text("oi")
|
|
293
357
|
*/
|
|
294
|
-
function buildSendApi(chat, client) {
|
|
295
|
-
const
|
|
296
|
-
|
|
358
|
+
function buildSendApi(chat, client, guardOptions = {}) {
|
|
359
|
+
const chatId = chat.id._serialized;
|
|
360
|
+
const { cooldown = true, jitter = true } = guardOptions;
|
|
361
|
+
const current = makeSender(chat, {}, chatId, chat, { cooldown, jitter });
|
|
297
362
|
return {
|
|
298
363
|
send: {
|
|
299
|
-
text: (text, opts)
|
|
300
|
-
image: (filePath, caption)
|
|
301
|
-
video: (filePath, caption)
|
|
302
|
-
audio: (filePath, opts)
|
|
303
|
-
sticker: (source)
|
|
304
|
-
|
|
364
|
+
text: (text, opts) => current.text(text, opts),
|
|
365
|
+
image: (filePath, caption) => current.image(filePath, caption),
|
|
366
|
+
video: (filePath, caption) => current.video(filePath, caption),
|
|
367
|
+
audio: (filePath, opts) => current.audio(filePath, opts),
|
|
368
|
+
sticker: (source) => current.sticker(source),
|
|
369
|
+
file: (filePath, filename) => current.file(filePath, filename),
|
|
305
370
|
/**
|
|
306
371
|
* Returns a sender bound to another chat.
|
|
307
|
-
*
|
|
308
|
-
* @
|
|
372
|
+
* Typing simulation is skipped (no Chat object available without a fetch).
|
|
373
|
+
* @param {string} targetChatId
|
|
309
374
|
*/
|
|
310
|
-
to: (
|
|
375
|
+
to: (targetChatId) =>
|
|
376
|
+
makeSender(chatIdTarget(client, targetChatId), {}, targetChatId, null),
|
|
311
377
|
},
|
|
312
378
|
};
|
|
313
379
|
}
|
|
314
|
-
|
|
315
380
|
/**
|
|
316
381
|
* Setup send API — no current chat, only .to().
|
|
317
382
|
*
|
|
@@ -320,46 +385,82 @@ function buildSendApi(chat, client) {
|
|
|
320
385
|
function buildSetupSendApi(client) {
|
|
321
386
|
return {
|
|
322
387
|
send: {
|
|
323
|
-
to: (
|
|
388
|
+
to: (targetChatId) =>
|
|
389
|
+
makeSender(chatIdTarget(client, targetChatId), {}, targetChatId, null),
|
|
324
390
|
},
|
|
325
391
|
};
|
|
326
392
|
}
|
|
327
393
|
|
|
328
394
|
// ── Events API (setup only) ───────────────────────────────────────────────────
|
|
329
395
|
|
|
330
|
-
|
|
396
|
+
/**
|
|
397
|
+
* @param {import("whatsapp-web.js").Client} client
|
|
398
|
+
* @param {string} pluginName
|
|
399
|
+
*/
|
|
400
|
+
const listenerRegistry = new Map();
|
|
401
|
+
|
|
402
|
+
function buildEventsApi(client, pluginName) {
|
|
331
403
|
return {
|
|
332
|
-
/**
|
|
333
|
-
* Registers a persistent listener for a client event.
|
|
334
|
-
* Returns an 'off()' function to cancel the listener when the plugin wants.
|
|
335
|
-
*
|
|
336
|
-
* @param {string} event
|
|
337
|
-
* @param {Function} handler
|
|
338
|
-
* @returns {Function} off
|
|
339
|
-
*
|
|
340
|
-
* @example
|
|
341
|
-
* const off = ctx.events.on("group_join", (notification) => { ... });
|
|
342
|
-
* // cancels when the plugin doesn't want anymore:
|
|
343
|
-
* off();
|
|
344
|
-
*/
|
|
345
404
|
on(event, handler) {
|
|
346
405
|
client.on(event, handler);
|
|
347
|
-
|
|
406
|
+
|
|
407
|
+
if (!listenerRegistry.has(pluginName)) {
|
|
408
|
+
listenerRegistry.set(pluginName, new Set());
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const ref = { event, handler };
|
|
412
|
+
|
|
413
|
+
listenerRegistry
|
|
414
|
+
.get(pluginName)
|
|
415
|
+
.add(ref);
|
|
416
|
+
|
|
417
|
+
return () => {
|
|
418
|
+
client.off(event, handler);
|
|
419
|
+
|
|
420
|
+
listenerRegistry
|
|
421
|
+
.get(pluginName)
|
|
422
|
+
?.delete(ref);
|
|
423
|
+
|
|
424
|
+
if (
|
|
425
|
+
listenerRegistry
|
|
426
|
+
.get(pluginName)
|
|
427
|
+
?.size === 0
|
|
428
|
+
) {
|
|
429
|
+
listenerRegistry.delete(pluginName);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
348
432
|
},
|
|
349
433
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
434
|
+
once(event) {
|
|
435
|
+
return new Promise((resolve) => {
|
|
436
|
+
const off =
|
|
437
|
+
this.on(
|
|
438
|
+
event,
|
|
439
|
+
(data) => {
|
|
440
|
+
off();
|
|
441
|
+
resolve(data);
|
|
442
|
+
}
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
cleanup() {
|
|
448
|
+
const list =
|
|
449
|
+
listenerRegistry.get(pluginName);
|
|
450
|
+
|
|
451
|
+
if (!list) return;
|
|
452
|
+
|
|
453
|
+
for (const {
|
|
454
|
+
event,
|
|
455
|
+
handler
|
|
456
|
+
} of list) {
|
|
457
|
+
client.off(
|
|
458
|
+
event,
|
|
459
|
+
handler
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
listenerRegistry.delete(pluginName);
|
|
463
|
+
},
|
|
363
464
|
};
|
|
364
465
|
}
|
|
365
466
|
|
|
@@ -367,7 +468,7 @@ function buildEventsApi(client) {
|
|
|
367
468
|
|
|
368
469
|
function buildBaseApi(client, pluginRegistry, pluginName) {
|
|
369
470
|
const botId = client.info?.wid?._serialized ?? null;
|
|
370
|
-
if (!botId) logger.warn("[pluginApi] botId is null
|
|
471
|
+
if (!botId) logger.warn("[pluginApi] botId is null — client may not be ready yet.");
|
|
371
472
|
|
|
372
473
|
return {
|
|
373
474
|
log,
|
|
@@ -390,14 +491,15 @@ function buildBaseApi(client, pluginRegistry, pluginName) {
|
|
|
390
491
|
* Passed to plugin.setup(ctx) during initialization.
|
|
391
492
|
*
|
|
392
493
|
* @param {import("whatsapp-web.js").Client} client
|
|
393
|
-
* @param {Map<string, any>}
|
|
494
|
+
* @param {Map<string, any>} pluginRegistry
|
|
495
|
+
* @param {string} pluginName
|
|
394
496
|
* @returns {object}
|
|
395
497
|
*/
|
|
396
|
-
export function buildSetupApi(client, pluginRegistry, pluginName) {
|
|
498
|
+
export function buildSetupApi(client, pluginRegistry, pluginName ) {
|
|
397
499
|
return {
|
|
398
500
|
...buildBaseApi(client, pluginRegistry, pluginName),
|
|
399
501
|
...buildSetupSendApi(client),
|
|
400
|
-
events: buildEventsApi(client),
|
|
502
|
+
events: buildEventsApi(client, pluginName),
|
|
401
503
|
};
|
|
402
504
|
}
|
|
403
505
|
|
|
@@ -412,17 +514,21 @@ export function buildSetupApi(client, pluginRegistry, pluginName) {
|
|
|
412
514
|
* @param {import("whatsapp-web.js").Chat} params.chat
|
|
413
515
|
* @param {import("whatsapp-web.js").Client} params.client
|
|
414
516
|
* @param {Map<string, any>} params.pluginRegistry
|
|
517
|
+
* @param {string} params.pluginName
|
|
415
518
|
* @returns {object} ctx
|
|
416
519
|
*/
|
|
417
|
-
export function buildApi({ msg, chat, client, pluginRegistry }) {
|
|
418
|
-
const prefix = CONFIG.CMD_PREFIX
|
|
520
|
+
export function buildApi({ msg, chat, client, pluginRegistry, pluginName, guardOptions = {} }) {
|
|
521
|
+
const prefix = CONFIG.CMD_PREFIX;
|
|
419
522
|
const rawArgs = msg.body?.trim().split(/\s+/) ?? [];
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
523
|
+
const first = rawArgs[0]?.toLowerCase() ?? "";
|
|
524
|
+
const hasPrefix = first.startsWith(prefix)
|
|
525
|
+
const command = hasPrefix ? first.slice(prefix.length) : "";
|
|
526
|
+
|
|
527
|
+
const chatId = chat.id._serialized;
|
|
528
|
+
const { cooldown = true, jitter = true } = guardOptions;
|
|
423
529
|
|
|
424
530
|
return {
|
|
425
|
-
...buildBaseApi(client, pluginRegistry),
|
|
531
|
+
...buildBaseApi(client, pluginRegistry, pluginName),
|
|
426
532
|
...buildSendApi(chat, client),
|
|
427
533
|
|
|
428
534
|
// ── msg ──────────────────────────────────────────────────
|
|
@@ -441,11 +547,11 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
|
|
|
441
547
|
args: rawArgs.slice(1),
|
|
442
548
|
|
|
443
549
|
/**
|
|
444
|
-
* Check if message
|
|
445
|
-
* @param {string} cmd
|
|
550
|
+
* Check if message matches a given command.
|
|
551
|
+
* @param {string} cmd
|
|
446
552
|
*/
|
|
447
553
|
is(cmd) {
|
|
448
|
-
return command === cmd.toLowerCase();
|
|
554
|
+
return this.hasPrefix && command === cmd.toLowerCase();
|
|
449
555
|
},
|
|
450
556
|
|
|
451
557
|
hasMedia: msg.hasMedia,
|
|
@@ -464,14 +570,14 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
|
|
|
464
570
|
return msg.getQuotedMessage();
|
|
465
571
|
},
|
|
466
572
|
|
|
467
|
-
|
|
468
|
-
return msg.reply(text);
|
|
469
|
-
},
|
|
573
|
+
reply: makeSender(chat, { quotedMessageId: msg.id._serialized }, chatId, chat, { cooldown, jitter }),
|
|
470
574
|
|
|
471
575
|
async react(emoji) {
|
|
472
576
|
return msg.react(emoji);
|
|
473
577
|
},
|
|
474
578
|
|
|
579
|
+
hasPrefix,
|
|
580
|
+
|
|
475
581
|
/**
|
|
476
582
|
* Get the sender as a normalized Contact object.
|
|
477
583
|
* Same shape as ctx.contacts.get().
|
|
@@ -490,9 +596,9 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
|
|
|
490
596
|
// ── chat ─────────────────────────────────────────────────
|
|
491
597
|
|
|
492
598
|
chat: {
|
|
493
|
-
id:
|
|
599
|
+
id: chatId,
|
|
494
600
|
name: chat.name || chat.id.user,
|
|
495
|
-
isGroup: /@g\.us$/.test(
|
|
601
|
+
isGroup: /@g\.us$/.test(chatId),
|
|
496
602
|
|
|
497
603
|
/**
|
|
498
604
|
* List of group participants.
|
|
@@ -2,36 +2,77 @@
|
|
|
2
2
|
* pluginGuard.js
|
|
3
3
|
*
|
|
4
4
|
* Runs a plugin safely.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
5
|
+
*
|
|
6
|
+
* Protections:
|
|
7
|
+
* - Hard timeout per plugin run (prevents infinite hangs from locking the queue)
|
|
8
|
+
* - Catches and logs all errors with structured context
|
|
9
|
+
* - Marks errored plugins so they are silently skipped from then on
|
|
8
10
|
* - Never crashes the bot
|
|
9
11
|
*
|
|
10
|
-
*
|
|
12
|
+
* Per-plugin overrides:
|
|
13
|
+
* Plugins may export a `guardOptions` object to opt out of specific
|
|
14
|
+
* protections. The pluginLoader is responsible for reading this export
|
|
15
|
+
* and storing it as `plugin.guardOptions` in the registry entry.
|
|
16
|
+
*
|
|
17
|
+
* Supported keys:
|
|
18
|
+
* timeout {boolean} — set to `false` to disable the hard timeout.
|
|
19
|
+
* Use only for plugins that intentionally block
|
|
20
|
+
* (e.g. heavy media processing, sticker generation).
|
|
11
21
|
*/
|
|
12
|
-
|
|
13
22
|
import { logger } from "#logger";
|
|
14
|
-
import { t } from "#i18n";
|
|
15
23
|
import { pluginRegistry } from "#kernel/pluginLoader";
|
|
16
24
|
|
|
25
|
+
/** Max ms a single plugin run is allowed to take before it's force-aborted. */
|
|
26
|
+
const PLUGIN_TIMEOUT_MS = 120_000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Races `promise` against a timeout rejection.
|
|
30
|
+
* @param {Promise} promise
|
|
31
|
+
* @param {number} ms
|
|
32
|
+
* @param {string} pluginName
|
|
33
|
+
*/
|
|
34
|
+
function withTimeout(promise, ms, pluginName) {
|
|
35
|
+
return Promise.race([
|
|
36
|
+
promise,
|
|
37
|
+
new Promise((_, reject) =>
|
|
38
|
+
setTimeout(
|
|
39
|
+
() => reject(new Error(`timed out after ${ms}ms`)),
|
|
40
|
+
ms
|
|
41
|
+
)
|
|
42
|
+
),
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
|
|
17
46
|
/**
|
|
18
47
|
* @param {object} plugin — pluginRegistry entry
|
|
19
48
|
* @param {object} context — buildApi ctx
|
|
49
|
+
*
|
|
50
|
+
* plugin.guardOptions (optional, read from plugin's own export):
|
|
51
|
+
* @param {boolean} [plugin.guardOptions.timeout=true]
|
|
20
52
|
*/
|
|
21
53
|
export async function runPlugin(plugin, context) {
|
|
22
54
|
if (plugin.status !== "active") return;
|
|
23
55
|
|
|
56
|
+
const useTimeout = plugin.guardOptions?.timeout !== false;
|
|
57
|
+
|
|
24
58
|
try {
|
|
25
|
-
|
|
59
|
+
const run = plugin.run(context);
|
|
60
|
+
await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
|
|
26
61
|
} catch (err) {
|
|
27
|
-
// Disable plugin to prevent further breakage
|
|
28
62
|
plugin.status = "error";
|
|
29
63
|
plugin.error = err;
|
|
30
64
|
pluginRegistry.set(plugin.name, plugin);
|
|
31
65
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
const isTimeout = useTimeout && err.message?.startsWith("timed out");
|
|
67
|
+
const headline = isTimeout
|
|
68
|
+
? `[pluginGuard] Plugin "${plugin.name}" forcibly stopped: ${err.message}`
|
|
69
|
+
: `[pluginGuard] Plugin "${plugin.name}" threw an unhandled error and was disabled`;
|
|
70
|
+
|
|
71
|
+
logger.error(headline);
|
|
72
|
+
logger.error(` message : ${err.message}`);
|
|
73
|
+
if (!isTimeout) {
|
|
74
|
+
const frame = err.stack?.split("\n")[1]?.trim() ?? "(no stack)";
|
|
75
|
+
logger.error(` at : ${frame}`);
|
|
76
|
+
}
|
|
36
77
|
}
|
|
37
78
|
}
|
|
@@ -15,7 +15,7 @@ import { logger } from "#logger";
|
|
|
15
15
|
import { t } from "#i18n";
|
|
16
16
|
import { pathToFileURL } from "url";
|
|
17
17
|
import { PATHS } from "#config";
|
|
18
|
-
import {
|
|
18
|
+
import { buildSetupApi } from "#manyapi";
|
|
19
19
|
|
|
20
20
|
const PLUGINS_DIR = path.join(PATHS.HOME, "plugins");
|
|
21
21
|
|
|
@@ -64,33 +64,59 @@ export async function loadPlugins(activePlugins) {
|
|
|
64
64
|
*
|
|
65
65
|
* @param {object} api — api without message context (only sendTo, log, schedule...)
|
|
66
66
|
*/
|
|
67
|
-
export async function setupPlugins(
|
|
67
|
+
export async function setupPlugins(client) {
|
|
68
68
|
for (const plugin of pluginRegistry.values()) {
|
|
69
|
-
if (plugin.status !== "active" || !plugin.setup)
|
|
69
|
+
if (plugin.status !== "active" || !plugin.setup)
|
|
70
|
+
continue;
|
|
71
|
+
|
|
70
72
|
try {
|
|
71
|
-
const api =
|
|
73
|
+
const api = buildSetupApi(
|
|
74
|
+
client,
|
|
75
|
+
pluginRegistry,
|
|
76
|
+
plugin.name
|
|
77
|
+
);
|
|
78
|
+
|
|
72
79
|
await plugin.setup(api);
|
|
80
|
+
|
|
73
81
|
} catch (err) {
|
|
74
|
-
logger.error(
|
|
82
|
+
logger.error(
|
|
83
|
+
t("system.pluginSetupFailed", {
|
|
84
|
+
name: plugin.name,
|
|
85
|
+
message: err.message
|
|
86
|
+
})
|
|
87
|
+
);
|
|
75
88
|
}
|
|
76
89
|
}
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
async function findPluginPath(name) {
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
const dir = path.join(PLUGINS_DIR, name);
|
|
94
|
+
const manifest = path.join(dir, "manyplug.json");
|
|
95
|
+
|
|
96
|
+
if (!fs.existsSync(manifest))
|
|
97
|
+
return null;
|
|
98
|
+
|
|
99
|
+
const { main = "index.js" } =
|
|
100
|
+
JSON.parse(
|
|
101
|
+
await fs.promises.readFile(
|
|
102
|
+
manifest,
|
|
103
|
+
"utf8"
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const entry =
|
|
108
|
+
path.join(
|
|
109
|
+
dir,
|
|
110
|
+
main
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
fs.existsSync(entry)
|
|
115
|
+
? entry
|
|
116
|
+
: null
|
|
117
|
+
);
|
|
93
118
|
}
|
|
119
|
+
|
|
94
120
|
/**
|
|
95
121
|
* Carrega um único plugin pelo nome.
|
|
96
122
|
* @param {string} name
|
|
@@ -121,9 +147,10 @@ async function loadPlugin(name) {
|
|
|
121
147
|
name,
|
|
122
148
|
status: "active",
|
|
123
149
|
run: mod.default,
|
|
124
|
-
setup: mod.setup ?? null,
|
|
150
|
+
setup: mod.setup ?? null,
|
|
125
151
|
exports: mod.api ?? null,
|
|
126
152
|
error: null,
|
|
153
|
+
guardOptions: mod.guardOptions ?? {},
|
|
127
154
|
});
|
|
128
155
|
|
|
129
156
|
logger.info(t("system.pluginLoaded", { name }));
|
|
@@ -132,3 +159,19 @@ async function loadPlugin(name) {
|
|
|
132
159
|
pluginRegistry.set(name, { name, status: "error", run: null, exports: null, error: err });
|
|
133
160
|
}
|
|
134
161
|
}
|
|
162
|
+
|
|
163
|
+
export async function cleanupPlugins() {
|
|
164
|
+
for (const plugin of pluginRegistry.values()) {
|
|
165
|
+
try {
|
|
166
|
+
await plugin.exports?.events?.cleanup?.();
|
|
167
|
+
|
|
168
|
+
} catch (err) {
|
|
169
|
+
logger.error(
|
|
170
|
+
t("system.pluginCleanupFailed", {
|
|
171
|
+
name: plugin.name,
|
|
172
|
+
message: err.message
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|