@manybot/manybot 5.8.0 → 5.9.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 (45) hide show
  1. package/README.md +15 -7
  2. package/dist/client/store.js +35 -1
  3. package/dist/download/queue.js +13 -4
  4. package/dist/drivers/baileys/adapter.js +75 -8
  5. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  6. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  7. package/dist/drivers/baileys/api/index.js +245 -51
  8. package/dist/drivers/baileys/index.js +30 -6
  9. package/dist/drivers/baileys/messageHandler.js +207 -21
  10. package/dist/drivers/baileys/messageHandler.test.js +256 -14
  11. package/dist/drivers/baileysAdapter.test.js +97 -0
  12. package/dist/drivers/jid.js +26 -0
  13. package/dist/drivers/jid.test.js +35 -1
  14. package/dist/i18n/index.js +5 -22
  15. package/dist/kernel/chatOverrides.js +46 -0
  16. package/dist/kernel/chatOverrides.test.js +59 -0
  17. package/dist/kernel/commandAccess.test.js +2 -2
  18. package/dist/kernel/commandDeprecation.js +4 -2
  19. package/dist/kernel/commandDeprecation.test.js +8 -1
  20. package/dist/kernel/commandMenu.js +91 -2
  21. package/dist/kernel/commandMenu.test.js +131 -2
  22. package/dist/kernel/commandPermissions.js +69 -23
  23. package/dist/kernel/commandPermissions.test.js +77 -9
  24. package/dist/kernel/commandRegistry.js +167 -43
  25. package/dist/kernel/commandRegistry.test.js +4 -2
  26. package/dist/kernel/commandsConfig.js +470 -38
  27. package/dist/kernel/commandsConfig.test.js +249 -3
  28. package/dist/kernel/contactAutoSave.js +6 -6
  29. package/dist/kernel/coreCommands.js +62 -0
  30. package/dist/kernel/pluginApi.test.js +20 -3
  31. package/dist/kernel/pluginGuard.js +5 -3
  32. package/dist/kernel/pluginLoader.js +73 -10
  33. package/dist/kernel/pluginLoader.test.js +111 -1
  34. package/dist/kernel/runCommand.js +57 -18
  35. package/dist/kernel/runCommand.test.js +269 -7
  36. package/dist/kernel/settingsDb.js +15 -2
  37. package/dist/kernel/testConfig.js +9 -0
  38. package/dist/locales/en.json +14 -1
  39. package/dist/locales/es.json +17 -4
  40. package/dist/locales/pt.json +18 -5
  41. package/dist/plugins/__manybot_integration__/index.js +33 -16
  42. package/dist/plugins/__manybot_integration__/index.test.js +42 -8
  43. package/dist/utils/phoneNumber.js +83 -0
  44. package/dist/utils/phoneNumber.test.js +53 -0
  45. package/package.json +4 -3
@@ -32,6 +32,7 @@ describe("kernel/commandsConfig", () => {
32
32
  assert.ok(config);
33
33
  assert.equal(config.defaults.notifyChanges, true);
34
34
  assert.equal(config.defaults.notifyPeriodDays, 7);
35
+ assert.equal(config.menu.enabled, false);
35
36
  assert.deepEqual(config.menu.aliases, ["help", "man", "menu", "bot", "?"]);
36
37
  assert.deepEqual(config.specs, []);
37
38
  });
@@ -109,16 +110,36 @@ missingCmd:
109
110
  cooldownSeconds: 4,
110
111
  whitelist: { groups: ["group@g.us"], users: ["user@c.us"] },
111
112
  blacklist: undefined,
113
+ dono: undefined,
114
+ allowedChats: undefined,
115
+ groupOnly: undefined,
116
+ dmOnly: undefined,
117
+ whitelistGroups: undefined,
118
+ blacklistUsers: undefined,
119
+ hiddenOutsideScope: undefined,
112
120
  },
