@manybot/manybot 5.8.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 (45) hide show
  1. package/README.md +15 -7
  2. package/dist/client/store.js +35 -1
  3. package/dist/download/queue.js +13 -4
  4. package/dist/drivers/baileys/adapter.js +75 -8
  5. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  6. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  7. package/dist/drivers/baileys/api/index.js +212 -38
  8. package/dist/drivers/baileys/index.js +30 -6
  9. package/dist/drivers/baileys/messageHandler.js +207 -21
  10. package/dist/drivers/baileys/messageHandler.test.js +256 -14
  11. package/dist/drivers/baileysAdapter.test.js +97 -0
  12. package/dist/drivers/jid.js +26 -0
  13. package/dist/drivers/jid.test.js +35 -1
  14. package/dist/i18n/index.js +5 -22
  15. package/dist/kernel/chatOverrides.js +46 -0
  16. package/dist/kernel/chatOverrides.test.js +59 -0
  17. package/dist/kernel/commandAccess.test.js +2 -2
  18. package/dist/kernel/commandDeprecation.js +4 -2
  19. package/dist/kernel/commandDeprecation.test.js +8 -1
  20. package/dist/kernel/commandMenu.js +91 -2
  21. package/dist/kernel/commandMenu.test.js +131 -2
  22. package/dist/kernel/commandPermissions.js +69 -23
  23. package/dist/kernel/commandPermissions.test.js +77 -9
  24. package/dist/kernel/commandRegistry.js +167 -43
  25. package/dist/kernel/commandRegistry.test.js +4 -2
  26. package/dist/kernel/commandsConfig.js +470 -38
  27. package/dist/kernel/commandsConfig.test.js +249 -3
  28. package/dist/kernel/contactAutoSave.js +6 -6
  29. package/dist/kernel/coreCommands.js +62 -0
  30. package/dist/kernel/pluginApi.test.js +20 -3
  31. package/dist/kernel/pluginGuard.js +5 -3
  32. package/dist/kernel/pluginLoader.js +73 -10
  33. package/dist/kernel/pluginLoader.test.js +111 -1
  34. package/dist/kernel/runCommand.js +57 -18
  35. package/dist/kernel/runCommand.test.js +269 -7
  36. package/dist/kernel/settingsDb.js +15 -2
  37. package/dist/kernel/testConfig.js +9 -0
  38. package/dist/locales/en.json +14 -1
  39. package/dist/locales/es.json +17 -4
  40. package/dist/locales/pt.json +18 -5
  41. package/dist/plugins/__manybot_integration__/index.js +33 -16
  42. package/dist/plugins/__manybot_integration__/index.test.js +42 -8
  43. package/dist/utils/phoneNumber.js +83 -0
  44. package/dist/utils/phoneNumber.test.js +53 -0
  45. package/package.json +4 -3
@@ -4,6 +4,12 @@ import * as yaml from "js-yaml";
4
4
  import { logger } from "#logger";
5
5
  import { t } from "#i18n";
6
6
  import { PATHS } from "#config";
7
+ /**
8
+ * Sentinel returned from a command function to short-circuit the chain of
9
+ * `functions:`. Anything else (including `undefined` and a void return)
10
+ * lets the next function in the list run with the same args.
11
+ */
12
+ export const STOP_CHAIN = Symbol.for("manybot.stopChain");
7
13
  const COMMANDS_FILE = path.join(PATHS.HOME, "commands.yaml");
8
14
  function asString(value) {
9
15
  if (typeof value !== "string")
@@ -107,6 +113,29 @@ function parseGroupUserList(raw) {
107
113
  users: users.length > 0 ? users : undefined,
108
114
  };
109
115
  }
