@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
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commandRegistry.ts
|
|
3
|
+
*
|
|
4
|
+
* Merges commands.yaml user overrides with each active plugin's
|
|
5
|
+
* `commands` defaults into a single, in-memory registry — the single
|
|
6
|
+
* source of truth for command routing. No routing happens here yet
|
|
7
|
+
* (that's the next step); this module just builds the structure and
|
|
8
|
+
* can be exercised in isolation with a unit test.
|
|
9
|
+
*/
|
|
10
|
+
import { logger } from "#logger";
|
|
11
|
+
import { t } from "#i18n";
|
|
12
|
+
import { loadCommandsConfig, } from "./commandsConfig.js";
|
|
13
|
+
import { pluginRegistry, resolvePluginCommandHandler, } from "./pluginLoader.js";
|
|
14
|
+
import { getActiveDeprecation, syncCommandHistory } from "./commandDeprecation.js";
|
|
15
|
+
export const DEFAULT_PERMISSION_MESSAGES = {
|
|
16
|
+
botNotAdmin: () => t("commandPermissions.botNotAdmin"),
|
|
17
|
+
senderNotAdmin: () => t("commandPermissions.senderNotAdmin"),
|
|
18
|
+
ownerOnly: () => t("commandPermissions.ownerOnly"),
|
|
19
|
+
wrongScope: () => t("commandPermissions.wrongScope"),
|
|
20
|
+
cooldown: () => t("commandPermissions.cooldown"),
|
|
21
|
+
};
|
|
22
|
+
export function resolvePermissions(specPerms, specMsgs, pluginPerms, defaultsPerms, defaultsMsgs, fallbackScope) {
|
|
23
|
+
const admin = specPerms?.admin ?? pluginPerms?.admin ?? false;
|
|
24
|
+
const botAdmin = specPerms?.botAdmin ?? pluginPerms?.botAdmin ?? false;
|
|
25
|
+
const scope = specPerms?.scope ?? pluginPerms?.scope ?? fallbackScope ?? "any";
|
|
26
|
+
const owner = specPerms?.owner ?? pluginPerms?.owner ?? false;
|
|
27
|
+
const cooldownSeconds = specPerms?.cooldownSeconds ?? pluginPerms?.cooldownSeconds ?? defaultsPerms?.cooldownSeconds ?? 0;
|
|
28
|
+
const rawWhitelist = specPerms?.whitelist ?? pluginPerms?.whitelist ?? defaultsPerms?.whitelist ?? null;
|
|
29
|
+
const rawBlacklist = specPerms?.blacklist ?? pluginPerms?.blacklist ?? defaultsPerms?.blacklist ?? null;
|
|
30
|
+
const whitelist = rawWhitelist
|
|
31
|
+
? {
|
|
32
|
+
groups: rawWhitelist.groups ? [...rawWhitelist.groups] : [],
|
|
33
|
+
users: rawWhitelist.users ? [...rawWhitelist.users] : [],
|
|
34
|
+
}
|
|
35
|
+
: null;
|
|
36
|
+
const blacklist = rawBlacklist
|
|
37
|
+
? {
|
|
38
|
+
groups: rawBlacklist.groups ? [...rawBlacklist.groups] : [],
|
|
39
|
+
users: rawBlacklist.users ? [...rawBlacklist.users] : [],
|
|
40
|
+
}
|
|
41
|
+
: null;
|
|
42
|
+
const messages = {
|
|
43
|
+
botNotAdmin: specMsgs?.botNotAdmin ?? defaultsMsgs?.botNotAdmin ?? DEFAULT_PERMISSION_MESSAGES.botNotAdmin(),
|
|
44
|
+
senderNotAdmin: specMsgs?.senderNotAdmin ?? defaultsMsgs?.senderNotAdmin ?? DEFAULT_PERMISSION_MESSAGES.senderNotAdmin(),
|
|
45
|
+
ownerOnly: specMsgs?.ownerOnly ?? defaultsMsgs?.ownerOnly ?? undefined,
|
|
46
|
+
wrongScope: specMsgs?.wrongScope ?? defaultsMsgs?.wrongScope ?? DEFAULT_PERMISSION_MESSAGES.wrongScope(),
|
|
47
|
+
cooldown: specMsgs?.cooldown ?? defaultsMsgs?.cooldown ?? DEFAULT_PERMISSION_MESSAGES.cooldown(),
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
admin,
|
|
51
|
+
botAdmin,
|
|
52
|
+
scope,
|
|
53
|
+
owner,
|
|
54
|
+
cooldownSeconds,
|
|
55
|
+
whitelist,
|
|
56
|
+
blacklist,
|
|
57
|
+
messages,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function pluginCommandKey(pluginName, functionName) {
|
|
61
|
+
return `${pluginName}::${functionName}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Plugin-provided command defaults (`export const commands = {...}`) are
|
|
65
|
+
* plain JS, not parsed/validated YAML — a plugin can hand us any runtime
|
|
66
|
+
* value regardless of the `PluginCommandDefault` compile-time type. `cmd`
|
|
67
|
+
* and `aliases` end up bound to SQLite params (deprecation lookups) and a
|
|
68
|
+
* non-string/undefined value there throws instead of failing gracefully.
|
|
69
|
+
* Sanitize before it ever reaches the registry.
|
|
70
|
+
*/
|
|
71
|
+
function sanitizePluginCmd(value) {
|
|
72
|
+
if (typeof value !== "string")
|
|
73
|
+
return null;
|
|
74
|
+
const trimmed = value.trim();
|
|
75
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
76
|
+
}
|
|
77
|
+
function sanitizePluginAliases(value) {
|
|
78
|
+
if (!Array.isArray(value))
|
|
79
|
+
return [];
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const item of value) {
|
|
82
|
+
if (typeof item !== "string")
|
|
83
|
+
continue;
|
|
84
|
+
const trimmed = item.trim();
|
|
85
|
+
if (trimmed.length > 0)
|
|
86
|
+
out.push(trimmed);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
function normalizePluginCommand(def) {
|
|
91
|
+
const handler = resolvePluginCommandHandler(def);
|
|
92
|
+
if (def && typeof def === "object") {
|
|
93
|
+
return {
|
|
94
|
+
cmd: sanitizePluginCmd(def.cmd),
|
|
95
|
+
aliases: sanitizePluginAliases(def.aliases),
|
|
96
|
+
desc: def.desc ?? null,
|
|
97
|
+
category: typeof def.category === "string" ? def.category : null,
|
|
98
|
+
manual: def.manual ?? null,
|
|
99
|
+
permissions: def.permissions ?? null,
|
|
100
|
+
handler,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Bare function (pure-function style — no built-in identity), or
|
|
104
|
+
// something malformed entirely (handler stays null in that case).
|
|
105
|
+
return { cmd: null, aliases: [], desc: null, category: null, manual: null, permissions: null, handler };
|
|
106
|
+
}
|
|
107
|
+
/** Whether a `commands[fn]` export is a shape worth remembering at all —
|
|
108
|
+
* a bare handler function, or an object (even one missing `handler`,
|
|
109
|
+
* which is only validated at dispatch time, same as before this
|
|
110
|
+
* module supported the bare-function shape). Anything else (a string,
|
|
111
|
+
* a number, null/undefined) is not a command definition. */
|
|
112
|
+
function isUsablePluginCommandExport(def) {
|
|
113
|
+
return typeof def === "function" || (def !== null && typeof def === "object");
|
|
114
|
+
}
|
|
115
|
+
function registerInvocation(byInvocation, text, entryId, isOverride) {
|
|
116
|
+
const existing = byInvocation.get(text);
|
|
117
|
+
if (existing === undefined) {
|
|
118
|
+
byInvocation.set(text, entryId);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (existing === entryId)
|
|
122
|
+
return true;
|
|
123
|
+
logger.warn(t("system.commandRegistryInvocationCollision", {
|
|
124
|
+
text,
|
|
125
|
+
winner: existing,
|
|
126
|
+
loser: entryId,
|
|
127
|
+
via: isOverride ? "yaml override" : "plugin default"
|
|
128
|
+
}));
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
function registerInvocationWithDeprecationGuard(byInvocation, text, entryId, isOverride, notifyChanges) {
|
|
132
|
+
if (!notifyChanges) {
|
|
133
|
+
return registerInvocation(byInvocation, text, entryId, isOverride);
|
|
134
|
+
}
|
|
135
|
+
const deprecation = getActiveDeprecation(text);
|
|
136
|
+
if (deprecation) {
|
|
137
|
+
logger.warn(t("system.commandDeprecationReservedInvocation", {
|
|
138
|
+
text,
|
|
139
|
+
id: deprecation.id
|
|
140
|
+
}));
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return registerInvocation(byInvocation, text, entryId, isOverride);
|
|
144
|
+
}
|
|
145
|
+
const DEFAULT_MENU_CONFIG = {
|
|
146
|
+
title: "🤖 ManyBot — Menu",
|
|
147
|
+
intro: {
|
|
148
|
+
en: "Use {prefix}<command> to run it or {prefix}help <command> to view its manual.",
|
|
149
|
+
pt: "Use {prefix}<comando> para executar ou {prefix}help <comando> para ver o manual.",
|
|
150
|
+
es: "Usa {prefix}<comando> para ejecutarlo o {prefix}help <comando> para ver el manual.",
|
|
151
|
+
},
|
|
152
|
+
footer: null,
|
|
153
|
+
cmd: "menu",
|
|
154
|
+
aliases: ["help", "man", "menu", "bot", "?"],
|
|
155
|
+
notFoundFallback: false,
|
|
156
|
+
welcomeMessage: null,
|
|
157
|
+
welcomeWindowDays: 3,
|
|
158
|
+
pageSize: 15,
|
|
159
|
+
};
|
|
160
|
+
function resolveCategoryHiddenInScope(category, categories) {
|
|
161
|
+
if (!category)
|
|
162
|
+
return null;
|
|
163
|
+
return categories[category]?.hiddenInScope ?? null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Resolve a `CommandSubcommandSpec` into the runtime `CommandSubcommand`.
|
|
167
|
+
* The sub shares the parent's plugin handler function by default; if
|
|
168
|
+
* `spec.function` is set, it overrides the parent's `function` field.
|
|
169
|
+
* Subcommand permissions inherit from the parent's resolved permissions
|
|
170
|
+
* unless the spec overrides them — `parent.permissions.scope` is passed
|
|
171
|
+
* as `fallbackScope` to close the category → command → subcommand chain.
|
|
172
|
+
*/
|
|
173
|
+
function buildSubcommandFromSpec(spec, parent, pluginName, defaultsByKey, defaults, manuals) {
|
|
174
|
+
const manual = spec.manual ?? manuals[spec.id] ?? null;
|
|
175
|
+
const fnName = spec.function ?? parent.function ?? "";
|
|
176
|
+
// Each sub can point at a *different* plugin function than its
|
|
177
|
+
// siblings (that's the whole point of "add"/"list"/"done" sharing one
|
|
178
|
+
// parent) — resolve this sub's own default permissions from its own
|
|
179
|
+
// function, not from whatever function the parent happens to carry.
|
|
180
|
+
const subDef = pluginName && fnName
|
|
181
|
+
? defaultsByKey.get(pluginCommandKey(pluginName, fnName))?.norm ?? null
|
|
182
|
+
: null;
|
|
183
|
+
return {
|
|
184
|
+
id: spec.id,
|
|
185
|
+
cmd: spec.cmd,
|
|
186
|
+
aliases: [...spec.aliases],
|
|
187
|
+
desc: spec.desc,
|
|
188
|
+
manual,
|
|
189
|
+
function: fnName,
|
|
190
|
+
arguments: [...spec.arguments],
|
|
191
|
+
permissions: resolvePermissions(spec.permissions, spec.messages, subDef?.permissions ?? null, defaults.permissions, defaults.messages, parent.permissions.scope),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function buildSubcommandsFromSpecs(specs, parent, pluginName, defaultsByKey, defaults, manuals) {
|
|
195
|
+
const out = {};
|
|
196
|
+
for (const spec of specs) {
|
|
197
|
+
const token = spec.cmd.toLowerCase();
|
|
198
|
+
if (out[token]) {
|
|
199
|
+
logger.warn(t("system.commandsConfigDuplicateSubcommand", {
|
|
200
|
+
id: parent.id,
|
|
201
|
+
cmd: spec.cmd
|
|
202
|
+
}));
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
out[token] = buildSubcommandFromSpec(spec, parent, pluginName, defaultsByKey, defaults, manuals);
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
export function buildCommandRegistry(specs, pluginRegistry, defaults = {
|
|
210
|
+
notifyChanges: true,
|
|
211
|
+
notifyPeriodDays: 7,
|
|
212
|
+
notifyMessage: null,
|
|
213
|
+
}, menu = { ...DEFAULT_MENU_CONFIG }, categories = {}, manuals = {}) {
|
|
214
|
+
const byId = new Map();
|
|
215
|
+
const byInvocation = new Map();
|
|
216
|
+
const defaultsByKey = new Map();
|
|
217
|
+
for (const plugin of pluginRegistry.values()) {
|
|
218
|
+
if (plugin.status !== "active")
|
|
219
|
+
continue;
|
|
220
|
+
if (!plugin.commands)
|
|
221
|
+
continue;
|
|
222
|
+
for (const [fn, def] of Object.entries(plugin.commands)) {
|
|
223
|
+
const id = pluginCommandKey(plugin.name, fn);
|
|
224
|
+
// Only a genuinely broken export (not a function and not an
|
|
225
|
+
// object at all) is unusable no matter what commands.yaml says
|
|
226
|
+
// later — that's the one case worth a warning + a full skip.
|
|
227
|
+
if (!isUsablePluginCommandExport(def)) {
|
|
228
|
+
logger.warn(t("system.commandRegistryInvalidPluginCommand", {
|
|
229
|
+
id,
|
|
230
|
+
plugin: plugin.name,
|
|
231
|
+
function: fn
|
|
232
|
+
}));
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const norm = normalizePluginCommand(def);
|
|
236
|
+
// Remembered regardless of whether the plugin gave it a `cmd` —
|
|
237
|
+
// a "pure function" export (bare handler, no built-in identity)
|
|
238
|
+
// is only reachable once a commands.yaml entry supplies `cmd`,
|
|
239
|
+
// but it still needs to be findable by pluginCommandKey() below.
|
|
240
|
+
// A malformed `cmd` (present but not a usable string) also lands
|
|
241
|
+
// here silently — not an error, just no auto-registration.
|
|
242
|
+
defaultsByKey.set(id, { plugin, fn, norm });
|
|
243
|
+
if (!norm.cmd)
|
|
244
|
+
continue; // no built-in identity — yaml-only until overridden
|
|
245
|
+
const manual = norm.manual ?? manuals[id] ?? manuals[norm.cmd] ?? null;
|
|
246
|
+
const category = norm.category;
|
|
247
|
+
const entry = {
|
|
248
|
+
id,
|
|
249
|
+
cmd: norm.cmd,
|
|
250
|
+
aliases: norm.aliases,
|
|
251
|
+
desc: norm.desc,
|
|
252
|
+
category,
|
|
253
|
+
group: null,
|
|
254
|
+
manual,
|
|
255
|
+
source: "plugin",
|
|
256
|
+
pluginName: plugin.name,
|
|
257
|
+
function: fn,
|
|
258
|
+
handler: norm.handler,
|
|
259
|
+
text: null,
|
|
260
|
+
permissions: resolvePermissions(null, null, norm.permissions, defaults.permissions, defaults.messages, category ? categories[category]?.scope : null),
|
|
261
|
+
arguments: [],
|
|
262
|
+
subcommands: {},
|
|
263
|
+
categoryHiddenInScope: null,
|
|
264
|
+
};
|
|
265
|
+
byId.set(id, entry);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (specs) {
|
|
269
|
+
for (const spec of specs) {
|
|
270
|
+
if (spec.plugin && spec.function) {
|
|
271
|
+
const key = pluginCommandKey(spec.plugin, spec.function);
|
|
272
|
+
const pluginDefault = defaultsByKey.get(key);
|
|
273
|
+
if (!pluginDefault) {
|
|
274
|
+
logger.warn(t("system.commandRegistryOrphanEntry", {
|
|
275
|
+
id: spec.id,
|
|
276
|
+
plugin: spec.plugin,
|
|
277
|
+
function: spec.function
|
|
278
|
+
}));
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const norm = pluginDefault.norm;
|
|
282
|
+
let existing = byId.get(key);
|
|
283
|
+
if (!existing) {
|
|
284
|
+
// Pure-function plugin export: no auto-registered entry yet
|
|
285
|
+
// (norm.cmd was null), so commands.yaml is the sole source of
|
|
286
|
+
// `cmd`. Without it there's nothing to invoke this command by.
|
|
287
|
+
const cmd = spec.cmd;
|
|
288
|
+
if (!cmd) {
|
|
289
|
+
logger.warn(t("system.commandRegistryOrphanEntry", {
|
|
290
|
+
id: spec.id,
|
|
291
|
+
plugin: spec.plugin,
|
|
292
|
+
function: spec.function
|
|
293
|
+
}));
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
existing = {
|
|
297
|
+
id: key,
|
|
298
|
+
cmd,
|
|
299
|
+
aliases: [],
|
|
300
|
+
desc: null,
|
|
301
|
+
category: null,
|
|
302
|
+
group: null,
|
|
303
|
+
manual: null,
|
|
304
|
+
source: "plugin",
|
|
305
|
+
pluginName: spec.plugin,
|
|
306
|
+
function: spec.function,
|
|
307
|
+
handler: norm.handler,
|
|
308
|
+
text: null,
|
|
309
|
+
permissions: resolvePermissions(null, null, null, defaults.permissions, defaults.messages, null),
|
|
310
|
+
arguments: [],
|
|
311
|
+
subcommands: {},
|
|
312
|
+
categoryHiddenInScope: null,
|
|
313
|
+
};
|
|
314
|
+
byId.set(key, existing);
|
|
315
|
+
}
|
|
316
|
+
if (spec.cmd)
|
|
317
|
+
existing.cmd = spec.cmd;
|
|
318
|
+
if (spec.aliases.length > 0)
|
|
319
|
+
existing.aliases = [...spec.aliases];
|
|
320
|
+
if (spec.desc)
|
|
321
|
+
existing.desc = spec.desc;
|
|
322
|
+
if (spec.category)
|
|
323
|
+
existing.category = spec.category;
|
|
324
|
+
if (spec.group !== null)
|
|
325
|
+
existing.group = spec.group;
|
|
326
|
+
existing.manual = spec.manual ?? norm.manual ?? manuals[spec.id] ?? manuals[existing.cmd] ?? null;
|
|
327
|
+
existing.permissions = resolvePermissions(spec.permissions, spec.messages, norm.permissions, defaults.permissions, defaults.messages, existing.category ? categories[existing.category]?.scope : null);
|
|
328
|
+
existing.arguments = [...spec.arguments];
|
|
329
|
+
existing.categoryHiddenInScope = resolveCategoryHiddenInScope(existing.category, categories);
|
|
330
|
+
existing.subcommands = buildSubcommandsFromSpecs(spec.subcommands, existing, spec.plugin, defaultsByKey, defaults, manuals);
|
|
331
|
+
}
|
|
332
|
+
else if (spec.plugin && !spec.function && spec.subcommands.length > 0) {
|
|
333
|
+
// Parent-only container: the top-level entry has no handler of
|
|
334
|
+
// its own (e.g. `todo:`), it just groups subcommands that each
|
|
335
|
+
// declare their own `function:`. Never dispatched directly —
|
|
336
|
+
// resolveDispatch() always routes into one of `subcommands`.
|
|
337
|
+
const id = `parent::${spec.id}`;
|
|
338
|
+
const entry = {
|
|
339
|
+
id,
|
|
340
|
+
cmd: spec.cmd,
|
|
341
|
+
aliases: [...spec.aliases],
|
|
342
|
+
desc: spec.desc,
|
|
343
|
+
category: spec.category ?? null,
|
|
344
|
+
group: spec.group,
|
|
345
|
+
manual: spec.manual ?? manuals[spec.id] ?? manuals[spec.cmd] ?? null,
|
|
346
|
+
source: "plugin",
|
|
347
|
+
pluginName: spec.plugin,
|
|
348
|
+
function: null,
|
|
349
|
+
handler: null,
|
|
350
|
+
text: null,
|
|
351
|
+
permissions: resolvePermissions(spec.permissions, spec.messages, null, defaults.permissions, defaults.messages, spec.category ? categories[spec.category]?.scope : null),
|
|
352
|
+
arguments: [...spec.arguments],
|
|
353
|
+
subcommands: {},
|
|
354
|
+
categoryHiddenInScope: resolveCategoryHiddenInScope(spec.category, categories),
|
|
355
|
+
};
|
|
356
|
+
entry.subcommands = buildSubcommandsFromSpecs(spec.subcommands, entry, spec.plugin, defaultsByKey, defaults, manuals);
|
|
357
|
+
byId.set(id, entry);
|
|
358
|
+
}
|
|
359
|
+
else if (!spec.plugin) {
|
|
360
|
+
if (!spec.text) {
|
|
361
|
+
logger.warn(t("system.commandRegistryInvalidEntry", {
|
|
362
|
+
id: spec.id
|
|
363
|
+
}));
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const id = `text::${spec.id}`;
|
|
367
|
+
const manual = spec.manual ?? manuals[spec.id] ?? manuals[spec.cmd] ?? null;
|
|
368
|
+
const entry = {
|
|
369
|
+
id,
|
|
370
|
+
cmd: spec.cmd,
|
|
371
|
+
aliases: [...spec.aliases],
|
|
372
|
+
desc: spec.desc,
|
|
373
|
+
category: spec.category ?? null,
|
|
374
|
+
group: spec.group,
|
|
375
|
+
manual,
|
|
376
|
+
source: "text",
|
|
377
|
+
pluginName: null,
|
|
378
|
+
function: null,
|
|
379
|
+
handler: null,
|
|
380
|
+
text: spec.text,
|
|
381
|
+
permissions: resolvePermissions(spec.permissions, spec.messages, null, defaults.permissions, defaults.messages, spec.category ? categories[spec.category]?.scope : null),
|
|
382
|
+
arguments: [...spec.arguments],
|
|
383
|
+
subcommands: {},
|
|
384
|
+
categoryHiddenInScope: resolveCategoryHiddenInScope(spec.category, categories),
|
|
385
|
+
};
|
|
386
|
+
entry.subcommands = buildSubcommandsFromSpecs(spec.subcommands, entry, null, defaultsByKey, defaults, manuals);
|
|
387
|
+
byId.set(id, entry);
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
// spec.plugin set, but neither `function:` nor a non-empty
|
|
391
|
+
// `subcommands:` block — nothing tells the kernel what to run.
|
|
392
|
+
logger.warn(t("system.commandRegistryInvalidEntry", {
|
|
393
|
+
id: spec.id
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (const entry of byId.values()) {
|
|
399
|
+
registerInvocationWithDeprecationGuard(byInvocation, entry.cmd, entry.id, false, defaults.notifyChanges);
|
|
400
|
+
for (const alias of entry.aliases) {
|
|
401
|
+
registerInvocationWithDeprecationGuard(byInvocation, alias, entry.id, false, defaults.notifyChanges);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Validate menu cmd + aliases against command invocations
|
|
405
|
+
const menuAliases = new Set();
|
|
406
|
+
const menuInvocations = [menu.cmd, ...menu.aliases.filter(a => a !== menu.cmd)];
|
|
407
|
+
for (const alias of menuInvocations) {
|
|
408
|
+
const existing = byInvocation.get(alias);
|
|
409
|
+
if (existing !== undefined) {
|
|
410
|
+
logger.warn(t("system.commandRegistryMenuAliasCollision", {
|
|
411
|
+
alias,
|
|
412
|
+
winner: existing
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
menuAliases.add(alias);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return { byId, byInvocation, defaults, menu, menuAliases, categories, manuals };
|
|
420
|
+
}
|
|
421
|
+
let currentRegistry = null;
|
|
422
|
+
export async function initCommandRegistry() {
|
|
423
|
+
const config = await loadCommandsConfig();
|
|
424
|
+
const defaults = config?.defaults ?? {
|
|
425
|
+
notifyChanges: true,
|
|
426
|
+
notifyPeriodDays: 7,
|
|
427
|
+
notifyMessage: null,
|
|
428
|
+
};
|
|
429
|
+
const menu = config?.menu ?? { ...DEFAULT_MENU_CONFIG };
|
|
430
|
+
const categories = config?.categories ?? {};
|
|
431
|
+
const manuals = config?.manuals ?? {};
|
|
432
|
+
const specs = config?.specs ?? [];
|
|
433
|
+
const registry = buildCommandRegistry(specs, pluginRegistry, defaults, menu, categories, manuals);
|
|
434
|
+
syncCommandHistory(registry.byId, defaults, specs);
|
|
435
|
+
currentRegistry = registry;
|
|
436
|
+
return registry;
|
|
437
|
+
}
|
|
438
|
+
export function getCommandRegistry() {
|
|
439
|
+
return currentRegistry;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Test-only: inject a registry built with {@link buildCommandRegistry}
|
|
443
|
+
* directly, bypassing `initCommandRegistry()`'s disk config + real
|
|
444
|
+
* `pluginRegistry` load. Lets `runCommand.test.ts` exercise
|
|
445
|
+
* `resolveDispatch()`/`runCommand()`, which read the module-singleton
|
|
446
|
+
* `currentRegistry` and have no injectable-registry parameter (unlike
|
|
447
|
+
* `commandAccess.ts`'s functions). Pass `null` to reset.
|
|
448
|
+
*/
|
|
449
|
+
export function __setRegistryForTests(registry) {
|
|
450
|
+
currentRegistry = registry;
|
|
451
|
+
}
|
|
452
|
+
export function getCommandByInvocation(command) {
|
|
453
|
+
if (!currentRegistry)
|
|
454
|
+
return null;
|
|
455
|
+
const id = currentRegistry.byInvocation.get(command);
|
|
456
|
+
if (id === undefined)
|
|
457
|
+
return null;
|
|
458
|
+
return currentRegistry.byId.get(id) ?? null;
|
|
459
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import test, { describe } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildCommandRegistry } from "#kernel/commandRegistry.js";
|
|
4
|
+
function createMockPluginRegistry(plugins) {
|
|
5
|
+
const map = new Map();
|
|
6
|
+
for (const p of plugins) {
|
|
7
|
+
map.set(p.name, {
|
|
8
|
+
name: p.name,
|
|
9
|
+
status: p.status ?? "active",
|
|
10
|
+
manifest: { name: p.name, version: "1.0.0" },
|
|
11
|
+
commands: p.commands ?? {},
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return map;
|
|
15
|
+
}
|
|
16
|
+
describe("kernel/commandRegistry", () => {
|
|
17
|
+
test("builds registry from active plugin defaults", () => {
|
|
18
|
+
const plugins = createMockPluginRegistry([
|
|
19
|
+
{
|
|
20
|
+
name: "pingPlugin",
|
|
21
|
+
commands: {
|
|
22
|
+
pingFn: {
|
|
23
|
+
cmd: "ping",
|
|
24
|
+
aliases: ["p"],
|
|
25
|
+
desc: "Pong command",
|
|
26
|
+
handler: async () => "pong",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
31
|
+
const registry = buildCommandRegistry(null, plugins);
|
|
32
|
+
assert.equal(registry.byId.size, 1);
|
|
33
|
+
const entry = registry.byId.get("pingPlugin::pingFn");
|
|
34
|
+
assert.ok(entry);
|
|
35
|
+
assert.equal(entry?.cmd, "ping");
|
|
36
|
+
assert.deepEqual(entry?.aliases, ["p"]);
|
|
37
|
+
assert.equal(entry?.source, "plugin");
|
|
38
|
+
assert.equal(registry.byInvocation.get("ping"), "pingPlugin::pingFn");
|
|
39
|
+
assert.equal(registry.byInvocation.get("p"), "pingPlugin::pingFn");
|
|
40
|
+
});
|
|
41
|
+
test("skips plugin commands with a non-string cmd instead of throwing", () => {
|
|
42
|
+
const plugins = createMockPluginRegistry([
|
|
43
|
+
{
|
|
44
|
+
name: "brokenPlugin",
|
|
45
|
+
commands: {
|
|
46
|
+
badFn: {
|
|
47
|
+
cmd: true,
|
|
48
|
+
handler: async () => "x",
|
|
49
|
+
},
|
|
50
|
+
goodFn: {
|
|
51
|
+
cmd: "ok",
|
|
52
|
+
aliases: [1, "valid", null],
|
|
53
|
+
handler: async () => "x",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
assert.doesNotThrow(() => buildCommandRegistry(null, plugins));
|
|
59
|
+
const registry = buildCommandRegistry(null, plugins);
|
|
60
|
+
assert.equal(registry.byId.has("brokenPlugin::badFn"), false);
|
|
61
|
+
const good = registry.byId.get("brokenPlugin::goodFn");
|
|
62
|
+
assert.ok(good);
|
|
63
|
+
assert.equal(good?.cmd, "ok");
|
|
64
|
+
assert.deepEqual(good?.aliases, ["valid"]);
|
|
65
|
+
});
|
|
66
|
+
test("applies spec overrides to plugin commands", () => {
|
|
67
|
+
const plugins = createMockPluginRegistry([
|
|
68
|
+
{
|
|
69
|
+
name: "funPlugin",
|
|
70
|
+
commands: {
|
|
71
|
+
jokeFn: {
|
|
72
|
+
cmd: "joke",
|
|
73
|
+
aliases: ["j"],
|
|
74
|
+
desc: "Original desc",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
const specs = [
|
|
80
|
+
{
|
|
81
|
+
id: "funPlugin::jokeFn",
|
|
82
|
+
plugin: "funPlugin",
|
|
83
|
+
function: "jokeFn",
|
|
84
|
+
cmd: "telljoke",
|
|
85
|
+
aliases: ["tj"],
|
|
86
|
+
desc: "Overridden desc",
|
|
87
|
+
category: "Fun",
|
|
88
|
+
group: null,
|
|
89
|
+
manual: null,
|
|
90
|
+
text: null,
|
|
91
|
+
deprecatedMessage: null,
|
|
92
|
+
notifyChanges: null,
|
|
93
|
+
permissions: { admin: true },
|
|
94
|
+
messages: null,
|
|
95
|
+
arguments: [],
|
|
96
|
+
subcommands: [],
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
const registry = buildCommandRegistry(specs, plugins);
|
|
100
|
+
const entry = registry.byId.get("funPlugin::jokeFn");
|
|
101
|
+
assert.ok(entry);
|
|
102
|
+
assert.equal(entry?.cmd, "telljoke");
|
|
103
|
+
assert.deepEqual(entry?.aliases, ["tj"]);
|
|
104
|
+
assert.equal(entry?.desc, "Overridden desc");
|
|
105
|
+
assert.equal(entry?.category, "Fun");
|
|
106
|
+
assert.equal(entry?.permissions.admin, true);
|
|
107
|
+
// Old invocation "joke" should no longer map to entry if overridden by new cmd/aliases
|
|
108
|
+
assert.equal(registry.byInvocation.get("telljoke"), "funPlugin::jokeFn");
|
|
109
|
+
assert.equal(registry.byInvocation.get("tj"), "funPlugin::jokeFn");
|
|
110
|
+
});
|
|
111
|
+
test("registers text-only command specs", () => {
|
|
112
|
+
const plugins = createMockPluginRegistry([]);
|
|
113
|
+
const specs = [
|
|
114
|
+
{
|
|
115
|
+
id: "custom_hello",
|
|
116
|
+
plugin: null,
|
|
117
|
+
function: null,
|
|
118
|
+
cmd: "hello",
|
|
119
|
+
aliases: ["hi"],
|
|
120
|
+
desc: "Says hello",
|
|
121
|
+
category: "General",
|
|
122
|
+
group: null,
|
|
123
|
+
manual: null,
|
|
124
|
+
text: "Hello World!",
|
|
125
|
+
deprecatedMessage: null,
|
|
126
|
+
notifyChanges: null,
|
|
127
|
+
permissions: null,
|
|
128
|
+
messages: null,
|
|
129
|
+
arguments: [],
|
|
130
|
+
subcommands: [],
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
const registry = buildCommandRegistry(specs, plugins);
|
|
134
|
+
assert.equal(registry.byId.size, 1);
|
|
135
|
+
const entry = registry.byId.get("text::custom_hello");
|
|
136
|
+
assert.ok(entry);
|
|
137
|
+
assert.equal(entry?.source, "text");
|
|
138
|
+
assert.equal(entry?.text, "Hello World!");
|
|
139
|
+
assert.equal(registry.byInvocation.get("hello"), "text::custom_hello");
|
|
140
|
+
assert.equal(registry.byInvocation.get("hi"), "text::custom_hello");
|
|
141
|
+
});
|
|
142
|
+
test("ignores inactive plugin commands", () => {
|
|
143
|
+
const plugins = createMockPluginRegistry([
|
|
144
|
+
{
|
|
145
|
+
name: "disabledPlugin",
|
|
146
|
+
status: "inactive",
|
|
147
|
+
commands: {
|
|
148
|
+
testFn: { cmd: "disabledCmd" },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
const registry = buildCommandRegistry(null, plugins);
|
|
153
|
+
assert.equal(registry.byId.size, 0);
|
|
154
|
+
assert.equal(registry.byInvocation.has("disabledCmd"), false);
|
|
155
|
+
});
|
|
156
|
+
});
|