113
- messages: { botNotAdmin: undefined, senderNotAdmin: undefined, ownerOnly: undefined, wrongScope: undefined, cooldown: "Wait" },
121
+ messages: {
122
+ botNotAdmin: undefined,
123
+ senderNotAdmin: undefined,
124
+ ownerOnly: undefined,
125
+ donoOnly: undefined,
126
+ wrongScope: undefined,
127
+ cooldown: "Wait",
128
+ blacklist: undefined,
129
+ allowedChats: undefined,
130
+ },
131
+ loading: null,
114
132
  });
115
133
  assert.deepEqual(config.menu, {
134
+ enabled: true,
116
135
  title: { en: "Commands", pt: "Comandos" },
117
136
  intro: "Intro",
118
137
  footer: "Footer",
119
138
  cmd: "menu",
120
139
  aliases: ["help", "?"],
121
140
  notFoundFallback: true,
141
+ suggestSimilar: false,
142
+ suggestMaxDistance: 2,
122
143
  welcomeMessage: null,
123
144
  welcomeWindowDays: 3,
124
145
  pageSize: 15,
@@ -134,7 +155,8 @@ missingCmd:
134
155
  cmd: "hello",
135
156
  aliases: ["hi", "oi"],
136
157
  plugin: "sample",
137
- function: "greet",
158
+ functions: ["greet"],
159
+ loading: null,
138
160
  text: "Reply from file",
139
161
  desc: { en: "Say hello", pt: "Diga oi" },
140
162
  category: "fun",
@@ -150,8 +172,24 @@ missingCmd:
150
172
  cooldownSeconds: 0,
151
173
  whitelist: undefined,
152
174
  blacklist: { groups: undefined, users: ["blocked@c.us"] },
175
+ dono: undefined,
176
+ allowedChats: undefined,
177
+ groupOnly: undefined,
178
+ dmOnly: undefined,
179
+ whitelistGroups: undefined,
180
+ blacklistUsers: undefined,
181
+ hiddenOutsideScope: undefined,
182
+ },
183
+ messages: {
184
+ botNotAdmin: undefined,
185
+ senderNotAdmin: undefined,
186
+ ownerOnly: "Owners only",
187
+ donoOnly: undefined,
188
+ wrongScope: undefined,
189
+ cooldown: undefined,
190
+ blacklist: undefined,
191
+ allowedChats: undefined,
153
192
  },
154
- messages: { botNotAdmin: undefined, senderNotAdmin: undefined, ownerOnly: "Owners only", wrongScope: undefined, cooldown: undefined },
155
193
  arguments: [],
156
194
  subcommands: [],
157
195
  });
@@ -233,4 +271,212 @@ hello:
233
271
  assert.equal(config.specs.length, 1);
234
272
  assert.equal(config.specs[0].id, "hello");
235
273
  });
