@manybot/manybot 5.7.0 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +28 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +91 -6
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/download/queue.js +13 -4
  9. package/dist/drivers/baileys/adapter.js +133 -15
  10. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  11. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  12. package/dist/drivers/baileys/api/index.js +384 -62
  13. package/dist/drivers/baileys/index.js +92 -36
  14. package/dist/drivers/baileys/loginPrompt.js +0 -2
  15. package/dist/drivers/baileys/messageHandler.js +344 -4
  16. package/dist/drivers/baileys/messageHandler.test.js +445 -0
  17. package/dist/drivers/baileysAdapter.test.js +378 -0
  18. package/dist/drivers/jid.js +26 -0
  19. package/dist/drivers/jid.test.js +74 -0
  20. package/dist/drivers/types.js +5 -5
  21. package/dist/i18n/index.js +20 -24
  22. package/dist/kernel/activeDriverSend.js +21 -0
  23. package/dist/kernel/activeDriverSend.test.js +89 -0
  24. package/dist/kernel/alerts.js +3 -9
  25. package/dist/kernel/chatOverrides.js +46 -0
  26. package/dist/kernel/chatOverrides.test.js +59 -0
  27. package/dist/kernel/chatSession.js +65 -0
  28. package/dist/kernel/chatSession.test.js +46 -0
  29. package/dist/kernel/commandAccess.js +66 -0
  30. package/dist/kernel/commandAccess.test.js +74 -0
  31. package/dist/kernel/commandDeprecation.js +170 -0
  32. package/dist/kernel/commandDeprecation.test.js +114 -0
  33. package/dist/kernel/commandMenu.js +357 -0
  34. package/dist/kernel/commandMenu.test.js +363 -0
  35. package/dist/kernel/commandPermissions.js +171 -0
  36. package/dist/kernel/commandPermissions.test.js +227 -0
  37. package/dist/kernel/commandRegistry.js +583 -0
  38. package/dist/kernel/commandRegistry.test.js +158 -0
  39. package/dist/kernel/commandsConfig.js +949 -0
  40. package/dist/kernel/commandsConfig.test.js +482 -0
  41. package/dist/kernel/contactAutoSave.js +6 -6
  42. package/dist/kernel/contactAutoSave.test.js +87 -0
  43. package/dist/kernel/coreCommands.js +62 -0
  44. package/dist/kernel/driverManager.js +10 -6
  45. package/dist/kernel/driverManager.test.js +90 -0
  46. package/dist/kernel/integrationMode.js +88 -0
  47. package/dist/kernel/integrationMode.test.js +95 -0
  48. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  49. package/dist/kernel/pluginApi.test.js +600 -0
  50. package/dist/kernel/pluginGuard.js +18 -13
  51. package/dist/kernel/pluginGuard.test.js +39 -0
  52. package/dist/kernel/pluginLoader.js +169 -11
  53. package/dist/kernel/pluginLoader.test.js +190 -0
  54. package/dist/kernel/runCommand.js +284 -0
  55. package/dist/kernel/runCommand.test.js +497 -0
  56. package/dist/kernel/sendFallbackGuard.js +19 -48
  57. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  58. package/dist/kernel/sendGuard.js +38 -42
  59. package/dist/kernel/sendGuard.test.js +102 -0
  60. package/dist/kernel/settingsDb.js +19 -5
  61. package/dist/kernel/statusServer.js +9 -2
  62. package/dist/kernel/statusServer.test.js +70 -0
  63. package/dist/kernel/testConfig.js +192 -0
  64. package/dist/kernel/testConfig.test.js +181 -0
  65. package/dist/kernel/updateCheck.js +33 -10
  66. package/dist/locales/en.json +77 -13
  67. package/dist/locales/es.json +77 -13
  68. package/dist/locales/pt.json +77 -13
  69. package/dist/logger/logger.js +23 -3
  70. package/dist/logger/logger.test.js +45 -0
  71. package/dist/main.js +5 -76
  72. package/dist/plugins/__manybot_integration__/index.js +184 -0
  73. package/dist/plugins/__manybot_integration__/index.test.js +218 -0
  74. package/dist/utils/phoneNumber.js +83 -0
  75. package/dist/utils/phoneNumber.test.js +53 -0
  76. package/package.json +76 -18
  77. package/dist/drivers/whatsmeow/client.js +0 -252
  78. package/dist/drivers/whatsmeow/index.js +0 -79
  79. package/dist/drivers/whatsmeow/installer.js +0 -86
  80. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  81. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
@@ -0,0 +1,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
+ }