@manybot/manybot 5.7.0 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +28 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +91 -6
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/download/queue.js +13 -4
  9. package/dist/drivers/baileys/adapter.js +133 -15
  10. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  11. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  12. package/dist/drivers/baileys/api/index.js +384 -62
  13. package/dist/drivers/baileys/index.js +92 -36
  14. package/dist/drivers/baileys/loginPrompt.js +0 -2
  15. package/dist/drivers/baileys/messageHandler.js +344 -4
  16. package/dist/drivers/baileys/messageHandler.test.js +445 -0
  17. package/dist/drivers/baileysAdapter.test.js +378 -0
  18. package/dist/drivers/jid.js +26 -0
  19. package/dist/drivers/jid.test.js +74 -0
  20. package/dist/drivers/types.js +5 -5
  21. package/dist/i18n/index.js +20 -24
  22. package/dist/kernel/activeDriverSend.js +21 -0
  23. package/dist/kernel/activeDriverSend.test.js +89 -0
  24. package/dist/kernel/alerts.js +3 -9
  25. package/dist/kernel/chatOverrides.js +46 -0
  26. package/dist/kernel/chatOverrides.test.js +59 -0
  27. package/dist/kernel/chatSession.js +65 -0
  28. package/dist/kernel/chatSession.test.js +46 -0
  29. package/dist/kernel/commandAccess.js +66 -0
  30. package/dist/kernel/commandAccess.test.js +74 -0
  31. package/dist/kernel/commandDeprecation.js +170 -0
  32. package/dist/kernel/commandDeprecation.test.js +114 -0
  33. package/dist/kernel/commandMenu.js +357 -0
  34. package/dist/kernel/commandMenu.test.js +363 -0
  35. package/dist/kernel/commandPermissions.js +171 -0
  36. package/dist/kernel/commandPermissions.test.js +227 -0
  37. package/dist/kernel/commandRegistry.js +583 -0
  38. package/dist/kernel/commandRegistry.test.js +158 -0
  39. package/dist/kernel/commandsConfig.js +949 -0
  40. package/dist/kernel/commandsConfig.test.js +482 -0
  41. package/dist/kernel/contactAutoSave.js +6 -6
  42. package/dist/kernel/contactAutoSave.test.js +87 -0
  43. package/dist/kernel/coreCommands.js +62 -0
  44. package/dist/kernel/driverManager.js +10 -6
  45. package/dist/kernel/driverManager.test.js +90 -0
  46. package/dist/kernel/integrationMode.js +88 -0
  47. package/dist/kernel/integrationMode.test.js +95 -0
  48. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  49. package/dist/kernel/pluginApi.test.js +600 -0
  50. package/dist/kernel/pluginGuard.js +18 -13
  51. package/dist/kernel/pluginGuard.test.js +39 -0
  52. package/dist/kernel/pluginLoader.js +169 -11
  53. package/dist/kernel/pluginLoader.test.js +190 -0
  54. package/dist/kernel/runCommand.js +284 -0
  55. package/dist/kernel/runCommand.test.js +497 -0
  56. package/dist/kernel/sendFallbackGuard.js +19 -48
  57. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  58. package/dist/kernel/sendGuard.js +38 -42
  59. package/dist/kernel/sendGuard.test.js +102 -0
  60. package/dist/kernel/settingsDb.js +19 -5
  61. package/dist/kernel/statusServer.js +9 -2
  62. package/dist/kernel/statusServer.test.js +70 -0
  63. package/dist/kernel/testConfig.js +192 -0
  64. package/dist/kernel/testConfig.test.js +181 -0
  65. package/dist/kernel/updateCheck.js +33 -10
  66. package/dist/locales/en.json +77 -13
  67. package/dist/locales/es.json +77 -13
  68. package/dist/locales/pt.json +77 -13
  69. package/dist/logger/logger.js +23 -3
  70. package/dist/logger/logger.test.js +45 -0
  71. package/dist/main.js +5 -76
  72. package/dist/plugins/__manybot_integration__/index.js +184 -0
  73. package/dist/plugins/__manybot_integration__/index.test.js +218 -0
  74. package/dist/utils/phoneNumber.js +83 -0
  75. package/dist/utils/phoneNumber.test.js +53 -0
  76. package/package.json +76 -18
  77. package/dist/drivers/whatsmeow/client.js +0 -252
  78. package/dist/drivers/whatsmeow/index.js +0 -79
  79. package/dist/drivers/whatsmeow/installer.js +0 -86
  80. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  81. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