274
+ // ── loading: snake_case flat-form props (reference yaml uses these) ──────
275
+ describe("loading: snake_case flat-form props", () => {
276
+ test("loading_presets accepts on_success/on_error (reaction) and interval_ms (spinner) — reference yaml's exact shape", async () => {
277
+ await fs.writeFile(commandsFile, `
278
+ loading_presets:
279
+ padrao:
280
+ type: reaction
281
+ icon: "⏳"
282
+ on_success: "✅"
283
+ on_error: "❌"
284
+ spinner_classico:
285
+ type: spinner
286
+ frames: ["⠋", "⠙"]
287
+ interval_ms: 1000
288
+ on_success: "✅ Pronto!"
289
+ on_error: "Erro: {erro}"
290
+ loading: padrao
291
+ `, "utf8");
292
+ const config = await loadCommandsConfig();
293
+ assert.ok(config);
294
+ assert.deepEqual(config.loadingPresets.padrao, {
295
+ type: "reaction",
296
+ icon: "⏳",
297
+ onSuccess: "✅",
298
+ onError: "❌",
299
+ });
300
+ assert.deepEqual(config.loadingPresets.spinner_classico, {
301
+ type: "spinner",
302
+ frames: ["⠋", "⠙"],
303
+ intervalMs: 1000,
304
+ onSuccess: "✅ Pronto!",
305
+ onError: "Erro: {erro}",
306
+ });
307
+ });
308
+ test("camelCase and snake_case forms are equivalent, not additive (last one parsed wins, neither is required)", async () => {
309
+ await fs.writeFile(commandsFile, `
310
+ loading_presets:
311
+ onlyCamel:
312
+ type: reaction
313
+ onSuccess: "camel"
314
+ onlySnake:
315
+ type: reaction
316
+ on_success: "snake"
317
+ `, "utf8");
318
+ const config = await loadCommandsConfig();
319
+ assert.ok(config);
320
+ assert.equal(config.loadingPresets.onlyCamel.onSuccess, "camel");
321
+ assert.equal(config.loadingPresets.onlySnake.onSuccess, "snake");
322
+ });
323
+ test("an actually-unknown property for the declared type is still fatal (malformed config)", async () => {
324
+ await fs.writeFile(commandsFile, `
325
+ loading_presets:
326
+ bad:
327
+ type: reaction
328
+ frames: ["not", "valid", "for", "reaction"]
329
+ `, "utf8");
330
+ const config = await loadCommandsConfig();
331
+ assert.ok(config);
332
+ assert.equal(config.loadingPresets.bad, undefined, "malformed preset is dropped, not silently accepted");
333
+ });
334
+ });
335
+ // ── top-level `loading:` global default overlays onto defaults.loading ──
336
+ test("top-level loading: <preset-name> overlays onto defaults.loading, same as notify_*/permission_messages", async () => {
337
+ await fs.writeFile(commandsFile, `
338
+ loading_presets:
339
+ padrao:
340
+ type: reaction
341
+ icon: "⏳"
342
+ loading: padrao
343
+ `, "utf8");
344
+ const config = await loadCommandsConfig();
345
+ assert.ok(config);
346
+ assert.deepEqual(config.defaults.loading, { type: "reaction", icon: "⏳" });
347
+ });
348
+ test("top-level loading: accepts an inline spec, not just a preset name", async () => {
349
+ await fs.writeFile(commandsFile, `
350
+ loading:
351
+ type: typing
352
+ `, "utf8");
353
+ const config = await loadCommandsConfig();
354
+ assert.ok(config);
355
+ assert.deepEqual(config.defaults.loading, { type: "typing" });
356
+ });
357
+ test("top-level loading: wins over defaults.loading when both are present (overlay semantics)", async () => {
358
+ await fs.writeFile(commandsFile, `
359
+ defaults:
360
+ loading:
361
+ type: typing
362
+ loading:
363
+ type: none
364
+ `, "utf8");
365
+ const config = await loadCommandsConfig();
366
+ assert.ok(config);
367
+ assert.deepEqual(config.defaults.loading, { type: "none" });
368
+ });
369
+ // ── plugin: registry-key normalization ───────────────────────────────
370
+ // The caller passes the active pluginRegistry's keys via
371
+ // loadCommandsConfig({ validPluginKeys }). parseEntry uses them to
372
+ // resolve shorthand `plugin: <name>` entries to the canonical
373
+ // `owner/repo` key (and to leave fully-qualified entries alone).
374
+ describe("plugin: registry-key normalization", () => {
375
+ test("keeps an exact owner/repo key verbatim", async () => {
376
+ await fs.writeFile(commandsFile, `
377
+ hello:
378
+ cmd: hello
379
+ plugin: synt-xerror/welcome
380
+ functions: [greet]
381
+ `, "utf8");
382
+ const config = await loadCommandsConfig(new Set(["synt-xerror/welcome", "de/welcome-test"]));
383
+ assert.ok(config);
384
+ assert.equal(config.specs.length, 1);
385
+ assert.equal(config.specs[0].plugin, "synt-xerror/welcome");
386
+ assert.deepEqual(config.specs[0].functions, ["greet"]);
387
+ });
388
+ test("resolves a bare name to the unique matching owner/repo key", async () => {
389
+ await fs.writeFile(commandsFile, `
390
+ hello:
391
+ cmd: hello
392
+ plugin: welcome
393
+ functions: [greet]
394
+ `, "utf8");
395
+ const config = await loadCommandsConfig(new Set(["synt-xerror/welcome", "de/welcome-test"]));
396
+ assert.ok(config);
397
+ assert.equal(config.specs[0].plugin, "synt-xerror/welcome");
398
+ });
399
+ test("splits the inline owner/repo.fn form before normalization", async () => {
400
+ await fs.writeFile(commandsFile, `
401
+ hello:
402
+ cmd: hello
403
+ plugin: synt-xerror/welcome.ping
404
+ `, "utf8");
405
+ const config = await loadCommandsConfig(new Set(["synt-xerror/welcome", "de/welcome-test"]));
406
+ assert.ok(config);
407
+ assert.equal(config.specs[0].plugin, "synt-xerror/welcome");
408
+ assert.deepEqual(config.specs[0].functions, ["ping"]);
409
+ });
410
+ test("splits the inline bare-name.fn form before normalization", async () => {
411
+ await fs.writeFile(commandsFile, `
412
+ hello:
413
+ cmd: hello
414
+ plugin: welcome.ping
415
+ `, "utf8");
416
+ const config = await loadCommandsConfig(new Set(["synt-xerror/welcome"]));
417
+ assert.ok(config);
418
+ assert.equal(config.specs[0].plugin, "synt-xerror/welcome");
419
+ assert.deepEqual(config.specs[0].functions, ["ping"]);
420
+ });
421
+ test("splits core.fn items in functions lists", async () => {
422
+ await fs.writeFile(commandsFile, `
423
+ hello:
424
+ cmd: hello
425
+ functions: [core.greet, core.reply]
426
+ `, "utf8");
427
+ const config = await loadCommandsConfig(new Set(["core"]));
428
+ assert.ok(config);
429
+ assert.equal(config.specs[0].plugin, "core");
430
+ assert.deepEqual(config.specs[0].functions, ["greet", "reply"]);
431
+ });
432
+ test("splits canonical owner/plugin.fn items in subcommands", async () => {
433
+ await fs.writeFile(commandsFile, `
434
+ figurinha:
435
+ cmd: f
436
+ subcommands:
437
+ - cmd: criar
438
+ functions: [synt-xerror/figurinha.validarMidia, synt-xerror/figurinha.criarFigurinha]
439
+ `, "utf8");
440
+ const config = await loadCommandsConfig(new Set(["synt-xerror/figurinha"]));
441
+ assert.ok(config);
442
+ assert.equal(config.specs[0].plugin, "synt-xerror/figurinha");
443
+ assert.deepEqual(config.specs[0].subcommands[0].functions, [
444
+ "validarMidia",
445
+ "criarFigurinha",
446
+ ]);
447
+ });
448
+ test("keeps an unknown bare name verbatim (caller surfaces the miss)", async () => {
449
+ await fs.writeFile(commandsFile, `
450
+ hello:
451
+ cmd: hello
452
+ plugin: unknown-plugin
453
+ functions: [greet]
454
+ `, "utf8");
455
+ const config = await loadCommandsConfig(new Set(["synt-xerror/welcome", "de/welcome-test"]));
456
+ assert.ok(config);
457
+ assert.equal(config.specs[0].plugin, "unknown-plugin");
458
+ });
459
+ test("keeps a name verbatim when more than one owner matches (ambiguity surfaces at dispatch)", async () => {
460
+ await fs.writeFile(commandsFile, `
461
+ hello:
462
+ cmd: hello
463
+ plugin: shared
464
+ functions: [greet]
465
+ `, "utf8");
466
+ const config = await loadCommandsConfig(new Set(["alice/shared", "bob/shared"]));
467
+ assert.ok(config);
468
+ assert.equal(config.specs[0].plugin, "shared");
469
+ });
470
+ test("without validPluginKeys the value is used verbatim (legacy path)", async () => {
471
+ await fs.writeFile(commandsFile, `
472
+ hello:
473
+ cmd: hello
474
+ plugin: welcome
475
+ functions: [greet]
476
+ `, "utf8");
477
+ const config = await loadCommandsConfig();
478
+ assert.ok(config);
479
+ assert.equal(config.specs[0].plugin, "welcome");
480
+ });
481
+ });
236
482
  });
