@manybot/manybot 5.6.1 → 5.8.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.
Files changed (71) hide show
  1. package/README.md +20 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +56 -5
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/drivers/baileys/adapter.js +207 -8
  9. package/dist/drivers/baileys/api/index.js +300 -130
  10. package/dist/drivers/baileys/index.js +62 -30
  11. package/dist/drivers/baileys/loginPrompt.js +0 -2
  12. package/dist/drivers/baileys/messageHandler.js +158 -4
  13. package/dist/drivers/baileys/messageHandler.test.js +203 -0
  14. package/dist/drivers/baileysAdapter.test.js +281 -0
  15. package/dist/drivers/jid.test.js +40 -0
  16. package/dist/drivers/types.js +5 -5
  17. package/dist/i18n/index.js +15 -2
  18. package/dist/kernel/activeDriverSend.js +21 -0
  19. package/dist/kernel/activeDriverSend.test.js +89 -0
  20. package/dist/kernel/alerts.js +3 -9
  21. package/dist/kernel/chatSession.js +65 -0
  22. package/dist/kernel/chatSession.test.js +46 -0
  23. package/dist/kernel/commandAccess.js +66 -0
  24. package/dist/kernel/commandAccess.test.js +74 -0
  25. package/dist/kernel/commandDeprecation.js +168 -0
  26. package/dist/kernel/commandDeprecation.test.js +107 -0
  27. package/dist/kernel/commandMenu.js +268 -0
  28. package/dist/kernel/commandMenu.test.js +234 -0
  29. package/dist/kernel/commandPermissions.js +125 -0
  30. package/dist/kernel/commandPermissions.test.js +159 -0
  31. package/dist/kernel/commandRegistry.js +459 -0
  32. package/dist/kernel/commandRegistry.test.js +156 -0
  33. package/dist/kernel/commandsConfig.js +517 -0
  34. package/dist/kernel/commandsConfig.test.js +236 -0
  35. package/dist/kernel/contactAutoSave.test.js +87 -0
  36. package/dist/kernel/driverManager.js +10 -6
  37. package/dist/kernel/driverManager.test.js +90 -0
  38. package/dist/kernel/integrationMode.js +88 -0
  39. package/dist/kernel/integrationMode.test.js +95 -0
  40. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  41. package/dist/kernel/pluginApi.test.js +583 -0
  42. package/dist/kernel/pluginGuard.js +15 -12
  43. package/dist/kernel/pluginGuard.test.js +39 -0
  44. package/dist/kernel/pluginLoader.js +96 -1
  45. package/dist/kernel/pluginLoader.test.js +80 -0
  46. package/dist/kernel/runCommand.js +245 -0
  47. package/dist/kernel/runCommand.test.js +235 -0
  48. package/dist/kernel/sendFallbackGuard.js +19 -48
  49. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  50. package/dist/kernel/sendGuard.js +38 -42
  51. package/dist/kernel/sendGuard.test.js +102 -0
  52. package/dist/kernel/settingsDb.js +4 -3
  53. package/dist/kernel/statusServer.js +9 -2
  54. package/dist/kernel/statusServer.test.js +70 -0
  55. package/dist/kernel/testConfig.js +183 -0
  56. package/dist/kernel/testConfig.test.js +181 -0
  57. package/dist/kernel/updateCheck.js +33 -10
  58. package/dist/locales/en.json +64 -13
  59. package/dist/locales/es.json +64 -13
  60. package/dist/locales/pt.json +64 -13
  61. package/dist/logger/logger.js +23 -3
  62. package/dist/logger/logger.test.js +45 -0
  63. package/dist/main.js +5 -76
  64. package/dist/plugins/__manybot_integration__/index.js +167 -0
  65. package/dist/plugins/__manybot_integration__/index.test.js +184 -0
  66. package/package.json +75 -18
  67. package/dist/drivers/whatsmeow/client.js +0 -252
  68. package/dist/drivers/whatsmeow/index.js +0 -79
  69. package/dist/drivers/whatsmeow/installer.js +0 -86
  70. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  71. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
