@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.
- package/README.md +15 -7
- package/dist/client/store.js +35 -1
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +75 -8
- package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
- package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
- package/dist/drivers/baileys/api/index.js +212 -38
- package/dist/drivers/baileys/index.js +30 -6
- package/dist/drivers/baileys/messageHandler.js +207 -21
- package/dist/drivers/baileys/messageHandler.test.js +256 -14
- package/dist/drivers/baileysAdapter.test.js +97 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +35 -1
- package/dist/i18n/index.js +5 -22
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/commandAccess.test.js +2 -2
- package/dist/kernel/commandDeprecation.js +4 -2
- package/dist/kernel/commandDeprecation.test.js +8 -1
- package/dist/kernel/commandMenu.js +91 -2
- package/dist/kernel/commandMenu.test.js +131 -2
- package/dist/kernel/commandPermissions.js +69 -23
- package/dist/kernel/commandPermissions.test.js +77 -9
- package/dist/kernel/commandRegistry.js +167 -43
- package/dist/kernel/commandRegistry.test.js +4 -2
- package/dist/kernel/commandsConfig.js +470 -38
- package/dist/kernel/commandsConfig.test.js +249 -3
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/pluginApi.test.js +20 -3
- package/dist/kernel/pluginGuard.js +5 -3
- package/dist/kernel/pluginLoader.js +73 -10
- package/dist/kernel/pluginLoader.test.js +111 -1
- package/dist/kernel/runCommand.js +57 -18
- package/dist/kernel/runCommand.test.js +269 -7
- package/dist/kernel/settingsDb.js +15 -2
- package/dist/kernel/testConfig.js +9 -0
- package/dist/locales/en.json +14 -1
- package/dist/locales/es.json +17 -4
- package/dist/locales/pt.json +18 -5
- package/dist/plugins/__manybot_integration__/index.js +33 -16
- package/dist/plugins/__manybot_integration__/index.test.js +42 -8
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +4 -3
|
@@ -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
|
-
|
|
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({
|
|
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({
|
|
124
|
+
const blacklistedUserCtx = createMockContext({ sender: senderOf("5511888888888@c.us") });
|
|
87
125
|
const blacklistedGroupCtx = createMockContext({ isGroup: true, chatId: "120363999999999999@g.us" });
|
|
88
|
-
const allowedCtx = createMockContext({
|
|
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({
|
|
101
|
-
const unwhitelistedUserCtx = createMockContext({
|
|
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({
|
|
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
|
-
*
|
|
168
|
-
*
|
|
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
|
|
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
|
|
178
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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 &&
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
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:
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
functions: [],
|
|
119
|
+
loading: null,
|
|
118
120
|
cmd: "hello",
|
|
119
121
|
aliases: ["hi"],
|
|
120
122
|
desc: "Says hello",
|