@manybot/manybot 5.1.0 → 5.2.1

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.
Files changed (51) hide show
  1. package/README.md +5 -7
  2. package/dist/client/banner.js +35 -0
  3. package/dist/client/store.js +135 -0
  4. package/dist/config.js +363 -0
  5. package/dist/core/adapter.js +12 -0
  6. package/dist/core/capabilities.js +16 -0
  7. package/dist/core/types.js +6 -0
  8. package/dist/download/queue.js +49 -0
  9. package/dist/drivers/index.js +14 -0
  10. package/dist/drivers/patches/index.js +9 -0
  11. package/dist/drivers/patches/libsignal.js +16 -0
  12. package/dist/drivers/patches/patch.js +1 -0
  13. package/dist/drivers/whatsapp/adapter.js +7 -0
  14. package/dist/drivers/whatsapp/api/index.js +1311 -0
  15. package/dist/drivers/whatsapp/index.js +164 -0
  16. package/dist/drivers/whatsapp/loginPrompt.js +81 -0
  17. package/dist/drivers/whatsapp/messageHandler.js +111 -0
  18. package/dist/drivers/whatsapp/sdk/baileysSock.js +124 -0
  19. package/dist/i18n/index.js +202 -0
  20. package/dist/kernel/pluginApi.js +11 -0
  21. package/dist/kernel/pluginGuard.js +88 -0
  22. package/dist/kernel/pluginLoader.js +322 -0
  23. package/dist/kernel/scheduler.js +110 -0
  24. package/dist/kernel/sendGuard.js +121 -0
  25. package/dist/kernel/settingsDb.js +205 -0
  26. package/{src → dist}/locales/en.json +18 -33
  27. package/dist/locales/es.json +52 -0
  28. package/{src → dist}/locales/pt.json +18 -34
  29. package/dist/logger/logger.js +16 -0
  30. package/dist/main.js +82 -0
  31. package/dist/types.js +16 -0
  32. package/{src → dist}/utils/file.js +3 -3
  33. package/package.json +35 -26
  34. package/src/client/banner.js +0 -57
  35. package/src/client/whatsappClient.js +0 -185
  36. package/src/config.js +0 -288
  37. package/src/download/queue.js +0 -55
  38. package/src/i18n/index.js +0 -235
  39. package/src/kernel/messageHandler.js +0 -88
  40. package/src/kernel/pluginApi.js +0 -630
  41. package/src/kernel/pluginGuard.js +0 -78
  42. package/src/kernel/pluginLoader.js +0 -177
  43. package/src/kernel/pluginState.js +0 -99
  44. package/src/kernel/scheduler.js +0 -48
  45. package/src/kernel/sendGuard.js +0 -166
  46. package/src/locales/es.json +0 -62
  47. package/src/logger/logger.js +0 -32
  48. package/src/main.js +0 -136
  49. package/src/utils/getChatId.js +0 -3
  50. package/src/utils/get_id.js +0 -177
  51. package/src/utils/pluginI18n.js +0 -129
