@manybot/manybot 5.7.0 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -3
- package/dist/client/banner.js +10 -0
- package/dist/client/banner.test.js +31 -0
- package/dist/client/store.js +91 -6
- package/dist/client/store.test.js +170 -0
- package/dist/config.js +28 -44
- package/dist/config.test.js +26 -0
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +133 -15
- 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 +384 -62
- package/dist/drivers/baileys/index.js +92 -36
- package/dist/drivers/baileys/loginPrompt.js +0 -2
- package/dist/drivers/baileys/messageHandler.js +344 -4
- package/dist/drivers/baileys/messageHandler.test.js +445 -0
- package/dist/drivers/baileysAdapter.test.js +378 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +74 -0
- package/dist/drivers/types.js +5 -5
- package/dist/i18n/index.js +20 -24
- package/dist/kernel/activeDriverSend.js +21 -0
- package/dist/kernel/activeDriverSend.test.js +89 -0
- package/dist/kernel/alerts.js +3 -9
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/chatSession.js +65 -0
- package/dist/kernel/chatSession.test.js +46 -0
- package/dist/kernel/commandAccess.js +66 -0
- package/dist/kernel/commandAccess.test.js +74 -0
- package/dist/kernel/commandDeprecation.js +170 -0
- package/dist/kernel/commandDeprecation.test.js +114 -0
- package/dist/kernel/commandMenu.js +357 -0
- package/dist/kernel/commandMenu.test.js +363 -0
- package/dist/kernel/commandPermissions.js +171 -0
- package/dist/kernel/commandPermissions.test.js +227 -0
- package/dist/kernel/commandRegistry.js +583 -0
- package/dist/kernel/commandRegistry.test.js +158 -0
- package/dist/kernel/commandsConfig.js +949 -0
- package/dist/kernel/commandsConfig.test.js +482 -0
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/contactAutoSave.test.js +87 -0
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/driverManager.js +10 -6
- package/dist/kernel/driverManager.test.js +90 -0
- package/dist/kernel/integrationMode.js +88 -0
- package/dist/kernel/integrationMode.test.js +95 -0
- package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
- package/dist/kernel/pluginApi.test.js +600 -0
- package/dist/kernel/pluginGuard.js +18 -13
- package/dist/kernel/pluginGuard.test.js +39 -0
- package/dist/kernel/pluginLoader.js +169 -11
- package/dist/kernel/pluginLoader.test.js +190 -0
- package/dist/kernel/runCommand.js +284 -0
- package/dist/kernel/runCommand.test.js +497 -0
- package/dist/kernel/sendFallbackGuard.js +19 -48
- package/dist/kernel/sendFallbackGuard.test.js +80 -0
- package/dist/kernel/sendGuard.js +38 -42
- package/dist/kernel/sendGuard.test.js +102 -0
- package/dist/kernel/settingsDb.js +19 -5
- package/dist/kernel/statusServer.js +9 -2
- package/dist/kernel/statusServer.test.js +70 -0
- package/dist/kernel/testConfig.js +192 -0
- package/dist/kernel/testConfig.test.js +181 -0
- package/dist/kernel/updateCheck.js +33 -10
- package/dist/locales/en.json +77 -13
- package/dist/locales/es.json +77 -13
- package/dist/locales/pt.json +77 -13
- package/dist/logger/logger.js +23 -3
- package/dist/logger/logger.test.js +45 -0
- package/dist/main.js +5 -76
- package/dist/plugins/__manybot_integration__/index.js +184 -0
- package/dist/plugins/__manybot_integration__/index.test.js +218 -0
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +76 -18
- package/dist/drivers/whatsmeow/client.js +0 -252
- package/dist/drivers/whatsmeow/index.js +0 -79
- package/dist/drivers/whatsmeow/installer.js +0 -86
- package/dist/drivers/whatsmeow/supervisor.js +0 -328
- package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import * as yaml from "js-yaml";
|
|
4
|
+
import { logger } from "#logger";
|
|
5
|
+
import { t } from "#i18n";
|
|
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");
|
|
13
|
+
const COMMANDS_FILE = path.join(PATHS.HOME, "commands.yaml");
|
|
14
|
+
function asString(value) {
|
|
15
|
+
if (typeof value !== "string")
|
|
16
|
+
return null;
|
|
17
|
+
const trimmed = value.trim();
|
|
18
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
19
|
+
}
|
|
20
|
+
function asAliasList(value) {
|
|
21
|
+
if (value === undefined || value === null)
|
|
22
|
+
return [];
|
|
23
|
+
if (!Array.isArray(value))
|
|
24
|
+
return [];
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const item of value) {
|
|
27
|
+
if (typeof item !== "string")
|
|
28
|
+
continue;
|
|
29
|
+
const trimmed = item.trim();
|
|
30
|
+
if (trimmed.length > 0)
|
|
31
|
+
out.push(trimmed);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function asImportList(value) {
|
|
36
|
+
if (typeof value === "string") {
|
|
37
|
+
const trimmed = value.trim();
|
|
38
|
+
return trimmed.length > 0 ? [trimmed] : [];
|
|
39
|
+
}
|
|
40
|
+
return asAliasList(value);
|
|
41
|
+
}
|
|
42
|
+
function asBool(value) {
|
|
43
|
+
if (typeof value === "boolean")
|
|
44
|
+
return value;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
function asPositiveInt(value, fallback) {
|
|
48
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
49
|
+
return fallback;
|
|
50
|
+
return Math.floor(value);
|
|
51
|
+
}
|
|
52
|
+
export function parseLocalizedString(raw) {
|
|
53
|
+
if (typeof raw === "string") {
|
|
54
|
+
const trimmed = raw.trim();
|
|
55
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
56
|
+
}
|
|
57
|
+
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const [lang, val] of Object.entries(raw)) {
|
|
60
|
+
if (typeof val === "string") {
|
|
61
|
+
const trimmed = val.trim();
|
|
62
|
+
if (trimmed.length > 0)
|
|
63
|
+
out[lang] = trimmed;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
export async function resolveFileRef(value) {
|
|
71
|
+
if (!value)
|
|
72
|
+
return null;
|
|
73
|
+
if (typeof value === "string") {
|
|
74
|
+
const trimmed = value.trim();
|
|
75
|
+
if (trimmed.startsWith("file:")) {
|
|
76
|
+
const relativePath = trimmed.slice(5).trim();
|
|
77
|
+
const fullPath = path.resolve(PATHS.HOME, relativePath);
|
|
78
|
+
try {
|
|
79
|
+
return await fs.readFile(fullPath, "utf8");
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
const err = e;
|
|
83
|
+
logger.warn(`commandsConfig: failed to read file ref "${trimmed}" (${fullPath}): ${err.message}`);
|
|
84
|
+
return trimmed;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return trimmed;
|
|
88
|
+
}
|
|
89
|
+
if (typeof value === "object" && value !== null) {
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const [lang, val] of Object.entries(value)) {
|
|
92
|
+
if (typeof val === "string") {
|
|
93
|
+
const resolved = await resolveFileRef(val);
|
|
94
|
+
if (resolved && typeof resolved === "string") {
|
|
95
|
+
out[lang] = resolved;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function parseGroupUserList(raw) {
|
|
104
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
105
|
+
return undefined;
|
|
106
|
+
const obj = raw;
|
|
107
|
+
const groups = asAliasList(obj.groups);
|
|
108
|
+
const users = asAliasList(obj.users);
|
|
109
|
+
if (groups.length === 0 && users.length === 0)
|
|
110
|
+
return undefined;
|
|
111
|
+
return {
|
|
112
|
+
groups: groups.length > 0 ? groups : undefined,
|
|
113
|
+
users: users.length > 0 ? users : undefined,
|
|
114
|
+
};
|
|
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
|
+
}
|
|
139
|
+
function parsePermissions(raw) {
|
|
140
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
141
|
+
return null;
|
|
142
|
+
const obj = raw;
|
|
143
|
+
const admin = asBool(obj.admin) ?? undefined;
|
|
144
|
+
const botAdmin = asBool(obj.botAdmin) ?? undefined;
|
|
145
|
+
const owner = asBool(obj.owner) ?? undefined;
|
|
146
|
+
const scope = parseScopeFromFlat(obj);
|
|
147
|
+
const dono = asString(obj.dono) ?? undefined;
|
|
148
|
+
let cooldownSeconds = undefined;
|
|
149
|
+
if (typeof obj.cooldownSeconds === "number" && Number.isFinite(obj.cooldownSeconds) && obj.cooldownSeconds >= 0) {
|
|
150
|
+
cooldownSeconds = obj.cooldownSeconds;
|
|
151
|
+
}
|
|
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;
|
|
174
|
+
if (admin === undefined &&
|
|
175
|
+
botAdmin === undefined &&
|
|
176
|
+
owner === undefined &&
|
|
177
|
+
scope === undefined &&
|
|
178
|
+
cooldownSeconds === undefined &&
|
|
179
|
+
whitelist === undefined &&
|
|
180
|
+
blacklist === undefined &&
|
|
181
|
+
dono === undefined &&
|
|
182
|
+
allowedChats.length === 0 &&
|
|
183
|
+
hiddenOutsideScope === undefined &&
|
|
184
|
+
obj.group_only === undefined &&
|
|
185
|
+
obj.dm_only === undefined) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
admin,
|
|
190
|
+
botAdmin,
|
|
191
|
+
owner,
|
|
192
|
+
scope,
|
|
193
|
+
cooldownSeconds,
|
|
194
|
+
whitelist,
|
|
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,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function parseMessages(raw) {
|
|
206
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
207
|
+
return null;
|
|
208
|
+
const obj = raw;
|
|
209
|
+
const botNotAdmin = asString(obj.botNotAdmin) ?? undefined;
|
|
210
|
+
const senderNotAdmin = asString(obj.senderNotAdmin) ?? undefined;
|
|
211
|
+
const ownerOnly = asString(obj.ownerOnly) ?? undefined;
|
|
212
|
+
const donoOnly = asString(obj.donoOnly) ?? undefined;
|
|
213
|
+
const wrongScope = asString(obj.wrongScope) ?? undefined;
|
|
214
|
+
const cooldown = asString(obj.cooldown) ?? undefined;
|
|
215
|
+
const blacklist = asString(obj.blacklist) ?? undefined;
|
|
216
|
+
const allowedChats = asString(obj.allowedChats) ?? undefined;
|
|
217
|
+
if (!botNotAdmin && !senderNotAdmin && !ownerOnly && !donoOnly && !wrongScope && !cooldown && !blacklist && !allowedChats) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
botNotAdmin,
|
|
222
|
+
senderNotAdmin,
|
|
223
|
+
ownerOnly,
|
|
224
|
+
donoOnly,
|
|
225
|
+
wrongScope,
|
|
226
|
+
cooldown,
|
|
227
|
+
blacklist,
|
|
228
|
+
allowedChats,
|
|
229
|
+
};
|
|
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
|
+
}
|
|
356
|
+
const ARGUMENT_TYPES = new Set([
|
|
357
|
+
"mention", "url", "media_direct", "media_reply", "number",
|
|
358
|
+
"duration", "choice", "boolean", "quoted_text", "reply",
|
|
359
|
+
]);
|
|
360
|
+
function parseArgument(raw, parentId) {
|
|
361
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
362
|
+
return null;
|
|
363
|
+
const obj = raw;
|
|
364
|
+
const name = asString(obj.name);
|
|
365
|
+
if (!name) {
|
|
366
|
+
logger.warn(t("system.commandsConfigArgumentMissingName", { id: parentId }));
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const typeStr = asString(obj.type);
|
|
370
|
+
if (!typeStr) {
|
|
371
|
+
logger.warn(t("system.commandsConfigArgumentMissingType", { id: parentId, name }));
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
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)) {
|
|
379
|
+
logger.warn(t("system.commandsConfigUnknownArgType", { id: parentId, name, type: typeStr }));
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
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
|
+
}
|
|
394
|
+
let choices;
|
|
395
|
+
if (normalizedType === "choice") {
|
|
396
|
+
choices = asAliasList(obj.choices);
|
|
397
|
+
if (choices.length === 0) {
|
|
398
|
+
logger.warn(t("system.commandsConfigChoiceArgumentNoChoices", { id: parentId, name }));
|
|
399
|
+
// Still allow the argument; renderUsage just falls back to "<name>".
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
name,
|
|
404
|
+
type: normalizedType,
|
|
405
|
+
required,
|
|
406
|
+
choices,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function parseArguments(raw, parentId) {
|
|
410
|
+
if (raw === undefined || raw === null)
|
|
411
|
+
return [];
|
|
412
|
+
if (!Array.isArray(raw)) {
|
|
413
|
+
logger.warn(t("system.commandsConfigArgumentsNotList", { id: parentId }));
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
const out = [];
|
|
417
|
+
for (const item of raw) {
|
|
418
|
+
const arg = parseArgument(item, parentId);
|
|
419
|
+
if (arg)
|
|
420
|
+
out.push(arg);
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
async function parseSubcommand(parentId, raw, presets, onPlugin) {
|
|
425
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
426
|
+
return null;
|
|
427
|
+
const obj = raw;
|
|
428
|
+
const cmd = asString(obj.cmd);
|
|
429
|
+
if (!cmd) {
|
|
430
|
+
logger.warn(t("system.commandsConfigSubcommandMissingCmd", { id: parentId }));
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const id = `${parentId}::${cmd}`;
|
|
434
|
+
const rawManual = parseLocalizedString(obj.manual);
|
|
435
|
+
const manual = rawManual ? await resolveFileRef(rawManual) : null;
|
|
436
|
+
return {
|
|
437
|
+
id,
|
|
438
|
+
cmd,
|
|
439
|
+
aliases: asAliasList(obj.aliases),
|
|
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),
|
|
446
|
+
desc: parseLocalizedString(obj.desc),
|
|
447
|
+
manual,
|
|
448
|
+
arguments: parseArguments(obj.arguments ?? obj.args, id),
|
|
449
|
+
permissions: parsePermissions(obj.permissions),
|
|
450
|
+
messages: parseMessages(obj.messages),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
async function parseSubcommands(raw, parentId, presets, onPlugin) {
|
|
454
|
+
if (raw === undefined || raw === null)
|
|
455
|
+
return [];
|
|
456
|
+
if (!Array.isArray(raw)) {
|
|
457
|
+
logger.warn(t("system.commandsConfigSubcommandsNotList", { id: parentId }));
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
const out = [];
|
|
461
|
+
for (const item of raw) {
|
|
462
|
+
const sub = await parseSubcommand(parentId, item, presets, onPlugin);
|
|
463
|
+
if (sub)
|
|
464
|
+
out.push(sub);
|
|
465
|
+
}
|
|
466
|
+
return out;
|
|
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
|
+
}
|
|
516
|
+
const DEFAULT_MENU_CONFIG = {
|
|
517
|
+
enabled: false,
|
|
518
|
+
title: "🤖 ManyBot — Menu",
|
|
519
|
+
intro: {
|
|
520
|
+
en: "Use {prefix}<command> to run it or {prefix}help <command> to view its manual.",
|
|
521
|
+
pt: "Use {prefix}<comando> para executar ou {prefix}help <comando> para ver o manual.",
|
|
522
|
+
es: "Usa {prefix}<comando> para ejecutarlo o {prefix}help <comando> para ver el manual.",
|
|
523
|
+
},
|
|
524
|
+
footer: null,
|
|
525
|
+
cmd: "menu",
|
|
526
|
+
aliases: ["help", "man", "menu", "bot", "?"],
|
|
527
|
+
notFoundFallback: false,
|
|
528
|
+
suggestSimilar: false,
|
|
529
|
+
suggestMaxDistance: 2,
|
|
530
|
+
welcomeMessage: null,
|
|
531
|
+
welcomeWindowDays: 3,
|
|
532
|
+
pageSize: 15,
|
|
533
|
+
};
|
|
534
|
+
function parseMenu(raw) {
|
|
535
|
+
if (raw === undefined) {
|
|
536
|
+
return { ...DEFAULT_MENU_CONFIG };
|
|
537
|
+
}
|
|
538
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
539
|
+
return { ...DEFAULT_MENU_CONFIG };
|
|
540
|
+
}
|
|
541
|
+
const obj = raw;
|
|
542
|
+
const aliases = asAliasList(obj.aliases);
|
|
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,
|
|
548
|
+
title: parseLocalizedString(obj.title) ?? DEFAULT_MENU_CONFIG.title,
|
|
549
|
+
intro: parseLocalizedString(obj.intro) ?? DEFAULT_MENU_CONFIG.intro,
|
|
550
|
+
footer: parseLocalizedString(obj.footer) ?? DEFAULT_MENU_CONFIG.footer,
|
|
551
|
+
cmd: asString(obj.cmd) ?? DEFAULT_MENU_CONFIG.cmd,
|
|
552
|
+
aliases: obj.aliases !== undefined ? aliases : [...DEFAULT_MENU_CONFIG.aliases],
|
|
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),
|
|
556
|
+
welcomeMessage: parseLocalizedString(obj.welcomeMessage) ?? DEFAULT_MENU_CONFIG.welcomeMessage,
|
|
557
|
+
welcomeWindowDays: asPositiveInt(obj.welcomeWindowDays, DEFAULT_MENU_CONFIG.welcomeWindowDays),
|
|
558
|
+
pageSize: asPositiveInt(obj.pageSize, DEFAULT_MENU_CONFIG.pageSize),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function parseScopeValue(raw) {
|
|
562
|
+
const s = asString(raw)?.toLowerCase();
|
|
563
|
+
if (s === "group" || s === "dm" || s === "any")
|
|
564
|
+
return s;
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
function parseCategories(raw, presets) {
|
|
568
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
569
|
+
return { categories: {}, categoryLoading: {} };
|
|
570
|
+
}
|
|
571
|
+
const obj = raw;
|
|
572
|
+
const categories = {};
|
|
573
|
+
const categoryLoading = {};
|
|
574
|
+
for (const [catKey, value] of Object.entries(obj)) {
|
|
575
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
576
|
+
continue;
|
|
577
|
+
const catObj = value;
|
|
578
|
+
const label = parseLocalizedString(catObj.label) ?? catKey;
|
|
579
|
+
const order = typeof catObj.order === "number" && Number.isFinite(catObj.order) ? catObj.order : 999;
|
|
580
|
+
const scope = parseScopeValue(catObj.scope);
|
|
581
|
+
const hiddenInScope = parseScopeValue(catObj.hiddenInScope);
|
|
582
|
+
categories[catKey] = {
|
|
583
|
+
label,
|
|
584
|
+
order,
|
|
585
|
+
scope: scope ?? null,
|
|
586
|
+
hiddenInScope: hiddenInScope ?? null,
|
|
587
|
+
};
|
|
588
|
+
const loading = parseLoadingSpec(catObj.loading, `categories.${catKey}`, presets);
|
|
589
|
+
if (loading)
|
|
590
|
+
categoryLoading[catKey] = loading;
|
|
591
|
+
}
|
|
592
|
+
return { categories, categoryLoading };
|
|
593
|
+
}
|
|
594
|
+
async function parseManuals(raw) {
|
|
595
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
596
|
+
return {};
|
|
597
|
+
const obj = raw;
|
|
598
|
+
const out = {};
|
|
599
|
+
for (const [id, value] of Object.entries(obj)) {
|
|
600
|
+
const parsed = parseLocalizedString(value);
|
|
601
|
+
if (parsed) {
|
|
602
|
+
const resolved = await resolveFileRef(parsed);
|
|
603
|
+
if (resolved)
|
|
604
|
+
out[id] = resolved;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
const DEFAULT_NOTIFY_CHANGES = true;
|
|
610
|
+
const DEFAULT_NOTIFY_PERIOD_DAYS = 7;
|
|
611
|
+
function parseDefaults(raw, presets) {
|
|
612
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
613
|
+
return {
|
|
614
|
+
notifyChanges: DEFAULT_NOTIFY_CHANGES,
|
|
615
|
+
notifyPeriodDays: DEFAULT_NOTIFY_PERIOD_DAYS,
|
|
616
|
+
notifyMessage: null,
|
|
617
|
+
permissions: null,
|
|
618
|
+
messages: null,
|
|
619
|
+
loading: null,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
const obj = raw;
|
|
623
|
+
return {
|
|
624
|
+
notifyChanges: asBool(obj.notifyChanges) ?? DEFAULT_NOTIFY_CHANGES,
|
|
625
|
+
notifyPeriodDays: asPositiveInt(obj.notifyPeriodDays, DEFAULT_NOTIFY_PERIOD_DAYS),
|
|
626
|
+
notifyMessage: asString(obj.notifyMessage),
|
|
627
|
+
permissions: parsePermissions(obj.permissions),
|
|
628
|
+
messages: parseMessages(obj.messages),
|
|
629
|
+
loading: parseLoadingSpec(obj.loading, "defaults", presets),
|
|
630
|
+
};
|
|
631
|
+
}
|
|
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) {
|
|
670
|
+
const cmd = asString(raw.cmd);
|
|
671
|
+
if (!cmd) {
|
|
672
|
+
logger.warn(t("system.commandsConfigMissingCmd", {
|
|
673
|
+
id,
|
|
674
|
+
path: COMMANDS_FILE
|
|
675
|
+
}));
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
const rawText = parseLocalizedString(raw.text);
|
|
679
|
+
const text = rawText ? await resolveFileRef(rawText) : null;
|
|
680
|
+
const rawManual = parseLocalizedString(raw.manual);
|
|
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
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
id,
|
|
723
|
+
cmd,
|
|
724
|
+
aliases: asAliasList(raw.aliases),
|
|
725
|
+
plugin,
|
|
726
|
+
functions: functions ?? [],
|
|
727
|
+
loading: parseLoadingSpec(raw.loading, id, presets),
|
|
728
|
+
text,
|
|
729
|
+
desc: parseLocalizedString(raw.desc),
|
|
730
|
+
category: asString(raw.category),
|
|
731
|
+
group: asString(raw.group),
|
|
732
|
+
manual,
|
|
733
|
+
deprecatedMessage: asString(raw.deprecatedMessage),
|
|
734
|
+
notifyChanges: asBool(raw.notifyChanges),
|
|
735
|
+
permissions: parsePermissions(raw.permissions),
|
|
736
|
+
messages: parseMessages(raw.messages),
|
|
737
|
+
arguments: parseArguments(raw.arguments ?? raw.args, id),
|
|
738
|
+
subcommands,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
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
|
+
]);
|
|
748
|
+
/**
|
|
749
|
+
* Resolves `import:` (a path or list of paths, relative to PATHS.HOME) by
|
|
750
|
+
* reading each referenced YAML file and folding its top-level sections
|
|
751
|
+
* into the main root object. Each top-level key (`menu`, `manuals`, a
|
|
752
|
+
* command id, ...) may be owned by exactly one source file — no deep
|
|
753
|
+
* merge, first owner wins and any later collision is reported as an
|
|
754
|
+
* error and skipped, keeping the rest of that import file usable.
|
|
755
|
+
*/
|
|
756
|
+
async function resolveImports(root) {
|
|
757
|
+
const importPaths = asImportList(root.import);
|
|
758
|
+
if (importPaths.length === 0)
|
|
759
|
+
return root;
|
|
760
|
+
const merged = { ...root };
|
|
761
|
+
delete merged.import;
|
|
762
|
+
const owner = new Map();
|
|
763
|
+
for (const key of Object.keys(merged))
|
|
764
|
+
owner.set(key, COMMANDS_FILE);
|
|
765
|
+
for (const relPath of importPaths) {
|
|
766
|
+
const fullPath = path.resolve(PATHS.HOME, relPath);
|
|
767
|
+
let raw;
|
|
768
|
+
try {
|
|
769
|
+
raw = await fs.readFile(fullPath, "utf8");
|
|
770
|
+
}
|
|
771
|
+
catch (e) {
|
|
772
|
+
const err = e;
|
|
773
|
+
logger.error(t("system.commandsConfigImportReadFailed", { path: fullPath, message: err.message }));
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
let parsed;
|
|
777
|
+
try {
|
|
778
|
+
parsed = yaml.load(raw, { filename: fullPath });
|
|
779
|
+
}
|
|
780
|
+
catch (e) {
|
|
781
|
+
const err = e;
|
|
782
|
+
logger.error(t("system.commandsConfigImportParseFailed", { path: fullPath, message: err.message }));
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (parsed === null || parsed === undefined)
|
|
786
|
+
continue;
|
|
787
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
788
|
+
logger.error(t("system.commandsConfigImportInvalidRoot", { path: fullPath }));
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
792
|
+
if (key === "import") {
|
|
793
|
+
logger.warn(t("system.commandsConfigImportNested", { path: fullPath }));
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const existingOwner = owner.get(key);
|
|
797
|
+
if (existingOwner !== undefined) {
|
|
798
|
+
logger.error(t("system.commandsConfigImportKeyConflict", { key, path: fullPath, owner: existingOwner }));
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
merged[key] = value;
|
|
802
|
+
owner.set(key, fullPath);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return merged;
|
|
806
|
+
}
|
|
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) {
|
|
846
|
+
let raw;
|
|
847
|
+
try {
|
|
848
|
+
raw = await fs.readFile(COMMANDS_FILE, "utf8");
|
|
849
|
+
}
|
|
850
|
+
catch (e) {
|
|
851
|
+
const err = e;
|
|
852
|
+
if (err.code === "ENOENT")
|
|
853
|
+
return null;
|
|
854
|
+
logger.error(t("system.commandsConfigReadFailed", {
|
|
855
|
+
path: COMMANDS_FILE,
|
|
856
|
+
message: err.message
|
|
857
|
+
}));
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
let parsed;
|
|
861
|
+
try {
|
|
862
|
+
parsed = yaml.load(raw, { filename: COMMANDS_FILE });
|
|
863
|
+
}
|
|
864
|
+
catch (e) {
|
|
865
|
+
const err = e;
|
|
866
|
+
logger.error(t("system.commandsConfigParseFailed", {
|
|
867
|
+
path: COMMANDS_FILE,
|
|
868
|
+
message: err.message
|
|
869
|
+
}));
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
if (parsed === null || parsed === undefined) {
|
|
873
|
+
return {
|
|
874
|
+
prefix: null,
|
|
875
|
+
defaults: {
|
|
876
|
+
notifyChanges: DEFAULT_NOTIFY_CHANGES,
|
|
877
|
+
notifyPeriodDays: DEFAULT_NOTIFY_PERIOD_DAYS,
|
|
878
|
+
notifyMessage: null,
|
|
879
|
+
permissions: null,
|
|
880
|
+
messages: null,
|
|
881
|
+
loading: null,
|
|
882
|
+
},
|
|
883
|
+
menu: { ...DEFAULT_MENU_CONFIG },
|
|
884
|
+
categories: {},
|
|
885
|
+
manuals: {},
|
|
886
|
+
loadingPresets: {},
|
|
887
|
+
categoryLoading: {},
|
|
888
|
+
specs: [],
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
892
|
+
logger.error(t("system.commandsConfigInvalidRoot", {
|
|
893
|
+
path: COMMANDS_FILE
|
|
894
|
+
}));
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
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);
|
|
930
|
+
const menu = parseMenu(root.menu);
|
|
931
|
+
const { categories, categoryLoading } = parseCategories(root.categories, loadingPresets);
|
|
932
|
+
const manuals = await parseManuals(root.manuals);
|
|
933
|
+
const out = [];
|
|
934
|
+
for (const [id, value] of Object.entries(root)) {
|
|
935
|
+
if (RESERVED_KEYS.has(id))
|
|
936
|
+
continue;
|
|
937
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
938
|
+
logger.warn(t("system.commandsConfigInvalidEntry", {
|
|
939
|
+
id,
|
|
940
|
+
path: COMMANDS_FILE
|
|
941
|
+
}));
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
const spec = await parseEntry(id, value, loadingPresets, validPluginKeys);
|
|
945
|
+
if (spec)
|
|
946
|
+
out.push(spec);
|
|
947
|
+
}
|
|
948
|
+
return { prefix, defaults, menu, categories, manuals, loadingPresets, categoryLoading, specs: out };
|
|
949
|
+
}
|