@@ -36,22 +36,21 @@ function withTimeout(promise, ms, pluginName) {
36
36
  });
37
37
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
38
38
  }
39
- /**
40
- * @param {object} plugin — pluginRegistry entry
41
- * @param {object} context — buildApi ctx
42
- *
43
- * plugin.guardOptions (optional, read from plugin's own export):
44
- * @param {boolean} [plugin.guardOptions.timeout=true]
45
- */
46
- export async function runPlugin(plugin, context) {
39
+ export async function runPlugin(plugin, context, handler, input, options) {
47
40
  if (plugin.status !== "active")
48
41
  return;
49
42
  const useTimeout = plugin.guardOptions?.timeout !== false;
50
43
  try {
51
- if (!plugin.run)
52
- return;
53
- const run = plugin.run(context);
54
- await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
44
+ if (handler) {
45
+ const run = handler(context, input);
46
+ await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
47
+ }
48
+ else {
49
+ if (!plugin.run)
50
+ return;
51
+ const run = plugin.run(context);
52
+ await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
53
+ }
55
54
  }
56
55
  catch (e) {
57
56
  const error = e instanceof Error ? e : new Error(String(e));
@@ -68,6 +67,8 @@ export async function runPlugin(plugin, context) {
68
67
  const frame = error.stack?.split("\n")[1]?.trim() ?? "(no stack)";
69
68
  logger.error(` at : ${frame}`);
70
69
  }
70
+ if (options?.rethrow)
71
+ throw error;
71
72
  }
72
73
  else {
73
74
  pluginRegistry.set(plugin.name, plugin);
@@ -77,6 +78,8 @@ export async function runPlugin(plugin, context) {
77
78
  const frame = error.stack?.split("\n")[1]?.trim() ?? "(no stack)";
78
79
  logger.warn(` at : ${frame}`);
79
80
  }
81
+ if (options?.rethrow)
82
+ throw error;
80
83
  // Reload the plugin dynamically to avoid circular dependency
81
84
  import("#kernel/pluginLoader.js").then(({ reloadPlugin }) => {
82
85
  reloadPlugin(plugin.name).catch(err => {
@@ -0,0 +1,39 @@
1
+ import test, { describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { runPlugin } from "#kernel/pluginGuard.js";
4
+ describe("kernel/pluginGuard", () => {
5
+ test("runs handler successfully without modifying plugin error status", async () => {
6
+ const plugin = {
7
+ name: "safePlugin",
8
+ status: "active",
9
+ manifest: { name: "safePlugin", version: "1.0.0" },
10
+ };
11
+ let executed = false;
12
+ const handler = async () => {
13
+ executed = true;
14
+ };
15
+ await runPlugin(plugin, {}, handler);
16
+ assert.equal(executed, true);
17
+ assert.equal(plugin.status, "active");
18
+ assert.equal(plugin.errorCount ?? 0, 0);
19
+ });
20
+ test("tracks errorCount and disables plugin after 3 failures", async () => {
21
+ const plugin = {
22
+ name: "failingPlugin",
23
+ status: "active",
24
+ manifest: { name: "failingPlugin", version: "1.0.0" },
25
+ };
26
+ const failingHandler = async () => {
27
+ throw new Error("Crash");
28
+ };
29
+ await runPlugin(plugin, {}, failingHandler);
30
+ assert.equal(plugin.errorCount, 1);
31
+ assert.equal(plugin.status, "active");
32
+ await runPlugin(plugin, {}, failingHandler);
33
+ assert.equal(plugin.errorCount, 2);
34
+ assert.equal(plugin.status, "active");
35
+ await runPlugin(plugin, {}, failingHandler);
36
+ assert.equal(plugin.errorCount, 3);
37
+ assert.equal(plugin.status, "error");
38
+ });
39
+ });
@@ -15,6 +15,20 @@ import { t } from "#i18n";
15
15
  import { pathToFileURL } from "url";
16
16
  import { PATHS } from "#config";
17
17
  import { buildSetupApi, cleanupPluginEvents } from "#manyapi";
18
+ import { initCommandRegistry } from "#kernel/commandRegistry.js";
19
+ /**
20
+ * Resolves the actual handler function out of either shape a plugin may
21
+ * export under `commands[fnName]`. Shared by the registry build step
22
+ * (commandRegistry.ts) and the dispatch step (runCommand.ts) so both
23
+ * agree on what counts as "this function is callable".
24
+ */
25
+ export function resolvePluginCommandHandler(def) {
26
+ if (typeof def === "function")
27
+ return def;
28
+ if (def && typeof def === "object" && typeof def.handler === "function")
29
+ return def.handler;
30
+ return null;
31
+ }
18
32
  const PLUGINS_DIR = path.join(PATHS.HOME, "plugins");
19
33
  export const pluginRegistry = new Map();
20
34
  let globalContract = null;
@@ -65,6 +79,7 @@ export async function loadPlugins(activePlugins) {
65
79
  await loadPlugin(name);
66
80
  }
67
81
  startConfigWatcher();
82
+ await initCommandRegistry();
68
83
  const total = pluginRegistry.size;
69
84
  const active = [...pluginRegistry.values()].filter(p => p.status === "active").length;
70
85
  const errors = total - active;
@@ -131,6 +146,7 @@ export async function loadPlugin(name, isReload = false) {
131
146
  status: "disabled",
132
147
  run: null,
133
148
  setup: null,
149
+ commands: null,
134
150
  exports: null,
135
151
  error: null,
136
152
  guardOptions: {},
@@ -146,6 +162,7 @@ export async function loadPlugin(name, isReload = false) {
146
162
  status: "disabled",
147
163
  run: null,
148
164
  setup: null,
165
+ commands: null,
149
166
  exports: null,
150
167
  error: null,
151
168
  guardOptions: {},
@@ -166,12 +183,20 @@ export async function loadPlugin(name, isReload = false) {
166
183
  status: "active",
167
184
  run: mod.default,
168
185
  setup: mod.setup ?? null,
186
+ commands: mod.commands ?? null,
169
187
  exports: mod.api ?? null,
170
188
  error: null,
171
189
  guardOptions: mod.guardOptions ?? {},
172
190
  errorCount: 0,
173
191
  });
174
- logger.info(t(isReload ? "system.pluginReloaded" : "system.pluginLoaded", { name }));
192
+ // Phase 9: a plugin is a library of ready-to-use functions invoked as
193
+ // commands, not something that loads all its logic at boot — a line
194
+ // per plugin no longer earns its place in default startup output.
195
+ // Still available with --debug.
196
+ logger.debug(t(isReload ? "system.pluginReloaded" : "system.pluginLoaded", { name }));
197
+ if (isReload) {
198
+ await initCommandRegistry();
199
+ }
175
200
  watchPluginDirectory(name);
176
201
  }
177
202
  catch (e) {
@@ -183,11 +208,15 @@ export async function loadPlugin(name, isReload = false) {
183
208
  status: newErrorCount >= 3 ? "error" : "active",
184
209
  run: null,
185
210
  setup: null,
211
+ commands: null,
186
212
  exports: null,
187
213
  error: err,
188
214
  guardOptions: {},
189
215
  errorCount: newErrorCount,
190
216
  });
217
+ if (isReload) {
218
+ await initCommandRegistry();
219
+ }
191
220
  }
192
221
  }
193
222
  /**
@@ -266,6 +295,7 @@ export async function syncPlugins() {
266
295
  }
267
296
  }
268
297
  }
298
+ await initCommandRegistry();
269
299
  }
270
300
  /**
271
301
  * Watch a plugin's directory for changes.
@@ -328,6 +358,14 @@ function startConfigWatcher() {
328
358
  logger.warn(`[watcher] Failed to start config directory watcher: ${err.message}`);
329
359
  }
330
360
  }
361
+ /**
362
+ * Tear down everything `loadPlugins()` started — config watcher, per-plugin
363
+ * directory watchers, and each plugin's exported `cleanup()` handler.
364
+ *
365
+ * Used by the process-shutdown path in main.ts and by tests that need a
366
+ * clean registry between cases. Idempotent: safe to call multiple times
367
+ * and safe to call before any plugin has been loaded.
368
+ */
331
369
  export async function cleanupPlugins() {
332
370
  if (configWatcher) {
333
371
  configWatcher.close();
@@ -352,3 +390,60 @@ export async function cleanupPlugins() {
352
390
  }
353
391
  }
354
392
  }
393
+ export async function loadIntegrationPlugin() {
394
+ // Integration mode is opt-in: the bot never loads this plugin in
395
+ // production. The check has to happen before any filesystem work so
396
+ // a forgotten opt-in fails fast with a clear message, not a stack
397
+ // trace from a missing file or a permission error. We only enforce
398
+ // the explicit opt-in flag here — `TEST_CHAT` is consulted by the
399
+ // plugin itself at runtime, not by the loader.
400
+ if (process.env.MANYBOT_RUN_WHATSAPP_TESTS !== "1") {
401
+ throw new Error(`[pluginLoader] cannot load the integration plugin: ` +
402
+ `MANYBOT_RUN_WHATSAPP_TESTS=1 is required to opt in to the integration test harness.`);
403
+ }
404
+ const { INTEGRATION_PLUGIN_NAME, getIntegrationPluginDir } = await import("#kernel/integrationMode.js");
405
+ // Idempotent: if a previous load already registered the integration
406
+ // plugin, hand the same entry back rather than re-importing and
407
+ // duplicating the registry (and the in-process event listeners that
408
+ // would otherwise attach twice).
409
+ const existing = pluginRegistry.get(INTEGRATION_PLUGIN_NAME);
410
+ if (existing)
411
+ return existing;
412
+ const dir = getIntegrationPluginDir();
413
+ const pluginPath = `${dir}/index.ts`;
414
+ try {
415
+ const mod = await import(pathToFileURL(pluginPath).href);
416
+ if (typeof mod.default !== "function") {
417
+ throw new Error(`Integration plugin "${INTEGRATION_PLUGIN_NAME}" does not export a default function`);
418
+ }
419
+ const entry = {
420
+ name: INTEGRATION_PLUGIN_NAME,
421
+ status: "active",
422
+ run: mod.default,
423
+ setup: mod.setup ?? null,
424
+ commands: null,
425
+ exports: mod.api ?? null,
426
+ error: null,
427
+ guardOptions: {},
428
+ errorCount: 0,
429
+ };
430
+ pluginRegistry.set(INTEGRATION_PLUGIN_NAME, entry);
431
+ return entry;
432
+ }
433
+ catch (e) {
434
+ const err = e instanceof Error ? e : new Error(String(e));
435
+ const entry = {
436
+ name: INTEGRATION_PLUGIN_NAME,
437
+ status: "error",
438
+ run: null,
439
+ setup: null,
440
+ commands: null,
441
+ exports: null,
442
+ error: err,
443
+ guardOptions: {},
444
+ errorCount: 1,
445
+ };
446
+ pluginRegistry.set(INTEGRATION_PLUGIN_NAME, entry);
447
+ throw err;
448
+ }
449
+ }
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, beforeEach, describe, test } from "node:test";
3
+ import fs from "fs/promises";
4
+ import os from "os";
5
+ import path from "path";
6
+ const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "manybot-plugin-loader-"));
7
+ process.env.MANYBOT_CONFIG_DIR = configDir;
8
+ const { cleanupPlugins, loadPlugin, pluginRegistry, reloadPlugin } = await import("#kernel/pluginLoader.js");
9
+ const pluginsDir = path.join(configDir, "plugins");
10
+ async function writePlugin(name, manifest, source) {
11
+ const dir = path.join(pluginsDir, name);
12
+ await fs.mkdir(dir, { recursive: true });
13
+ await fs.writeFile(path.join(dir, "manyplug.json"), manifest, "utf8");
14
+ if (source !== undefined)
15
+ await fs.writeFile(path.join(dir, "index.js"), source, "utf8");
16
+ }
17
+ beforeEach(async () => {
18
+ await cleanupPlugins();
19
+ pluginRegistry.clear();
20
+ await fs.rm(pluginsDir, { recursive: true, force: true });
21
+ });
22
+ after(async () => {
23
+ await cleanupPlugins();
24
+ await fs.rm(configDir, { recursive: true, force: true });
25
+ });
26
+ describe("kernel/pluginLoader", () => {
27
+ test("marks a plugin without a manifest as disabled", async () => {
28
+ await loadPlugin("missing");
29
+ assert.deepEqual(pluginRegistry.get("missing"), {
30
+ name: "missing",
31
+ status: "disabled",
32
+ run: null,
33
+ setup: null,
34
+ commands: null,
35
+ exports: null,
36
+ error: null,
37
+ guardOptions: {},
38
+ errorCount: 0,
39
+ });
40
+ });
41
+ test("loads an ESM plugin with optional public exports", async () => {
42
+ await writePlugin("hello", '{"main":"index.js"}', `
43
+ export default async function run() {}
44
+ export async function setup() {}
45
+ export const commands = { greet: { cmd: "greet", handler: async () => "hello" } }
46
+ export const api = { version: 1 }
47
+ export const guardOptions = { retries: 2 }
48
+ `);
49
+ await loadPlugin("hello");
50
+ const plugin = pluginRegistry.get("hello");
51
+ assert.ok(plugin);
52
+ assert.equal(plugin.status, "active");
53
+ assert.equal(typeof plugin.run, "function");
54
+ assert.equal(typeof plugin.setup, "function");
55
+ assert.equal(plugin.commands?.greet?.cmd, "greet");
56
+ assert.deepEqual(plugin.exports, { version: 1 });
57
+ assert.deepEqual(plugin.guardOptions, { retries: 2 });
58
+ assert.equal(plugin.errorCount, 0);
59
+ });
60
+ test("records a load error when a plugin has no default handler", async () => {
61
+ await writePlugin("invalid", "{}", "export const api = {};\n");
62
+ await loadPlugin("invalid");
63
+ const plugin = pluginRegistry.get("invalid");
64
+ assert.ok(plugin);
65
+ assert.equal(plugin.status, "error");
66
+ assert.equal(plugin.run, null);
67
+ assert.match(plugin.error?.message ?? "", /does not export a default function/);
68
+ assert.equal(plugin.errorCount, 3);
69
+ });
70
+ test("reloads an active plugin", async () => {
71
+ await writePlugin("reloadable", "{}", "export default async function run() {}\nexport const api = { version: 1 };\n");
72
+ await loadPlugin("reloadable");
73
+ assert.deepEqual(pluginRegistry.get("reloadable")?.exports, { version: 1 });
74
+ await reloadPlugin("reloadable");
75
+ const plugin = pluginRegistry.get("reloadable");
76
+ assert.equal(plugin?.status, "active");
77
+ assert.deepEqual(plugin?.exports, { version: 1 });
78
+ assert.equal(plugin?.errorCount, 0);
79
+ });
80
+ });
@@ -0,0 +1,245 @@
1
+ /**
2
+ * kernel/runCommand.ts
3
+ *
4
+ * ManyBot v6 — single kernel-side dispatcher for routed prefix
5
+ * commands. Centralising this gives Phase 8 a single try/catch hook
6
+ * (the "natural capture point" called out in MANYBOT-6.md) and keeps
7
+ * the per-command logic out of the message handler so each step
8
+ * (permission → required-arguments → sub-command routing → handler
9
+ * dispatch → crash alert) can be tested independently.
10
+ *
11
+ * Pipeline:
12
+ * 1. Resolve the parent entry by invocation (= cmd or alias).
13
+ * 2. Permission check (owner/scope/blacklist/whitelist/admin/botAdmin/cooldown).
14
+ * 3. Sub-command routing: if the parent has subcommands, look up the
15
+ * next token; if a match exists, dispatch to the sub instead.
16
+ * 4. Required-argument validation against the matched entry's
17
+ * declared `arguments:` block.
18
+ * 5. Handler dispatch via `runPlugin` (same timeout + 3-strikes guard
19
+ * as the message handler uses for the legacy plugin.run path).
20
+ * 6. Crash capture: any throw inside step 5 fires `fireAlert` at
21
+ * phase 8 hook level before re-raising; this is the unique
22
+ * advantage of the unified dispatcher over the legacy
23
+ * `for (plugin of pluginRegistry) await runPlugin(...)` loop.
24
+ */
25
+ import { logger } from "#logger";
26
+ import { t } from "#i18n";
27
+ import { CMD_PREFIX } from "#config";
28
+ import { fireAlert } from "./alerts.js";
29
+ import { runPlugin } from "./pluginGuard.js";
30
+ import { checkPermission } from "./commandPermissions.js";
31
+ import { getCommandRegistry } from "./commandRegistry.js";
32
+ import { pluginRegistry, resolvePluginCommandHandler } from "./pluginLoader.js";
33
+ function flatten(target) {
34
+ if (target.kind === "sub") {
35
+ return {
36
+ kind: "sub",
37
+ entry: target.parent,
38
+ name: `${target.parent.cmd} ${target.sub.cmd}`,
39
+ permissions: target.sub.permissions,
40
+ arguments: target.sub.arguments ?? [],
41
+ args: target.args,
42
+ };
43
+ }
44
+ return {
45
+ kind: "parent",
46
+ entry: target.entry,
47
+ name: target.entry.cmd,
48
+ permissions: target.entry.permissions,
49
+ arguments: target.entry.arguments ?? [],
50
+ args: target.args,
51
+ };
52
+ }
53
+ /**
54
+ * Pure resolution — finds the parent + sub (if any) without invoking.
55
+ * `rawArgs` is the body fragment with the leading `<cmd> ` stripped.
56
+ */
57
+ export function resolveDispatch(command, rawArgs) {
58
+ const registry = getCommandRegistry();
59
+ if (!registry)
60
+ return { target: { kind: "none" } };
61
+ const parentId = registry.byInvocation.get(command);
62
+ if (!parentId)
63
+ return { target: { kind: "none" } };
64
+ const entry = registry.byId.get(parentId);
65
+ if (!entry)
66
+ return { target: { kind: "none" } };
67
+ const trimmed = rawArgs.trim();
68
+ if (Object.keys(entry.subcommands).length === 0 || !trimmed) {
69
+ return { target: { kind: "parent", entry, args: trimmed ? trimmed.split(/\s+/) : [] } };
70
+ }
71
+ const token = trimmed.split(/\s+/)[0].toLowerCase();
72
+ const sub = entry.subcommands[token];
73
+ if (!sub) {
74
+ const remaining = trimmed.split(/\s+/).slice(1);
75
+ return {
76
+ target: { kind: "parent", entry, args: remaining },
77
+ unmatchedSubToken: token,
78
+ };
79
+ }
80
+ const rest = trimmed.split(/\s+/).slice(1);
81
+ return { target: { kind: "sub", parent: entry, sub, args: rest } };
82
+ }
83
+ /**
84
+ * Imperative run: takes the dispatcher-ready shapes (plugin, ctx,
85
+ * resolved target) and runs them through the unified pipeline.
86
+ *
87
+ * Returns a small result so the caller (the message handler) can
88
+ * decide whether to send any extra reply, log the action, or fall
89
+ * through to the legacy run loop.
90
+ */
91
+ export async function runCommand(opts) {
92
+ const { resolution, pluginName, ctx, reply } = opts;
93
+ const { target } = resolution;
94
+ if (target.kind === "none") {
95
+ return { status: "no_dispatch", sentReply: null, suggestedReply: null };
96
+ }
97
+ const flat = flatten(target);
98
+ // Sub-tokens that don't match any declared sub go through the parent
99
+ // handler — same convention as a CLI tool with an unknown subcommand.
100
+ if (resolution.unmatchedSubToken && target.kind === "parent") {
101
+ const validSubs = Object.keys(target.entry.subcommands);
102
+ const help = t("commandRun.unknownSubcommand", {
103
+ sub: resolution.unmatchedSubToken,
104
+ cmd: target.entry.cmd,
105
+ valid: validSubs.join(", ") || "(none)",
106
+ });
107
+ await reply.text(help);
108
+ return { status: "unknown_sub", sentReply: help, suggestedReply: help };
109
+ }
110
+ // Permission check uses the resolved (parent OR sub) permissions.
111
+ const permEntry = target.kind === "sub" ? subAsEntry(target.parent, target.sub) : target.entry;
112
+ const perm = await checkPermission(permEntry, {
113
+ isGroup: ctx.chat.isGroup,
114
+ chatId: ctx.chat.id,
115
+ senderId: ctx.msg.sender,
116
+ isSenderAdmin: () => ctx.chat.isSenderAdmin(),
117
+ isBotAdmin: () => ctx.chat.isBotAdmin(),
118
+ });
119
+ if (!perm.allowed) {
120
+ if (perm.message) {
121
+ await reply.text(perm.message);
122
+ }
123
+ return { status: "permission_denied", sentReply: perm.message ?? null, suggestedReply: perm.message ?? null };
124
+ }
125
+ // Required-argument check — purely advisory, the plugin still gets
126
+ // called with whatever args arrived. Surface the kernel-side message
127
+ // + auto-generated usage before invoking.
128
+ const requiredCount = flat.arguments.filter(a => a.required).length;
129
+ if (requiredCount > flat.args.length) {
130
+ const usage = renderUsage(target);
131
+ const msg = `${t("commandRun.missingRequiredArg")}\n\n${usage}`;
132
+ await reply.text(msg);
133
+ return { status: "argument_missing", sentReply: msg, suggestedReply: msg };
134
+ }
135
+ // Wrap the dispatch in a try/catch and fire alerts on any throw.
136
+ // This is the Phase 8 hook.
137
+ try {
138
+ if (!pluginName) {
139
+ // text-only command handled below (caller will handle the fixed-text path).
140
+ return { status: "no_dispatch", sentReply: null, suggestedReply: null };
141
+ }
142
+ // The actual handler invocation is delegated to runPlugin (which
143
+ // enforces timeout + 3-strikes). Pass the resolved sub-function
144
+ // name via the unified entrypoint so handlers can be the same
145
+ // function whether called as the parent or as a sub.
146
+ const fnName = target.kind === "sub" ? target.sub.function : (target.entry.function ?? target.entry.cmd);
147
+ const plugin = lookupPlugin(pluginName);
148
+ const handler = plugin ? resolvePluginCommandHandler(plugin.commands?.[fnName]) : null;
149
+ if (!plugin || !handler) {
150
+ const msg = `Plugin "${pluginName}" does not expose handler for !${flat.name}`;
151
+ logger.warn(`[runCommand] ${msg}`);
152
+ return { status: "no_dispatch", sentReply: null, suggestedReply: msg };
153
+ }
154
+ await runPlugin(plugin, ctx, handler, { args: flat.args, subcommand: target.kind === "sub" ? target.sub.cmd : undefined }, { rethrow: true });
155
+ return { status: "executed", sentReply: null, suggestedReply: null };
156
+ }
157
+ catch (e) {
158
+ const err = e instanceof Error ? e : new Error(String(e));
159
+ fireAlert("plugin_crash", {
160
+ plugin: pluginName,
161
+ command: flat.name,
162
+ kind: err.message?.startsWith("timed out") ? "timeout" : "exception",
163
+ message: err.message,
164
+ });
165
+ // Re-raise so pluginGuard can keep its 3-strike bookkeeping.
166
+ throw err;
167
+ }
168
+ }
169
+ /**
170
+ * Treat a sub-command as if it were a CommandEntry for permission
171
+ * checks. We materialise a virtual entry sharing the sub's resolved
172
+ * permissions, since `checkPermission` works against `CommandEntry`.
173
+ */
174
+ function subAsEntry(parent, sub) {
175
+ return {
176
+ ...parent,
177
+ id: sub.id,
178
+ cmd: sub.cmd,
179
+ permissions: sub.permissions,
180
+ subcommands: {},
181
+ arguments: sub.arguments,
182
+ group: null,
183
+ manual: sub.manual,
184
+ categoryHiddenInScope: null,
185
+ };
186
+ }
187
+ /**
188
+ * Look up the real, live `PluginEntry` for a plugin command dispatch.
189
+ * `pluginRegistry` (from `pluginLoader.ts`) is the same module-level
190
+ * map `commandRegistry.ts` reads at build time — its `commands` field
191
+ * carries the plugin's raw `commands` export as-is (bare handler
192
+ * function or `{ handler, ... }` object); `resolvePluginCommandHandler`
193
+ * normalizes either shape into a callable handler.
194
+ *
195
+ * Returning the *real* entry (not a throwaway `{ name }` stand-in)
196
+ * matters: `runPlugin` (`pluginGuard.ts`) gates on `plugin.status ===
197
+ * "active"` and persists `errorCount`/3-strikes bookkeeping onto the
198
+ * object it's given — a fresh literal each call would silently no-op
199
+ * every dispatch (fails the `status` check) and never accumulate
200
+ * crash counts.
201
+ *
202
+ * NOTE: this is deliberately NOT `ctx.plugins.require(pluginName)` —
203
+ * that facet returns `PluginEntry.exports` (the plugin's pure `api`
204
+ * object, Phase 3's `export const api = {...}`, which never receives
205
+ * `ctx`). Command/sub-command handlers are a different facet
206
+ * (`PluginEntry.commands`) and always receive `ctx`.
207
+ */
208
+ function lookupPlugin(pluginName) {
209
+ const plugin = pluginRegistry.get(pluginName);
210
+ if (!plugin || plugin.status !== "active" || !plugin.commands)
211
+ return null;
212
+ return plugin;
213
+ }
214
+ /**
215
+ * Render an auto-generated usage line for a command/sub. Format:
216
+ * !<cmd>[ <sub>] [--<arg1> ...]
217
+ * Built from the declared `arguments:` block.
218
+ */
219
+ export function renderUsage(target) {
220
+ if (target.kind === "none")
221
+ return "";
222
+ const flat = flatten(target);
223
+ const head = target.kind === "sub"
224
+ ? `${CMD_PREFIX}${target.parent.cmd} ${target.sub.cmd}`
225
+ : `${CMD_PREFIX}${flat.entry.cmd}`;
226
+ const headClean = head.replace(/\s+$/, "");
227
+ if (flat.arguments.length === 0)
228
+ return headClean;
229
+ const parts = [];
230
+ for (const arg of flat.arguments) {
231
+ const tok = arg.type === "boolean" ? `--${arg.name}[=true|false]` :
232
+ arg.type === "choice" ? `--${arg.name}=<${arg.choices?.join("|") ?? "..."}>` :
233
+ arg.type === "mention" ? `@<user>` :
234
+ arg.type === "url" ? `<url>` :
235
+ arg.type === "media_direct" ? `<media>` :
236
+ arg.type === "media_reply" ? `<reply-media>` :
237
+ arg.type === "number" ? `<n>` :
238
+ arg.type === "duration" ? `<duration>` :
239
+ arg.type === "quoted_text" ? `"<text>"` :
240
+ arg.type === "reply" ? `<reply>` :
241
+ `<${arg.name}>`;
242
+ parts.push(arg.required ? tok : `[${tok}]`);
243
+ }
244
+ return `${headClean} ${parts.join(" ")}`;
245
+ }