@@ -1,630 +0,0 @@
1
- /**
2
- * pluginApi.js
3
- *
4
- * Builds the `ctx` object each plugin receives.
5
- * Plugins can only do what's here — never touch client directly.
6
- *
7
- * `chat` is already filtered by kernel (only allowed chats from .conf),
8
- * so plugins don't need and can't choose destination, unless they use sendTo.
9
- */
10
-
11
- import { logger } from "#logger";
12
- import { t, createPluginT, reloadTranslations,
13
- getCurrentLang } from "#i18n";
14
- import { CONFIG, CONFIG_DIR } from "#config";
15
- import { enqueue } from "#download";
16
- import { emptyFolder } from "#utils/file";
17
- import { getChatId } from "#utils/getChatId";
18
- import pkg from "whatsapp-web.js";
19
- import { mkdirSync } from "fs";
20
- import path from "path";
21
- import { waitForSendSlot, simulateState,
22
- typingDuration, mediaDuration } from "#sendguard";
23
-
24
- const { MessageMedia } = pkg;
25
-
26
- // ── Storage API ──────────────────────────────────────────────────────────────
27
-
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
-
33
- const dir = path.join(CONFIG_DIR, "data", pluginName);
34
- mkdirSync(dir, { recursive: true });
35
-
36
- return {
37
- dir,
38
-
39
- /**
40
- * Resolves a path inside the plugin's data directory.
41
- * Creates subdirectories automatically.
42
- * @param {string} relativePath
43
- * @returns {string}
44
- */
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
-
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
-
65
- mkdirSync(path.dirname(resolved), { recursive: true });
66
- return resolved;
67
- },
68
- };
69
- }
70
-
71
- // ── Config API ───────────────────────────────────────────────────────────────
72
-
73
- function buildConfigApi() {
74
- return {
75
- /**
76
- * Get a config value with optional default.
77
- * @param {string} key
78
- * @param {any} [defaultValue]
79
- */
80
- get(key, defaultValue = null) {
81
- return CONFIG[key] ?? defaultValue;
82
- },
83
- };
84
- }
85
-
86
- // ── i18n API ─────────────────────────────────────────────────────────────────
87
-
88
- function buildI18nApi() {
89
- return {
90
- /** Translate a core key. */
91
- t,
92
-
93
- /**
94
- * Create a scoped t() for a plugin's own locale files.
95
- * @param {string} pluginMetaUrl — pass import.meta.url from the plugin
96
- */
97
- createT: createPluginT,
98
-
99
- /** Reload all translations (e.g. after language change). */
100
- reload: reloadTranslations,
101
-
102
- /** Returns current language code. */
103
- getCurrentLang,
104
- };
105
- }
106
-
107
- // ── Utils API ────────────────────────────────────────────────────────────────
108
-
109
- function buildUtilsApi() {
110
- return {
111
- /**
112
- * Empty a folder's contents without removing the folder itself.
113
- * @param {string} folder
114
- */
115
- emptyFolder,
116
-
117
- /**
118
- * Get the serialized chat ID from a chat object.
119
- * @param {import("whatsapp-web.js").Chat} chat
120
- */
121
- getChatId,
122
- };
123
- }
124
-
125
- // ── Download API ─────────────────────────────────────────────────────────────
126
-
127
- function buildDownloadApi() {
128
- return {
129
- /**
130
- * Enqueue a download work function.
131
- * @param {Function} workFn
132
- * @param {Function} [errorFn]
133
- */
134
- enqueue,
135
- };
136
- }
137
-
138
- // ── Plugin registry API ──────────────────────────────────────────────────────
139
-
140
- function buildPluginsApi(pluginRegistry) {
141
- return {
142
- /**
143
- * Return public API of another plugin, or null if not active.
144
- * @param {string} name
145
- * @returns {any|null}
146
- */
147
- get(name) {
148
- return pluginRegistry.get(name)?.exports ?? null;
149
- },
150
-
151
- /**
152
- * Return public API of another plugin, or throw if not active.
153
- * Analogous to require() — use when the dependency is mandatory.
154
- * @param {string} name
155
- * @returns {any}
156
- */
157
- require(name) {
158
- const plugin = pluginRegistry.get(name);
159
- if (!plugin || plugin.status !== "active") {
160
- throw new Error(`[plugins] dependency "${name}" does not exist or is not active`);
161
- }
162
- return plugin.exports;
163
- },
164
-
165
- /**
166
- * Check if a plugin is active.
167
- * @param {string} name
168
- * @returns {boolean}
169
- */
170
- exists(name) {
171
- return pluginRegistry.get(name)?.status === "active";
172
- },
173
- };
174
- }
175
-
176
- // ── Log API ──────────────────────────────────────────────────────────────────
177
-
178
- const log = {
179
- info: (...a) => logger.info(...a),
180
- warn: (...a) => logger.warn(...a),
181
- error: (...a) => logger.error(...a),
182
- success: (...a) => logger.success(...a),
183
- };
184
-
185
- // ── Contact API ──────────────────────────────────────────────────────────────
186
-
187
- /**
188
- * Normalizes a raw whatsapp-web.js Contact into a plain object.
189
- * Used internally so both ctx.contacts and ctx.msg.getContact()
190
- * always return the same shape.
191
- * @param {import("whatsapp-web.js").Contact} c
192
- * @returns {object}
193
- */
194
- function normalizeContact(c) {
195
- return {
196
- id: c.id._serialized,
197
- number: c.number,
198
- pushname: c.pushname ?? null,
199
- name: c.name ?? null,
200
- shortName: c.shortName ?? null,
201
- isBusiness: c.isBusiness,
202
- isEnterprise: c.isEnterprise,
203
- isBlocked: c.isBlocked,
204
- isMe: c.isMe,
205
- isMyContact: c.isMyContact,
206
- isWAContact: c.isWAContact,
207
- isUser: c.isUser,
208
- isGroup: c.isGroup,
209
- };
210
- }
211
-
212
- function buildContactsApi(client) {
213
- return {
214
- /**
215
- * Get a normalized Contact object by ID.
216
- * @param {string} contactId — serialized ID (e.g. "5511999999999@c.us")
217
- * @returns {Promise<object|null>}
218
- */
219
- async get(contactId) {
220
- try {
221
- const c = await client.getContactById(contactId);
222
- return normalizeContact(c);
223
- } catch {
224
- return null;
225
- }
226
- },
227
-
228
- /**
229
- * Get the profile picture URL of a contact.
230
- * Respects privacy settings — may return null.
231
- * @param {string} contactId
232
- * @returns {Promise<string|null>}
233
- */
234
- async getProfilePicUrl(contactId) {
235
- try {
236
- const c = await client.getContactById(contactId);
237
- return await c.getProfilePicUrl();
238
- } catch {
239
- return null;
240
- }
241
- },
242
-
243
- /**
244
- * Get the "about" text of a contact.
245
- * Returns null if privacy settings block access.
246
- * @param {string} contactId
247
- * @returns {Promise<string|null>}
248
- */
249
- async getAbout(contactId) {
250
- try {
251
- const c = await client.getContactById(contactId);
252
- return await c.getAbout();
253
- } catch {
254
- return null;
255
- }
256
- },
257
- };
258
- }
259
-
260
- // ── Internal media helpers ───────────────────────────────────────────────────
261
-
262
- /**
263
- * @param {string|Buffer} source
264
- * @param {string} mimetype — required, no ambiguous default
265
- */
266
- function mediaFromSource(source, mimetype) {
267
- return typeof source === "string"
268
- ? MessageMedia.fromFilePath(source)
269
- : new MessageMedia(mimetype, source.toString("base64"));
270
- }
271
-
272
- /**
273
- * Returns send methods bound to a target that exposes `.sendMessage()`.
274
- *
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
279
- */
280
- function makeSender(target, extraOpts = {}, chatId = null, chatObj = null, { cooldown = true, jitter = true } = {}) {
281
- return {
282
- async text(content, opts = {}) {
283
- if (chatId) {
284
- await waitForSendSlot(chatId, { cooldown, jitter });
285
- await simulateState(chatObj, typingDuration(content), "typing");
286
- }
287
- return target.sendMessage(content, { ...extraOpts, ...opts });
288
- },
289
-
290
- async image(filePath, caption = "") {
291
- if (chatId) {
292
- await waitForSendSlot(chatId, { cooldown, jitter });
293
- await simulateState(chatObj, mediaDuration(), "typing");
294
- }
295
- const media = MessageMedia.fromFilePath(filePath);
296
- return target.sendMessage(media, { caption, ...extraOpts });
297
- },
298
-
299
- async video(filePath, caption = "") {
300
- if (chatId) {
301
- await waitForSendSlot(chatId, { cooldown, jitter });
302
- await simulateState(chatObj, mediaDuration(), "typing");
303
- }
304
- const media = MessageMedia.fromFilePath(filePath);
305
- return target.sendMessage(media, { caption, ...extraOpts });
306
- },
307
-
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
- }
314
- const media = MessageMedia.fromFilePath(filePath);
315
- return target.sendMessage(media, { sendAudioAsVoice: asVoice, ...extraOpts });
316
- },
317
-
318
- async sticker(source) {
319
- if (chatId) {
320
- await waitForSendSlot(chatId, { cooldown, jitter });
321
- await simulateState(chatObj, mediaDuration(), "typing");
322
- }
323
- const media = mediaFromSource(source, "image/webp");
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
- });
338
- },
339
- };
340
- }
341
-
342
- /** Adapts client.sendMessage(chatId, ...) to the makeSender interface. */
343
- function chatIdTarget(client, chatId) {
344
- return {
345
- sendMessage: (content, opts) => client.sendMessage(chatId, content, opts),
346
- };
347
- }
348
-
349
- // ── Send API ─────────────────────────────────────────────────────────────────
350
-
351
- /**
352
- * Runtime send API — current chat + .to() for other chats.
353
- *
354
- * ctx.send.text("oi")
355
- * ctx.send.image("./foto.jpg", "legenda")
356
- * ctx.send.to("5511@c.us").text("oi")
357
- */
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 });
362
- return {
363
- send: {
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),
370
- /**
371
- * Returns a sender bound to another chat.
372
- * Typing simulation is skipped (no Chat object available without a fetch).
373
- * @param {string} targetChatId
374
- */
375
- to: (targetChatId) =>
376
- makeSender(chatIdTarget(client, targetChatId), {}, targetChatId, null),
377
- },
378
- };
379
- }
380
- /**
381
- * Setup send API — no current chat, only .to().
382
- *
383
- * ctx.send.to(adminChatId).text("bot iniciado")
384
- */
385
- function buildSetupSendApi(client) {
386
- return {
387
- send: {
388
- to: (targetChatId) =>
389
- makeSender(chatIdTarget(client, targetChatId), {}, targetChatId, null),
390
- },
391
- };
392
- }
393
-
394
- // ── Events API (setup only) ───────────────────────────────────────────────────
395
-
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) {
403
- return {
404
- on(event, handler) {
405
- client.on(event, handler);
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
- };
432
- },
433
-
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
- },
464
- };
465
- }
466
-
467
- // ── Base API (shared between setup and runtime) ───────────────────────────────
468
-
469
- function buildBaseApi(client, pluginRegistry, pluginName) {
470
- const botId = client.info?.wid?._serialized ?? null;
471
- if (!botId) logger.warn("[pluginApi] botId is null — client may not be ready yet.");
472
-
473
- return {
474
- log,
475
- t,
476
- config: buildConfigApi(),
477
- i18n: buildI18nApi(),
478
- utils: buildUtilsApi(),
479
- download: buildDownloadApi(),
480
- plugins: buildPluginsApi(pluginRegistry),
481
- contacts: buildContactsApi(client),
482
- storage: buildStorageApi(pluginName),
483
- botId,
484
- };
485
- }
486
-
487
- // ── Setup API ────────────────────────────────────────────────────────────────
488
-
489
- /**
490
- * Setup API — without message context.
491
- * Passed to plugin.setup(ctx) during initialization.
492
- *
493
- * @param {import("whatsapp-web.js").Client} client
494
- * @param {Map<string, any>} pluginRegistry
495
- * @param {string} pluginName
496
- * @returns {object}
497
- */
498
- export function buildSetupApi(client, pluginRegistry, pluginName ) {
499
- return {
500
- ...buildBaseApi(client, pluginRegistry, pluginName),
501
- ...buildSetupSendApi(client),
502
- events: buildEventsApi(client, pluginName),
503
- };
504
- }
505
-
506
- // ── Runtime API ──────────────────────────────────────────────────────────────
507
-
508
- /**
509
- * Runtime API — full context with message and chat.
510
- * Passed to plugin.default(ctx) on every message.
511
- *
512
- * @param {object} params
513
- * @param {import("whatsapp-web.js").Message} params.msg
514
- * @param {import("whatsapp-web.js").Chat} params.chat
515
- * @param {import("whatsapp-web.js").Client} params.client
516
- * @param {Map<string, any>} params.pluginRegistry
517
- * @param {string} params.pluginName
518
- * @returns {object} ctx
519
- */
520
- export function buildApi({ msg, chat, client, pluginRegistry, pluginName, guardOptions = {} }) {
521
- const prefix = CONFIG.CMD_PREFIX;
522
- const rawArgs = msg.body?.trim().split(/\s+/) ?? [];
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;
529
-
530
- return {
531
- ...buildBaseApi(client, pluginRegistry, pluginName),
532
- ...buildSendApi(chat, client),
533
-
534
- // ── msg ──────────────────────────────────────────────────
535
-
536
- msg: {
537
- body: msg.body ?? "",
538
- type: msg.type,
539
- fromMe: msg.fromMe,
540
- sender: msg.author || msg.from,
541
- senderName: msg._data?.notifyName || String(msg.from).replace(/(:\d+)?@.*$/, ""),
542
-
543
- /** Command token without prefix (e.g. "play" for "!play foo"). */
544
- command,
545
-
546
- /** Arguments after the command token. */
547
- args: rawArgs.slice(1),
548
-
549
- /**
550
- * Check if message matches a given command.
551
- * @param {string} cmd
552
- */
553
- is(cmd) {
554
- return this.hasPrefix && command === cmd.toLowerCase();
555
- },
556
-
557
- hasMedia: msg.hasMedia,
558
- isGif: msg._data?.isGif ?? false,
559
-
560
- async downloadMedia() {
561
- const media = await msg.downloadMedia();
562
- if (!media) return null;
563
- return { mimetype: media.mimetype, data: media.data };
564
- },
565
-
566
- hasReply: msg.hasQuotedMsg,
567
-
568
- async getReply() {
569
- if (!msg.hasQuotedMsg) return null;
570
- return msg.getQuotedMessage();
571
- },
572
-
573
- reply: makeSender(chat, { quotedMessageId: msg.id._serialized }, chatId, chat, { cooldown, jitter }),
574
-
575
- async react(emoji) {
576
- return msg.react(emoji);
577
- },
578
-
579
- hasPrefix,
580
-
581
- /**
582
- * Get the sender as a normalized Contact object.
583
- * Same shape as ctx.contacts.get().
584
- * @returns {Promise<object|null>}
585
- */
586
- async getContact() {
587
- try {
588
- const c = await msg.getContact();
589
- return normalizeContact(c);
590
- } catch {
591
- return null;
592
- }
593
- },
594
- },
595
-
596
- // ── chat ─────────────────────────────────────────────────
597
-
598
- chat: {
599
- id: chatId,
600
- name: chat.name || chat.id.user,
601
- isGroup: /@g\.us$/.test(chatId),
602
-
603
- /**
604
- * List of group participants.
605
- * Returns [] for non-group chats.
606
- * @returns {Promise<Array<{ id: string, isAdmin: boolean, isSuperAdmin: boolean }>>}
607
- */
608
- async getParticipants() {
609
- if (!chat.participants) return [];
610
- return chat.participants.map((p) => ({
611
- id: p.id._serialized,
612
- isAdmin: p.isAdmin,
613
- isSuperAdmin: p.isSuperAdmin,
614
- }));
615
- },
616
-
617
- /**
618
- * Check if a contact is an admin of this group.
619
- * Always returns false for non-group chats.
620
- * @param {string} contactId
621
- * @returns {Promise<boolean>}
622
- */
623
- async isAdmin(contactId) {
624
- return chat.participants?.some(
625
- (p) => p.id._serialized === contactId && (p.isAdmin || p.isSuperAdmin)
626
- ) ?? false;
627
- },
628
- },
629
- };
630
- }
@@ -1,78 +0,0 @@
1
- /**
2
- * pluginGuard.js
3
- *
4
- * Runs a plugin safely.
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
10
- * - Never crashes the bot
11
- *
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).
21
- */
22
- import { logger } from "#logger";
23
- import { pluginRegistry } from "#kernel/pluginLoader";
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
-
46
- /**
47
- * @param {object} plugin — pluginRegistry entry
48
- * @param {object} context — buildApi ctx
49
- *
50
- * plugin.guardOptions (optional, read from plugin's own export):
51
- * @param {boolean} [plugin.guardOptions.timeout=true]
52
- */
53
- export async function runPlugin(plugin, context) {
54
- if (plugin.status !== "active") return;
55
-
56
- const useTimeout = plugin.guardOptions?.timeout !== false;
57
-
58
- try {
59
- const run = plugin.run(context);
60
- await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
61
- } catch (err) {
62
- plugin.status = "error";
63
- plugin.error = err;
64
- pluginRegistry.set(plugin.name, plugin);
65
-
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
- }
77
- }
78
- }