@manybot/manybot 5.7.0 → 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.
- package/README.md +20 -3
- package/dist/client/banner.js +10 -0
- package/dist/client/banner.test.js +31 -0
- package/dist/client/store.js +56 -5
- package/dist/client/store.test.js +170 -0
- package/dist/config.js +28 -44
- package/dist/config.test.js +26 -0
- package/dist/drivers/baileys/adapter.js +58 -7
- package/dist/drivers/baileys/api/index.js +172 -24
- package/dist/drivers/baileys/index.js +62 -30
- package/dist/drivers/baileys/loginPrompt.js +0 -2
- package/dist/drivers/baileys/messageHandler.js +158 -4
- package/dist/drivers/baileys/messageHandler.test.js +203 -0
- package/dist/drivers/baileysAdapter.test.js +281 -0
- package/dist/drivers/jid.test.js +40 -0
- package/dist/drivers/types.js +5 -5
- package/dist/i18n/index.js +15 -2
- package/dist/kernel/activeDriverSend.js +21 -0
- package/dist/kernel/activeDriverSend.test.js +89 -0
- package/dist/kernel/alerts.js +3 -9
- package/dist/kernel/chatSession.js +65 -0
- package/dist/kernel/chatSession.test.js +46 -0
- package/dist/kernel/commandAccess.js +66 -0
- package/dist/kernel/commandAccess.test.js +74 -0
- package/dist/kernel/commandDeprecation.js +168 -0
- package/dist/kernel/commandDeprecation.test.js +107 -0
- package/dist/kernel/commandMenu.js +268 -0
- package/dist/kernel/commandMenu.test.js +234 -0
- package/dist/kernel/commandPermissions.js +125 -0
- package/dist/kernel/commandPermissions.test.js +159 -0
- package/dist/kernel/commandRegistry.js +459 -0
- package/dist/kernel/commandRegistry.test.js +156 -0
- package/dist/kernel/commandsConfig.js +517 -0
- package/dist/kernel/commandsConfig.test.js +236 -0
- package/dist/kernel/contactAutoSave.test.js +87 -0
- package/dist/kernel/driverManager.js +10 -6
- package/dist/kernel/driverManager.test.js +90 -0
- package/dist/kernel/integrationMode.js +88 -0
- package/dist/kernel/integrationMode.test.js +95 -0
- package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
- package/dist/kernel/pluginApi.test.js +583 -0
- package/dist/kernel/pluginGuard.js +15 -12
- package/dist/kernel/pluginGuard.test.js +39 -0
- package/dist/kernel/pluginLoader.js +96 -1
- package/dist/kernel/pluginLoader.test.js +80 -0
- package/dist/kernel/runCommand.js +245 -0
- package/dist/kernel/runCommand.test.js +235 -0
- package/dist/kernel/sendFallbackGuard.js +19 -48
- package/dist/kernel/sendFallbackGuard.test.js +80 -0
- package/dist/kernel/sendGuard.js +38 -42
- package/dist/kernel/sendGuard.test.js +102 -0
- package/dist/kernel/settingsDb.js +4 -3
- package/dist/kernel/statusServer.js +9 -2
- package/dist/kernel/statusServer.test.js +70 -0
- package/dist/kernel/testConfig.js +183 -0
- package/dist/kernel/testConfig.test.js +181 -0
- package/dist/kernel/updateCheck.js +33 -10
- package/dist/locales/en.json +64 -13
- package/dist/locales/es.json +64 -13
- package/dist/locales/pt.json +64 -13
- package/dist/logger/logger.js +23 -3
- package/dist/logger/logger.test.js +45 -0
- package/dist/main.js +5 -76
- package/dist/plugins/__manybot_integration__/index.js +167 -0
- package/dist/plugins/__manybot_integration__/index.test.js +184 -0
- package/package.json +74 -17
- package/dist/drivers/whatsmeow/client.js +0 -252
- package/dist/drivers/whatsmeow/index.js +0 -79
- package/dist/drivers/whatsmeow/installer.js +0 -86
- package/dist/drivers/whatsmeow/supervisor.js +0 -328
- 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 (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
+
}
|