@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,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
|
+
}
|