@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
@@ -2,11 +2,14 @@ import test, { describe } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { checkPermission, clearCooldowns, matchId, matchesAny } from "./commandPermissions.js";
4
4
  import { resolvePermissions } from "./commandRegistry.js";
5
+ function senderOf(pn) {
6
+ return { lid: null, pn };
7
+ }
5
8
  function createMockContext(overrides) {
6
9
  return {
7
10
  isGroup: false,
8
11
  chatId: "123456789@c.us",
9
- senderId: "5511999999999@c.us",
12
+ sender: senderOf("5511999999999@c.us"),
10
13
  isSenderAdmin: async () => false,
11
14
  isBotAdmin: async () => false,
12
15
  ...overrides,
@@ -25,12 +28,15 @@ function createMockEntry(specPerms, specMsgs, pluginPerms, defaultsPerms, defaul
25
28
  source: "plugin",
26
29
  pluginName: "testPlugin",
27
30
  function: null,
31
+ functions: [],
32
+ loading: null,
28
33
  handler: async () => "ok",
29
34
  text: null,
30
35
  permissions,
31
36
  arguments: [],
32
37
  subcommands: {},
33
38
  categoryHiddenInScope: null,
39
+ hiddenOutsideScope: null,
34
40
  };
35
41
  }
36
42
  describe("commandPermissions", () => {
@@ -51,7 +57,7 @@ describe("commandPermissions", () => {
51
57
  });
52
58
  test("owner permission check", async () => {
53
59
  const ownerEntry = createMockEntry({ owner: true }, { ownerOnly: "Only owner allowed" });
54
- const nonOwnerCtx = createMockContext({ senderId: "5511888888888@c.us" });
60
+ const nonOwnerCtx = createMockContext({ sender: senderOf("5511888888888@c.us") });
55
61
  // When OWNER_NUMBER is not set or sender does not match
56
62
  const res = await checkPermission(ownerEntry, nonOwnerCtx);
57
63
  assert.equal(res.allowed, false);
@@ -76,6 +82,38 @@ describe("commandPermissions", () => {
76
82
  const res4 = await checkPermission(dmEntry, dmCtx);
77
83
  assert.equal(res4.allowed, true);
78
84
  });
85
+ test("dono permission check (specific-owner JID, independent of OWNER_NUMBER)", async () => {
86
+ const donoEntry = createMockEntry({ dono: "5511999999999@c.us" }, { donoOnly: "Only the dono can use this" });
87
+ const nonDonoCtx = createMockContext({ sender: senderOf("5511888888888@c.us") });
88
+ const res1 = await checkPermission(donoEntry, nonDonoCtx);
89
+ assert.equal(res1.allowed, false);
90
+ if (!res1.allowed)
91
+ assert.equal(res1.message, "Only the dono can use this");
92
+ const donoCtx = createMockContext({ sender: senderOf("5511999999999@c.us") });
93
+ const res2 = await checkPermission(donoEntry, donoCtx);
94
+ assert.equal(res2.allowed, true);
95
+ });
96
+ test("dono check accepts either LID or PN form of the sender", async () => {
97
+ const donoEntry = createMockEntry({ dono: "5511999999999" });
98
+ const lidOnlyCtx = createMockContext({ sender: { lid: "5511999999999@lid", pn: null } });
99
+ assert.equal((await checkPermission(donoEntry, lidOnlyCtx)).allowed, true);
100
+ });
101
+ test("allowedChats check (closed list of chats the command may run in)", async () => {
102
+ const entry = createMockEntry({ allowedChats: ["120363111111111111@g.us"] }, { allowedChats: "This command doesn't run here" });
103
+ const allowedCtx = createMockContext({ isGroup: true, chatId: "120363111111111111@g.us" });
104
+ const disallowedCtx = createMockContext({ isGroup: true, chatId: "120363999999999999@g.us" });
105
+ const resAllowed = await checkPermission(entry, allowedCtx);
106
+ assert.equal(resAllowed.allowed, true);
107
+ const resDisallowed = await checkPermission(entry, disallowedCtx);
108
+ assert.equal(resDisallowed.allowed, false);
109
+ if (!resDisallowed.allowed)
110
+ assert.equal(resDisallowed.message, "This command doesn't run here");
111
+ });
112
+ test("allowedChats check is skipped entirely when the list is empty/unset", async () => {
113
+ const entry = createMockEntry({ allowedChats: [] });
114
+ const anyChatCtx = createMockContext({ isGroup: true, chatId: "120363000000000000@g.us" });
115
+ assert.equal((await checkPermission(entry, anyChatCtx)).allowed, true);
116
+ });
79
117
  test("blacklist check", async () => {
80
118
  const entry = createMockEntry({
81
119
  blacklist: {
@@ -83,9 +121,9 @@ describe("commandPermissions", () => {
83
121
  users: ["5511888888888@c.us"],
84
122
  },
85
123
  });
86
- const blacklistedUserCtx = createMockContext({ senderId: "5511888888888@c.us" });
124
+ const blacklistedUserCtx = createMockContext({ sender: senderOf("5511888888888@c.us") });
87
125
  const blacklistedGroupCtx = createMockContext({ isGroup: true, chatId: "120363999999999999@g.us" });
88
- const allowedCtx = createMockContext({ senderId: "5511999999999@c.us" });
126
+ const allowedCtx = createMockContext({ sender: senderOf("5511999999999@c.us") });
89
127
  assert.equal((await checkPermission(entry, blacklistedUserCtx)).allowed, false);
90
128
  assert.equal((await checkPermission(entry, blacklistedGroupCtx)).allowed, false);
91
129
  assert.equal((await checkPermission(entry, allowedCtx)).allowed, true);
@@ -97,14 +135,14 @@ describe("commandPermissions", () => {
97
135
  users: ["5511999999999@c.us"],
98
136
  },
99
137
  });
100
- const whitelistedUserCtx = createMockContext({ senderId: "5511999999999@c.us" });
101
- const unwhitelistedUserCtx = createMockContext({ senderId: "5511888888888@c.us" });
138
+ const whitelistedUserCtx = createMockContext({ sender: senderOf("5511999999999@c.us") });
139
+ const unwhitelistedUserCtx = createMockContext({ sender: senderOf("5511888888888@c.us") });
102
140
  assert.equal((await checkPermission(entry, whitelistedUserCtx)).allowed, true);
103
141
  assert.equal((await checkPermission(entry, unwhitelistedUserCtx)).allowed, false);
104
142
  });
105
143
  test("admin and botAdmin checks", async () => {
106
- const adminEntry = createMockEntry({ admin: true }, { senderNotAdmin: "Need admin" });
107
- const botAdminEntry = createMockEntry({ botAdmin: true }, { botNotAdmin: "Need bot admin" });
144
+ const adminEntry = createMockEntry({ admin: true }, { senderNotAdmin: "Need admin", wrongScope: "Group required" });
145
+ const botAdminEntry = createMockEntry({ botAdmin: true }, { botNotAdmin: "Need bot admin", wrongScope: "Group required" });
108
146
  const nonAdminGroupCtx = createMockContext({
109
147
  isGroup: true,
110
148
  isSenderAdmin: async () => false,
@@ -124,12 +162,24 @@ describe("commandPermissions", () => {
124
162
  if (!resBotAdmin.allowed)
125
163
  assert.equal(resBotAdmin.message, "Need bot admin");
126
164
  assert.equal((await checkPermission(adminEntry, adminGroupCtx)).allowed, true);
165
+ // Outside a group there's no admin concept at all — this must be
166
+ // reported as wrongScope, not as "you/the bot aren't admin", since
167
+ // isSenderAdmin()/isBotAdmin() are never even called here.
168
+ const dmCtx = createMockContext({ isGroup: false });
169
+ const resAdminDm = await checkPermission(adminEntry, dmCtx);
170
+ assert.equal(resAdminDm.allowed, false);
171
+ if (!resAdminDm.allowed)
172
+ assert.equal(resAdminDm.message, "Group required");
173
+ const resBotAdminDm = await checkPermission(botAdminEntry, dmCtx);
174
+ assert.equal(resBotAdminDm.allowed, false);
175
+ if (!resBotAdminDm.allowed)
176
+ assert.equal(resBotAdminDm.message, "Group required");
127
177
  assert.equal((await checkPermission(botAdminEntry, adminGroupCtx)).allowed, true);
128
178
  });
129
179
  test("cooldown check and state consumption", async () => {
130
180
  clearCooldowns();
131
181
  const entry = createMockEntry({ cooldownSeconds: 30 }, { cooldown: "Wait {{seconds}}s" });
132
- const ctx = createMockContext({ senderId: "5511999999999@c.us" });
182
+ const ctx = createMockContext({ sender: senderOf("5511999999999@c.us") });
133
183
  // First invocation passes and sets cooldown
134
184
  const res1 = await checkPermission(entry, ctx);
135
185
  assert.equal(res1.allowed, true);
@@ -144,6 +194,24 @@ describe("commandPermissions", () => {
144
194
  const res3 = await checkPermission(entry, ctx);
145
195
  assert.equal(res3.allowed, true);
146
196
  });
197
+ test("evaluation order: dono is checked before allowedChats/blacklist", async () => {
198
+ const entry = createMockEntry({
199
+ dono: "5511999999999@c.us",
200
+ allowedChats: ["120363111111111111@g.us"],
201
+ blacklist: { groups: [], users: ["5511888888888@c.us"] },
202
+ }, { donoOnly: "dono message", allowedChats: "chats message", blacklist: "blacklist message" });
203
+ // Sender fails dono AND would also fail allowedChats/blacklist — dono's
204
+ // message must win since it's evaluated first.
205
+ const ctx = createMockContext({
206
+ sender: senderOf("5511888888888@c.us"),
207
+ isGroup: true,
208
+ chatId: "120363999999999999@g.us",
209
+ });
210
+ const res = await checkPermission(entry, ctx);
211
+ assert.equal(res.allowed, false);
212
+ if (!res.allowed)
213
+ assert.equal(res.message, "dono message");
214
+ });
147
215
  test("cooldown is NOT consumed when an earlier check fails", async () => {
148
216
  clearCooldowns();
149
217
  const entry = createMockEntry({ scope: "group", cooldownSeconds: 30 }, { wrongScope: "Group only" });
@@ -12,12 +12,16 @@ import { t } from "#i18n";
12
12
  import { loadCommandsConfig, } from "./commandsConfig.js";
13
13
  import { pluginRegistry, resolvePluginCommandHandler, } from "./pluginLoader.js";
14
14
  import { getActiveDeprecation, syncCommandHistory } from "./commandDeprecation.js";
15
+ import { resolveCoreCommandHandler } from "./coreCommands.js";
15
16
  export const DEFAULT_PERMISSION_MESSAGES = {
16
17
  botNotAdmin: () => t("commandPermissions.botNotAdmin"),
17
18
  senderNotAdmin: () => t("commandPermissions.senderNotAdmin"),
18
19
  ownerOnly: () => t("commandPermissions.ownerOnly"),
20
+ donoOnly: () => t("commandPermissions.donoOnly"),
19
21
  wrongScope: () => t("commandPermissions.wrongScope"),
20
22
  cooldown: () => t("commandPermissions.cooldown"),
23
+ blacklist: () => t("commandPermissions.blacklist"),
24
+ allowedChats: () => t("commandPermissions.allowedChats"),
21
25
  };
22
26
  export function resolvePermissions(specPerms, specMsgs, pluginPerms, defaultsPerms, defaultsMsgs, fallbackScope) {
23
27
  const admin = specPerms?.admin ?? pluginPerms?.admin ?? false;
@@ -25,6 +29,33 @@ export function resolvePermissions(specPerms, specMsgs, pluginPerms, defaultsPer
25
29
  const scope = specPerms?.scope ?? pluginPerms?.scope ?? fallbackScope ?? "any";
26
30
  const owner = specPerms?.owner ?? pluginPerms?.owner ?? false;
27
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";
28
59
  const rawWhitelist = specPerms?.whitelist ?? pluginPerms?.whitelist ?? defaultsPerms?.whitelist ?? null;
29
60
  const rawBlacklist = specPerms?.blacklist ?? pluginPerms?.blacklist ?? defaultsPerms?.blacklist ?? null;
30
61
  const whitelist = rawWhitelist
@@ -43,8 +74,11 @@ export function resolvePermissions(specPerms, specMsgs, pluginPerms, defaultsPer
43
74
  botNotAdmin: specMsgs?.botNotAdmin ?? defaultsMsgs?.botNotAdmin ?? DEFAULT_PERMISSION_MESSAGES.botNotAdmin(),
44
75
  senderNotAdmin: specMsgs?.senderNotAdmin ?? defaultsMsgs?.senderNotAdmin ?? DEFAULT_PERMISSION_MESSAGES.senderNotAdmin(),
45
76
  ownerOnly: specMsgs?.ownerOnly ?? defaultsMsgs?.ownerOnly ?? undefined,
77
+ donoOnly: specMsgs?.donoOnly ?? defaultsMsgs?.donoOnly ?? DEFAULT_PERMISSION_MESSAGES.donoOnly(),
46
78
  wrongScope: specMsgs?.wrongScope ?? defaultsMsgs?.wrongScope ?? DEFAULT_PERMISSION_MESSAGES.wrongScope(),
47
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(),
48
82
  };
49
83
  return {
50
84
  admin,
@@ -54,6 +88,9 @@ export function resolvePermissions(specPerms, specMsgs, pluginPerms, defaultsPer
54
88
  cooldownSeconds,
55
89
  whitelist,
56
90
  blacklist,
91
+ dono,
92
+ allowedChats,
93
+ hiddenOutsideScope,
57
94
  messages,
58
95
  };
59
96
  }
@@ -133,7 +170,7 @@ function registerInvocationWithDeprecationGuard(byInvocation, text, entryId, isO
133
170
  return registerInvocation(byInvocation, text, entryId, isOverride);
134
171
  }
135
172
  const deprecation = getActiveDeprecation(text);
136
- if (deprecation) {
173
+ if (deprecation && deprecation.id !== entryId) {
137
174
  logger.warn(t("system.commandDeprecationReservedInvocation", {
138
175
  text,
139
176
  id: deprecation.id
@@ -142,7 +179,8 @@ function registerInvocationWithDeprecationGuard(byInvocation, text, entryId, isO
142
179
  }
143
180
  return registerInvocation(byInvocation, text, entryId, isOverride);
144
181
  }
145
- const DEFAULT_MENU_CONFIG = {
182
+ export const DEFAULT_MENU_CONFIG = {
183
+ enabled: false,
146
184
  title: "🤖 ManyBot — Menu",
147
185
  intro: {
148
186
  en: "Use {prefix}<command> to run it or {prefix}help <command> to view its manual.",
@@ -153,6 +191,8 @@ const DEFAULT_MENU_CONFIG = {
153
191
  cmd: "menu",
154
192
  aliases: ["help", "man", "menu", "bot", "?"],
155
193
  notFoundFallback: false,
194
+ suggestSimilar: false,
195
+ suggestMaxDistance: 2,
156
196
  welcomeMessage: null,
157
197
  welcomeWindowDays: 3,
158
198
  pageSize: 15,
@@ -164,19 +204,25 @@ function resolveCategoryHiddenInScope(category, categories) {
164
204
  }
165
205
  /**
166
206
  * 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.
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
+ *
169
215
  * Subcommand permissions inherit from the parent's resolved permissions
170
216
  * unless the spec overrides them — `parent.permissions.scope` is passed
171
217
  * as `fallbackScope` to close the category → command → subcommand chain.
172
218
  */
173
- function buildSubcommandFromSpec(spec, parent, pluginName, defaultsByKey, defaults, manuals) {
219
+ function buildSubcommandFromSpec(spec, parent, pluginName, defaultsByKey, defaults, manuals, loadingPresetOverrides) {
174
220
  const manual = spec.manual ?? manuals[spec.id] ?? null;
175
- const fnName = spec.function ?? parent.function ?? "";
221
+ const functions = spec.functions !== null ? [...spec.functions] : [...parent.functions];
222
+ const fnName = functions[0] ?? parent.function ?? "";
176
223
  // 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.
224
+ // siblings — resolve this sub's own default permissions from its own
225
+ // primary function, not from whatever the parent happens to carry.
180
226
  const subDef = pluginName && fnName
181
227
  ? defaultsByKey.get(pluginCommandKey(pluginName, fnName))?.norm ?? null
182
228
  : null;
@@ -187,11 +233,13 @@ function buildSubcommandFromSpec(spec, parent, pluginName, defaultsByKey, defaul
187
233
  desc: spec.desc,
188
234
  manual,
189
235
  function: fnName,
236
+ functions,
237
+ loading: spec.loading ?? parent.loading,
190
238
  arguments: [...spec.arguments],
191
239
  permissions: resolvePermissions(spec.permissions, spec.messages, subDef?.permissions ?? null, defaults.permissions, defaults.messages, parent.permissions.scope),
192
240
  };
193
241
  }
194
- function buildSubcommandsFromSpecs(specs, parent, pluginName, defaultsByKey, defaults, manuals) {
242
+ function buildSubcommandsFromSpecs(specs, parent, pluginName, defaultsByKey, defaults, manuals, loadingPresetOverrides) {
195
243
  const out = {};
196
244
  for (const spec of specs) {
197
245
  const token = spec.cmd.toLowerCase();
@@ -202,19 +250,60 @@ function buildSubcommandsFromSpecs(specs, parent, pluginName, defaultsByKey, def
202
250
  }));
203
251
  continue;
204
252
  }
205
- out[token] = buildSubcommandFromSpec(spec, parent, pluginName, defaultsByKey, defaults, manuals);
253
+ out[token] = buildSubcommandFromSpec(spec, parent, pluginName, defaultsByKey, defaults, manuals, loadingPresetOverrides);
206
254
  }
207
255
  return out;
208
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
+ }
209
273
  export function buildCommandRegistry(specs, pluginRegistry, defaults = {
210
274
  notifyChanges: true,
211
275
  notifyPeriodDays: 7,
212
276
  notifyMessage: null,
213
- }, menu = { ...DEFAULT_MENU_CONFIG }, categories = {}, manuals = {}) {
277
+ }, menu = { ...DEFAULT_MENU_CONFIG }, categories = {}, manuals = {}, loadingPresets = {}, categoryLoading = {}, prefix = null) {
214
278
  const byId = new Map();
215
279
  const byInvocation = new Map();
216
280
  const defaultsByKey = new Map();
217
- for (const plugin of pluginRegistry.values()) {
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()) {
218
307
  if (plugin.status !== "active")
219
308
  continue;
220
309
  if (!plugin.commands)
@@ -255,26 +344,31 @@ export function buildCommandRegistry(specs, pluginRegistry, defaults = {
255
344
  source: "plugin",
256
345
  pluginName: plugin.name,
257
346
  function: fn,
347
+ functions: [fn],
348
+ loading: resolveLoadingChain(null, category, categoryLoading, defaults),
258
349
  handler: norm.handler,
259
350
  text: null,
260
351
  permissions: resolvePermissions(null, null, norm.permissions, defaults.permissions, defaults.messages, category ? categories[category]?.scope : null),
261
352
  arguments: [],
262
353
  subcommands: {},
263
354
  categoryHiddenInScope: null,
355
+ hiddenOutsideScope: null,
264
356
  };
357
+ entry.hiddenOutsideScope = entry.permissions.hiddenOutsideScope;
265
358
  byId.set(id, entry);
266
359
  }
267
360
  }
268
361
  if (specs) {
269
362
  for (const spec of specs) {
270
- if (spec.plugin && spec.function) {
271
- const key = pluginCommandKey(spec.plugin, spec.function);
363
+ const primaryFn = spec.functions[0] ?? null;
364
+ if (spec.plugin && primaryFn) {
365
+ const key = pluginCommandKey(spec.plugin, primaryFn);
272
366
  const pluginDefault = defaultsByKey.get(key);
273
367
  if (!pluginDefault) {
274
368
  logger.warn(t("system.commandRegistryOrphanEntry", {
275
369
  id: spec.id,
276
370
  plugin: spec.plugin,
277
- function: spec.function
371
+ function: primaryFn
278
372
  }));
279
373
  continue;
280
374
  }
@@ -289,7 +383,7 @@ export function buildCommandRegistry(specs, pluginRegistry, defaults = {
289
383
  logger.warn(t("system.commandRegistryOrphanEntry", {
290
384
  id: spec.id,
291
385
  plugin: spec.plugin,
292
- function: spec.function
386
+ function: primaryFn
293
387
  }));
294
388
  continue;
295
389
  }
@@ -303,14 +397,18 @@ export function buildCommandRegistry(specs, pluginRegistry, defaults = {
303
397
  manual: null,
304
398
  source: "plugin",
305
399
  pluginName: spec.plugin,
306
- function: spec.function,
400
+ function: primaryFn,
401
+ functions: [primaryFn],
402
+ loading: resolveLoadingChain(spec.loading, null, categoryLoading, defaults),
307
403
  handler: norm.handler,
308
404
  text: null,
309
405
  permissions: resolvePermissions(null, null, null, defaults.permissions, defaults.messages, null),
310
406
  arguments: [],
311
407
  subcommands: {},
312
408
  categoryHiddenInScope: null,
409
+ hiddenOutsideScope: null,
313
410
  };
411
+ existing.hiddenOutsideScope = existing.permissions.hiddenOutsideScope;
314
412
  byId.set(key, existing);
315
413
  }
316
414
  if (spec.cmd)
@@ -325,16 +423,28 @@ export function buildCommandRegistry(specs, pluginRegistry, defaults = {
325
423
  existing.group = spec.group;
326
424
  existing.manual = spec.manual ?? norm.manual ?? manuals[spec.id] ?? manuals[existing.cmd] ?? null;
327
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];
328
434
  existing.arguments = [...spec.arguments];
329
435
  existing.categoryHiddenInScope = resolveCategoryHiddenInScope(existing.category, categories);
330
- existing.subcommands = buildSubcommandsFromSpecs(spec.subcommands, existing, spec.plugin, defaultsByKey, defaults, manuals);
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);
331
439
  }
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}`;
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;
338
448
  const entry = {
339
449
  id,
340
450
  cmd: spec.cmd,
@@ -346,14 +456,17 @@ export function buildCommandRegistry(specs, pluginRegistry, defaults = {
346
456
  source: "plugin",
347
457
  pluginName: spec.plugin,
348
458
  function: null,
459
+ functions: [],
460
+ loading: resolveLoadingChain(spec.loading, spec.category ?? null, categoryLoading, defaults),
349
461
  handler: null,
350
462
  text: null,
351
463
  permissions: resolvePermissions(spec.permissions, spec.messages, null, defaults.permissions, defaults.messages, spec.category ? categories[spec.category]?.scope : null),
352
464
  arguments: [...spec.arguments],
353
465
  subcommands: {},
354
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,
355
468
  };
356
- entry.subcommands = buildSubcommandsFromSpecs(spec.subcommands, entry, spec.plugin, defaultsByKey, defaults, manuals);
469
+ entry.subcommands = buildSubcommandsFromSpecs(spec.subcommands, entry, spec.plugin, defaultsByKey, defaults, manuals, loadingPresets);
357
470
  byId.set(id, entry);
358
471
  }
359
472
  else if (!spec.plugin) {
@@ -365,6 +478,7 @@ export function buildCommandRegistry(specs, pluginRegistry, defaults = {
365
478
  }
366
479
  const id = `text::${spec.id}`;
367
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);
368
482
  const entry = {
369
483
  id,
370
484
  cmd: spec.cmd,
@@ -376,14 +490,17 @@ export function buildCommandRegistry(specs, pluginRegistry, defaults = {
376
490
  source: "text",
377
491
  pluginName: null,
378
492
  function: null,
493
+ functions: [],
494
+ loading: resolveLoadingChain(spec.loading, spec.category ?? null, categoryLoading, defaults),
379
495
  handler: null,
380
496
  text: spec.text,
381
- permissions: resolvePermissions(spec.permissions, spec.messages, null, defaults.permissions, defaults.messages, spec.category ? categories[spec.category]?.scope : null),
497
+ permissions: entryPerms,
382
498
  arguments: [...spec.arguments],
383
499
  subcommands: {},
384
500
  categoryHiddenInScope: resolveCategoryHiddenInScope(spec.category, categories),
501
+ hiddenOutsideScope: entryPerms.hiddenOutsideScope,
385
502
  };
386
- entry.subcommands = buildSubcommandsFromSpecs(spec.subcommands, entry, null, defaultsByKey, defaults, manuals);
503
+ entry.subcommands = buildSubcommandsFromSpecs(spec.subcommands, entry, null, defaultsByKey, defaults, manuals, loadingPresets);
387
504
  byId.set(id, entry);
388
505
  }
389
506
  else {
@@ -401,26 +518,30 @@ export function buildCommandRegistry(specs, pluginRegistry, defaults = {
401
518
  registerInvocationWithDeprecationGuard(byInvocation, alias, entry.id, false, defaults.notifyChanges);
402
519
  }
403
520
  }
404
- // Validate menu cmd + aliases against command invocations
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.
405
524
  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);
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
+ }
417
538
  }
418
539
  }
419
- return { byId, byInvocation, defaults, menu, menuAliases, categories, manuals };
540
+ return { byId, byInvocation, defaults, menu, menuAliases, categories, manuals, loadingPresets, categoryLoading, prefix };
420
541
  }
421
542
  let currentRegistry = null;
422
543
  export async function initCommandRegistry() {
423
- const config = await loadCommandsConfig();
544
+ const config = await loadCommandsConfig(new Set(pluginRegistry.keys()));
424
545
  const defaults = config?.defaults ?? {
425
546
  notifyChanges: true,
426
547
  notifyPeriodDays: 7,
@@ -429,8 +550,11 @@ export async function initCommandRegistry() {
429
550
  const menu = config?.menu ?? { ...DEFAULT_MENU_CONFIG };
430
551
  const categories = config?.categories ?? {};
431
552
  const manuals = config?.manuals ?? {};
553
+ const loadingPresets = config?.loadingPresets ?? {};
554
+ const categoryLoading = config?.categoryLoading ?? {};
555
+ const prefix = config?.prefix ?? null;
432
556
  const specs = config?.specs ?? [];
433
- const registry = buildCommandRegistry(specs, pluginRegistry, defaults, menu, categories, manuals);
557
+ const registry = buildCommandRegistry(specs, pluginRegistry, defaults, menu, categories, manuals, loadingPresets, categoryLoading, prefix);
434
558
  syncCommandHistory(registry.byId, defaults, specs);
435
559
  currentRegistry = registry;
436
560
  return registry;
@@ -80,7 +80,8 @@ describe("kernel/commandRegistry", () => {
80
80
  {
81
81
  id: "funPlugin::jokeFn",
82
82
  plugin: "funPlugin",
83
- function: "jokeFn",
83
+ functions: ["jokeFn"],
84
+ loading: null,
84
85
  cmd: "telljoke",
85
86
  aliases: ["tj"],
86
87
  desc: "Overridden desc",
@@ -114,7 +115,8 @@ describe("kernel/commandRegistry", () => {
114
115
  {
115
116
  id: "custom_hello",
116
117
  plugin: null,
117
- function: null,
118
+ functions: [],
119
+ loading: null,
118
120
  cmd: "hello",
119
121
  aliases: ["hi"],
120
122
  desc: "Says hello",