@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.
- package/README.md +15 -7
- package/dist/client/store.js +35 -1
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +75 -8
- package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
- package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
- package/dist/drivers/baileys/api/index.js +245 -51
- package/dist/drivers/baileys/index.js +30 -6
- package/dist/drivers/baileys/messageHandler.js +207 -21
- package/dist/drivers/baileys/messageHandler.test.js +256 -14
- package/dist/drivers/baileysAdapter.test.js +97 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +35 -1
- package/dist/i18n/index.js +5 -22
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/commandAccess.test.js +2 -2
- package/dist/kernel/commandDeprecation.js +4 -2
- package/dist/kernel/commandDeprecation.test.js +8 -1
- package/dist/kernel/commandMenu.js +91 -2
- package/dist/kernel/commandMenu.test.js +131 -2
- package/dist/kernel/commandPermissions.js +69 -23
- package/dist/kernel/commandPermissions.test.js +77 -9
- package/dist/kernel/commandRegistry.js +167 -43
- package/dist/kernel/commandRegistry.test.js +4 -2
- package/dist/kernel/commandsConfig.js +470 -38
- package/dist/kernel/commandsConfig.test.js +249 -3
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/pluginApi.test.js +20 -3
- package/dist/kernel/pluginGuard.js +5 -3
- package/dist/kernel/pluginLoader.js +73 -10
- package/dist/kernel/pluginLoader.test.js +111 -1
- package/dist/kernel/runCommand.js +57 -18
- package/dist/kernel/runCommand.test.js +269 -7
- package/dist/kernel/settingsDb.js +15 -2
- package/dist/kernel/testConfig.js +9 -0
- package/dist/locales/en.json +14 -1
- package/dist/locales/es.json +17 -4
- package/dist/locales/pt.json +18 -5
- package/dist/plugins/__manybot_integration__/index.js +33 -16
- package/dist/plugins/__manybot_integration__/index.test.js +42 -8
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +4 -3
|
@@ -5,8 +5,10 @@ import os from "os";
|
|
|
5
5
|
import path from "path";
|
|
6
6
|
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "manybot-plugin-loader-"));
|
|
7
7
|
process.env.MANYBOT_CONFIG_DIR = configDir;
|
|
8
|
-
const { cleanupPlugins, loadPlugin, pluginRegistry, reloadPlugin } = await import("#kernel/pluginLoader.js");
|
|
8
|
+
const { cleanupPlugins, loadPlugin, loadPlugins, pluginRegistry, reloadCommandRegistry, reloadPlugin } = await import("#kernel/pluginLoader.js");
|
|
9
|
+
const { getCommandRegistry } = await import("#kernel/commandRegistry.js");
|
|
9
10
|
const pluginsDir = path.join(configDir, "plugins");
|
|
11
|
+
const commandsFile = path.join(configDir, "commands.yaml");
|
|
10
12
|
async function writePlugin(name, manifest, source) {
|
|
11
13
|
const dir = path.join(pluginsDir, name);
|
|
12
14
|
await fs.mkdir(dir, { recursive: true });
|
|
@@ -14,10 +16,22 @@ async function writePlugin(name, manifest, source) {
|
|
|
14
16
|
if (source !== undefined)
|
|
15
17
|
await fs.writeFile(path.join(dir, "index.js"), source, "utf8");
|
|
16
18
|
}
|
|
19
|
+
async function waitFor(label, read, timeoutMs = 3000) {
|
|
20
|
+
const deadline = Date.now() + timeoutMs;
|
|
21
|
+
while (Date.now() < deadline) {
|
|
22
|
+
const value = read();
|
|
23
|
+
if (value !== undefined)
|
|
24
|
+
return value;
|
|
25
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`timed out waiting for ${label}`);
|
|
28
|
+
}
|
|
17
29
|
beforeEach(async () => {
|
|
18
30
|
await cleanupPlugins();
|
|
19
31
|
pluginRegistry.clear();
|
|
20
32
|
await fs.rm(pluginsDir, { recursive: true, force: true });
|
|
33
|
+
await fs.rm(commandsFile, { force: true });
|
|
34
|
+
await fs.rm(path.join(configDir, "menu.yaml"), { force: true });
|
|
21
35
|
});
|
|
22
36
|
after(async () => {
|
|
23
37
|
await cleanupPlugins();
|
|
@@ -78,3 +92,99 @@ describe("kernel/pluginLoader", () => {
|
|
|
78
92
|
assert.equal(plugin?.errorCount, 0);
|
|
79
93
|
});
|
|
80
94
|
});
|
|
95
|
+
describe("kernel/pluginLoader — commands.yaml hot reload", () => {
|
|
96
|
+
test("reloadCommandRegistry re-reads commands.yaml and rebuilds the registry", async () => {
|
|
97
|
+
await writePlugin("reloaddummy", '{"main":"index.js"}', `
|
|
98
|
+
export default async function run() {}
|
|
99
|
+
`);
|
|
100
|
+
await loadPlugins(["reloaddummy"]);
|
|
101
|
+
await fs.writeFile(commandsFile, `
|
|
102
|
+
defaults:
|
|
103
|
+
notifyChanges: false
|
|
104
|
+
helloReload:
|
|
105
|
+
cmd: hello
|
|
106
|
+
plugin: reloaddummy
|
|
107
|
+
desc: "First version"
|
|
108
|
+
functions: []
|
|
109
|
+
`, "utf8");
|
|
110
|
+
await reloadCommandRegistry();
|
|
111
|
+
const registry = getCommandRegistry();
|
|
112
|
+
assert.ok(registry, "registry should be initialized after reload");
|
|
113
|
+
assert.equal(registry.byInvocation.get("hello"), "helloReload");
|
|
114
|
+
assert.equal(registry.byId.get("helloReload")?.desc, "First version");
|
|
115
|
+
assert.equal(registry.defaults.notifyChanges, false);
|
|
116
|
+
await fs.writeFile(commandsFile, `
|
|
117
|
+
helloReload:
|
|
118
|
+
cmd: hello
|
|
119
|
+
plugin: reloaddummy
|
|
120
|
+
desc: "Second version"
|
|
121
|
+
functions: []
|
|
122
|
+
`, "utf8");
|
|
123
|
+
await reloadCommandRegistry();
|
|
124
|
+
const registry2 = getCommandRegistry();
|
|
125
|
+
assert.ok(registry2);
|
|
126
|
+
assert.equal(registry2.byId.get("helloReload")?.desc, "Second version");
|
|
127
|
+
assert.equal(registry2.defaults.notifyChanges, true, "defaults should reset to built-in when omitted");
|
|
128
|
+
});
|
|
129
|
+
test("config watcher reloads the registry when commands.yaml is edited", async () => {
|
|
130
|
+
await writePlugin("watchdummy", '{"main":"index.js"}', `
|
|
131
|
+
export default async function run() {}
|
|
132
|
+
`);
|
|
133
|
+
await loadPlugins(["watchdummy"]);
|
|
134
|
+
assert.equal(getCommandRegistry()?.byInvocation.get("watch"), undefined);
|
|
135
|
+
await fs.writeFile(commandsFile, `
|
|
136
|
+
helloWatch:
|
|
137
|
+
cmd: watch
|
|
138
|
+
plugin: watchdummy
|
|
139
|
+
desc: "Watcher picks this up"
|
|
140
|
+
functions: []
|
|
141
|
+
`, "utf8");
|
|
142
|
+
const registry = await waitFor("registry to pick up commands.yaml change", () => {
|
|
143
|
+
const r = getCommandRegistry();
|
|
144
|
+
return r?.byId.get("helloWatch")?.desc === "Watcher picks this up" ? r : undefined;
|
|
145
|
+
});
|
|
146
|
+
assert.equal(registry.byInvocation.get("watch"), "helloWatch");
|
|
147
|
+
});
|
|
148
|
+
test("config watcher reloads the registry when an imported YAML file is edited", async () => {
|
|
149
|
+
await writePlugin("importdummy", '{"main":"index.js"}', `
|
|
150
|
+
export default async function run() {}
|
|
151
|
+
`);
|
|
152
|
+
await fs.writeFile(path.join(configDir, "menu.yaml"), `
|
|
153
|
+
helloImport:
|
|
154
|
+
cmd: importcmd
|
|
155
|
+
plugin: importdummy
|
|
156
|
+
desc: "From menu.yaml (first)"
|
|
157
|
+
functions: []
|
|
158
|
+
`, "utf8");
|
|
159
|
+
await fs.writeFile(commandsFile, `import: menu.yaml\n`, "utf8");
|
|
160
|
+
await loadPlugins(["importdummy"]);
|
|
161
|
+
const first = await waitFor("initial import to be picked up", () => {
|
|
162
|
+
const r = getCommandRegistry();
|
|
163
|
+
return r?.byId.get("helloImport")?.desc === "From menu.yaml (first)" ? r : undefined;
|
|
164
|
+
});
|
|
165
|
+
assert.equal(first.byInvocation.get("importcmd"), "helloImport");
|
|
166
|
+
await fs.writeFile(path.join(configDir, "menu.yaml"), `
|
|
167
|
+
helloImport:
|
|
168
|
+
cmd: importcmd
|
|
169
|
+
plugin: importdummy
|
|
170
|
+
desc: "From menu.yaml (second)"
|
|
171
|
+
functions: []
|
|
172
|
+
`, "utf8");
|
|
173
|
+
const second = await waitFor("imported yaml change to be picked up", () => {
|
|
174
|
+
const r = getCommandRegistry();
|
|
175
|
+
return r?.byId.get("helloImport")?.desc === "From menu.yaml (second)" ? r : undefined;
|
|
176
|
+
});
|
|
177
|
+
assert.equal(second.byInvocation.get("importcmd"), "helloImport");
|
|
178
|
+
});
|
|
179
|
+
test("config watcher ignores non-yaml/non-toml files in PATHS.HOME", async () => {
|
|
180
|
+
await writePlugin("ignoredummy", '{"main":"index.js"}', `
|
|
181
|
+
export default async function run() {}
|
|
182
|
+
`);
|
|
183
|
+
await loadPlugins(["ignoredummy"]);
|
|
184
|
+
const registryBefore = getCommandRegistry();
|
|
185
|
+
await fs.writeFile(path.join(configDir, "README.md"), "unrelated", "utf8");
|
|
186
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
187
|
+
const registryAfter = getCommandRegistry();
|
|
188
|
+
assert.equal(registryAfter, registryBefore, "registry reference must be stable when no relevant file changed");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -23,13 +23,16 @@
|
|
|
23
23
|
* `for (plugin of pluginRegistry) await runPlugin(...)` loop.
|
|
24
24
|
*/
|
|
25
25
|
import { logger } from "#logger";
|
|
26
|
-
import {
|
|
26
|
+
import { tFor } from "#i18n";
|
|
27
27
|
import { CMD_PREFIX } from "#config";
|
|
28
28
|
import { fireAlert } from "./alerts.js";
|
|
29
29
|
import { runPlugin } from "./pluginGuard.js";
|
|
30
30
|
import { checkPermission } from "./commandPermissions.js";
|
|
31
31
|
import { getCommandRegistry } from "./commandRegistry.js";
|
|
32
32
|
import { pluginRegistry, resolvePluginCommandHandler } from "./pluginLoader.js";
|
|
33
|
+
import { STOP_CHAIN } from "./commandsConfig.js";
|
|
34
|
+
import { resolveCoreCommandHandler } from "./coreCommands.js";
|
|
35
|
+
import { getChatLocale, getChatPrefix } from "./chatOverrides.js";
|
|
33
36
|
function flatten(target) {
|
|
34
37
|
if (target.kind === "sub") {
|
|
35
38
|
return {
|
|
@@ -89,8 +92,13 @@ export function resolveDispatch(command, rawArgs) {
|
|
|
89
92
|
* through to the legacy run loop.
|
|
90
93
|
*/
|
|
91
94
|
export async function runCommand(opts) {
|
|
92
|
-
const { resolution, pluginName, ctx, reply } = opts;
|
|
95
|
+
const { resolution, pluginName, ctx, reply, chatId } = opts;
|
|
93
96
|
const { target } = resolution;
|
|
97
|
+
// Resolved once per dispatch: this chat's `!config` overrides (or the
|
|
98
|
+
// global defaults when none were set) for every kernel-authored reply
|
|
99
|
+
// below (unknown-sub hint, missing-argument usage, ...).
|
|
100
|
+
const lang = chatId ? getChatLocale(chatId) : undefined;
|
|
101
|
+
const prefix = chatId ? getChatPrefix(chatId) : CMD_PREFIX;
|
|
94
102
|
if (target.kind === "none") {
|
|
95
103
|
return { status: "no_dispatch", sentReply: null, suggestedReply: null };
|
|
96
104
|
}
|
|
@@ -99,7 +107,7 @@ export async function runCommand(opts) {
|
|
|
99
107
|
// handler — same convention as a CLI tool with an unknown subcommand.
|
|
100
108
|
if (resolution.unmatchedSubToken && target.kind === "parent") {
|
|
101
109
|
const validSubs = Object.keys(target.entry.subcommands);
|
|
102
|
-
const help =
|
|
110
|
+
const help = tFor(lang, "commandRun.unknownSubcommand", {
|
|
103
111
|
sub: resolution.unmatchedSubToken,
|
|
104
112
|
cmd: target.entry.cmd,
|
|
105
113
|
valid: validSubs.join(", ") || "(none)",
|
|
@@ -112,7 +120,7 @@ export async function runCommand(opts) {
|
|
|
112
120
|
const perm = await checkPermission(permEntry, {
|
|
113
121
|
isGroup: ctx.chat.isGroup,
|
|
114
122
|
chatId: ctx.chat.id,
|
|
115
|
-
|
|
123
|
+
sender: { lid: ctx.msg.sender, pn: ctx.msg.senderPn },
|
|
116
124
|
isSenderAdmin: () => ctx.chat.isSenderAdmin(),
|
|
117
125
|
isBotAdmin: () => ctx.chat.isBotAdmin(),
|
|
118
126
|
});
|
|
@@ -127,8 +135,8 @@ export async function runCommand(opts) {
|
|
|
127
135
|
// + auto-generated usage before invoking.
|
|
128
136
|
const requiredCount = flat.arguments.filter(a => a.required).length;
|
|
129
137
|
if (requiredCount > flat.args.length) {
|
|
130
|
-
const usage = renderUsage(target);
|
|
131
|
-
const msg = `${
|
|
138
|
+
const usage = renderUsage(target, prefix);
|
|
139
|
+
const msg = `${tFor(lang, "commandRun.missingRequiredArg")}\n\n${usage}`;
|
|
132
140
|
await reply.text(msg);
|
|
133
141
|
return { status: "argument_missing", sentReply: msg, suggestedReply: msg };
|
|
134
142
|
}
|
|
@@ -139,19 +147,50 @@ export async function runCommand(opts) {
|
|
|
139
147
|
// text-only command handled below (caller will handle the fixed-text path).
|
|
140
148
|
return { status: "no_dispatch", sentReply: null, suggestedReply: null };
|
|
141
149
|
}
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// function
|
|
146
|
-
|
|
150
|
+
// Function chain dispatch — v6 reference yaml allows a command to
|
|
151
|
+
// declare `functions: [a, b, c]` and have them run top-to-bottom.
|
|
152
|
+
// Each function receives the same `(ctx, { args, subcommand })`
|
|
153
|
+
// shape; a function may short-circuit the rest of the chain by
|
|
154
|
+
// returning the sentinel `STOP_CHAIN` (exported from
|
|
155
|
+
// `commandsConfig.ts`). Anything else (void/undefined/regular value)
|
|
156
|
+
// lets the chain continue. Empty chain → "no_dispatch" (parent is
|
|
157
|
+
// metadata-only, e.g. a sub-container with no override).
|
|
158
|
+
const fnNames = target.kind === "sub" ? target.sub.functions : target.entry.functions;
|
|
159
|
+
if (fnNames.length === 0) {
|
|
160
|
+
return { status: "no_dispatch", sentReply: null, suggestedReply: null };
|
|
161
|
+
}
|
|
162
|
+
const subId = target.kind === "sub" ? target.sub.cmd : undefined;
|
|
163
|
+
const input = { args: flat.args, subcommand: subId };
|
|
164
|
+
if (pluginName === "core") {
|
|
165
|
+
for (const fnName of fnNames) {
|
|
166
|
+
const handler = resolveCoreCommandHandler(fnName);
|
|
167
|
+
if (!handler) {
|
|
168
|
+
logger.warn(`[runCommand] Core namespace does not expose handler "${fnName}"`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const result = await handler(ctx, input);
|
|
172
|
+
if (result === STOP_CHAIN)
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
return { status: "executed", sentReply: null, suggestedReply: null };
|
|
176
|
+
}
|
|
147
177
|
const plugin = lookupPlugin(pluginName);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const msg = `Plugin "${pluginName}" does not expose handler for !${flat.name}`;
|
|
178
|
+
if (!plugin) {
|
|
179
|
+
const msg = `Plugin "${pluginName}" is not active`;
|
|
151
180
|
logger.warn(`[runCommand] ${msg}`);
|
|
152
181
|
return { status: "no_dispatch", sentReply: null, suggestedReply: msg };
|
|
153
182
|
}
|
|
154
|
-
|
|
183
|
+
for (const fnName of fnNames) {
|
|
184
|
+
const handler = resolvePluginCommandHandler(plugin.commands?.[fnName]);
|
|
185
|
+
if (!handler) {
|
|
186
|
+
const msg = `Plugin "${pluginName}" does not expose handler for !${flat.name}.${fnName}`;
|
|
187
|
+
logger.warn(`[runCommand] ${msg}`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const result = await runPlugin(plugin, ctx, handler, input, { rethrow: true });
|
|
191
|
+
if (result === STOP_CHAIN)
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
155
194
|
return { status: "executed", sentReply: null, suggestedReply: null };
|
|
156
195
|
}
|
|
157
196
|
catch (e) {
|
|
@@ -216,13 +255,13 @@ function lookupPlugin(pluginName) {
|
|
|
216
255
|
* !<cmd>[ <sub>] [--<arg1> ...]
|
|
217
256
|
* Built from the declared `arguments:` block.
|
|
218
257
|
*/
|
|
219
|
-
export function renderUsage(target) {
|
|
258
|
+
export function renderUsage(target, prefix = CMD_PREFIX) {
|
|
220
259
|
if (target.kind === "none")
|
|
221
260
|
return "";
|
|
222
261
|
const flat = flatten(target);
|
|
223
262
|
const head = target.kind === "sub"
|
|
224
|
-
? `${
|
|
225
|
-
: `${
|
|
263
|
+
? `${prefix}${target.parent.cmd} ${target.sub.cmd}`
|
|
264
|
+
: `${prefix}${flat.entry.cmd}`;
|
|
226
265
|
const headClean = head.replace(/\s+$/, "");
|
|
227
266
|
if (flat.arguments.length === 0)
|
|
228
267
|
return headClean;
|
|
@@ -3,13 +3,23 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import { resolveDispatch, runCommand, renderUsage } from "#kernel/runCommand.js";
|
|
4
4
|
import { buildCommandRegistry, __setRegistryForTests } from "#kernel/commandRegistry.js";
|
|
5
5
|
import { pluginRegistry } from "#kernel/pluginLoader.js";
|
|
6
|
+
import { STOP_CHAIN } from "#kernel/commandsConfig.js";
|
|
7
|
+
import { buildSettingsApi } from "#kernel/settingsDb.js";
|
|
8
|
+
import { CONFIG } from "#config";
|
|
9
|
+
// Pin the "no override" global language independently of whatever
|
|
10
|
+
// ~/.manybot/manybot.toml happens to say on the machine running the
|
|
11
|
+
// suite — CONFIG.LANGUAGE is read from the real config dir unless
|
|
12
|
+
// MANYBOT_CONFIG_DIR is set, and i18n's `currentLang` locks in on the
|
|
13
|
+
// first t()/tFor() call, so this must run before any test does.
|
|
14
|
+
CONFIG.LANGUAGE = "en";
|
|
6
15
|
function emptySpec(overrides) {
|
|
7
16
|
return {
|
|
8
17
|
id: overrides.id ?? "todo::add",
|
|
9
18
|
cmd: overrides.cmd ?? "todo",
|
|
10
19
|
aliases: overrides.aliases ?? [],
|
|
11
20
|
plugin: overrides.plugin ?? "todoPlugin",
|
|
12
|
-
|
|
21
|
+
functions: overrides.functions ?? ["addFn"],
|
|
22
|
+
loading: overrides.loading ?? null,
|
|
13
23
|
text: overrides.text ?? null,
|
|
14
24
|
desc: overrides.desc ?? null,
|
|
15
25
|
category: overrides.category ?? null,
|
|
@@ -28,7 +38,8 @@ function emptySub(overrides) {
|
|
|
28
38
|
id: overrides.id ?? "todo::list",
|
|
29
39
|
cmd: overrides.cmd ?? "list",
|
|
30
40
|
aliases: overrides.aliases ?? [],
|
|
31
|
-
|
|
41
|
+
functions: overrides.functions ?? null,
|
|
42
|
+
loading: overrides.loading ?? null,
|
|
32
43
|
desc: overrides.desc ?? null,
|
|
33
44
|
manual: overrides.manual ?? null,
|
|
34
45
|
arguments: overrides.arguments ?? [],
|
|
@@ -71,6 +82,11 @@ function registerTodoPlugin() {
|
|
|
71
82
|
throw new Error("boom");
|
|
72
83
|
},
|
|
73
84
|
},
|
|
85
|
+
gateFn: {
|
|
86
|
+
cmd: "gate",
|
|
87
|
+
aliases: [],
|
|
88
|
+
handler: async () => STOP_CHAIN,
|
|
89
|
+
},
|
|
74
90
|
},
|
|
75
91
|
};
|
|
76
92
|
pluginRegistry.set("todoPlugin", plugin);
|
|
@@ -113,7 +129,7 @@ describe("kernel/runCommand", () => {
|
|
|
113
129
|
}
|
|
114
130
|
});
|
|
115
131
|
test("resolveDispatch: kind sub when a declared subcommand token matches", () => {
|
|
116
|
-
const spec = emptySpec({ subcommands: [emptySub({
|
|
132
|
+
const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
|
|
117
133
|
__setRegistryForTests(buildRegistry([spec]));
|
|
118
134
|
const { target } = resolveDispatch("todo", "list");
|
|
119
135
|
assert.equal(target.kind, "sub");
|
|
@@ -122,7 +138,7 @@ describe("kernel/runCommand", () => {
|
|
|
122
138
|
}
|
|
123
139
|
});
|
|
124
140
|
test("resolveDispatch: unmatchedSubToken falls through to parent", () => {
|
|
125
|
-
const spec = emptySpec({ subcommands: [emptySub({
|
|
141
|
+
const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
|
|
126
142
|
__setRegistryForTests(buildRegistry([spec]));
|
|
127
143
|
const { target, unmatchedSubToken } = resolveDispatch("todo", "wat now");
|
|
128
144
|
assert.equal(target.kind, "parent");
|
|
@@ -144,7 +160,7 @@ describe("kernel/runCommand", () => {
|
|
|
144
160
|
assert.equal(replies.length, 0);
|
|
145
161
|
});
|
|
146
162
|
test("runCommand: routes to the subcommand handler, not the parent's", async () => {
|
|
147
|
-
const spec = emptySpec({ subcommands: [emptySub({
|
|
163
|
+
const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
|
|
148
164
|
__setRegistryForTests(buildRegistry([spec]));
|
|
149
165
|
const resolution = resolveDispatch("todo", "list");
|
|
150
166
|
const result = await runCommand({
|
|
@@ -158,7 +174,7 @@ describe("kernel/runCommand", () => {
|
|
|
158
174
|
assert.equal(addCalls.length, 0);
|
|
159
175
|
});
|
|
160
176
|
test("runCommand: unmatchedSubToken replies with a usage hint and does not dispatch", async () => {
|
|
161
|
-
const spec = emptySpec({ subcommands: [emptySub({
|
|
177
|
+
const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
|
|
162
178
|
__setRegistryForTests(buildRegistry([spec]));
|
|
163
179
|
const resolution = resolveDispatch("todo", "wat");
|
|
164
180
|
const replies = [];
|
|
@@ -199,7 +215,7 @@ describe("kernel/runCommand", () => {
|
|
|
199
215
|
assert.equal(addCalls.length, 0);
|
|
200
216
|
});
|
|
201
217
|
test("runCommand: re-throws on handler crash after firing the alert", async () => {
|
|
202
|
-
const spec = emptySpec({ id: "todo::crash", cmd: "crashcmd",
|
|
218
|
+
const spec = emptySpec({ id: "todo::crash", cmd: "crashcmd", functions: ["crashFn"] });
|
|
203
219
|
__setRegistryForTests(buildRegistry([spec]));
|
|
204
220
|
const resolution = resolveDispatch("crashcmd", "");
|
|
205
221
|
await assert.rejects(() => runCommand({
|
|
@@ -232,4 +248,250 @@ describe("kernel/runCommand", () => {
|
|
|
232
248
|
const usage = renderUsage({ kind: "none" });
|
|
233
249
|
assert.equal(usage, "");
|
|
234
250
|
});
|
|
251
|
+
test("renderUsage: accepts a prefix override", () => {
|
|
252
|
+
const spec = emptySpec({
|
|
253
|
+
arguments: [{ name: "item", type: "quoted_text", required: true }],
|
|
254
|
+
});
|
|
255
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
256
|
+
const { target } = resolveDispatch("todo", "");
|
|
257
|
+
const usage = renderUsage(target, "#");
|
|
258
|
+
assert.match(usage, /^#todo /);
|
|
259
|
+
});
|
|
260
|
+
describe("per-chat overrides (prefix / language via !config)", () => {
|
|
261
|
+
const overrideChatId = "5511988887777@c.us"; // already-normalized form
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
buildSettingsApi("core", overrideChatId).deleteAll();
|
|
264
|
+
});
|
|
265
|
+
afterEach(() => {
|
|
266
|
+
buildSettingsApi("core", overrideChatId).deleteAll();
|
|
267
|
+
});
|
|
268
|
+
test("missing-argument usage message uses the chat's saved prefix override", async () => {
|
|
269
|
+
buildSettingsApi("core", overrideChatId).set("chat_prefix", "#");
|
|
270
|
+
const spec = emptySpec({ arguments: [{ name: "item", type: "quoted_text", required: true }] });
|
|
271
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
272
|
+
const resolution = resolveDispatch("todo", "");
|
|
273
|
+
const replies = [];
|
|
274
|
+
const result = await runCommand({
|
|
275
|
+
pluginName: "todoPlugin",
|
|
276
|
+
ctx: fakeCtx(),
|
|
277
|
+
resolution,
|
|
278
|
+
reply: { text: (t) => replies.push(t) },
|
|
279
|
+
chatId: overrideChatId,
|
|
280
|
+
});
|
|
281
|
+
assert.equal(result.status, "argument_missing");
|
|
282
|
+
assert.match(replies[0], /#todo/, "usage line should use the chat's saved prefix, not the global one");
|
|
283
|
+
});
|
|
284
|
+
test("missing-argument usage message falls back to the global prefix when no override is set", async () => {
|
|
285
|
+
const spec = emptySpec({ arguments: [{ name: "item", type: "quoted_text", required: true }] });
|
|
286
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
287
|
+
const resolution = resolveDispatch("todo", "");
|
|
288
|
+
const replies = [];
|
|
289
|
+
await runCommand({
|
|
290
|
+
pluginName: "todoPlugin",
|
|
291
|
+
ctx: fakeCtx(),
|
|
292
|
+
resolution,
|
|
293
|
+
reply: { text: (t) => replies.push(t) },
|
|
294
|
+
chatId: overrideChatId,
|
|
295
|
+
});
|
|
296
|
+
assert.match(replies[0], /!todo/);
|
|
297
|
+
});
|
|
298
|
+
test("unknown-subcommand message uses the chat's saved language override", async () => {
|
|
299
|
+
buildSettingsApi("core", overrideChatId).set("chat_locale", "pt");
|
|
300
|
+
const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
|
|
301
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
302
|
+
const resolution = resolveDispatch("todo", "wat");
|
|
303
|
+
const replies = [];
|
|
304
|
+
await runCommand({
|
|
305
|
+
pluginName: "todoPlugin",
|
|
306
|
+
ctx: fakeCtx(),
|
|
307
|
+
resolution,
|
|
308
|
+
reply: { text: (t) => replies.push(t) },
|
|
309
|
+
chatId: overrideChatId,
|
|
310
|
+
});
|
|
311
|
+
assert.match(replies[0], /Subcomando "wat" desconhecido/);
|
|
312
|
+
});
|
|
313
|
+
test("unknown-subcommand message falls back to the global language when no override is set", async () => {
|
|
314
|
+
const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
|
|
315
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
316
|
+
const resolution = resolveDispatch("todo", "wat");
|
|
317
|
+
const replies = [];
|
|
318
|
+
await runCommand({
|
|
319
|
+
pluginName: "todoPlugin",
|
|
320
|
+
ctx: fakeCtx(),
|
|
321
|
+
resolution,
|
|
322
|
+
reply: { text: (t) => replies.push(t) },
|
|
323
|
+
chatId: overrideChatId,
|
|
324
|
+
});
|
|
325
|
+
assert.match(replies[0], /Unknown subcommand "wat"/);
|
|
326
|
+
});
|
|
327
|
+
test("omitting chatId behaves exactly like an unset override (backward compatible)", async () => {
|
|
328
|
+
const spec = emptySpec({ arguments: [{ name: "item", type: "quoted_text", required: true }] });
|
|
329
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
330
|
+
const resolution = resolveDispatch("todo", "");
|
|
331
|
+
const replies = [];
|
|
332
|
+
const result = await runCommand({
|
|
333
|
+
pluginName: "todoPlugin",
|
|
334
|
+
ctx: fakeCtx(),
|
|
335
|
+
resolution,
|
|
336
|
+
reply: { text: (t) => replies.push(t) },
|
|
337
|
+
});
|
|
338
|
+
assert.equal(result.status, "argument_missing");
|
|
339
|
+
assert.match(replies[0], /!todo/);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
describe("functions: chain + STOP_CHAIN", () => {
|
|
343
|
+
test("runs every function in the chain in declared order", async () => {
|
|
344
|
+
let second = false;
|
|
345
|
+
let third = false;
|
|
346
|
+
const plugin = {
|
|
347
|
+
name: "chainPlugin",
|
|
348
|
+
status: "active",
|
|
349
|
+
run: null,
|
|
350
|
+
setup: null,
|
|
351
|
+
exports: {},
|
|
352
|
+
error: null,
|
|
353
|
+
guardOptions: {},
|
|
354
|
+
commands: {
|
|
355
|
+
firstFn: { cmd: "todo", aliases: [], handler: async () => { second = true; } },
|
|
356
|
+
secondFn: { cmd: "todo", aliases: [], handler: async () => {
|
|
357
|
+
assert.equal(second, true, "second handler ran before first finished");
|
|
358
|
+
third = true;
|
|
359
|
+
} },
|
|
360
|
+
thirdFn: { cmd: "todo", aliases: [], handler: async () => {
|
|
361
|
+
assert.equal(third, true, "third handler ran before second finished");
|
|
362
|
+
} },
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
pluginRegistry.set("chainPlugin", plugin);
|
|
366
|
+
const specs = [
|
|
367
|
+
{
|
|
368
|
+
id: "chainPlugin::firstFn",
|
|
369
|
+
cmd: "chain",
|
|
370
|
+
aliases: [],
|
|
371
|
+
plugin: "chainPlugin",
|
|
372
|
+
functions: ["firstFn", "secondFn", "thirdFn"],
|
|
373
|
+
loading: null,
|
|
374
|
+
text: null,
|
|
375
|
+
desc: null,
|
|
376
|
+
category: null,
|
|
377
|
+
group: null,
|
|
378
|
+
manual: null,
|
|
379
|
+
deprecatedMessage: null,
|
|
380
|
+
notifyChanges: null,
|
|
381
|
+
permissions: null,
|
|
382
|
+
messages: null,
|
|
383
|
+
arguments: [],
|
|
384
|
+
subcommands: [],
|
|
385
|
+
},
|
|
386
|
+
];
|
|
387
|
+
__setRegistryForTests(buildCommandRegistry(specs, pluginRegistry));
|
|
388
|
+
const resolution = resolveDispatch("chain", "");
|
|
389
|
+
const result = await runCommand({
|
|
390
|
+
pluginName: "chainPlugin",
|
|
391
|
+
ctx: fakeCtx(),
|
|
392
|
+
resolution,
|
|
393
|
+
reply: { text: () => { } },
|
|
394
|
+
});
|
|
395
|
+
assert.equal(result.status, "executed");
|
|
396
|
+
assert.equal(third, true);
|
|
397
|
+
pluginRegistry.delete("chainPlugin");
|
|
398
|
+
});
|
|
399
|
+
test("STOP_CHAIN short-circuits the rest of the chain", async () => {
|
|
400
|
+
let secondRan = false;
|
|
401
|
+
const plugin = {
|
|
402
|
+
name: "stopPlugin",
|
|
403
|
+
status: "active",
|
|
404
|
+
run: null,
|
|
405
|
+
setup: null,
|
|
406
|
+
exports: {},
|
|
407
|
+
error: null,
|
|
408
|
+
guardOptions: {},
|
|
409
|
+
commands: {
|
|
410
|
+
firstFn: { cmd: "stop", aliases: [], handler: async () => STOP_CHAIN },
|
|
411
|
+
secondFn: { cmd: "stop", aliases: [], handler: async () => { secondRan = true; } },
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
pluginRegistry.set("stopPlugin", plugin);
|
|
415
|
+
const specs = [
|
|
416
|
+
{
|
|
417
|
+
id: "stopPlugin::firstFn",
|
|
418
|
+
cmd: "stop",
|
|
419
|
+
aliases: [],
|
|
420
|
+
plugin: "stopPlugin",
|
|
421
|
+
functions: ["firstFn", "secondFn"],
|
|
422
|
+
loading: null,
|
|
423
|
+
text: null,
|
|
424
|
+
desc: null,
|
|
425
|
+
category: null,
|
|
426
|
+
group: null,
|
|
427
|
+
manual: null,
|
|
428
|
+
deprecatedMessage: null,
|
|
429
|
+
notifyChanges: null,
|
|
430
|
+
permissions: null,
|
|
431
|
+
messages: null,
|
|
432
|
+
arguments: [],
|
|
433
|
+
subcommands: [],
|
|
434
|
+
},
|
|
435
|
+
];
|
|
436
|
+
__setRegistryForTests(buildCommandRegistry(specs, pluginRegistry));
|
|
437
|
+
const resolution = resolveDispatch("stop", "");
|
|
438
|
+
const result = await runCommand({
|
|
439
|
+
pluginName: "stopPlugin",
|
|
440
|
+
ctx: fakeCtx(),
|
|
441
|
+
resolution,
|
|
442
|
+
reply: { text: () => { } },
|
|
443
|
+
});
|
|
444
|
+
assert.equal(result.status, "executed");
|
|
445
|
+
assert.equal(secondRan, false);
|
|
446
|
+
pluginRegistry.delete("stopPlugin");
|
|
447
|
+
});
|
|
448
|
+
test("chain throw propagates and stops the chain", async () => {
|
|
449
|
+
let secondRan = false;
|
|
450
|
+
const plugin = {
|
|
451
|
+
name: "throwPlugin",
|
|
452
|
+
status: "active",
|
|
453
|
+
run: null,
|
|
454
|
+
setup: null,
|
|
455
|
+
exports: {},
|
|
456
|
+
error: null,
|
|
457
|
+
guardOptions: {},
|
|
458
|
+
commands: {
|
|
459
|
+
firstFn: { cmd: "throw", aliases: [], handler: async () => { throw new Error("mid"); } },
|
|
460
|
+
secondFn: { cmd: "throw", aliases: [], handler: async () => { secondRan = true; } },
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
pluginRegistry.set("throwPlugin", plugin);
|
|
464
|
+
const specs = [
|
|
465
|
+
{
|
|
466
|
+
id: "throwPlugin::firstFn",
|
|
467
|
+
cmd: "throw",
|
|
468
|
+
aliases: [],
|
|
469
|
+
plugin: "throwPlugin",
|
|
470
|
+
functions: ["firstFn", "secondFn"],
|
|
471
|
+
loading: null,
|
|
472
|
+
text: null,
|
|
473
|
+
desc: null,
|
|
474
|
+
category: null,
|
|
475
|
+
group: null,
|
|
476
|
+
manual: null,
|
|
477
|
+
deprecatedMessage: null,
|
|
478
|
+
notifyChanges: null,
|
|
479
|
+
permissions: null,
|
|
480
|
+
messages: null,
|
|
481
|
+
arguments: [],
|
|
482
|
+
subcommands: [],
|
|
483
|
+
},
|
|
484
|
+
];
|
|
485
|
+
__setRegistryForTests(buildCommandRegistry(specs, pluginRegistry));
|
|
486
|
+
const resolution = resolveDispatch("throw", "");
|
|
487
|
+
await assert.rejects(() => runCommand({
|
|
488
|
+
pluginName: "throwPlugin",
|
|
489
|
+
ctx: fakeCtx(),
|
|
490
|
+
resolution,
|
|
491
|
+
reply: { text: () => { } },
|
|
492
|
+
}), /mid/);
|
|
493
|
+
assert.equal(secondRan, false);
|
|
494
|
+
pluginRegistry.delete("throwPlugin");
|
|
495
|
+
});
|
|
496
|
+
});
|
|
235
497
|
});
|
|
@@ -94,6 +94,17 @@ function dbDelete(pluginName, chatId, key) {
|
|
|
94
94
|
function dbDeleteAll(pluginName, chatId) {
|
|
95
95
|
stmts.deleteAll.run(pluginName, chatId);
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Direct read of a single (plugin, chat, key) setting, bypassing the
|
|
99
|
+
* `ctx.settings` scoped-accessor pattern. For call sites that need a
|
|
100
|
+
* value before a `PluginContext` exists yet — e.g. resolving the
|
|
101
|
+
* per-chat command prefix while still parsing the incoming message,
|
|
102
|
+
* or resolving the per-chat language from outside the "core" plugin's
|
|
103
|
+
* own context. See `kernel/chatOverrides.ts`.
|
|
104
|
+
*/
|
|
105
|
+
export function getPluginSetting(pluginName, chatId, key) {
|
|
106
|
+
return dbGet(pluginName, chatId, key);
|
|
107
|
+
}
|
|
97
108
|
// ── Scoped accessor factory ───────────────────────────────────────────────────
|
|
98
109
|
/**
|
|
99
110
|
* Returns a settings accessor for a specific (pluginName, chatId) pair.
|
|
@@ -106,9 +117,11 @@ function scopedAccessor(pluginName, chatId) {
|
|
|
106
117
|
* @param {string} key
|
|
107
118
|
* @param {*} [defaultValue]
|
|
108
119
|
*/
|
|
109
|
-
get(key, defaultValue
|
|
120
|
+
get(key, defaultValue) {
|
|
110
121
|
const val = dbGet(pluginName, chatId, key);
|
|
111
|
-
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- `get<T>` is a
|
|
123
|
+
// type-level convenience for callers; the store itself is untyped JSON.
|
|
124
|
+
return (val !== undefined ? val : defaultValue);
|
|
112
125
|
},
|
|
113
126
|
/**
|
|
114
127
|
* Get all settings for this chat as a plain object.
|
|
@@ -12,6 +12,15 @@
|
|
|
12
12
|
* c. otherwise absent (`chat === null`) — integration tests skip
|
|
13
13
|
* with an explanatory message instead of crashing.
|
|
14
14
|
*
|
|
15
|
+
* This module accepts any chat shape `normalizeTestChat()` allows,
|
|
16
|
+
* including a group (`@g.us`) — it has no opinion on what a given
|
|
17
|
+
* test file actually needs. Individual suites are the ones with
|
|
18
|
+
* that requirement: contacts.integration.test.ts, for instance,
|
|
19
|
+
* needs an individual chat (a phone number, or a JID ending in
|
|
20
|
+
* `@s.whatsapp.net`/`@c.us`/`@lid`) because it asserts on
|
|
21
|
+
* per-person contact fields (LID, number, country) that a group
|
|
22
|
+
* simply doesn't have — see that file's own header for details.
|
|
23
|
+
*
|
|
15
24
|
* 2. `runWhatsApp` — explicit opt-in flag (env `MANYBOT_RUN_WHATSAPP_TESTS=1`).
|
|
16
25
|
* A saved WhatsApp session plus a `TEST_CHAT` is NOT enough to fire real
|
|
17
26
|
* messages; this is the single, deliberate signal that the operator
|