@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,114 @@
1
+ import test, { describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { syncCommandHistory, getActiveDeprecation, formatDeprecationMessage } from "#kernel/commandDeprecation.js";
4
+ function createMockEntry(id, cmd) {
5
+ return {
6
+ id,
7
+ cmd,
8
+ aliases: [],
9
+ desc: null,
10
+ category: null,
11
+ group: null,
12
+ manual: null,
13
+ source: "plugin",
14
+ pluginName: "testPlugin",
15
+ function: null,
16
+ functions: [],
17
+ loading: null,
18
+ handler: null,
19
+ text: null,
20
+ permissions: {
21
+ admin: false,
22
+ botAdmin: false,
23
+ scope: "any",
24
+ owner: false,
25
+ cooldownSeconds: 0,
26
+ whitelist: null,
27
+ blacklist: null,
28
+ dono: null,
29
+ allowedChats: null,
30
+ hiddenOutsideScope: null,
31
+ messages: {},
32
+ },
33
+ arguments: [],
34
+ subcommands: {},
35
+ categoryHiddenInScope: null,
36
+ hiddenOutsideScope: null,
37
+ };
38
+ }
39
+ describe("kernel/commandDeprecation", () => {
40
+ const defaults = {
41
+ notifyChanges: true,
42
+ notifyPeriodDays: 7,
43
+ notifyMessage: null,
44
+ };
45
+ test("silently inserts history on first sync", () => {
46
+ const byId = new Map([
47
+ ["plugin::cmd1", createMockEntry("plugin::cmd1", "oldname")],
48
+ ]);
49
+ syncCommandHistory(byId, defaults, []);
50
+ // First sighting should not create an active deprecation for oldname
51
+ assert.equal(getActiveDeprecation("oldname"), null);
52
+ });
53
+ test("records deprecation when command is renamed across syncs", () => {
54
+ const byId1 = new Map([
55
+ ["plugin::cmd1", createMockEntry("plugin::cmd1", "oldname")],
56
+ ]);
57
+ syncCommandHistory(byId1, defaults, []);
58
+ // Sync 2: cmd name changed to newname
59
+ const byId2 = new Map([
60
+ ["plugin::cmd1", createMockEntry("plugin::cmd1", "newname")],
61
+ ]);
62
+ syncCommandHistory(byId2, defaults, []);
63
+ const deprecation = getActiveDeprecation("oldname");
64
+ assert.ok(deprecation);
65
+ assert.equal(deprecation?.old_cmd, "oldname");
66
+ assert.equal(deprecation?.new_cmd, "newname");
67
+ const formatted = formatDeprecationMessage(deprecation, defaults);
68
+ assert.ok(formatted.length > 0);
69
+ });
70
+ test("records deprecation when command is removed from registry", () => {
71
+ const byId1 = new Map([
72
+ ["plugin::cmdRem", createMockEntry("plugin::cmdRem", "removedcmd")],
73
+ ]);
74
+ syncCommandHistory(byId1, defaults, []);
75
+ // Sync 2: command removed
76
+ const byId2 = new Map();
77
+ syncCommandHistory(byId2, defaults, []);
78
+ const deprecation = getActiveDeprecation("removedcmd");
79
+ assert.ok(deprecation);
80
+ assert.equal(deprecation?.old_cmd, "removedcmd");
81
+ assert.equal(deprecation?.new_cmd, null);
82
+ });
83
+ test("respects notifyChanges = false opt-out", () => {
84
+ const byId1 = new Map([
85
+ ["plugin::optOut", createMockEntry("plugin::optOut", "alpha")],
86
+ ]);
87
+ const specs1 = [{
88
+ id: "plugin::optOut",
89
+ plugin: "plugin",
90
+ functions: ["optOut"],
91
+ loading: null,
92
+ cmd: "alpha",
93
+ aliases: [],
94
+ desc: null,
95
+ category: null,
96
+ group: null,
97
+ manual: null,
98
+ text: null,
99
+ deprecatedMessage: null,
100
+ notifyChanges: false,
101
+ permissions: null,
102
+ messages: null,
103
+ arguments: [],
104
+ subcommands: [],
105
+ }];
106
+ syncCommandHistory(byId1, defaults, specs1);
107
+ // Rename with notifyChanges = false
108
+ const byId2 = new Map([
109
+ ["plugin::optOut", createMockEntry("plugin::optOut", "beta")],
110
+ ]);
111
+ syncCommandHistory(byId2, defaults, specs1);
112
+ assert.equal(getActiveDeprecation("alpha"), null);
113
+ });
114
+ });
@@ -0,0 +1,357 @@
1
+ /**
2
+ * commandMenu.ts
3
+ *
4
+ * Menu system for ManyBot (overview, category, manual, not-found).
5
+ */
6
+ import { CMD_PREFIX } from "#config";
7
+ import { getCurrentLang, tFor } from "#i18n";
8
+ import { buildSettingsApi } from "./settingsDb.js";
9
+ /** Maximum age (ms) of an incoming message for the welcome to fire.
10
+ * Matches `MAX_MESSAGE_AGE_SECONDS` in `drivers/baileys/index.ts` so the
11
+ * welcome can never fire on a message the driver would have dropped as
12
+ * stale. See `checkAndTriggerWelcomeMessage` for the full rationale. */
13
+ const MAX_WELCOME_AGE_MS = 60 * 1000;
14
+ /**
15
+ * Checks if the user should be shown the welcome message,
16
+ * and marks them as seen if so.
17
+ *
18
+ * Two extra gates protect against the "ghost welcome" failure mode where
19
+ * a welcome fires out of the blue for a sender the bot never heard from:
20
+ *
21
+ * - The incoming message must have non-empty text. Baileys 7.x has a
22
+ * documented offline-flush bug where, after a reconnect, the
23
+ * event-buffer can reclassify receipts as `messages.upsert` events
24
+ * (the buffer interleaves message and receipt frames during the
25
+ * replay window). Those events arrive with `fromMe=false`, in a
26
+ * real DM, with `body` empty — every other condition for a welcome
27
+ * fires, but they aren't real messages. Requiring a non-empty body
28
+ * filters this entire class of synthetic upserts.
29
+ *
30
+ * - The incoming message's `timestamp` must be within
31
+ * `MAX_WELCOME_AGE_MS` of `now`. The Baileys 7.x line also has
32
+ * widely-reported cases where the event-buffer delivers
33
+ * `messages.upsert` events minutes, hours, or even days after they
34
+ * originally occurred (they sit in the buffer during a long
35
+ * disconnect and are drained in order on reconnect). A sender
36
+ * whose first-ever contact with the bot was days ago is not a
37
+ * "first-time" sender in any meaningful sense — the welcome is for
38
+ * a real, live first contact, not a delayed replay. This mirrors
39
+ * `isMessageStale()` in `drivers/baileys/index.ts` (60s), which
40
+ * is the same threshold the driver itself uses to drop stale
41
+ * events before they reach the handler. We keep the threshold the
42
+ * same here so the two stay in sync: if the driver decided the
43
+ * message is "real-time enough" to dispatch, the welcome fires;
44
+ * if the driver would have dropped it, we never reach this path.
45
+ */
46
+ export function checkAndTriggerWelcomeMessage(userId, registry, msg, lang, prefix = CMD_PREFIX) {
47
+ if (!registry.menu.welcomeMessage)
48
+ return null;
49
+ if (!msg || !msg.body || msg.body.trim() === "")
50
+ return null;
51
+ const msgTsMs = msg.timestamp ?? 0;
52
+ if (msgTsMs > 0) {
53
+ const ageMs = Date.now() - msgTsMs;
54
+ if (ageMs > MAX_WELCOME_AGE_MS)
55
+ return null;
56
+ }
57
+ const settings = buildSettingsApi("kernel", userId);
58
+ const lastSeen = settings.get("last_welcome_seen");
59
+ const now = Math.floor(Date.now() / 1000);
60
+ const windowSeconds = (registry.menu.welcomeWindowDays || 3) * 86400;
61
+ if (!lastSeen || now - lastSeen > windowSeconds) {
62
+ settings.set("last_welcome_seen", now);
63
+ const rawMsg = resolveLocalizedString(registry.menu.welcomeMessage, lang);
64
+ if (!rawMsg)
65
+ return null;
66
+ return rawMsg.replace(/\{prefix\}/g, prefix);
67
+ }
68
+ return null;
69
+ }
70
+ /**
71
+ * Resolves a LocalizedString (string | Record<string, string>) to a single
72
+ * string for the requested language (or system language), falling back to
73
+ * English or the first available string.
74
+ */
75
+ export function resolveLocalizedString(raw, lang) {
76
+ if (!raw)
77
+ return null;
78
+ if (typeof raw === "string")
79
+ return raw;
80
+ if (typeof raw === "object" && raw !== null) {
81
+ const targetLang = (lang || getCurrentLang()).toLowerCase();
82
+ if (typeof raw[targetLang] === "string")
83
+ return raw[targetLang];
84
+ if (typeof raw.en === "string")
85
+ return raw.en;
86
+ if (typeof raw.pt === "string")
87
+ return raw.pt;
88
+ if (typeof raw.es === "string")
89
+ return raw.es;
90
+ const values = Object.values(raw);
91
+ if (values.length > 0 && typeof values[0] === "string")
92
+ return values[0];
93
+ }
94
+ return null;
95
+ }
96
+ export function renderOverview(registry, lang, page, scope) {
97
+ const parts = [];
98
+ const titleStr = resolveLocalizedString(registry.menu.title, lang) ?? "🤖 ManyBot — Menu";
99
+ parts.push(`*${titleStr}*`);
100
+ const rawIntro = resolveLocalizedString(registry.menu.intro, lang) ??
101
+ tFor(lang, "menu.intro");
102
+ const introStr = rawIntro.replace(/\{prefix\}/g, CMD_PREFIX);
103
+ parts.push(introStr);
104
+ parts.push(""); // blank line before categories/commands
105
+ const allEntries = Array.from(registry.byId.values());
106
+ const categories = Object.entries(registry.categories);
107
+ const pageSize = registry.menu.pageSize ?? 15;
108
+ if (categories.length > 0) {
109
+ // Sort defined categories by order ascending
110
+ const sortedCategories = categories.sort((a, b) => a[1].order - b[1].order);
111
+ const assignedIds = new Set();
112
+ for (const [catKey, catConfig] of sortedCategories) {
113
+ if (scope && catConfig.scope && catConfig.scope !== scope) {
114
+ continue;
115
+ }
116
+ const entries = allEntries
117
+ .filter(e => e.category === catKey)
118
+ .filter(e => {
119
+ if (!scope)
120
+ return true;
121
+ if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
122
+ return false;
123
+ if (e.hiddenOutsideScope && e.hiddenOutsideScope !== "any" && e.hiddenOutsideScope !== scope)
124
+ return false;
125
+ return true;
126
+ })
127
+ .sort((a, b) => a.cmd.localeCompare(b.cmd));
128
+ if (entries.length === 0)
129
+ continue;
130
+ const catLabel = resolveLocalizedString(catConfig.label, lang) ?? catKey;
131
+ parts.push(`📁 *${catLabel}*`);
132
+ for (const entry of entries) {
133
+ assignedIds.add(entry.id);
134
+ const descStr = resolveLocalizedString(entry.desc, lang);
135
+ if (descStr) {
136
+ parts.push(` • ${CMD_PREFIX}${entry.cmd} — ${descStr}`);
137
+ }
138
+ else {
139
+ parts.push(` • ${CMD_PREFIX}${entry.cmd}`);
140
+ }
141
+ }
142
+ parts.push("");
143
+ }
144
+ // Uncategorized entries
145
+ const uncategorized = allEntries
146
+ .filter(e => !assignedIds.has(e.id))
147
+ .filter(e => {
148
+ if (!scope)
149
+ return true;
150
+ if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
151
+ return false;
152
+ if (e.hiddenOutsideScope && e.hiddenOutsideScope !== "any" && e.hiddenOutsideScope !== scope)
153
+ return false;
154
+ return true;
155
+ })
156
+ .sort((a, b) => a.cmd.localeCompare(b.cmd));
157
+ if (uncategorized.length > 0) {
158
+ const otherLabel = tFor(lang, "menu.other");
159
+ parts.push(`📁 *${otherLabel}*`);
160
+ for (const entry of uncategorized) {
161
+ const descStr = resolveLocalizedString(entry.desc, lang);
162
+ if (descStr) {
163
+ parts.push(` • ${CMD_PREFIX}${entry.cmd} — ${descStr}`);
164
+ }
165
+ else {
166
+ parts.push(` • ${CMD_PREFIX}${entry.cmd}`);
167
+ }
168
+ }
169
+ parts.push("");
170
+ }
171
+ }
172
+ else {
173
+ // Flat command list with pagination
174
+ const filteredEntries = allEntries.filter(e => {
175
+ if (!scope)
176
+ return true;
177
+ if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
178
+ return false;
179
+ if (e.hiddenOutsideScope && e.hiddenOutsideScope !== "any" && e.hiddenOutsideScope !== scope)
180
+ return false;
181
+ return true;
182
+ });
183
+ const startIdx = page ? (page - 1) * pageSize : 0;
184
+ const visibleEntries = filteredEntries.slice(startIdx, startIdx + pageSize);
185
+ for (const entry of visibleEntries) {
186
+ const descStr = resolveLocalizedString(entry.desc, lang);
187
+ if (descStr) {
188
+ parts.push(`• ${CMD_PREFIX}${entry.cmd} — ${descStr}`);
189
+ }
190
+ else {
191
+ parts.push(`• ${CMD_PREFIX}${entry.cmd}`);
192
+ }
193
+ }
194
+ parts.push("");
195
+ }
196
+ const footerStr = resolveLocalizedString(registry.menu.footer, lang);
197
+ if (footerStr) {
198
+ parts.push(footerStr.replace(/\{prefix\}/g, CMD_PREFIX));
199
+ }
200
+ return parts.join("\n").trim();
201
+ }
202
+ export function renderCategory(registry, categoryKey, lang, scope) {
203
+ const normTarget = categoryKey.trim().toLowerCase();
204
+ // Match category key or category label
205
+ let matchedKey = null;
206
+ let matchedLabel = null;
207
+ for (const [catKey, catConfig] of Object.entries(registry.categories)) {
208
+ if (scope && catConfig.scope && catConfig.scope !== scope) {
209
+ continue;
210
+ }
211
+ const labelStr = resolveLocalizedString(catConfig.label, lang) ?? catKey;
212
+ if (catKey.toLowerCase() === normTarget || labelStr.toLowerCase() === normTarget) {
213
+ matchedKey = catKey;
214
+ matchedLabel = labelStr;
215
+ break;
216
+ }
217
+ }
218
+ if (!matchedKey) {
219
+ // Try matching if any command has entry.category === categoryKey
220
+ const hasCategoryInEntries = Array.from(registry.byId.values()).some(e => e.category?.toLowerCase() === normTarget);
221
+ if (hasCategoryInEntries) {
222
+ matchedKey = categoryKey;
223
+ matchedLabel = categoryKey;
224
+ }
225
+ else {
226
+ return null; // Category not found
227
+ }
228
+ }
229
+ // If we have a scope and the matched category has a scope that doesn't match, return null
230
+ if (scope && matchedKey && registry.categories[matchedKey]?.scope && registry.categories[matchedKey].scope !== scope) {
231
+ return null;
232
+ }
233
+ const entries = Array.from(registry.byId.values())
234
+ .filter(e => e.category?.toLowerCase() === matchedKey.toLowerCase())
235
+ .filter(e => {
236
+ if (!scope)
237
+ return true;
238
+ // If entry has a specific scope defined and it doesn't match the requested scope, exclude it
239
+ if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
240
+ return false;
241
+ if (e.hiddenOutsideScope && e.hiddenOutsideScope !== "any" && e.hiddenOutsideScope !== scope)
242
+ return false;
243
+ return true;
244
+ })
245
+ .sort((a, b) => a.cmd.localeCompare(b.cmd));
246
+ if (entries.length === 0)
247
+ return null;
248
+ const parts = [];
249
+ parts.push(`📁 *${tFor(lang, "menu.category")}: ${matchedLabel}*`);
250
+ parts.push("");
251
+ for (const entry of entries) {
252
+ const descStr = resolveLocalizedString(entry.desc, lang);
253
+ if (descStr) {
254
+ parts.push(`• ${CMD_PREFIX}${entry.cmd} — ${descStr}`);
255
+ }
256
+ else {
257
+ parts.push(`• ${CMD_PREFIX}${entry.cmd}`);
258
+ }
259
+ }
260
+ return parts.join("\\n").trim();
261
+ }
262
+ export function renderManual(entry, registry, lang) {
263
+ const parts = [];
264
+ parts.push(`📖 *${tFor(lang, "menu.manual")}: ${CMD_PREFIX}${entry.cmd}*`);
265
+ if (entry.aliases.length > 0) {
266
+ parts.push(`*Aliases:* ${entry.aliases.map(a => CMD_PREFIX + a).join(", ")}`);
267
+ }
268
+ if (entry.category && registry.categories[entry.category]) {
269
+ const catLabel = resolveLocalizedString(registry.categories[entry.category].label, lang) ?? entry.category;
270
+ parts.push(`*${tFor(lang, "menu.category")}:* ${catLabel}`);
271
+ }
272
+ parts.push("");
273
+ const descStr = resolveLocalizedString(entry.desc, lang);
274
+ if (descStr) {
275
+ parts.push(`*${tFor(lang, "menu.description")}:* ${descStr}`);
276
+ parts.push("");
277
+ }
278
+ const manualStr = resolveLocalizedString(entry.manual, lang);
279
+ if (manualStr) {
280
+ parts.push(manualStr);
281
+ }
282
+ else {
283
+ parts.push(tFor(lang, "system.commandManualMissing", { cmd: entry.cmd }));
284
+ }
285
+ return parts.join("\n").trim();
286
+ }
287
+ function levenshtein(a, b) {
288
+ const m = a.length, n = b.length;
289
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
290
+ for (let i = 0; i <= m; i++)
291
+ dp[i][0] = i;
292
+ for (let j = 0; j <= n; j++)
293
+ dp[0][j] = j;
294
+ for (let i = 1; i <= m; i++) {
295
+ for (let j = 1; j <= n; j++) {
296
+ dp[i][j] = a[i - 1] === b[j - 1]
297
+ ? dp[i - 1][j - 1]
298
+ : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
299
+ }
300
+ }
301
+ return dp[m][n];
302
+ }
303
+ /** Closest known cmd/alias to `invocation` within `maxDistance`, or null. */
304
+ function findClosestInvocation(invocation, registry, maxDistance) {
305
+ let best = null;
306
+ let bestDist = Infinity;
307
+ for (const known of registry.byInvocation.keys()) {
308
+ const dist = levenshtein(invocation, known);
309
+ if (dist < bestDist) {
310
+ bestDist = dist;
311
+ best = known;
312
+ }
313
+ }
314
+ return best !== null && bestDist > 0 && bestDist <= maxDistance ? best : null;
315
+ }
316
+ export function renderNotFound(invocation, registry, lang) {
317
+ const menuCmd = registry.menu.cmd;
318
+ if (registry.menu.suggestSimilar) {
319
+ const suggestion = findClosestInvocation(invocation, registry, registry.menu.suggestMaxDistance);
320
+ if (suggestion) {
321
+ return tFor(lang, "system.commandNotFoundSuggestion", {
322
+ cmd: invocation,
323
+ suggestion,
324
+ prefix: CMD_PREFIX,
325
+ menuCmd
326
+ });
327
+ }
328
+ }
329
+ return tFor(lang, "system.commandNotFound", { cmd: invocation, prefix: CMD_PREFIX, menuCmd });
330
+ }
331
+ export function handleMenuCommand(command, rawArgs, registry, lang, scope) {
332
+ const trimmed = rawArgs.trim();
333
+ if (!trimmed) {
334
+ return renderOverview(registry, lang, undefined, scope);
335
+ }
336
+ // Check if rawArgs is a page number or starts with "page <number>"
337
+ const pageMatch = trimmed.match(/^(?:page\s+)?(\d+)$/i);
338
+ if (pageMatch) {
339
+ const pageNum = parseInt(pageMatch[1], 10);
340
+ return renderOverview(registry, lang, pageNum, scope);
341
+ }
342
+ const arg1 = trimmed.split(/\s+/)[0].toLowerCase();
343
+ const cleanArg = arg1.startsWith(CMD_PREFIX) ? arg1.slice(CMD_PREFIX.length) : arg1;
344
+ // 1. Match category
345
+ const categoryResult = renderCategory(registry, cleanArg, lang, scope);
346
+ if (categoryResult) {
347
+ return categoryResult;
348
+ }
349
+ // 2. Match command entry
350
+ const entryId = registry.byInvocation.get(cleanArg);
351
+ const entry = entryId ? registry.byId.get(entryId) : null;
352
+ if (entry) {
353
+ return renderManual(entry, registry, lang);
354
+ }
355
+ // 3. Fallback not-found
356
+ return renderNotFound(cleanArg, registry, lang);
357
+ }