@manybot/manybot 5.7.0 → 5.8.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 (71) hide show
  1. package/README.md +20 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +56 -5
  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/drivers/baileys/adapter.js +58 -7
  9. package/dist/drivers/baileys/api/index.js +172 -24
  10. package/dist/drivers/baileys/index.js +62 -30
  11. package/dist/drivers/baileys/loginPrompt.js +0 -2
  12. package/dist/drivers/baileys/messageHandler.js +158 -4
  13. package/dist/drivers/baileys/messageHandler.test.js +203 -0
  14. package/dist/drivers/baileysAdapter.test.js +281 -0
  15. package/dist/drivers/jid.test.js +40 -0
  16. package/dist/drivers/types.js +5 -5
  17. package/dist/i18n/index.js +15 -2
  18. package/dist/kernel/activeDriverSend.js +21 -0
  19. package/dist/kernel/activeDriverSend.test.js +89 -0
  20. package/dist/kernel/alerts.js +3 -9
  21. package/dist/kernel/chatSession.js +65 -0
  22. package/dist/kernel/chatSession.test.js +46 -0
  23. package/dist/kernel/commandAccess.js +66 -0
  24. package/dist/kernel/commandAccess.test.js +74 -0
  25. package/dist/kernel/commandDeprecation.js +168 -0
  26. package/dist/kernel/commandDeprecation.test.js +107 -0
  27. package/dist/kernel/commandMenu.js +268 -0
  28. package/dist/kernel/commandMenu.test.js +234 -0
  29. package/dist/kernel/commandPermissions.js +125 -0
  30. package/dist/kernel/commandPermissions.test.js +159 -0
  31. package/dist/kernel/commandRegistry.js +459 -0
  32. package/dist/kernel/commandRegistry.test.js +156 -0
  33. package/dist/kernel/commandsConfig.js +517 -0
  34. package/dist/kernel/commandsConfig.test.js +236 -0
  35. package/dist/kernel/contactAutoSave.test.js +87 -0
  36. package/dist/kernel/driverManager.js +10 -6
  37. package/dist/kernel/driverManager.test.js +90 -0
  38. package/dist/kernel/integrationMode.js +88 -0
  39. package/dist/kernel/integrationMode.test.js +95 -0
  40. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  41. package/dist/kernel/pluginApi.test.js +583 -0
  42. package/dist/kernel/pluginGuard.js +15 -12
  43. package/dist/kernel/pluginGuard.test.js +39 -0
  44. package/dist/kernel/pluginLoader.js +96 -1
  45. package/dist/kernel/pluginLoader.test.js +80 -0
  46. package/dist/kernel/runCommand.js +245 -0
  47. package/dist/kernel/runCommand.test.js +235 -0
  48. package/dist/kernel/sendFallbackGuard.js +19 -48
  49. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  50. package/dist/kernel/sendGuard.js +38 -42
  51. package/dist/kernel/sendGuard.test.js +102 -0
  52. package/dist/kernel/settingsDb.js +4 -3
  53. package/dist/kernel/statusServer.js +9 -2
  54. package/dist/kernel/statusServer.test.js +70 -0
  55. package/dist/kernel/testConfig.js +183 -0
  56. package/dist/kernel/testConfig.test.js +181 -0
  57. package/dist/kernel/updateCheck.js +33 -10
  58. package/dist/locales/en.json +64 -13
  59. package/dist/locales/es.json +64 -13
  60. package/dist/locales/pt.json +64 -13
  61. package/dist/logger/logger.js +23 -3
  62. package/dist/logger/logger.test.js +45 -0
  63. package/dist/main.js +5 -76
  64. package/dist/plugins/__manybot_integration__/index.js +167 -0
  65. package/dist/plugins/__manybot_integration__/index.test.js +184 -0
  66. package/package.json +74 -17
  67. package/dist/drivers/whatsmeow/client.js +0 -252
  68. package/dist/drivers/whatsmeow/index.js +0 -79
  69. package/dist/drivers/whatsmeow/installer.js +0 -86
  70. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  71. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