@@ -75,10 +75,10 @@ function randomInt(min, max) {
75
75
  * failure would be mistaken for a real save and never retried.
76
76
  */
77
77
  async function addContact(contract, jid, name) {
78
- // `jid` here is this framework's internal "@c.us" form (see
79
- // getMsgSender()/normalizeJid()) Baileys needs the real wire JID or
80
- // it silently no-ops instead of throwing, which is exactly how this
81
- // went unnoticed before.
78
+ // `jid` here is LID-canonical (see getMsgSender()) Baileys needs the
79
+ // real wire JID or it silently no-ops instead of throwing, which is
80
+ // exactly how this went unnoticed before. toWireJid() passes @lid
81
+ // through unchanged, so no extra handling is needed here.
82
82
  const wireJid = toWireJid(jid);
83
83
  try {
84
84
  await contract.addOrEditContact(wireJid, {
@@ -101,7 +101,7 @@ async function addContact(contract, jid, name) {
101
101
  *
102
102
  * @param {WaContract} contract
103
103
  * @param {BotMessage} msg — driver-neutral incoming message envelope
104
- * @param {string} senderJid — normalized sender JID (never a group JID)
104
+ * @param {string|null} senderJid — normalized sender JID (LID-canonical; never a group JID), or null when no LID is known yet for this contact — those messages are skipped, since there's no stable id to track progress against
105
105
  * @param {boolean} isGroup — whether this message came from a group chat
106
106
  * @param {boolean} triggeredBot — true if this message invoked the bot
107
107
  * (command prefix). Ignored outside groups.
@@ -109,7 +109,7 @@ async function addContact(contract, jid, name) {
109
109
  export async function trackIncomingForContactSave(contract, msg, senderJid, isGroup, triggeredBot) {
110
110
  try {
111
111
  const pushName = msg.pushName?.trim();
112
- if (!pushName || senderJid.endsWith("@g.us"))
112
+ if (!pushName || !senderJid || senderJid.endsWith("@g.us"))
113
113
  return;
114
114
  let s = getSenderState(senderJid);
115
115
  // Completing a refresh takes priority over new-save logic, but in
@@ -0,0 +1,62 @@
1
+ /**
2
+ * coreCommands.ts
3
+ *
4
+ * Built-in command handlers live in the kernel namespace, independently of
5
+ * the external plugin registry and manyplug configuration.
6
+ */
7
+ // Mirrors src/locales/*.json — the only languages with a translation file.
8
+ const AVAILABLE_LOCALES = ["pt", "en", "es"];
9
+ const handlers = {
10
+ ping: async (ctx) => {
11
+ await ctx.send.text("pong");
12
+ },
13
+ status: async (ctx) => {
14
+ await ctx.send.text("ManyBot está online.");
15
+ },
16
+ // `!configurar` / `!config` / `!cfg` (no subcommand) — shows the
17
+ // current per-chat overrides. Persisted via ctx.settings, which
18
+ // (per pluginApi.ts) is already scoped to plugin "core" + the
19
+ // current chat — same storage pattern already used for
20
+ // last_welcome_seen in commandMenu.ts.
21
+ setChatConfig: async (ctx) => {
22
+ const pctx = ctx;
23
+ const prefix = pctx.settings.get("chat_prefix", null);
24
+ const locale = pctx.settings.get("chat_locale", null);
25
+ await pctx.send.text("⚙️ Configuração deste chat:\n" +
26
+ `• Prefixo: ${prefix ?? "(padrão)"}\n` +
27
+ `• Idioma: ${locale ?? "(padrão)"}\n\n` +
28
+ "Use !config prefixo <novo> ou !config idioma <pt|en|es> para alterar.");
29
+ },
30
+ // `!config prefixo <novo>`
31
+ setChatPrefix: async (ctx, input) => {
32
+ const pctx = ctx;
33
+ const value = input?.args?.[0];
34
+ if (!value || value.length > 5) {
35
+ await pctx.send.text("Uso: !config prefixo <novo prefixo> (até 5 caracteres)");
36
+ return;
37
+ }
38
+ pctx.settings.set("chat_prefix", value);
39
+ await pctx.send.text(`✅ Prefixo salvo como "${value}" para este chat.\n` +
40
+ `A partir de agora, use "${value}" em vez do prefixo padrão neste chat.`);
41
+ },
42
+ // `!config idioma <pt|en|es>`
43
+ setChatLocale: async (ctx, input) => {
44
+ const pctx = ctx;
45
+ const value = input?.args?.[0]?.toLowerCase();
46
+ if (!value || !AVAILABLE_LOCALES.includes(value)) {
47
+ await pctx.send.text(`Uso: !config idioma <${AVAILABLE_LOCALES.join("|")}>`);
48
+ return;
49
+ }
50
+ pctx.settings.set("chat_locale", value);
51
+ await pctx.send.text(`✅ Idioma salvo como "${value}" para este chat.\n` +
52
+ "As mensagens do sistema de comandos (menu, permissões, avisos de uso) passam a usar esse idioma neste chat.");
53
+ },
54
+ };
55
+ export function resolveCoreCommandHandler(name) {
56
+ return handlers[name] ?? (async () => {
57
+ throw new Error(`Core handler "${name}" is not registered`);
58
+ });
59
+ }
60
+ export function registerCoreCommand(name, handler) {
61
+ handlers[name] = handler;
62
+ }
@@ -267,7 +267,7 @@ describe("kernel/pluginApi — buildSetupApi with Mock WaContract", () => {
267
267
  assert.ok(ctx.chats);
268
268
  assert.ok(ctx.contacts);
269
269
  assert.ok(ctx.storage);
270
- assert.equal(ctx.botId, "5516999999999@s.whatsapp.net");
270
+ assert.equal(ctx.botId, "99999@lid");
271
271
  // Setup send only has .to()
272
272
  assert.ok(ctx.send.to);
273
273
  assert.equal(typeof ctx.send.to, "function");
@@ -371,13 +371,25 @@ describe("kernel/pluginApi — buildApi (Runtime) with Mock WaContract", () => {
371
371
  await ctx.admin.kick("5516777777777@s.whatsapp.net");
372
372
  assert.equal(calls.groupParticipantUpdates.length, 2);
373
373
  assert.equal(calls.groupParticipantUpdates[1].action, "remove");
374
+ await assert.rejects(() => ctx.admin.kick("5516999999999@s.whatsapp.net"));
375
+ assert.equal(calls.groupParticipantUpdates.length, 2);
374
376
  const inviteLink = await ctx.admin.getInviteLink();
375
377
  assert.match(inviteLink, /chat\.whatsapp\.com\/mock-invite-code-123/);
376
378
  // Contacts helper
377
379
  const contact = await ctx.contacts.get("5516999999999@s.whatsapp.net");
378
380
  assert.ok(contact);
379
- assert.equal(contact?.number, "5516999999999");
381
+ // normalizeContact now returns E.164 (with leading "+") per the
382
+ // new contract invariant; assert accordingly.
383
+ assert.equal(contact?.number, "+5516999999999");
380
384
  assert.equal(contact?.isMe, true);
385
+ // Looking up the bot by its own LID (ctx.botId) must resolve the
386
+ // same identity — number populated via the proactively-learned
387
+ // LID↔PN mapping, not left null for lack of a resolveLid() hit.
388
+ const selfByLid = await ctx.contacts.get(ctx.botId);
389
+ assert.ok(selfByLid);
390
+ assert.equal(selfByLid?.id, "99999@lid");
391
+ assert.equal(selfByLid?.number, "+5516999999999");
392
+ assert.equal(selfByLid?.isMe, true);
381
393
  await ctx.contacts.block("5516777777777@s.whatsapp.net");
382
394
  assert.equal(calls.blockUpdates.length, 1);
383
395
  assert.equal(calls.blockUpdates[0].action, "block");
@@ -578,6 +590,11 @@ describe("kernel/pluginApi — buildApi (Runtime) with Mock WaContract", () => {
578
590
  // contacts.get() with a raw @lid routes through contract.resolveLid()
579
591
  // (mock: "12345@lid" -> "5516777777777@s.whatsapp.net").
580
592
  const contact = await ctx.contacts.get("12345@lid");
581
- assert.equal(contact?.id, "5516777777777@c.us");
593
+ // normalizeContact now keeps the ID as the LID form ("12345@lid")
594
+ // when known, per the invariant that user IDs are LID-or-null; plugins
595
+ // should treat contact.id as the canonical JID for addressing and
596
+ // contact.number/contact.numberRaw as the dialable string.
597
+ assert.equal(contact?.id, "12345@lid");
598
+ assert.equal(contact?.number, "+5516777777777");
582
599
  });
583
600
  });
@@ -38,18 +38,19 @@ function withTimeout(promise, ms, pluginName) {
38
38
  }
39
39
  export async function runPlugin(plugin, context, handler, input, options) {
40
40
  if (plugin.status !== "active")
41
- return;
41
+ return undefined;
42
42
  const useTimeout = plugin.guardOptions?.timeout !== false;
43
43
  try {
44
44
  if (handler) {
45
45
  const run = handler(context, input);
46
- await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
46
+ return await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
47
47
  }
48
48
  else {
49
49
  if (!plugin.run)
50
- return;
50
+ return undefined;
51
51
  const run = plugin.run(context);
52
52
  await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
53
+ return undefined;
53
54
  }
54
55
  }
55
56
  catch (e) {
@@ -87,5 +88,6 @@ export async function runPlugin(plugin, context, handler, input, options) {
87
88
  });
88
89
  });
89
90
  }
91
+ return undefined;
90
92
  }
91
93
  }
@@ -6,7 +6,7 @@
6
6
  * 2. Loading each plugin from ~/.manybot/plugins folder
7
7
  * 3. Registering in pluginRegistry with status and public exports
8
8
  * 4. Exposing pluginRegistry to kernel and pluginApi
9
- * 5. Watching plugin files and config file for hot reloading
9
+ * 5. Watching plugin files, config files and commands.yaml for hot reloading
10
10
  */
11
11
  import fs from "fs";
12
12
  import path from "path";
@@ -33,6 +33,23 @@ const PLUGINS_DIR = path.join(PATHS.HOME, "plugins");
33
33
  export const pluginRegistry = new Map();
34
34
  let globalContract = null;
35
35
  let globalStore = null;
36
+ /**
37
+ * Live WaContract + BotStore the kernel most-recently wired through
38
+ * `setupPlugins()`. Exposed for the integration-test harness only
39
+ * (see `src/plugins/__manybot_integration__/` and the
40
+ * `*.integration.test.ts` suite under the project root): production
41
+ * code paths go through `ctx.wa.contract` / `ctx.store`, not through
42
+ * this module-scoped singleton.
43
+ *
44
+ * Returns `null` when the bot hasn't connected yet (or after a
45
+ * disconnect). The integration suite waits for a non-null value
46
+ * before issuing any real round-trip.
47
+ */
48
+ export function getGlobalKernelRefs() {
49
+ if (!globalContract || !globalStore)
50
+ return null;
51
+ return { contract: globalContract, store: globalStore };
52
+ }
36
53
  const pluginWatchers = new Map();
37
54
  // fs.watch's `recursive: true` emulates recursion on Linux by opening one
38
55
  // inotify watch per subdirectory — a plugin shipping its own node_modules
@@ -335,22 +352,68 @@ export function unwatchPlugin(name) {
335
352
  }
336
353
  }
337
354
  /**
338
- * Watch the config directory for manyplug.toml or manybot.toml changes.
355
+ * Reload the command registry from disk (commands.yaml + imports).
356
+ *
357
+ * Mirrors `reloadPlugin()`: callable on its own and safe to invoke from
358
+ * a watcher. Unlike `reloadPlugin()` there's no setup retry / disable
359
+ * state to track — `initCommandRegistry()` either builds a fresh
360
+ * `CommandRegistry` from the current file content or leaves the
361
+ * previous one in place via `commandsConfig.ts`'s own error handling
362
+ * (a malformed YAML logs an error and returns null; `initCommandRegistry`
363
+ * then falls back to the empty defaults).
364
+ */
365
+ export async function reloadCommandRegistry() {
366
+ try {
367
+ await initCommandRegistry();
368
+ logger.info(`[watcher] Command registry reloaded from commands.yaml.`);
369
+ }
370
+ catch (e) {
371
+ const err = e instanceof Error ? e : new Error(String(e));
372
+ logger.error(`[watcher] Failed to reload command registry: ${err.message}`);
373
+ }
374
+ }
375
+ /**
376
+ * Watch the config directory for changes to manyplug.toml / manybot.toml
377
+ * (plugin list sync) or to commands.yaml and its .yaml/.yml imports
378
+ * (command registry reload). Both paths share the same 500ms debounce
379
+ * so a save that emits several inotify events only fires once.
339
380
  */
340
381
  function startConfigWatcher() {
341
382
  if (configWatcher)
342
383
  return;
384
+ const isTomlChange = (filename) => filename === "manyplug.toml" || filename === "manybot.toml";
385
+ const isYamlChange = (filename) => {
386
+ if (!filename)
387
+ return false;
388
+ const lower = filename.toLowerCase();
389
+ return lower.endsWith(".yaml") || lower.endsWith(".yml");
390
+ };
343
391
  try {
344
392
  let configTimeout = null;
393
+ let yamlTimeout = null;
394
+ const scheduleTomlReload = (filename) => {
395
+ if (!isTomlChange(filename))
396
+ return;
397
+ if (configTimeout)
398
+ clearTimeout(configTimeout);
399
+ configTimeout = setTimeout(async () => {
400
+ logger.info(`[watcher] Config file change detected: ${filename}. Syncing plugins...`);
401
+ await syncPlugins();
402
+ }, 500);
403
+ };
404
+ const scheduleYamlReload = (filename) => {
405
+ if (!isYamlChange(filename))
406
+ return;
407
+ if (yamlTimeout)
408
+ clearTimeout(yamlTimeout);
409
+ yamlTimeout = setTimeout(async () => {
410
+ logger.info(`[watcher] commands.yaml change detected (${filename}). Reloading command registry...`);
411
+ await reloadCommandRegistry();
412
+ }, 500);
413
+ };
345
414
  configWatcher = fs.watch(PATHS.HOME, (eventType, filename) => {
346
- if (filename === "manyplug.toml" || filename === "manybot.toml") {
347
- if (configTimeout)
348
- clearTimeout(configTimeout);
349
- configTimeout = setTimeout(async () => {
350
- logger.info(`[watcher] Config file change detected: ${filename}. Syncing plugins...`);
351
- await syncPlugins();
352
- }, 500);
353
- }
415
+ scheduleTomlReload(filename);
416
+ scheduleYamlReload(filename);
354
417
  });
355
418
  }
356
419
  catch (e) {