@@ -0,0 +1,583 @@
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
+ import { resolveCoreCommandHandler } from "./coreCommands.js";
16
+ export const DEFAULT_PERMISSION_MESSAGES = {
17
+ botNotAdmin: () => t("commandPermissions.botNotAdmin"),
18
+ senderNotAdmin: () => t("commandPermissions.senderNotAdmin"),
19
+ ownerOnly: () => t("commandPermissions.ownerOnly"),
20
+ donoOnly: () => t("commandPermissions.donoOnly"),
21
+ wrongScope: () => t("commandPermissions.wrongScope"),
22
+ cooldown: () => t("commandPermissions.cooldown"),
23
+ blacklist: () => t("commandPermissions.blacklist"),
24
+ allowedChats: () => t("commandPermissions.allowedChats"),
25
+ };
26
+ export function resolvePermissions(specPerms, specMsgs, pluginPerms, defaultsPerms, defaultsMsgs, fallbackScope) {
27
+ const admin = specPerms?.admin ?? pluginPerms?.admin ?? false;
28
+ const botAdmin = specPerms?.botAdmin ?? pluginPerms?.botAdmin ?? false;
29
+ const scope = specPerms?.scope ?? pluginPerms?.scope ?? fallbackScope ?? "any";
30
+ const owner = specPerms?.owner ?? pluginPerms?.owner ?? false;
31
+ const cooldownSeconds = specPerms?.cooldownSeconds ?? pluginPerms?.cooldownSeconds ?? defaultsPerms?.cooldownSeconds ?? 0;
32
+ // `dono` is YAML-only (no plugin-level equivalent). Per-spec dono
33
+ // wins; falls back to null = "no specific owner restriction" (the
34
+ // global OWNER_NUMBER in manybot.toml is independent).
35
+ const dono = specPerms?.dono ?? null;
36
+ // `allowed_chats:` is a closed list of JIDs the command may run in.
37
+ // Only set by the YAML spec; null = no chat restriction.
38
+ const allowedChats = specPerms?.allowedChats && specPerms.allowedChats.length > 0
39
+ ? [...specPerms.allowedChats]
40
+ : null;
41
+ // `hidden_outside_scope:` mirrors `group_only` / `dm_only`. Compute it
42
+ // here so the resolved value is consistent across the menu and the
43
+ // permission check. spec wins over plugin: a plugin manifest that
44
+ // declares `group_only: true` is hidden from DMs unless the spec
45
+ // narrows it differently.
46
+ let hiddenOutsideScope = null;
47
+ const scopeSrc = specPerms?.scope ?? pluginPerms?.scope ?? null;
48
+ const hiddenFlag = specPerms?.hiddenOutsideScope ?? pluginPerms?.hiddenOutsideScope ?? false;
49
+ const groupOnlyFlag = specPerms?.groupOnly ?? pluginPerms?.groupOnly ?? false;
50
+ const dmOnlyFlag = specPerms?.dmOnly ?? pluginPerms?.dmOnly ?? false;
51
+ if (scopeSrc === "group")
52
+ hiddenOutsideScope = "group";
53
+ else if (scopeSrc === "dm")
54
+ hiddenOutsideScope = "dm";
55
+ else if (hiddenFlag === true || groupOnlyFlag === true)
56
+ hiddenOutsideScope = "group";
57
+ else if (dmOnlyFlag === true)
58
+ hiddenOutsideScope = "dm";
59
+ const rawWhitelist = specPerms?.whitelist ?? pluginPerms?.whitelist ?? defaultsPerms?.whitelist ?? null;
60
+ const rawBlacklist = specPerms?.blacklist ?? pluginPerms?.blacklist ?? defaultsPerms?.blacklist ?? null;
61
+ const whitelist = rawWhitelist
62
+ ? {
63
+ groups: rawWhitelist.groups ? [...rawWhitelist.groups] : [],
64
+ users: rawWhitelist.users ? [...rawWhitelist.users] : [],
65
+ }
66
+ : null;
67
+ const blacklist = rawBlacklist
68
+ ? {
69
+ groups: rawBlacklist.groups ? [...rawBlacklist.groups] : [],
70
+ users: rawBlacklist.users ? [...rawBlacklist.users] : [],
71
+ }
72
+ : null;
73
+ const messages = {
74
+ botNotAdmin: specMsgs?.botNotAdmin ?? defaultsMsgs?.botNotAdmin ?? DEFAULT_PERMISSION_MESSAGES.botNotAdmin(),
75
+ senderNotAdmin: specMsgs?.senderNotAdmin ?? defaultsMsgs?.senderNotAdmin ?? DEFAULT_PERMISSION_MESSAGES.senderNotAdmin(),
76
+ ownerOnly: specMsgs?.ownerOnly ?? defaultsMsgs?.ownerOnly ?? undefined,
77
+ donoOnly: specMsgs?.donoOnly ?? defaultsMsgs?.donoOnly ?? DEFAULT_PERMISSION_MESSAGES.donoOnly(),
78
+ wrongScope: specMsgs?.wrongScope ?? defaultsMsgs?.wrongScope ?? DEFAULT_PERMISSION_MESSAGES.wrongScope(),
79
+ cooldown: specMsgs?.cooldown ?? defaultsMsgs?.cooldown ?? DEFAULT_PERMISSION_MESSAGES.cooldown(),
80
+ blacklist: specMsgs?.blacklist ?? defaultsMsgs?.blacklist ?? DEFAULT_PERMISSION_MESSAGES.blacklist(),
81
+ allowedChats: specMsgs?.allowedChats ?? defaultsMsgs?.allowedChats ?? DEFAULT_PERMISSION_MESSAGES.allowedChats(),
82
+ };
83
+ return {
84
+ admin,
85
+ botAdmin,
86
+ scope,
87
+ owner,
88
+ cooldownSeconds,
89
+ whitelist,
90
+ blacklist,
91
+ dono,
92
+ allowedChats,
93
+ hiddenOutsideScope,
94
+ messages,
95
+ };
96
+ }
97
+ function pluginCommandKey(pluginName, functionName) {
98
+ return `${pluginName}::${functionName}`;
99
+ }
100
+ /**
101
+ * Plugin-provided command defaults (`export const commands = {...}`) are
102
+ * plain JS, not parsed/validated YAML — a plugin can hand us any runtime
103
+ * value regardless of the `PluginCommandDefault` compile-time type. `cmd`
104
+ * and `aliases` end up bound to SQLite params (deprecation lookups) and a
105
+ * non-string/undefined value there throws instead of failing gracefully.
106
+ * Sanitize before it ever reaches the registry.
107
+ */
108
+ function sanitizePluginCmd(value) {
109
+ if (typeof value !== "string")
110
+ return null;
111
+ const trimmed = value.trim();
112
+ return trimmed.length > 0 ? trimmed : null;
113
+ }
114
+ function sanitizePluginAliases(value) {
115
+ if (!Array.isArray(value))
116
+ return [];
117
+ const out = [];
118
+ for (const item of value) {
119
+ if (typeof item !== "string")
120
+ continue;
121
+ const trimmed = item.trim();
122
+ if (trimmed.length > 0)
123
+ out.push(trimmed);
124
+ }
125
+ return out;
126
+ }
127
+ function normalizePluginCommand(def) {
128
+ const handler = resolvePluginCommandHandler(def);
129
+ if (def && typeof def === "object") {
130
+ return {
131
+ cmd: sanitizePluginCmd(def.cmd),
132
+ aliases: sanitizePluginAliases(def.aliases),
133
+ desc: def.desc ?? null,
134
+ category: typeof def.category === "string" ? def.category : null,
135
+ manual: def.manual ?? null,
136
+ permissions: def.permissions ?? null,
137
+ handler,
138
+ };
139
+ }
140
+ // Bare function (pure-function style — no built-in identity), or
141
+ // something malformed entirely (handler stays null in that case).
142
+ return { cmd: null, aliases: [], desc: null, category: null, manual: null, permissions: null, handler };
143
+ }
144
+ /** Whether a `commands[fn]` export is a shape worth remembering at all —
145
+ * a bare handler function, or an object (even one missing `handler`,
146
+ * which is only validated at dispatch time, same as before this
147
+ * module supported the bare-function shape). Anything else (a string,
148
+ * a number, null/undefined) is not a command definition. */
149
+ function isUsablePluginCommandExport(def) {
150
+ return typeof def === "function" || (def !== null && typeof def === "object");
151
+ }
152
+ function registerInvocation(byInvocation, text, entryId, isOverride) {
153
+ const existing = byInvocation.get(text);
154
+ if (existing === undefined) {
155
+ byInvocation.set(text, entryId);
156
+ return true;
157
+ }
158
+ if (existing === entryId)
159
+ return true;
160
+ logger.warn(t("system.commandRegistryInvocationCollision", {
161
+ text,
162
+ winner: existing,
163
+ loser: entryId,
164
+ via: isOverride ? "yaml override" : "plugin default"
165
+ }));
166
+ return false;
167
+ }
168
+ function registerInvocationWithDeprecationGuard(byInvocation, text, entryId, isOverride, notifyChanges) {
169
+ if (!notifyChanges) {
170
+ return registerInvocation(byInvocation, text, entryId, isOverride);
171
+ }
172
+ const deprecation = getActiveDeprecation(text);
173
+ if (deprecation && deprecation.id !== entryId) {
174
+ logger.warn(t("system.commandDeprecationReservedInvocation", {
175
+ text,
176
+ id: deprecation.id
177
+ }));
178
+ return false;
179
+ }
180
+ return registerInvocation(byInvocation, text, entryId, isOverride);
181
+ }
182
+ export const DEFAULT_MENU_CONFIG = {
183
+ enabled: false,
184
+ title: "🤖 ManyBot — Menu",
185
+ intro: {
186
+ en: "Use {prefix}<command> to run it or {prefix}help <command> to view its manual.",
187
+ pt: "Use {prefix}<comando> para executar ou {prefix}help <comando> para ver o manual.",
188
+ es: "Usa {prefix}<comando> para ejecutarlo o {prefix}help <comando> para ver el manual.",
189
+ },
190
+ footer: null,
191
+ cmd: "menu",
192
+ aliases: ["help", "man", "menu", "bot", "?"],
193
+ notFoundFallback: false,
194
+ suggestSimilar: false,
195
+ suggestMaxDistance: 2,
196
+ welcomeMessage: null,
197
+ welcomeWindowDays: 3,
198
+ pageSize: 15,
199
+ };
200
+ function resolveCategoryHiddenInScope(category, categories) {
201
+ if (!category)
202
+ return null;
203
+ return categories[category]?.hiddenInScope ?? null;
204
+ }
205
+ /**
206
+ * Resolve a `CommandSubcommandSpec` into the runtime `CommandSubcommand`.
207
+ *
208
+ * Function-chain inheritance: a sub with `spec.functions === null` (no
209
+ * override) inherits the parent's resolved chain. With an explicit list
210
+ * (even an empty one) it uses that. The primary `function` is set to the
211
+ * first non-empty entry, falling back to the parent's primary. Same
212
+ * convention for `loading:` — spec override wins, otherwise the parent's
213
+ * resolved spec carries through.
214
+ *
215
+ * Subcommand permissions inherit from the parent's resolved permissions
216
+ * unless the spec overrides them — `parent.permissions.scope` is passed
217
+ * as `fallbackScope` to close the category → command → subcommand chain.
218
+ */
219
+ function buildSubcommandFromSpec(spec, parent, pluginName, defaultsByKey, defaults, manuals, loadingPresetOverrides) {
220
+ const manual = spec.manual ?? manuals[spec.id] ?? null;
221
+ const functions = spec.functions !== null ? [...spec.functions] : [...parent.functions];
222
+ const fnName = functions[0] ?? parent.function ?? "";
223
+ // Each sub can point at a *different* plugin function than its
224
+ // siblings — resolve this sub's own default permissions from its own
225
+ // primary function, not from whatever the parent happens to carry.
226
+ const subDef = pluginName && fnName
227
+ ? defaultsByKey.get(pluginCommandKey(pluginName, fnName))?.norm ?? null
228
+ : null;
229
+ return {
230
+ id: spec.id,
231
+ cmd: spec.cmd,
232
+ aliases: [...spec.aliases],
233
+ desc: spec.desc,
234
+ manual,
235
+ function: fnName,
236
+ functions,
237
+ loading: spec.loading ?? parent.loading,
238
+ arguments: [...spec.arguments],
239
+ permissions: resolvePermissions(spec.permissions, spec.messages, subDef?.permissions ?? null, defaults.permissions, defaults.messages, parent.permissions.scope),
240
+ };
241
+ }
242
+ function buildSubcommandsFromSpecs(specs, parent, pluginName, defaultsByKey, defaults, manuals, loadingPresetOverrides) {
243
+ const out = {};
244
+ for (const spec of specs) {
245
+ const token = spec.cmd.toLowerCase();
246
+ if (out[token]) {
247
+ logger.warn(t("system.commandsConfigDuplicateSubcommand", {
248
+ id: parent.id,
249
+ cmd: spec.cmd
250
+ }));
251
+ continue;
252
+ }
253
+ out[token] = buildSubcommandFromSpec(spec, parent, pluginName, defaultsByKey, defaults, manuals, loadingPresetOverrides);
254
+ }
255
+ return out;
256
+ }
257
+ /**
258
+ * Chain inheritance for `loading:` is:
259
+ *
260
+ * defaults.loading → categoryLoading[cat] → spec.loading
261
+ *
262
+ * Returns the first non-null in that order. An inline `spec.loading`
263
+ * already won against any preset (the parser resolves preset names at
264
+ * parse time). Categories that don't declare `loading:` pass `null`
265
+ * through, so the defaults spec wins.
266
+ */
267
+ function resolveLoadingChain(spec, categoryKey, categoryLoading, defaults) {
268
+ return spec
269
+ ?? (categoryKey ? categoryLoading[categoryKey] ?? null : null)
270
+ ?? defaults.loading
271
+ ?? null;
272
+ }
273
+ export function buildCommandRegistry(specs, pluginRegistry, defaults = {
274
+ notifyChanges: true,
275
+ notifyPeriodDays: 7,
276
+ notifyMessage: null,
277
+ }, menu = { ...DEFAULT_MENU_CONFIG }, categories = {}, manuals = {}, loadingPresets = {}, categoryLoading = {}, prefix = null) {
278
+ const byId = new Map();
279
+ const byInvocation = new Map();
280
+ const defaultsByKey = new Map();
281
+ const coreFunctionNames = new Set();
282
+ for (const spec of specs ?? []) {
283
+ for (const fn of spec.functions)
284
+ coreFunctionNames.add(fn);
285
+ for (const sub of spec.subcommands) {
286
+ for (const fn of sub.functions ?? [])
287
+ coreFunctionNames.add(fn);
288
+ }
289
+ }
290
+ const allPlugins = new Map(pluginRegistry);
291
+ allPlugins.set("core", {
292
+ name: "core",
293
+ status: "active",
294
+ run: null,
295
+ setup: null,
296
+ commands: Object.fromEntries([...coreFunctionNames, "ping", "status"].map((name) => [
297
+ name,
298
+ {
299
+ handler: resolveCoreCommandHandler(name),
300
+ },
301
+ ])),
302
+ exports: null,
303
+ error: null,
304
+ guardOptions: { timeout: false },
305
+ });
306
+ for (const plugin of allPlugins.values()) {
307
+ if (plugin.status !== "active")
308
+ continue;
309
+ if (!plugin.commands)
310
+ continue;
311
+ for (const [fn, def] of Object.entries(plugin.commands)) {
312
+ const id = pluginCommandKey(plugin.name, fn);
313
+ // Only a genuinely broken export (not a function and not an
314
+ // object at all) is unusable no matter what commands.yaml says
315
+ // later — that's the one case worth a warning + a full skip.
316
+ if (!isUsablePluginCommandExport(def)) {
317
+ logger.warn(t("system.commandRegistryInvalidPluginCommand", {
318
+ id,
319
+ plugin: plugin.name,
320
+ function: fn
321
+ }));
322
+ continue;
323
+ }
324
+ const norm = normalizePluginCommand(def);
325
+ // Remembered regardless of whether the plugin gave it a `cmd` —
326
+ // a "pure function" export (bare handler, no built-in identity)
327
+ // is only reachable once a commands.yaml entry supplies `cmd`,
328
+ // but it still needs to be findable by pluginCommandKey() below.
329
+ // A malformed `cmd` (present but not a usable string) also lands
330
+ // here silently — not an error, just no auto-registration.
331
+ defaultsByKey.set(id, { plugin, fn, norm });
332
+ if (!norm.cmd)
333
+ continue; // no built-in identity — yaml-only until overridden
334
+ const manual = norm.manual ?? manuals[id] ?? manuals[norm.cmd] ?? null;
335
+ const category = norm.category;
336
+ const entry = {
337
+ id,
338
+ cmd: norm.cmd,
339
+ aliases: norm.aliases,
340
+ desc: norm.desc,
341
+ category,
342
+ group: null,
343
+ manual,
344
+ source: "plugin",
345
+ pluginName: plugin.name,
346
+ function: fn,
347
+ functions: [fn],
348
+ loading: resolveLoadingChain(null, category, categoryLoading, defaults),
349
+ handler: norm.handler,
350
+ text: null,
351
+ permissions: resolvePermissions(null, null, norm.permissions, defaults.permissions, defaults.messages, category ? categories[category]?.scope : null),
352
+ arguments: [],
353
+ subcommands: {},
354
+ categoryHiddenInScope: null,
355
+ hiddenOutsideScope: null,
356
+ };
357
+ entry.hiddenOutsideScope = entry.permissions.hiddenOutsideScope;
358
+ byId.set(id, entry);
359
+ }
360
+ }
361
+ if (specs) {
362
+ for (const spec of specs) {
363
+ const primaryFn = spec.functions[0] ?? null;
364
+ if (spec.plugin && primaryFn) {
365
+ const key = pluginCommandKey(spec.plugin, primaryFn);
366
+ const pluginDefault = defaultsByKey.get(key);
367
+ if (!pluginDefault) {
368
+ logger.warn(t("system.commandRegistryOrphanEntry", {
369
+ id: spec.id,
370
+ plugin: spec.plugin,
371
+ function: primaryFn
372
+ }));
373
+ continue;
374
+ }
375
+ const norm = pluginDefault.norm;
376
+ let existing = byId.get(key);
377
+ if (!existing) {
378
+ // Pure-function plugin export: no auto-registered entry yet
379
+ // (norm.cmd was null), so commands.yaml is the sole source of
380
+ // `cmd`. Without it there's nothing to invoke this command by.
381
+ const cmd = spec.cmd;
382
+ if (!cmd) {
383
+ logger.warn(t("system.commandRegistryOrphanEntry", {
384
+ id: spec.id,
385
+ plugin: spec.plugin,
386
+ function: primaryFn
387
+ }));
388
+ continue;
389
+ }
390
+ existing = {
391
+ id: key,
392
+ cmd,
393
+ aliases: [],
394
+ desc: null,
395
+ category: null,
396
+ group: null,
397
+ manual: null,
398
+ source: "plugin",
399
+ pluginName: spec.plugin,
400
+ function: primaryFn,
401
+ functions: [primaryFn],
402
+ loading: resolveLoadingChain(spec.loading, null, categoryLoading, defaults),
403
+ handler: norm.handler,
404
+ text: null,
405
+ permissions: resolvePermissions(null, null, null, defaults.permissions, defaults.messages, null),
406
+ arguments: [],
407
+ subcommands: {},
408
+ categoryHiddenInScope: null,
409
+ hiddenOutsideScope: null,
410
+ };
411
+ existing.hiddenOutsideScope = existing.permissions.hiddenOutsideScope;
412
+ byId.set(key, existing);
413
+ }
414
+ if (spec.cmd)
415
+ existing.cmd = spec.cmd;
416
+ if (spec.aliases.length > 0)
417
+ existing.aliases = [...spec.aliases];
418
+ if (spec.desc)
419
+ existing.desc = spec.desc;
420
+ if (spec.category)
421
+ existing.category = spec.category;
422
+ if (spec.group !== null)
423
+ existing.group = spec.group;
424
+ existing.manual = spec.manual ?? norm.manual ?? manuals[spec.id] ?? manuals[existing.cmd] ?? null;
425
+ existing.permissions = resolvePermissions(spec.permissions, spec.messages, norm.permissions, defaults.permissions, defaults.messages, existing.category ? categories[existing.category]?.scope : null);
426
+ // Function chain: explicit `functions:` wins; fall back to the
427
+ // single primary if the YAML only declared `function:` /
428
+ // `plugin:` shorthand. Empty list intentionally means "no
429
+ // handlers" — see `CommandSpec.functions` doc.
430
+ if (spec.functions.length > 0)
431
+ existing.functions = [...spec.functions];
432
+ else if (primaryFn)
433
+ existing.functions = [primaryFn];
434
+ existing.arguments = [...spec.arguments];
435
+ existing.categoryHiddenInScope = resolveCategoryHiddenInScope(existing.category, categories);
436
+ existing.hiddenOutsideScope = existing.permissions.hiddenOutsideScope;
437
+ existing.loading = resolveLoadingChain(spec.loading, existing.category, categoryLoading, defaults);
438
+ existing.subcommands = buildSubcommandsFromSpecs(spec.subcommands, existing, spec.plugin, defaultsByKey, defaults, manuals, loadingPresets);
439
+ }
440
+ else if (spec.plugin && spec.functions.length === 0) {
441
+ // Metadata-only plugin entry: no handler of its own. Either a
442
+ // parent container grouping subcommands that each declare their
443
+ // own `function:` (e.g. `todo:`), or a bare declarative entry
444
+ // with no subcommands at all — either way `functions` stays
445
+ // empty and runCommand() short-circuits to "no_dispatch" (see
446
+ // its `fnNames.length === 0` check).
447
+ const id = spec.subcommands.length > 0 ? `parent::${spec.id}` : spec.id;
448
+ const entry = {
449
+ id,
450
+ cmd: spec.cmd,
451
+ aliases: [...spec.aliases],
452
+ desc: spec.desc,
453
+ category: spec.category ?? null,
454
+ group: spec.group,
455
+ manual: spec.manual ?? manuals[spec.id] ?? manuals[spec.cmd] ?? null,
456
+ source: "plugin",
457
+ pluginName: spec.plugin,
458
+ function: null,
459
+ functions: [],
460
+ loading: resolveLoadingChain(spec.loading, spec.category ?? null, categoryLoading, defaults),
461
+ handler: null,
462
+ text: null,
463
+ permissions: resolvePermissions(spec.permissions, spec.messages, null, defaults.permissions, defaults.messages, spec.category ? categories[spec.category]?.scope : null),
464
+ arguments: [...spec.arguments],
465
+ subcommands: {},
466
+ categoryHiddenInScope: resolveCategoryHiddenInScope(spec.category, categories),
467
+ hiddenOutsideScope: resolvePermissions(spec.permissions, spec.messages, null, defaults.permissions, defaults.messages, spec.category ? categories[spec.category]?.scope : null).hiddenOutsideScope,
468
+ };
469
+ entry.subcommands = buildSubcommandsFromSpecs(spec.subcommands, entry, spec.plugin, defaultsByKey, defaults, manuals, loadingPresets);
470
+ byId.set(id, entry);
471
+ }
472
+ else if (!spec.plugin) {
473
+ if (!spec.text) {
474
+ logger.warn(t("system.commandRegistryInvalidEntry", {
475
+ id: spec.id
476
+ }));
477
+ continue;
478
+ }
479
+ const id = `text::${spec.id}`;
480
+ const manual = spec.manual ?? manuals[spec.id] ?? manuals[spec.cmd] ?? null;
481
+ const entryPerms = resolvePermissions(spec.permissions, spec.messages, null, defaults.permissions, defaults.messages, spec.category ? categories[spec.category]?.scope : null);
482
+ const entry = {
483
+ id,
484
+ cmd: spec.cmd,
485
+ aliases: [...spec.aliases],
486
+ desc: spec.desc,
487
+ category: spec.category ?? null,
488
+ group: spec.group,
489
+ manual,
490
+ source: "text",
491
+ pluginName: null,
492
+ function: null,
493
+ functions: [],
494
+ loading: resolveLoadingChain(spec.loading, spec.category ?? null, categoryLoading, defaults),
495
+ handler: null,
496
+ text: spec.text,
497
+ permissions: entryPerms,
498
+ arguments: [...spec.arguments],
499
+ subcommands: {},
500
+ categoryHiddenInScope: resolveCategoryHiddenInScope(spec.category, categories),
501
+ hiddenOutsideScope: entryPerms.hiddenOutsideScope,
502
+ };
503
+ entry.subcommands = buildSubcommandsFromSpecs(spec.subcommands, entry, null, defaultsByKey, defaults, manuals, loadingPresets);
504
+ byId.set(id, entry);
505
+ }
506
+ else {
507
+ // spec.plugin set, but neither `function:` nor a non-empty
508
+ // `subcommands:` block — nothing tells the kernel what to run.
509
+ logger.warn(t("system.commandRegistryInvalidEntry", {
510
+ id: spec.id
511
+ }));
512
+ }
513
+ }
514
+ }
515
+ for (const entry of byId.values()) {
516
+ registerInvocationWithDeprecationGuard(byInvocation, entry.cmd, entry.id, false, defaults.notifyChanges);
517
+ for (const alias of entry.aliases) {
518
+ registerInvocationWithDeprecationGuard(byInvocation, alias, entry.id, false, defaults.notifyChanges);
519
+ }
520
+ }
521
+ // Validate menu cmd + aliases against command invocations. Fully opt-in:
522
+ // when menu.enabled is false (default), the native menu claims nothing —
523
+ // "menu"/"help" stay free for a plugin (legacy or commands.yaml) to use.
524
+ const menuAliases = new Set();
525
+ if (menu.enabled) {
526
+ const menuInvocations = [menu.cmd, ...menu.aliases.filter(a => a !== menu.cmd)];
527
+ for (const alias of menuInvocations) {
528
+ const existing = byInvocation.get(alias);
529
+ if (existing !== undefined) {
530
+ logger.warn(t("system.commandRegistryMenuAliasCollision", {
531
+ alias,
532
+ winner: existing
533
+ }));
534
+ }
535
+ else {
536
+ menuAliases.add(alias);
537
+ }
538
+ }
539
+ }
540
+ return { byId, byInvocation, defaults, menu, menuAliases, categories, manuals, loadingPresets, categoryLoading, prefix };
541
+ }
542
+ let currentRegistry = null;
543
+ export async function initCommandRegistry() {
544
+ const config = await loadCommandsConfig(new Set(pluginRegistry.keys()));
545
+ const defaults = config?.defaults ?? {
546
+ notifyChanges: true,
547
+ notifyPeriodDays: 7,
548
+ notifyMessage: null,
549
+ };
550
+ const menu = config?.menu ?? { ...DEFAULT_MENU_CONFIG };
551
+ const categories = config?.categories ?? {};
552
+ const manuals = config?.manuals ?? {};
553
+ const loadingPresets = config?.loadingPresets ?? {};
554
+ const categoryLoading = config?.categoryLoading ?? {};
555
+ const prefix = config?.prefix ?? null;
556
+ const specs = config?.specs ?? [];
557
+ const registry = buildCommandRegistry(specs, pluginRegistry, defaults, menu, categories, manuals, loadingPresets, categoryLoading, prefix);
558
+ syncCommandHistory(registry.byId, defaults, specs);
559
+ currentRegistry = registry;
560
+ return registry;
561
+ }
562
+ export function getCommandRegistry() {
563
+ return currentRegistry;
564
+ }
565
+ /**
566
+ * Test-only: inject a registry built with {@link buildCommandRegistry}
567
+ * directly, bypassing `initCommandRegistry()`'s disk config + real
568
+ * `pluginRegistry` load. Lets `runCommand.test.ts` exercise
569
+ * `resolveDispatch()`/`runCommand()`, which read the module-singleton
570
+ * `currentRegistry` and have no injectable-registry parameter (unlike
571
+ * `commandAccess.ts`'s functions). Pass `null` to reset.
572
+ */
573
+ export function __setRegistryForTests(registry) {
574
+ currentRegistry = registry;
575
+ }
576
+ export function getCommandByInvocation(command) {
577
+ if (!currentRegistry)
578
+ return null;
579
+ const id = currentRegistry.byInvocation.get(command);
580
+ if (id === undefined)
581
+ return null;
582
+ return currentRegistry.byId.get(id) ?? null;
583
+ }