@@ -0,0 +1,66 @@
1
+ /**
2
+ * kernel/commandAccess.ts
3
+ *
4
+ * ctx.commands — read-only registry queries exposed to plugins (Phase 2
5
+ * of MANYBOT-6.md). Lets a plugin check whether another command exists,
6
+ * or read its desc/manual, without ctx.plugins.require()'ing the owning
7
+ * plugin. Primary consumer: many-ai, so it can verify a command before
8
+ * mentioning it instead of hallucinating.
9
+ *
10
+ * Lookups are case-sensitive and unaliased-vs-aliased the same way
11
+ * resolveDispatch() and the menu-alias check in commandRegistry.ts are —
12
+ * keyed exactly as the command/alias was declared, no implicit
13
+ * lowercasing here.
14
+ */
15
+ import { getCommandRegistry } from "./commandRegistry.js";
16
+ import { resolveLocalizedString } from "./commandMenu.js";
17
+ /**
18
+ * All lookups take an optional explicit `registry` (defaulting to the
19
+ * live singleton via `getCommandRegistry()`) so this module can be unit
20
+ * tested with `buildCommandRegistry(...)` directly, the same convention
21
+ * `commandMenu.ts`'s `renderOverview(registry, lang)` uses.
22
+ */
23
+ function resolveEntry(registry, invocation) {
24
+ if (!registry)
25
+ return null;
26
+ const id = registry.byInvocation.get(invocation);
27
+ if (!id)
28
+ return null;
29
+ return registry.byId.get(id) ?? null;
30
+ }
31
+ /** Whether `invocation` (a cmd or alias) resolves to a registered command. */
32
+ export function exists(invocation, registry = getCommandRegistry()) {
33
+ return resolveEntry(registry, invocation) !== null;
34
+ }
35
+ /** Localized short description for `invocation`, or null if unknown/unset. */
36
+ export function desc(invocation, lang, registry = getCommandRegistry()) {
37
+ const entry = resolveEntry(registry, invocation);
38
+ if (!entry)
39
+ return null;
40
+ return resolveLocalizedString(entry.desc, lang);
41
+ }
42
+ /** Localized manual text for `invocation`, falling back to `desc`. */
43
+ export function manual(invocation, lang, registry = getCommandRegistry()) {
44
+ const entry = resolveEntry(registry, invocation);
45
+ if (!entry)
46
+ return null;
47
+ return resolveLocalizedString(entry.manual, lang) ?? resolveLocalizedString(entry.desc, lang);
48
+ }
49
+ /** All registered top-level commands, one entry per stable id. */
50
+ export function list(lang, registry = getCommandRegistry()) {
51
+ if (!registry)
52
+ return [];
53
+ return Array.from(registry.byId.values(), (entry) => ({
54
+ id: entry.id,
55
+ cmd: entry.cmd,
56
+ aliases: [...entry.aliases],
57
+ category: entry.category,
58
+ desc: resolveLocalizedString(entry.desc, lang),
59
+ }));
60
+ }
61
+ /** Whether `text` is one of the menu command's own aliases (e.g. "menu", "help"). */
62
+ export function isMenuAlias(text, registry = getCommandRegistry()) {
63
+ if (!registry)
64
+ return false;
65
+ return registry.menuAliases.has(text);
66
+ }
@@ -0,0 +1,74 @@
1
+ import test, { describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { exists, desc, manual, list, isMenuAlias } from "#kernel/commandAccess.js";
4
+ import { buildCommandRegistry } from "#kernel/commandRegistry.js";
5
+ function createTestRegistry() {
6
+ const plugins = new Map([
7
+ [
8
+ "utilPlugin",
9
+ {
10
+ name: "utilPlugin",
11
+ status: "active",
12
+ manifest: { name: "utilPlugin", version: "1.0.0" },
13
+ commands: {
14
+ pingFn: {
15
+ cmd: "ping",
16
+ aliases: ["p"],
17
+ desc: { pt: "Testa a latência", en: "Tests latency" },
18
+ category: "utils",
19
+ manual: "Uso: !ping",
20
+ },
21
+ infoFn: {
22
+ cmd: "info",
23
+ aliases: [],
24
+ desc: "Informações do bot",
25
+ category: "utils",
26
+ },
27
+ },
28
+ },
29
+ ],
30
+ ]);
31
+ const categories = {
32
+ utils: { label: { pt: "Utilitários", en: "Utilities" }, order: 1 },
33
+ };
34
+ return buildCommandRegistry(null, plugins, undefined, undefined, categories);
35
+ }
36
+ describe("kernel/commandAccess", () => {
37
+ test("exists() is true for cmd and alias, false for unknown", () => {
38
+ const registry = createTestRegistry();
39
+ assert.equal(exists("ping", registry), true);
40
+ assert.equal(exists("p", registry), true);
41
+ assert.equal(exists("nope", registry), false);
42
+ });
43
+ test("exists() returns false with no registry", () => {
44
+ assert.equal(exists("ping", null), false);
45
+ });
46
+ test("desc() resolves the localized string for the requested lang", () => {
47
+ const registry = createTestRegistry();
48
+ assert.equal(desc("ping", "pt", registry), "Testa a latência");
49
+ assert.equal(desc("ping", "en", registry), "Tests latency");
50
+ assert.equal(desc("info", "pt", registry), "Informações do bot");
51
+ assert.equal(desc("nope", "pt", registry), null);
52
+ });
53
+ test("manual() falls back to desc() when no manual is set", () => {
54
+ const registry = createTestRegistry();
55
+ assert.equal(manual("ping", "pt", registry), "Uso: !ping");
56
+ assert.equal(manual("info", "pt", registry), "Informações do bot");
57
+ });
58
+ test("list() returns one entry per command with resolved desc", () => {
59
+ const registry = createTestRegistry();
60
+ const items = list("pt", registry);
61
+ assert.equal(items.length, 2);
62
+ const ping = items.find((i) => i.cmd === "ping");
63
+ assert.ok(ping);
64
+ assert.deepEqual(ping?.aliases, ["p"]);
65
+ assert.equal(ping?.category, "utils");
66
+ assert.equal(ping?.desc, "Testa a latência");
67
+ });
68
+ test("isMenuAlias() checks the registry's menuAliases set", () => {
69
+ const registry = createTestRegistry();
70
+ assert.equal(isMenuAlias("help", registry), true);
71
+ assert.equal(isMenuAlias("menu", registry), true);
72
+ assert.equal(isMenuAlias("ping", registry), false);
73
+ });
74
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * commandDeprecation.ts
3
+ *
4
+ * Tracks command cmd/alias rename and removal across registry rebuilds.
5
+ * Persists state in the same SQLite database used by settingsDb.ts (separate
6
+ * file-scope DatabaseSync handle — same DB_PATH is fine).
7
+ *
8
+ * Two tables:
9
+ * command_cmd_history — current known (id → cmd). Populated on every
10
+ * syncCommandHistory() call; first-time inserts
11
+ * are silent (no deprecation triggered).
12
+ * command_deprecations — old_cmd → deprecation row. Created on rename
13
+ * or removal; expires by `notify_until`.
14
+ *
15
+ * Lookup is by old_cmd text — expired rows are treated as absent (lazy
16
+ * filtering, no cleanup sweep here).
17
+ */
18
+ import { DatabaseSync } from "node:sqlite";
19
+ import path from "path";
20
+ import { mkdirSync } from "fs";
21
+ import { logger } from "#logger";
22
+ import { t } from "#i18n";
23
+ import { CONFIG_DIR } from "#config";
24
+ const DB_PATH = process.env.NODE_ENV === "test" ? ":memory:" : path.join(CONFIG_DIR, "settings.db");
25
+ if (DB_PATH !== ":memory:") {
26
+ mkdirSync(path.dirname(DB_PATH), { recursive: true });
27
+ }
28
+ const db = new DatabaseSync(DB_PATH);
29
+ db.exec("PRAGMA journal_mode = WAL");
30
+ db.exec(`
31
+ CREATE TABLE IF NOT EXISTS command_cmd_history (
32
+ id TEXT PRIMARY KEY,
33
+ cmd TEXT NOT NULL,
34
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS command_deprecations (
38
+ old_cmd TEXT PRIMARY KEY,
39
+ id TEXT NOT NULL,
40
+ new_cmd TEXT,
41
+ notify_until INTEGER NOT NULL,
42
+ message TEXT
43
+ );
44
+ `);
45
+ const stmts = {
46
+ getHistoryCmd: db.prepare("SELECT cmd FROM command_cmd_history WHERE id = ?"),
47
+ insertHistory: db.prepare(`
48
+ INSERT INTO command_cmd_history (id, cmd, updated_at)
49
+ VALUES (?, ?, unixepoch())
50
+ ON CONFLICT (id)
51
+ DO UPDATE SET cmd = excluded.cmd, updated_at = excluded.updated_at
52
+ `),
53
+ deleteHistory: db.prepare("DELETE FROM command_cmd_history WHERE id = ?"),
54
+ allHistoryIds: db.prepare("SELECT id FROM command_cmd_history"),
55
+ getDeprecation: db.prepare("SELECT old_cmd, id, new_cmd, notify_until, message FROM command_deprecations WHERE old_cmd = ?"),
56
+ upsertDeprecation: db.prepare(`
57
+ INSERT INTO command_deprecations (old_cmd, id, new_cmd, notify_until, message)
58
+ VALUES (?, ?, ?, ?, ?)
59
+ ON CONFLICT (old_cmd)
60
+ DO UPDATE SET id = excluded.id,
61
+ new_cmd = excluded.new_cmd,
62
+ notify_until = excluded.notify_until,
63
+ message = excluded.message
64
+ `),
65
+ };
66
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
67
+ /**
68
+ * Reconcile the current registry `byId` against the persisted history.
69
+ *
70
+ * Per id:
71
+ * - not in history → INSERT silently (first sighting, no notice).
72
+ * - in history, same cmd → no-op.
73
+ * - in history, different cmd → INSERT into command_deprecations with the
74
+ * OLD cmd, new_cmd = new cmd, notify_until = now + period.
75
+ *
76
+ * Ids that vanished from byId → INSERT into command_deprecations with
77
+ * new_cmd = null, and DELETE the history row (the deprecation IS the record
78
+ * that the id is gone).
79
+ *
80
+ * `spec.notifyChanges ?? defaults.notifyChanges` controls whether any row
81
+ * is written for that id. When false, the history is still updated silently
82
+ * and no deprecation is recorded (admin-opt-out).
83
+ */
84
+ export function syncCommandHistory(byId, defaults, specs) {
85
+ const specById = new Map(specs.map(s => [s.id, s]));
86
+ const now = Date.now();
87
+ for (const [id, entry] of byId) {
88
+ const spec = specById.get(id);
89
+ const notify = spec?.notifyChanges ?? defaults.notifyChanges;
90
+ const existing = stmts.getHistoryCmd.get(id);
91
+ const prevCmd = existing?.cmd ?? null;
92
+ if (prevCmd === null) {
93
+ stmts.insertHistory.run(id, entry.cmd);
94
+ continue;
95
+ }
96
+ if (prevCmd === entry.cmd)
97
+ continue;
98
+ if (notify) {
99
+ const periodMs = defaults.notifyPeriodDays * MS_PER_DAY;
100
+ stmts.upsertDeprecation.run(prevCmd, id, entry.cmd, now + periodMs, spec?.deprecatedMessage ?? null);
101
+ logger.warn(t("system.commandDeprecationRenamed", {
102
+ id,
103
+ old: prevCmd,
104
+ new: entry.cmd
105
+ }));
106
+ }
107
+ stmts.insertHistory.run(id, entry.cmd);
108
+ }
109
+ const currentIds = new Set(byId.keys());
110
+ const persistedIds = stmts.allHistoryIds.all().map(r => r.id);
111
+ for (const id of persistedIds) {
112
+ if (currentIds.has(id))
113
+ continue;
114
+ const existing = stmts.getHistoryCmd.get(id);
115
+ if (!existing)
116
+ continue;
117
+ const spec = specById.get(id);
118
+ const notify = spec?.notifyChanges ?? defaults.notifyChanges;
119
+ if (notify) {
120
+ const periodMs = defaults.notifyPeriodDays * MS_PER_DAY;
121
+ stmts.upsertDeprecation.run(existing.cmd, id, null, now + periodMs, spec?.deprecatedMessage ?? null);
122
+ logger.warn(t("system.commandDeprecationRemoved", {
123
+ id,
124
+ old: existing.cmd
125
+ }));
126
+ }
127
+ stmts.deleteHistory.run(id);
128
+ }
129
+ }
130
+ /**
131
+ * Returns the active deprecation row for `cmdText`, or null if there is no
132
+ * deprecation or it has already expired.
133
+ */
134
+ export function getActiveDeprecation(cmdText) {
135
+ const row = stmts.getDeprecation.get(cmdText);
136
+ if (!row)
137
+ return null;
138
+ if (row.notify_until <= Date.now())
139
+ return null;
140
+ return row;
141
+ }
142
+ function interpolate(template, vars) {
143
+ return template.replace(/\{\{(\w+)\}\}/g, (_match, name) => Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : `{{${name}}}`);
144
+ }
145
+ /**
146
+ * Resolve the final user-facing message:
147
+ * 1. row.message (per-command override from yaml `deprecatedMessage`)
148
+ * 2. defaults.notifyMessage (global override)
149
+ * 3. built-in fallback ("Command \"{old}\" was renamed to \"{new}\"..." or
150
+ * "Command \"{old}\" has been removed" for removals)
151
+ */
152
+ export function formatDeprecationMessage(row, defaults) {
153
+ const vars = {
154
+ old: row.old_cmd,
155
+ new: row.new_cmd ?? "",
156
+ days: String(Math.max(0, Math.ceil((row.notify_until - Date.now()) / MS_PER_DAY))),
157
+ };
158
+ if (row.message && row.message.trim().length > 0) {
159
+ return interpolate(row.message, vars);
160
+ }
161
+ if (defaults.notifyMessage && defaults.notifyMessage.trim().length > 0) {
162
+ return interpolate(defaults.notifyMessage, vars);
163
+ }
164
+ const fallbackKey = row.new_cmd === null
165
+ ? "system.commandDeprecationFallbackRemoved"
166
+ : "system.commandDeprecationFallbackRenamed";
167
+ return interpolate(t(fallbackKey), vars);
168
+ }
@@ -0,0 +1,107 @@
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
+ handler: null,
17
+ text: null,
18
+ permissions: {
19
+ admin: false,
20
+ botAdmin: false,
21
+ scope: "any",
22
+ owner: false,
23
+ cooldownSeconds: 0,
24
+ whitelist: null,
25
+ blacklist: null,
26
+ messages: {},
27
+ },
28
+ arguments: [],
29
+ subcommands: {},
30
+ categoryHiddenInScope: null,
31
+ };
32
+ }
33
+ describe("kernel/commandDeprecation", () => {
34
+ const defaults = {
35
+ notifyChanges: true,
36
+ notifyPeriodDays: 7,
37
+ notifyMessage: null,
38
+ };
39
+ test("silently inserts history on first sync", () => {
40
+ const byId = new Map([
41
+ ["plugin::cmd1", createMockEntry("plugin::cmd1", "oldname")],
42
+ ]);
43
+ syncCommandHistory(byId, defaults, []);
44
+ // First sighting should not create an active deprecation for oldname
45
+ assert.equal(getActiveDeprecation("oldname"), null);
46
+ });
47
+ test("records deprecation when command is renamed across syncs", () => {
48
+ const byId1 = new Map([
49
+ ["plugin::cmd1", createMockEntry("plugin::cmd1", "oldname")],
50
+ ]);
51
+ syncCommandHistory(byId1, defaults, []);
52
+ // Sync 2: cmd name changed to newname
53
+ const byId2 = new Map([
54
+ ["plugin::cmd1", createMockEntry("plugin::cmd1", "newname")],
55
+ ]);
56
+ syncCommandHistory(byId2, defaults, []);
57
+ const deprecation = getActiveDeprecation("oldname");
58
+ assert.ok(deprecation);
59
+ assert.equal(deprecation?.old_cmd, "oldname");
60
+ assert.equal(deprecation?.new_cmd, "newname");
61
+ const formatted = formatDeprecationMessage(deprecation, defaults);
62
+ assert.ok(formatted.length > 0);
63
+ });
64
+ test("records deprecation when command is removed from registry", () => {
65
+ const byId1 = new Map([
66
+ ["plugin::cmdRem", createMockEntry("plugin::cmdRem", "removedcmd")],
67
+ ]);
68
+ syncCommandHistory(byId1, defaults, []);
69
+ // Sync 2: command removed
70
+ const byId2 = new Map();
71
+ syncCommandHistory(byId2, defaults, []);
72
+ const deprecation = getActiveDeprecation("removedcmd");
73
+ assert.ok(deprecation);
74
+ assert.equal(deprecation?.old_cmd, "removedcmd");
75
+ assert.equal(deprecation?.new_cmd, null);
76
+ });
77
+ test("respects notifyChanges = false opt-out", () => {
78
+ const byId1 = new Map([
79
+ ["plugin::optOut", createMockEntry("plugin::optOut", "alpha")],
80
+ ]);
81
+ const specs1 = [{
82
+ id: "plugin::optOut",
83
+ plugin: "plugin",
84
+ function: "optOut",
85
+ cmd: "alpha",
86
+ aliases: [],
87
+ desc: null,
88
+ category: null,
89
+ group: null,
90
+ manual: null,
91
+ text: null,
92
+ deprecatedMessage: null,
93
+ notifyChanges: false,
94
+ permissions: null,
95
+ messages: null,
96
+ arguments: [],
97
+ subcommands: [],
98
+ }];
99
+ syncCommandHistory(byId1, defaults, specs1);
100
+ // Rename with notifyChanges = false
101
+ const byId2 = new Map([
102
+ ["plugin::optOut", createMockEntry("plugin::optOut", "beta")],
103
+ ]);
104
+ syncCommandHistory(byId2, defaults, specs1);
105
+ assert.equal(getActiveDeprecation("alpha"), null);
106
+ });
107
+ });
@@ -0,0 +1,268 @@
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
+ /**
10
+ * Checks if the user should be shown the welcome message,
11
+ * and marks them as seen if so.
12
+ */
13
+ export function checkAndTriggerWelcomeMessage(userId, registry, lang) {
14
+ if (!registry.menu.welcomeMessage)
15
+ return null;
16
+ const settings = buildSettingsApi("kernel", userId);
17
+ const lastSeen = settings.get("last_welcome_seen");
18
+ const now = Math.floor(Date.now() / 1000);
19
+ const windowSeconds = (registry.menu.welcomeWindowDays || 3) * 86400;
20
+ if (!lastSeen || now - lastSeen > windowSeconds) {
21
+ settings.set("last_welcome_seen", now);
22
+ const rawMsg = resolveLocalizedString(registry.menu.welcomeMessage, lang);
23
+ if (!rawMsg)
24
+ return null;
25
+ return rawMsg.replace(/\{prefix\}/g, CMD_PREFIX);
26
+ }
27
+ return null;
28
+ }
29
+ /**
30
+ * Resolves a LocalizedString (string | Record<string, string>) to a single
31
+ * string for the requested language (or system language), falling back to
32
+ * English or the first available string.
33
+ */
34
+ export function resolveLocalizedString(raw, lang) {
35
+ if (!raw)
36
+ return null;
37
+ if (typeof raw === "string")
38
+ return raw;
39
+ if (typeof raw === "object" && raw !== null) {
40
+ const targetLang = (lang || getCurrentLang()).toLowerCase();
41
+ if (typeof raw[targetLang] === "string")
42
+ return raw[targetLang];
43
+ if (typeof raw.en === "string")
44
+ return raw.en;
45
+ if (typeof raw.pt === "string")
46
+ return raw.pt;
47
+ if (typeof raw.es === "string")
48
+ return raw.es;
49
+ const values = Object.values(raw);
50
+ if (values.length > 0 && typeof values[0] === "string")
51
+ return values[0];
52
+ }
53
+ return null;
54
+ }
55
+ export function renderOverview(registry, lang, page, scope) {
56
+ const parts = [];
57
+ const titleStr = resolveLocalizedString(registry.menu.title, lang) ?? "🤖 ManyBot — Menu";
58
+ parts.push(`*${titleStr}*`);
59
+ const rawIntro = resolveLocalizedString(registry.menu.intro, lang) ??
60
+ tFor(lang, "menu.intro");
61
+ const introStr = rawIntro.replace(/\{prefix\}/g, CMD_PREFIX);
62
+ parts.push(introStr);
63
+ parts.push(""); // blank line before categories/commands
64
+ const allEntries = Array.from(registry.byId.values());
65
+ const categories = Object.entries(registry.categories);
66
+ const pageSize = registry.menu.pageSize ?? 15;
67
+ if (categories.length > 0) {
68
+ // Sort defined categories by order ascending
69
+ const sortedCategories = categories.sort((a, b) => a[1].order - b[1].order);
70
+ const assignedIds = new Set();
71
+ for (const [catKey, catConfig] of sortedCategories) {
72
+ if (scope && catConfig.scope && catConfig.scope !== scope) {
73
+ continue;
74
+ }
75
+ const entries = allEntries
76
+ .filter(e => e.category === catKey)
77
+ .filter(e => {
78
+ if (!scope)
79
+ return true;
80
+ if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
81
+ return false;
82
+ return true;
83
+ })
84
+ .sort((a, b) => a.cmd.localeCompare(b.cmd));
85
+ if (entries.length === 0)
86
+ continue;
87
+ const catLabel = resolveLocalizedString(catConfig.label, lang) ?? catKey;
88
+ parts.push(`📁 *${catLabel}*`);
89
+ for (const entry of entries) {
90
+ assignedIds.add(entry.id);
91
+ const descStr = resolveLocalizedString(entry.desc, lang);
92
+ if (descStr) {
93
+ parts.push(` • ${CMD_PREFIX}${entry.cmd} — ${descStr}`);
94
+ }
95
+ else {
96
+ parts.push(` • ${CMD_PREFIX}${entry.cmd}`);
97
+ }
98
+ }
99
+ parts.push("");
100
+ }
101
+ // Uncategorized entries
102
+ const uncategorized = allEntries
103
+ .filter(e => !assignedIds.has(e.id))
104
+ .filter(e => {
105
+ if (!scope)
106
+ return true;
107
+ if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
108
+ return false;
109
+ return true;
110
+ })
111
+ .sort((a, b) => a.cmd.localeCompare(b.cmd));
112
+ if (uncategorized.length > 0) {
113
+ const otherLabel = tFor(lang, "menu.other");
114
+ parts.push(`📁 *${otherLabel}*`);
115
+ for (const entry of uncategorized) {
116
+ const descStr = resolveLocalizedString(entry.desc, lang);
117
+ if (descStr) {
118
+ parts.push(` • ${CMD_PREFIX}${entry.cmd} — ${descStr}`);
119
+ }
120
+ else {
121
+ parts.push(` • ${CMD_PREFIX}${entry.cmd}`);
122
+ }
123
+ }
124
+ parts.push("");
125
+ }
126
+ }
127
+ else {
128
+ // Flat command list with pagination
129
+ const filteredEntries = allEntries.filter(e => {
130
+ if (!scope)
131
+ return true;
132
+ if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
133
+ return false;
134
+ return true;
135
+ });
136
+ const startIdx = page ? (page - 1) * pageSize : 0;
137
+ const visibleEntries = filteredEntries.slice(startIdx, startIdx + pageSize);
138
+ for (const entry of visibleEntries) {
139
+ const descStr = resolveLocalizedString(entry.desc, lang);
140
+ if (descStr) {
141
+ parts.push(`• ${CMD_PREFIX}${entry.cmd} — ${descStr}`);
142
+ }
143
+ else {
144
+ parts.push(`• ${CMD_PREFIX}${entry.cmd}`);
145
+ }
146
+ }
147
+ parts.push("");
148
+ }
149
+ const footerStr = resolveLocalizedString(registry.menu.footer, lang);
150
+ if (footerStr) {
151
+ parts.push(footerStr.replace(/\{prefix\}/g, CMD_PREFIX));
152
+ }
153
+ return parts.join("\n").trim();
154
+ }
155
+ export function renderCategory(registry, categoryKey, lang, scope) {
156
+ const normTarget = categoryKey.trim().toLowerCase();
157
+ // Match category key or category label
158
+ let matchedKey = null;
159
+ let matchedLabel = null;
160
+ for (const [catKey, catConfig] of Object.entries(registry.categories)) {
161
+ if (scope && catConfig.scope && catConfig.scope !== scope) {
162
+ continue;
163
+ }
164
+ const labelStr = resolveLocalizedString(catConfig.label, lang) ?? catKey;
165
+ if (catKey.toLowerCase() === normTarget || labelStr.toLowerCase() === normTarget) {
166
+ matchedKey = catKey;
167
+ matchedLabel = labelStr;
168
+ break;
169
+ }
170
+ }
171
+ if (!matchedKey) {
172
+ // Try matching if any command has entry.category === categoryKey
173
+ const hasCategoryInEntries = Array.from(registry.byId.values()).some(e => e.category?.toLowerCase() === normTarget);
174
+ if (hasCategoryInEntries) {
175
+ matchedKey = categoryKey;
176
+ matchedLabel = categoryKey;
177
+ }
178
+ else {
179
+ return null; // Category not found
180
+ }
181
+ }
182
+ // If we have a scope and the matched category has a scope that doesn't match, return null
183
+ if (scope && matchedKey && registry.categories[matchedKey]?.scope && registry.categories[matchedKey].scope !== scope) {
184
+ return null;
185
+ }
186
+ const entries = Array.from(registry.byId.values())
187
+ .filter(e => e.category?.toLowerCase() === matchedKey.toLowerCase())
188
+ .filter(e => {
189
+ if (!scope)
190
+ return true;
191
+ // If entry has a specific scope defined and it doesn't match the requested scope, exclude it
192
+ if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
193
+ return false;
194
+ return true;
195
+ })
196
+ .sort((a, b) => a.cmd.localeCompare(b.cmd));
197
+ if (entries.length === 0)
198
+ return null;
199
+ const parts = [];
200
+ parts.push(`📁 *${tFor(lang, "menu.category")}: ${matchedLabel}*`);
201
+ parts.push("");
202
+ for (const entry of entries) {
203
+ const descStr = resolveLocalizedString(entry.desc, lang);
204
+ if (descStr) {
205
+ parts.push(`• ${CMD_PREFIX}${entry.cmd} — ${descStr}`);
206
+ }
207
+ else {
208
+ parts.push(`• ${CMD_PREFIX}${entry.cmd}`);
209
+ }
210
+ }
211
+ return parts.join("\\n").trim();
212
+ }
213
+ export function renderManual(entry, registry, lang) {
214
+ const parts = [];
215
+ parts.push(`📖 *${tFor(lang, "menu.manual")}: ${CMD_PREFIX}${entry.cmd}*`);
216
+ if (entry.aliases.length > 0) {
217
+ parts.push(`*Aliases:* ${entry.aliases.map(a => CMD_PREFIX + a).join(", ")}`);
218
+ }
219
+ if (entry.category && registry.categories[entry.category]) {
220
+ const catLabel = resolveLocalizedString(registry.categories[entry.category].label, lang) ?? entry.category;
221
+ parts.push(`*${tFor(lang, "menu.category")}:* ${catLabel}`);
222
+ }
223
+ parts.push("");
224
+ const descStr = resolveLocalizedString(entry.desc, lang);
225
+ if (descStr) {
226
+ parts.push(`*${tFor(lang, "menu.description")}:* ${descStr}`);
227
+ parts.push("");
228
+ }
229
+ const manualStr = resolveLocalizedString(entry.manual, lang);
230
+ if (manualStr) {
231
+ parts.push(manualStr);
232
+ }
233
+ else {
234
+ parts.push(tFor(lang, "system.commandManualMissing", { cmd: entry.cmd }));
235
+ }
236
+ return parts.join("\n").trim();
237
+ }
238
+ export function renderNotFound(invocation, registry, lang) {
239
+ const menuCmd = registry.menu.cmd;
240
+ return tFor(lang, "system.commandNotFound", { cmd: invocation, prefix: CMD_PREFIX, menuCmd });
241
+ }
242
+ export function handleMenuCommand(command, rawArgs, registry, lang, scope) {
243
+ const trimmed = rawArgs.trim();
244
+ if (!trimmed) {
245
+ return renderOverview(registry, lang, undefined, scope);
246
+ }
247
+ // Check if rawArgs is a page number or starts with "page <number>"
248
+ const pageMatch = trimmed.match(/^(?:page\s+)?(\d+)$/i);
249
+ if (pageMatch) {
250
+ const pageNum = parseInt(pageMatch[1], 10);
251
+ return renderOverview(registry, lang, pageNum, scope);
252
+ }
253
+ const arg1 = trimmed.split(/\s+/)[0].toLowerCase();
254
+ const cleanArg = arg1.startsWith(CMD_PREFIX) ? arg1.slice(CMD_PREFIX.length) : arg1;
255
+ // 1. Match category
256
+ const categoryResult = renderCategory(registry, cleanArg, lang, scope);
257
+ if (categoryResult) {
258
+ return categoryResult;
259
+ }
260
+ // 2. Match command entry
261
+ const entryId = registry.byInvocation.get(cleanArg);
262
+ const entry = entryId ? registry.byId.get(entryId) : null;
263
+ if (entry) {
264
+ return renderManual(entry, registry, lang);
265
+ }
266
+ // 3. Fallback not-found
267
+ return renderNotFound(cleanArg, registry, lang);
268
+ }