116
+ /**
117
+ * Map the flat-form scope aliases (`group_only` / `dm_only`) plus an
118
+ * explicit `scope:` into a single resolved "group" | "dm" | "any" | undefined
119
+ * value. `group_only: true` and `dm_only: true` are mutually exclusive —
120
+ * when both are set the function logs a warning and prefers the explicit
121
+ * `scope:` (or `group_only` if `scope` is also absent).
122
+ */
123
+ function parseScopeFromFlat(obj) {
124
+ const explicit = asString(obj.scope)?.toLowerCase();
125
+ if (explicit === "group" || explicit === "dm" || explicit === "any")
126
+ return explicit;
127
+ const groupOnly = obj.group_only;
128
+ const dmOnly = obj.dm_only;
129
+ if (asBool(groupOnly) === true && asBool(dmOnly) === true) {
130
+ logger.warn(t("system.commandsConfigConflictingScopeFlags", { id: "(permissions)" }));
131
+ return undefined;
132
+ }
133
+ if (asBool(groupOnly) === true)
134
+ return "group";
135
+ if (asBool(dmOnly) === true)
136
+ return "dm";
137
+ return undefined;
138
+ }
110
139
  function parsePermissions(raw) {
111
140
  if (raw === null || typeof raw !== "object" || Array.isArray(raw))
112
141
  return null;
@@ -114,24 +143,46 @@ function parsePermissions(raw) {
114
143
  const admin = asBool(obj.admin) ?? undefined;
115
144
  const botAdmin = asBool(obj.botAdmin) ?? undefined;
116
145
  const owner = asBool(obj.owner) ?? undefined;
117
- let scope = undefined;
118
- const scopeStr = asString(obj.scope)?.toLowerCase();
119
- if (scopeStr === "group" || scopeStr === "dm" || scopeStr === "any") {
120
- scope = scopeStr;
121
- }
146
+ const scope = parseScopeFromFlat(obj);
147
+ const dono = asString(obj.dono) ?? undefined;
122
148
  let cooldownSeconds = undefined;
123
149
  if (typeof obj.cooldownSeconds === "number" && Number.isFinite(obj.cooldownSeconds) && obj.cooldownSeconds >= 0) {
124
150
  cooldownSeconds = obj.cooldownSeconds;
125
151
  }
126
- const whitelist = parseGroupUserList(obj.whitelist);
127
- const blacklist = parseGroupUserList(obj.blacklist);
152
+ // Canonical nested whitelist/blacklist, with the flat-form
153
+ // `whitelist_groups` / `blacklist_users` fields merged in.
154
+ const nestedWhitelist = parseGroupUserList(obj.whitelist);
155
+ const nestedBlacklist = parseGroupUserList(obj.blacklist);
156
+ const flatWhitelistGroups = asAliasList(obj.whitelist_groups);
157
+ const flatBlacklistUsers = asAliasList(obj.blacklist_users);
158
+ let whitelist = nestedWhitelist;
159
+ if (flatWhitelistGroups.length > 0) {
160
+ whitelist = {
161
+ groups: [...(nestedWhitelist?.groups ?? []), ...flatWhitelistGroups],
162
+ users: nestedWhitelist?.users,
163
+ };
164
+ }
165
+ let blacklist = nestedBlacklist;
166
+ if (flatBlacklistUsers.length > 0) {
167
+ blacklist = {
168
+ groups: nestedBlacklist?.groups,
169
+ users: [...(nestedBlacklist?.users ?? []), ...flatBlacklistUsers],
170
+ };
171
+ }
172
+ const allowedChats = asAliasList(obj.allowed_chats);
173
+ const hiddenOutsideScope = asBool(obj.hidden_outside_scope) ?? undefined;
128
174
  if (admin === undefined &&
129
175
  botAdmin === undefined &&
130
176
  owner === undefined &&
131
177
  scope === undefined &&
132
178
  cooldownSeconds === undefined &&
133
179
  whitelist === undefined &&
134
- blacklist === undefined) {
180
+ blacklist === undefined &&
181
+ dono === undefined &&
182
+ allowedChats.length === 0 &&
183
+ hiddenOutsideScope === undefined &&
184
+ obj.group_only === undefined &&
185
+ obj.dm_only === undefined) {
135
186
  return null;
136
187
  }
137
188
  return {
@@ -142,6 +193,13 @@ function parsePermissions(raw) {
142
193
  cooldownSeconds,
143
194
  whitelist,
144
195
  blacklist,
196
+ dono: dono ?? undefined,
197
+ groupOnly: obj.group_only !== undefined ? asBool(obj.group_only) ?? undefined : undefined,
198
+ dmOnly: obj.dm_only !== undefined ? asBool(obj.dm_only) ?? undefined : undefined,
199
+ whitelistGroups: flatWhitelistGroups.length > 0 ? flatWhitelistGroups : undefined,
200
+ blacklistUsers: flatBlacklistUsers.length > 0 ? flatBlacklistUsers : undefined,
201
+ allowedChats: allowedChats.length > 0 ? allowedChats : undefined,
202
+ hiddenOutsideScope,
145
203
  };
146
204
  }
147
205
  function parseMessages(raw) {
@@ -151,19 +209,150 @@ function parseMessages(raw) {
151
209
  const botNotAdmin = asString(obj.botNotAdmin) ?? undefined;
152
210
  const senderNotAdmin = asString(obj.senderNotAdmin) ?? undefined;
153
211
  const ownerOnly = asString(obj.ownerOnly) ?? undefined;
212
+ const donoOnly = asString(obj.donoOnly) ?? undefined;
154
213
  const wrongScope = asString(obj.wrongScope) ?? undefined;
155
214
  const cooldown = asString(obj.cooldown) ?? undefined;
156
- if (!botNotAdmin && !senderNotAdmin && !ownerOnly && !wrongScope && !cooldown) {
215
+ const blacklist = asString(obj.blacklist) ?? undefined;
216
+ const allowedChats = asString(obj.allowedChats) ?? undefined;
217
+ if (!botNotAdmin && !senderNotAdmin && !ownerOnly && !donoOnly && !wrongScope && !cooldown && !blacklist && !allowedChats) {
157
218
  return null;
158
219
  }
159
220
  return {
160
221
  botNotAdmin,
161
222
  senderNotAdmin,
162
223
  ownerOnly,
224
+ donoOnly,
163
225
  wrongScope,
164
226
  cooldown,
227
+ blacklist,
228
+ allowedChats,
165
229
  };
166
230
  }
231
+ /**
232
+ * Translate the reference yaml's flat `permission_messages:` block into a
233
+ * `CommandMessages` shape — same keys, just renamed. Stored alongside the
234
+ * rest of `defaults.messages` so per-command `messages:` overrides still
235
+ * take precedence.
236
+ */
237
+ function parsePermissionMessagesBlock(raw) {
238
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
239
+ return null;
240
+ const obj = raw;
241
+ return parseMessages({
242
+ botNotAdmin: obj.admin_only,
243
+ senderNotAdmin: obj.admin_only,
244
+ ownerOnly: obj.dono_only,
245
+ donoOnly: obj.dono_only,
246
+ wrongScope: obj.group_only ?? obj.dm_only,
247
+ cooldown: obj.cooldown,
248
+ blacklist: obj.blacklist,
249
+ allowedChats: obj.allowed_chats,
250
+ });
251
+ }
252
+ // ── Loading indicator parsing ────────────────────────────────────────────────
253
+ const LOADING_TYPES = new Set([
254
+ "reaction", "typing", "recording_audio", "spinner", "none",
255
+ ]);
256
+ const LOADING_PROPS_BY_TYPE = {
257
+ reaction: ["icon", "onSuccess", "on_success", "onError", "on_error"],
258
+ typing: [],
259
+ recording_audio: [],
260
+ spinner: ["frames", "intervalMs", "interval_ms", "onSuccess", "on_success", "onError", "on_error"],
261
+ none: [],
262
+ };
263
+ /**
264
+ * Parse a single `loading:` value, which can be:
265
+ * - a preset name (string) — looked up in `presets`
266
+ * - an inline object: `{ type, ... }`
267
+ * Returns the resolved spec, or `null` when the value is absent. Throws
268
+ * nothing: malformed entries log a warning and are dropped (we keep going
269
+ * with `null`, which means "no override; fall back to the next level in
270
+ * the inheritance chain").
271
+ */
272
+ function parseLoadingSpec(raw, contextId, presets) {
273
+ if (raw === undefined || raw === null)
274
+ return null;
275
+ // `loading: spinner_classico` (preset reference)
276
+ if (typeof raw === "string") {
277
+ const trimmed = raw.trim();
278
+ if (trimmed.length === 0)
279
+ return null;
280
+ const preset = presets[trimmed];
281
+ if (!preset) {
282
+ logger.warn(t("system.commandsConfigLoadingPresetMissing", { id: contextId, name: trimmed }));
283
+ return null;
284
+ }
285
+ return preset;
286
+ }
287
+ // Inline: `loading: { type: reaction, icon: "⏳", ... }`
288
+ if (typeof raw !== "object" || Array.isArray(raw)) {
289
+ logger.warn(t("system.commandsConfigLoadingInvalid", { id: contextId }));
290
+ return null;
291
+ }
292
+ const obj = raw;
293
+ const typeStr = asString(obj.type)?.toLowerCase();
294
+ if (!typeStr || !LOADING_TYPES.has(typeStr)) {
295
+ logger.warn(t("system.commandsConfigLoadingUnknownType", { id: contextId, type: typeStr ?? "(none)" }));
296
+ return null;
297
+ }
298
+ const type = typeStr;
299
+ const allowed = new Set(LOADING_PROPS_BY_TYPE[type]);
300
+ const recognized = [];
301
+ for (const key of Object.keys(obj)) {
302
+ if (key === "type")
303
+ continue;
304
+ if (!allowed.has(key)) {
305
+ logger.error(t("system.commandsConfigLoadingUnknownProp", { id: contextId, type, key }));
306
+ return null; // malformed config — fail closed
307
+ }
308
+ recognized.push(key);
309
+ }
310
+ const spec = { type };
311
+ if (type === "reaction" || type === "spinner") {
312
+ if (obj.icon !== undefined) {
313
+ const icon = asString(obj.icon);
314
+ if (icon)
315
+ spec.icon = icon;
316
+ }
317
+ if (obj.onSuccess !== undefined || obj.on_success !== undefined) {
318
+ const s = asString(obj.onSuccess ?? obj.on_success);
319
+ if (s)
320
+ spec.onSuccess = s;
321
+ }
322
+ if (obj.onError !== undefined || obj.on_error !== undefined) {
323
+ const s = asString(obj.onError ?? obj.on_error);
324
+ if (s)
325
+ spec.onError = s;
326
+ }
327
+ }
328
+ if (type === "spinner") {
329
+ if (obj.frames !== undefined) {
330
+ const frames = asAliasList(obj.frames);
331
+ if (frames.length > 0)
332
+ spec.frames = frames;
333
+ }
334
+ if (obj.intervalMs !== undefined || obj.interval_ms !== undefined) {
335
+ const rawVal = obj.intervalMs ?? obj.interval_ms;
336
+ const n = typeof rawVal === "number" && Number.isFinite(rawVal) && rawVal >= 100
337
+ ? Math.max(1000, Math.floor(rawVal))
338
+ : null;
339
+ if (n !== null)
340
+ spec.intervalMs = n;
341
+ }
342
+ }
343
+ return spec;
344
+ }
345
+ function parseLoadingPresets(raw) {
346
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
347
+ return {};
348
+ const out = {};
349
+ for (const [name, value] of Object.entries(raw)) {
350
+ const spec = parseLoadingSpec(value, `loading_presets.${name}`, {});
351
+ if (spec)
352
+ out[name] = spec;
353
+ }
354
+ return out;
355
+ }
167
356
  const ARGUMENT_TYPES = new Set([
168
357
  "mention", "url", "media_direct", "media_reply", "number",
169
358
  "duration", "choice", "boolean", "quoted_text", "reply",
@@ -182,13 +371,28 @@ function parseArgument(raw, parentId) {
182
371
  logger.warn(t("system.commandsConfigArgumentMissingType", { id: parentId, name }));
183
372
  return null;
184
373
  }
185
- if (!ARGUMENT_TYPES.has(typeStr)) {
374
+ // Reference yaml uses `media_direct_or_reply` (the bot accepts either a
375
+ // direct media attachment or a reply-with-media). It's a superset of
376
+ // `media_reply`, so we silently normalize it down.
377
+ const normalizedType = typeStr === "media_direct_or_reply" ? "media_reply" : typeStr;
378
+ if (!ARGUMENT_TYPES.has(normalizedType)) {
186
379
  logger.warn(t("system.commandsConfigUnknownArgType", { id: parentId, name, type: typeStr }));
187
380
  return null;
188
381
  }
189
- const required = asBool(obj.required) ?? false;
382
+ // `required: true` and `optional: true` are two ways to say the same
383
+ // thing in opposite directions. The reference yaml prefers `optional:`.
384
+ let required;
385
+ if (asBool(obj.required) !== null) {
386
+ required = asBool(obj.required) ?? false;
387
+ }
388
+ else if (asBool(obj.optional) !== null) {
389
+ required = !(asBool(obj.optional) ?? false);
390
+ }
391
+ else {
392
+ required = false;
393
+ }
190
394
  let choices;
191
- if (typeStr === "choice") {
395
+ if (normalizedType === "choice") {
192
396
  choices = asAliasList(obj.choices);
193
397
  if (choices.length === 0) {
194
398
  logger.warn(t("system.commandsConfigChoiceArgumentNoChoices", { id: parentId, name }));
@@ -197,7 +401,7 @@ function parseArgument(raw, parentId) {
197
401
  }
198
402
  return {
199
403
  name,
200
- type: typeStr,
404
+ type: normalizedType,
201
405
  required,
202
406
  choices,
203
407
  };
@@ -217,7 +421,7 @@ function parseArguments(raw, parentId) {
217
421
  }
218
422
  return out;
219
423
  }
220
- async function parseSubcommand(parentId, raw) {
424
+ async function parseSubcommand(parentId, raw, presets, onPlugin) {
221
425
  if (raw === null || typeof raw !== "object" || Array.isArray(raw))
222
426
  return null;
223
427
  const obj = raw;
@@ -233,15 +437,20 @@ async function parseSubcommand(parentId, raw) {
233
437
  id,
234
438
  cmd,
235
439
  aliases: asAliasList(obj.aliases),
236
- function: asString(obj.function),
440
+ // Per-sub function chain. `functions: [...]` wins; a single
441
+ // `function: "x"` becomes a one-element list. `null` means "inherit
442
+ // the parent's chain at build time" — the kernel resolves that in
443
+ // commandRegistry, not here.
444
+ functions: parseFunctionList(obj.function, obj.functions, onPlugin),
445
+ loading: parseLoadingSpec(obj.loading, id, presets),
237
446
  desc: parseLocalizedString(obj.desc),
238
447
  manual,
239
- arguments: parseArguments(obj.arguments, id),
448
+ arguments: parseArguments(obj.arguments ?? obj.args, id),
240
449
  permissions: parsePermissions(obj.permissions),
241
450
  messages: parseMessages(obj.messages),
242
451
  };
243
452
  }
244
- async function parseSubcommands(raw, parentId) {
453
+ async function parseSubcommands(raw, parentId, presets, onPlugin) {
245
454
  if (raw === undefined || raw === null)
246
455
  return [];
247
456
  if (!Array.isArray(raw)) {
@@ -250,13 +459,62 @@ async function parseSubcommands(raw, parentId) {
250
459
  }
251
460
  const out = [];
252
461
  for (const item of raw) {
253
- const sub = await parseSubcommand(parentId, item);
462
+ const sub = await parseSubcommand(parentId, item, presets, onPlugin);
254
463
  if (sub)
255
464
  out.push(sub);
256
465
  }
257
466
  return out;
258
467
  }
468
+ /**
469
+ * Resolve `function:` (single name) and/or `functions:` (ordered list)
470
+ * into a single ordered chain. Reference yaml mixes both forms per command.
471
+ * Empty result means "inherit the parent's chain at build time" — the
472
+ * spec carries `null` to flag that, and the registry resolves it.
473
+ *
474
+ * Qualified items use the `plugin.function` form. `core` is the reserved
475
+ * kernel namespace; external plugins use their canonical `owner/plugin` key.
476
+ * The registry stores and dispatches only the function portion because a
477
+ * command has one owning plugin.
478
+ */
479
+ function splitQualifiedFunction(value) {
480
+ const dot = value.indexOf(".");
481
+ if (dot <= 0 || dot === value.length - 1) {
482
+ return { plugin: null, functionName: value };
483
+ }
484
+ return {
485
+ plugin: value.slice(0, dot),
486
+ functionName: value.slice(dot + 1),
487
+ };
488
+ }
489
+ function parseFunctionList(single, list, onPlugin) {
490
+ if (list !== undefined && list !== null) {
491
+ if (!Array.isArray(list))
492
+ return [];
493
+ const out = [];
494
+ for (const item of list) {
495
+ const s = asString(item);
496
+ if (s) {
497
+ const qualified = splitQualifiedFunction(s);
498
+ if (qualified.plugin)
499
+ onPlugin?.(qualified.plugin);
500
+ out.push(qualified.functionName);
501
+ }
502
+ }
503
+ return out;
504
+ }
505
+ if (single !== undefined && single !== null) {
506
+ const s = asString(single);
507
+ if (!s)
508
+ return [];
509
+ const qualified = splitQualifiedFunction(s);
510
+ if (qualified.plugin)
511
+ onPlugin?.(qualified.plugin);
512
+ return [qualified.functionName];
513
+ }
514
+ return null;
515
+ }
259
516
  const DEFAULT_MENU_CONFIG = {
517
+ enabled: false,
260
518
  title: "🤖 ManyBot — Menu",
261
519
  intro: {
262
520
  en: "Use {prefix}<command> to run it or {prefix}help <command> to view its manual.",
@@ -267,23 +525,34 @@ const DEFAULT_MENU_CONFIG = {
267
525
  cmd: "menu",
268
526
  aliases: ["help", "man", "menu", "bot", "?"],
269
527
  notFoundFallback: false,
528
+ suggestSimilar: false,
529
+ suggestMaxDistance: 2,
270
530
  welcomeMessage: null,
271
531
  welcomeWindowDays: 3,
272
532
  pageSize: 15,
273
533
  };
274
534
  function parseMenu(raw) {
535
+ if (raw === undefined) {
536
+ return { ...DEFAULT_MENU_CONFIG };
537
+ }
275
538
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
276
539
  return { ...DEFAULT_MENU_CONFIG };
277
540
  }
278
541
  const obj = raw;
279
542
  const aliases = asAliasList(obj.aliases);
280
543
  return {
544
+ // Block present in commands.yaml → enabled by default; `enabled: false`
545
+ // inside it still opts back out (e.g. keeping notFoundFallback/welcome
546
+ // config without claiming the menu/help invocations).
547
+ enabled: asBool(obj.enabled) ?? true,
281
548
  title: parseLocalizedString(obj.title) ?? DEFAULT_MENU_CONFIG.title,
282
549
  intro: parseLocalizedString(obj.intro) ?? DEFAULT_MENU_CONFIG.intro,
283
550
  footer: parseLocalizedString(obj.footer) ?? DEFAULT_MENU_CONFIG.footer,
284
551
  cmd: asString(obj.cmd) ?? DEFAULT_MENU_CONFIG.cmd,
285
- aliases: aliases.length > 0 ? aliases : [...DEFAULT_MENU_CONFIG.aliases],
552
+ aliases: obj.aliases !== undefined ? aliases : [...DEFAULT_MENU_CONFIG.aliases],
286
553
  notFoundFallback: asBool(obj.notFoundFallback) ?? DEFAULT_MENU_CONFIG.notFoundFallback,
554
+ suggestSimilar: asBool(obj.suggestSimilar) ?? DEFAULT_MENU_CONFIG.suggestSimilar,
555
+ suggestMaxDistance: asPositiveInt(obj.suggestMaxDistance, DEFAULT_MENU_CONFIG.suggestMaxDistance),
287
556
  welcomeMessage: parseLocalizedString(obj.welcomeMessage) ?? DEFAULT_MENU_CONFIG.welcomeMessage,
288
557
  welcomeWindowDays: asPositiveInt(obj.welcomeWindowDays, DEFAULT_MENU_CONFIG.welcomeWindowDays),
289
558
  pageSize: asPositiveInt(obj.pageSize, DEFAULT_MENU_CONFIG.pageSize),
@@ -295,11 +564,13 @@ function parseScopeValue(raw) {
295
564
  return s;
296
565
  return null;
297
566
  }
298
- function parseCategories(raw) {
299
- if (raw === null || typeof raw !== "object" || Array.isArray(raw))
300
- return {};
567
+ function parseCategories(raw, presets) {
568
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
569
+ return { categories: {}, categoryLoading: {} };
570
+ }
301
571
  const obj = raw;
302
- const out = {};
572
+ const categories = {};
573
+ const categoryLoading = {};
303
574
  for (const [catKey, value] of Object.entries(obj)) {
304
575
  if (value === null || typeof value !== "object" || Array.isArray(value))
305
576
  continue;
@@ -308,14 +579,17 @@ function parseCategories(raw) {
308
579
  const order = typeof catObj.order === "number" && Number.isFinite(catObj.order) ? catObj.order : 999;
309
580
  const scope = parseScopeValue(catObj.scope);
310
581
  const hiddenInScope = parseScopeValue(catObj.hiddenInScope);
311
- out[catKey] = {
582
+ categories[catKey] = {
312
583
  label,
313
584
  order,
314
585
  scope: scope ?? null,
315
586
  hiddenInScope: hiddenInScope ?? null,
316
587
  };
588
+ const loading = parseLoadingSpec(catObj.loading, `categories.${catKey}`, presets);
589
+ if (loading)
590
+ categoryLoading[catKey] = loading;
317
591
  }
318
- return out;
592
+ return { categories, categoryLoading };
319
593
  }
320
594
  async function parseManuals(raw) {
321
595
  if (raw === null || typeof raw !== "object" || Array.isArray(raw))
@@ -334,7 +608,7 @@ async function parseManuals(raw) {
334
608
  }
335
609
  const DEFAULT_NOTIFY_CHANGES = true;
336
610
  const DEFAULT_NOTIFY_PERIOD_DAYS = 7;
337
- function parseDefaults(raw) {
611
+ function parseDefaults(raw, presets) {
338
612
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
339
613
  return {
340
614
  notifyChanges: DEFAULT_NOTIFY_CHANGES,
@@ -342,6 +616,7 @@ function parseDefaults(raw) {
342
616
  notifyMessage: null,
343
617
  permissions: null,
344
618
  messages: null,
619
+ loading: null,
345
620
  };
346
621
  }
347
622
  const obj = raw;
@@ -351,9 +626,47 @@ function parseDefaults(raw) {
351
626
  notifyMessage: asString(obj.notifyMessage),
352
627
  permissions: parsePermissions(obj.permissions),
353
628
  messages: parseMessages(obj.messages),
629
+ loading: parseLoadingSpec(obj.loading, "defaults", presets),
354
630
  };
355
631
  }
356
- async function parseEntry(id, raw) {
632
+ /**
633
+ * Normalize the value of a `plugin:` entry against the live
634
+ * `pluginRegistry`. The parser runs after `loadPlugins()` has populated
635
+ * the registry, so by the time this fires every active plugin is
636
+ * reachable under its full `owner/repo` key.
637
+ *
638
+ * Accepted shapes:
639
+ * - full registry key `owner/repo` — used verbatim if it matches;
640
+ * - bare `name` (no slash) — resolved to the unique `.../name` key
641
+ * in the registry. If zero keys match, the original `name` is
642
+ * returned. If more than one matches, the original `name` is also
643
+ * returned so the ambiguity surfaces at dispatch instead of being
644
+ * silently resolved by picking one owner;
645
+ * - inline `owner/repo.fn` or `name.fn` — split on the first dot by
646
+ * `parseEntry` before reaching here; only the prefix half is
647
+ * passed in.
648
+ *
649
+ * Pure: no I/O, no module imports. The registry is passed in because
650
+ * importing `pluginLoader.ts` from this file would close a
651
+ * commandsConfig to pluginLoader to commandRegistry to commandsConfig
652
+ * cycle (this module already imports types from commandRegistry).
653
+ */
654
+ function resolvePluginKey(raw, validPluginKeys) {
655
+ if (validPluginKeys.has(raw))
656
+ return raw;
657
+ if (raw.includes("/"))
658
+ return raw;
659
+ let match = null;
660
+ for (const key of validPluginKeys) {
661
+ if (key.endsWith("/" + raw)) {
662
+ if (match !== null)
663
+ return raw;
664
+ match = key;
665
+ }
666
+ }
667
+ return match ?? raw;
668
+ }
669
+ async function parseEntry(id, raw, presets, validPluginKeys) {
357
670
  const cmd = asString(raw.cmd);
358
671
  if (!cmd) {
359
672
  logger.warn(t("system.commandsConfigMissingCmd", {
@@ -366,12 +679,52 @@ async function parseEntry(id, raw) {
366
679
  const text = rawText ? await resolveFileRef(rawText) : null;
367
680
  const rawManual = parseLocalizedString(raw.manual);
368
681
  const manual = rawManual ? await resolveFileRef(rawManual) : null;
682
+ // `plugin:` accepts both an `owner/repo` registry key (the canonical
683
+ // form) and the legacy shorthand `name` (resolved against the active
684
+ // `pluginRegistry` keys passed in via `validPluginKeys`). It also
685
+ // accepts an inline form split on the first dot, where everything
686
+ // after the dot is a function name treated as the head of the
687
+ // `functions:` chain. When `validPluginKeys` is provided the parser
688
+ // prefers an exact registry key and falls back to a `/<plugin>` suffix
689
+ // match (one plugin per name across owners); without it the value is
690
+ // used verbatim and the caller is responsible for resolving it.
691
+ const pluginFull = asString(raw.plugin);
692
+ let plugin = null;
693
+ let inlineFn = null;
694
+ if (pluginFull) {
695
+ const dot = pluginFull.indexOf(".");
696
+ if (dot >= 0) {
697
+ plugin = pluginFull.slice(0, dot);
698
+ inlineFn = pluginFull.slice(dot + 1);
699
+ }
700
+ else {
701
+ plugin = pluginFull;
702
+ }
703
+ if (validPluginKeys)
704
+ plugin = resolvePluginKey(plugin, validPluginKeys);
705
+ }
706
+ // `function:` → `functions: [fn]`, `functions: [...]` → as-is,
707
+ // "core.ping" inline above → `[ping]`, no fields → null (inherit).
708
+ let functionPlugin = null;
709
+ const inlineFunctions = parseFunctionList(raw.function, raw.functions, (name) => {
710
+ functionPlugin ??= name;
711
+ });
712
+ const functions = inlineFn !== null
713
+ ? [inlineFn, ...(inlineFunctions ?? [])]
714
+ : inlineFunctions;
715
+ const subcommands = await parseSubcommands(raw.subcommands, id, presets, (name) => {
716
+ functionPlugin ??= name;
717
+ });
718
+ if (!plugin && functionPlugin) {
719
+ plugin = resolvePluginKey(functionPlugin, validPluginKeys ?? new Set());
720
+ }
369
721
  return {
370
722
  id,
371
723
  cmd,
372
724
  aliases: asAliasList(raw.aliases),
373
- plugin: asString(raw.plugin),
374
- function: asString(raw.function),
725
+ plugin,
726
+ functions: functions ?? [],
727
+ loading: parseLoadingSpec(raw.loading, id, presets),
375
728
  text,
376
729
  desc: parseLocalizedString(raw.desc),
377
730
  category: asString(raw.category),
@@ -381,11 +734,17 @@ async function parseEntry(id, raw) {
381
734
  notifyChanges: asBool(raw.notifyChanges),
382
735
  permissions: parsePermissions(raw.permissions),
383
736
  messages: parseMessages(raw.messages),
384
- arguments: parseArguments(raw.arguments, id),
385
- subcommands: await parseSubcommands(raw.subcommands, id),
737
+ arguments: parseArguments(raw.arguments ?? raw.args, id),
738
+ subcommands,
386
739
  };
387
740
  }
388
- const RESERVED_KEYS = new Set(["defaults", "menu", "categories", "manuals", "import"]);
741
+ const RESERVED_KEYS = new Set([
742
+ "defaults", "menu", "categories", "manuals", "import",
743
+ "loading_presets", "loading",
744
+ "prefix",
745
+ "notify_changes", "notify_period_days", "deprecation_message", "permission_messages",
746
+ "commands",
747
+ ]);
389
748
  /**
390
749
  * Resolves `import:` (a path or list of paths, relative to PATHS.HOME) by
391
750
  * reading each referenced YAML file and folding its top-level sections
@@ -445,7 +804,45 @@ async function resolveImports(root) {
445
804
  }
446
805
  return merged;
447
806
  }
448
- export async function loadCommandsConfig() {
807
+ /**
808
+ * Unwrap a `commands:` wrapper block, if present, so the rest of the
809
+ * loader doesn't need to know whether the user wrote
810
+ *
811
+ * mycommand:
812
+ * cmd: foo
813
+ *
814
+ * or
815
+ *
816
+ * commands:
817
+ * mycommand:
818
+ * cmd: foo
819
+ *
820
+ * Both forms get parsed identically. The reference yaml uses the wrapper
821
+ * because it's clearer once you also have a `defaults:` block at the top
822
+ * (otherwise the command ids sit at the same indent as `defaults` and the
823
+ * file looks ambiguous).
824
+ */
825
+ function unwrapCommandsWrapper(root) {
826
+ const wrapper = root.commands;
827
+ if (wrapper === undefined || wrapper === null)
828
+ return root;
829
+ if (typeof wrapper !== "object" || Array.isArray(wrapper)) {
830
+ logger.error(t("system.commandsConfigCommandsWrapperInvalid"));
831
+ return root;
832
+ }
833
+ const inner = wrapper;
834
+ const out = { ...root };
835
+ delete out.commands;
836
+ for (const [key, value] of Object.entries(inner)) {
837
+ if (key in out) {
838
+ logger.error(t("system.commandsConfigCommandsWrapperCollision", { key }));
839
+ continue;
840
+ }
841
+ out[key] = value;
842
+ }
843
+ return out;
844
+ }
845
+ export async function loadCommandsConfig(validPluginKeys) {
449
846
  let raw;
450
847
  try {
451
848
  raw = await fs.readFile(COMMANDS_FILE, "utf8");
@@ -474,16 +871,20 @@ export async function loadCommandsConfig() {
474
871
  }
475
872
  if (parsed === null || parsed === undefined) {
476
873
  return {
874
+ prefix: null,
477
875
  defaults: {
478
876
  notifyChanges: DEFAULT_NOTIFY_CHANGES,
479
877
  notifyPeriodDays: DEFAULT_NOTIFY_PERIOD_DAYS,
480
878
  notifyMessage: null,
481
879
  permissions: null,
482
880
  messages: null,
881
+ loading: null,
483
882
  },
484
883
  menu: { ...DEFAULT_MENU_CONFIG },
485
884
  categories: {},
486
885
  manuals: {},
886
+ loadingPresets: {},
887
+ categoryLoading: {},
487
888
  specs: [],
488
889
  };
489
890
  }
@@ -493,10 +894,41 @@ export async function loadCommandsConfig() {
493
894
  }));
494
895
  return null;
495
896
  }
496
- const root = await resolveImports(parsed);
497
- const defaults = parseDefaults(root.defaults);
897
+ const imported = await resolveImports(parsed);
898
+ const root = unwrapCommandsWrapper(imported);
899
+ const prefix = asString(root.prefix) ?? null;
900
+ const loadingPresets = parseLoadingPresets(root.loading_presets);
901
+ // Top-level notify_*/deprecation_message/permission_messages/loading act
902
+ // as a shallow overlay over `defaults:` — when present, each key wins
903
+ // over the same key under `defaults:`. Same shape, no deep merge (same
904
+ // rule as imports).
905
+ const defaultsRaw = (root.defaults ?? {});
906
+ const notifyChangesTop = asBool(root.notify_changes);
907
+ const notifyPeriodTop = typeof root.notify_period_days === "number" && Number.isFinite(root.notify_period_days)
908
+ ? root.notify_period_days
909
+ : null;
910
+ const deprecationMessageTop = asString(root.deprecation_message);
911
+ const permissionMessagesTop = parsePermissionMessagesBlock(root.permission_messages);
912
+ const mergedDefaults = { ...defaultsRaw };
913
+ if (notifyChangesTop !== null)
914
+ mergedDefaults.notifyChanges = notifyChangesTop;
915
+ if (notifyPeriodTop !== null)
916
+ mergedDefaults.notifyPeriodDays = notifyPeriodTop;
917
+ if (deprecationMessageTop !== null)
918
+ mergedDefaults.notifyMessage = deprecationMessageTop;
919
+ if (permissionMessagesTop)
920
+ mergedDefaults.messages = {
921
+ ...(defaultsRaw.messages ?? {}),
922
+ ...permissionMessagesTop,
923
+ };
924
+ // Top-level `loading: <preset-name-or-inline-spec>` — reference yaml's
925
+ // global default (`loading: padrao`). Only overlays when actually
926
+ // present; `defaults.loading` (nested form) still works on its own.
927
+ if (root.loading !== undefined)
928
+ mergedDefaults.loading = root.loading;
929
+ const defaults = parseDefaults(mergedDefaults, loadingPresets);
498
930
  const menu = parseMenu(root.menu);
499
- const categories = parseCategories(root.categories);
931
+ const { categories, categoryLoading } = parseCategories(root.categories, loadingPresets);
500
932
  const manuals = await parseManuals(root.manuals);
501
933
  const out = [];
502
934
  for (const [id, value] of Object.entries(root)) {
@@ -509,9 +941,9 @@ export async function loadCommandsConfig() {
509
941
  }));
510
942
  continue;
511
943
  }
512
- const spec = await parseEntry(id, value);
944
+ const spec = await parseEntry(id, value, loadingPresets, validPluginKeys);
513
945
  if (spec)
514
946
  out.push(spec);
515
947
  }
516
- return { defaults, menu, categories, manuals, specs: out };
948
+ return { prefix, defaults, menu, categories, manuals, loadingPresets, categoryLoading, specs: out };
517
949
  }