@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,89 @@
1
+ import test, { describe, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { sendActiveDriverText } from "#kernel/activeDriverSend.js";
4
+ import { getDriverManager, _resetDriverManagerForTests } from "#kernel/driverManager.js";
5
+ function createMockDriver(name, ready = true, failsSend = false) {
6
+ const mockRef = (id) => ({ id, chatId: "123@c.us", timestamp: Date.now() });
7
+ return {
8
+ name,
9
+ isReady: () => ready,
10
+ sendText: async (jid, text) => {
11
+ if (failsSend)
12
+ throw new Error(`${name} sendText failed`);
13
+ return mockRef(`msg_${name}`);
14
+ },
15
+ connect: async () => { },
16
+ disconnect: async () => { },
17
+ me: () => ({ id: "123@c.us" }),
18
+ sendImage: async () => mockRef("image"),
19
+ sendVideo: async () => mockRef("image"),
20
+ sendAudio: async () => mockRef("image"),
21
+ sendDocument: async () => mockRef("image"),
22
+ sendSticker: async () => mockRef("image"),
23
+ sendLocation: async () => mockRef("loc"),
24
+ sendContact: async () => mockRef("contact"),
25
+ sendReaction: async () => { },
26
+ sendPoll: async () => mockRef("poll"),
27
+ react: async () => { },
28
+ deleteMessage: async () => { },
29
+ editMessage: async () => { },
30
+ sendPresenceUpdate: async () => { },
31
+ readMessages: async () => { },
32
+ onWhatsApp: async () => null,
33
+ getBusinessProfile: async () => null,
34
+ profilePictureUrl: async () => null,
35
+ fetchStatus: async () => null,
36
+ updateBlockStatus: async () => { },
37
+ addOrEditContact: async () => { },
38
+ removeContact: async () => { },
39
+ groupMetadata: async () => ({ subject: "Test Group", participants: [] }),
40
+ groupParticipantsUpdate: async () => [],
41
+ groupUpdateSubject: async () => { },
42
+ groupUpdateDescription: async () => { },
43
+ groupInviteCode: async () => "",
44
+ groupRevokeInvite: async () => "",
45
+ updateProfilePicture: async () => { },
46
+ updateProfileName: async () => { },
47
+ updateProfileStatus: async () => { },
48
+ downloadMedia: async () => null,
49
+ on: () => () => { },
50
+ };
51
+ }
52
+ describe("kernel/activeDriverSend", () => {
53
+ beforeEach(() => {
54
+ _resetDriverManagerForTests();
55
+ });
56
+ test("delivers text via the active driver when healthy", async () => {
57
+ const dm = getDriverManager();
58
+ const driver = createMockDriver("baileys");
59
+ dm.register(driver, { isPrimary: true });
60
+ const ref = await sendActiveDriverText("5511999999999@c.us", "hello");
61
+ assert.equal(ref.id, "msg_baileys");
62
+ });
63
+ test("propagates error when the active driver fails to send", async () => {
64
+ const dm = getDriverManager();
65
+ const failingDriver = createMockDriver("baileys", true, true);
66
+ dm.register(failingDriver, { isPrimary: true });
67
+ await assert.rejects(async () => sendActiveDriverText("5511999999999@c.us", "failing send"), /baileys sendText failed/);
68
+ });
69
+ test("passes quoted and mentions options through to the driver", async () => {
70
+ const dm = getDriverManager();
71
+ let receivedOpts = null;
72
+ const driver = {
73
+ ...createMockDriver("baileys"),
74
+ sendText: async (_jid, _text, opts) => {
75
+ receivedOpts = opts;
76
+ return { id: "msg1", chatId: _jid, timestamp: Date.now() };
77
+ },
78
+ };
79
+ dm.register(driver, { isPrimary: true });
80
+ const quotedRef = { id: "orig-msg", remoteJid: "123@s.whatsapp.net", fromMe: false };
81
+ const ref = await sendActiveDriverText("5511999999999@c.us", "reply", { quoted: quotedRef, mentions: ["@user1"] });
82
+ assert.equal(ref.id, "msg1");
83
+ assert.deepStrictEqual(receivedOpts, { quoted: quotedRef, mentions: ["@user1"] });
84
+ });
85
+ test("throws when no driver is registered", async () => {
86
+ const dm = getDriverManager();
87
+ await assert.rejects(async () => sendActiveDriverText("5511999999999@c.us", "no driver"), /no active driver/i);
88
+ });
89
+ });
@@ -29,6 +29,7 @@ import { spawn } from "child_process";
29
29
  import nodemailer from "nodemailer";
30
30
  import { CONFIG_DIR, ADMIN_JID, SMTP_HOST, SMTP_PORT, SMTP_SEC, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_TO, SMTP_INSECURE, } from "#config";
31
31
  import { logger } from "#logger";
32
+ import { t } from "#i18n";
32
33
  const ALERTS_LOG_FILE = path.join(CONFIG_DIR, "alerts.log");
33
34
  let sockProvider = null;
34
35
  /**
@@ -160,25 +161,18 @@ export function fireAlert(kind, details = {}) {
160
161
  if (kind === "send_failed_no_fallback") {
161
162
  event = {
162
163
  level: "critical",
163
- title: "manybot: sem driver de fallback",
164
+ title: t("alerts.noFallbackTitle"),
164
165
  message: `jid=${details.jid} primary=${details.primary}`,
165
166
  };
166
167
  }
167
168
  else if (kind === "send_failed_both_drivers") {
168
169
  event = {
169
170
  level: "critical",
170
- title: "manybot: envio falhou nos dois drivers",
171
+ title: t("alerts.bothDriversFailedTitle"),
171
172
  message: `jid=${details.jid} ${details.primary}->${details.secondary}` +
172
173
  (details.error ? ` error=${String(details.error)}` : ""),
173
174
  };
174
175
  }
175
- else if (kind === "whatsmeow_subprocess_halted") {
176
- event = {
177
- level: "critical",
178
- title: "manybot: subprocesso whatsmeow halted",
179
- message: `fallback indisponível: ${details.reason ?? "unknown"} — bot segue só com Baileys`,
180
- };
181
- }
182
176
  else {
183
177
  event = {
184
178
  level: "warning",
@@ -0,0 +1,46 @@
1
+ /**
2
+ * kernel/chatOverrides.ts
3
+ *
4
+ * Per-chat overrides for the bot's global command prefix and language,
5
+ * set via `!config prefixo` / `!config idioma` (see coreCommands.ts).
6
+ * Both are stored under the "core" plugin namespace in settingsDb —
7
+ * the same storage plugins use via `ctx.settings`, keyed by the same
8
+ * `normalizeJid(msg.chatId)` that `buildApi()` uses to scope
9
+ * `ctx.settings` — so a value written here is read back under the
10
+ * exact same key it was written under.
11
+ *
12
+ * Kept as a standalone leaf module (only #config, jid utils, and
13
+ * settingsDb as deps) so it can be imported from anywhere in the
14
+ * dispatch pipeline (api/index.ts, messageHandler.ts, runCommand.ts,
15
+ * coreCommands.ts) without risking an import cycle.
16
+ */
17
+ import { getPluginSetting } from "./settingsDb.js";
18
+ import { normalizeJid } from "#drivers/jid.js";
19
+ import { CMD_PREFIX } from "#config";
20
+ const CORE_PLUGIN = "core";
21
+ /**
22
+ * Resolves the effective command prefix for a chat: the chat's saved
23
+ * override (`!config prefixo`) if one was set, else the global
24
+ * `CMD_PREFIX`.
25
+ *
26
+ * @param chatId - the *raw* chat id (e.g. `msg.chatId`), not yet
27
+ * normalized — this function normalizes it itself so callers don't
28
+ * have to know which normalization the settings layer expects.
29
+ */
30
+ export function getChatPrefix(chatId) {
31
+ const override = getPluginSetting(CORE_PLUGIN, normalizeJid(chatId), "chat_prefix");
32
+ return typeof override === "string" && override.length > 0 ? override : CMD_PREFIX;
33
+ }
34
+ /**
35
+ * Resolves the effective language code for a chat: the chat's saved
36
+ * override (`!config idioma`) if one was set, else `undefined` so the
37
+ * caller can fall back to the global language (`getCurrentLang()` /
38
+ * plain `t()`).
39
+ *
40
+ * @param chatId - the *raw* chat id (e.g. `msg.chatId`); normalized
41
+ * internally, same as {@link getChatPrefix}.
42
+ */
43
+ export function getChatLocale(chatId) {
44
+ const override = getPluginSetting(CORE_PLUGIN, normalizeJid(chatId), "chat_locale");
45
+ return typeof override === "string" && override.length > 0 ? override : undefined;
46
+ }
@@ -0,0 +1,59 @@
1
+ import test, { describe, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { getChatPrefix, getChatLocale } from "#kernel/chatOverrides.js";
4
+ import { buildSettingsApi } from "#kernel/settingsDb.js";
5
+ import { CMD_PREFIX } from "#config";
6
+ describe("kernel/chatOverrides", () => {
7
+ // Already in normalized form ("@c.us") so writes and reads use the
8
+ // exact same storage key without relying on normalizeJid to no-op.
9
+ const chatId = "5511977776666@c.us";
10
+ beforeEach(() => {
11
+ buildSettingsApi("core", chatId).deleteAll();
12
+ });
13
+ afterEach(() => {
14
+ buildSettingsApi("core", chatId).deleteAll();
15
+ });
16
+ describe("getChatPrefix", () => {
17
+ test("returns the global CMD_PREFIX when no override is set", () => {
18
+ assert.equal(getChatPrefix(chatId), CMD_PREFIX);
19
+ });
20
+ test("returns the chat's saved override once !config prefixo has set one", () => {
21
+ buildSettingsApi("core", chatId).set("chat_prefix", "#");
22
+ assert.equal(getChatPrefix(chatId), "#");
23
+ });
24
+ test("does not leak one chat's override into another chat", () => {
25
+ buildSettingsApi("core", chatId).set("chat_prefix", "#");
26
+ assert.equal(getChatPrefix("5511900000000@c.us"), CMD_PREFIX);
27
+ });
28
+ test("reads back a value written under the raw (non-normalized) wire jid form", () => {
29
+ // buildApi()/buildMessageContext() scope ctx.settings with
30
+ // normalizeJid(msg.chatId) — a raw "@s.whatsapp.net" jid (with a
31
+ // device suffix, as WhatsApp sends it) must normalize to the same
32
+ // key so a write from the live message path is visible here too.
33
+ const rawJid = "5511977776666:12@s.whatsapp.net";
34
+ buildSettingsApi("core", "5511977776666@c.us").set("chat_prefix", "$");
35
+ assert.equal(getChatPrefix(rawJid), "$");
36
+ });
37
+ test("falls back to the global prefix for a blank saved override", () => {
38
+ buildSettingsApi("core", chatId).set("chat_prefix", "");
39
+ assert.equal(getChatPrefix(chatId), CMD_PREFIX);
40
+ });
41
+ });
42
+ describe("getChatLocale", () => {
43
+ test("returns undefined when no override is set, so callers fall back to the global language", () => {
44
+ assert.equal(getChatLocale(chatId), undefined);
45
+ });
46
+ test("returns the chat's saved override once !config idioma has set one", () => {
47
+ buildSettingsApi("core", chatId).set("chat_locale", "es");
48
+ assert.equal(getChatLocale(chatId), "es");
49
+ });
50
+ test("does not leak one chat's override into another chat", () => {
51
+ buildSettingsApi("core", chatId).set("chat_locale", "es");
52
+ assert.equal(getChatLocale("5511900000000@c.us"), undefined);
53
+ });
54
+ test("falls back to undefined for a blank saved override", () => {
55
+ buildSettingsApi("core", chatId).set("chat_locale", "");
56
+ assert.equal(getChatLocale(chatId), undefined);
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * chatSession.ts
3
+ *
4
+ * Phase 7 of MANYBOT-6.md — exclusive chat session, kernel primitive.
5
+ *
6
+ * Prevents two plugins from running an interactive flow (a game, the
7
+ * figurinha timeout session, a music-download prompt, etc.) in the same
8
+ * chat at the same time. The kernel only owns the lock itself — WHO holds
9
+ * it and for how long. Everything about the session's own state (timeout,
10
+ * collected media, turn tracking, ...) stays entirely inside the owning
11
+ * plugin; `commands.yaml` only ever registers commands, never internal
12
+ * flow state.
13
+ *
14
+ * Deliberately NOT persisted (no settingsDb / SQLite): a session lock only
15
+ * makes sense for the lifetime of the running process — restarting the
16
+ * bot should never leave a chat stuck "locked" by a plugin that no longer
17
+ * remembers it opened one.
18
+ *
19
+ * many-ai's passive continuation window (its own multi-turn follow-up
20
+ * mechanism) is a separate category by design and never touches this
21
+ * lock — see the Phase 7 note in MANYBOT-6.md. This module does not
22
+ * special-case many-ai; it simply never gets called by it.
23
+ */
24
+ const sessions = new Map();
25
+ /**
26
+ * Attempts to open an exclusive session for `pluginName` in `chatId`.
27
+ * Returns `true` if the session is now held by `pluginName` — either it
28
+ * was free, or `pluginName` already held it (idempotent re-acquire, e.g.
29
+ * a plugin calling acquire() again on a later message of its own flow).
30
+ * Returns `false` if another plugin already holds the session.
31
+ */
32
+ export function acquireSession(chatId, pluginName) {
33
+ const current = sessions.get(chatId);
34
+ if (current && current.pluginName !== pluginName) {
35
+ return false;
36
+ }
37
+ sessions.set(chatId, { pluginName, acquiredAt: current?.acquiredAt ?? Date.now() });
38
+ return true;
39
+ }
40
+ /**
41
+ * Releases the session in `chatId`, but only if it is currently held by
42
+ * `pluginName` — a plugin can never release a lock it doesn't own. No-op
43
+ * (returns `false`) if the chat has no session, or it's held by someone
44
+ * else.
45
+ */
46
+ export function releaseSession(chatId, pluginName) {
47
+ const current = sessions.get(chatId);
48
+ if (!current || current.pluginName !== pluginName) {
49
+ return false;
50
+ }
51
+ sessions.delete(chatId);
52
+ return true;
53
+ }
54
+ /** Whether `chatId` currently has an open exclusive session (by anyone). */
55
+ export function isSessionLocked(chatId) {
56
+ return sessions.has(chatId);
57
+ }
58
+ /** Which plugin currently holds the session in `chatId`, if any. */
59
+ export function getSessionHolder(chatId) {
60
+ return sessions.get(chatId)?.pluginName ?? null;
61
+ }
62
+ /** Test-only: wipe all session state between tests. */
63
+ export function __resetSessionsForTests() {
64
+ sessions.clear();
65
+ }
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { describe, beforeEach } from "node:test";
3
+ import { acquireSession, releaseSession, isSessionLocked, getSessionHolder, __resetSessionsForTests, } from "#kernel/chatSession.js";
4
+ describe("kernel/chatSession — Phase 7 exclusive chat session", () => {
5
+ beforeEach(() => {
6
+ __resetSessionsForTests();
7
+ });
8
+ test("acquire on a free chat succeeds and locks it", () => {
9
+ assert.equal(acquireSession("chat1", "gamePlugin"), true);
10
+ assert.equal(isSessionLocked("chat1"), true);
11
+ assert.equal(getSessionHolder("chat1"), "gamePlugin");
12
+ });
13
+ test("a different plugin cannot acquire an already-held session", () => {
14
+ assert.equal(acquireSession("chat1", "gamePlugin"), true);
15
+ assert.equal(acquireSession("chat1", "figurinhaPlugin"), false);
16
+ assert.equal(getSessionHolder("chat1"), "gamePlugin", "holder unchanged");
17
+ });
18
+ test("the same plugin re-acquiring its own session is idempotent", () => {
19
+ assert.equal(acquireSession("chat1", "gamePlugin"), true);
20
+ assert.equal(acquireSession("chat1", "gamePlugin"), true);
21
+ assert.equal(getSessionHolder("chat1"), "gamePlugin");
22
+ });
23
+ test("release only works for the plugin that holds the session", () => {
24
+ acquireSession("chat1", "gamePlugin");
25
+ assert.equal(releaseSession("chat1", "figurinhaPlugin"), false, "wrong plugin cannot release");
26
+ assert.equal(isSessionLocked("chat1"), true, "still locked");
27
+ assert.equal(releaseSession("chat1", "gamePlugin"), true);
28
+ assert.equal(isSessionLocked("chat1"), false);
29
+ assert.equal(getSessionHolder("chat1"), null);
30
+ });
31
+ test("releasing a chat with no session is a no-op", () => {
32
+ assert.equal(releaseSession("neverLocked", "anyPlugin"), false);
33
+ });
34
+ test("sessions are independent per chat", () => {
35
+ assert.equal(acquireSession("chatA", "gamePlugin"), true);
36
+ assert.equal(acquireSession("chatB", "figurinhaPlugin"), true);
37
+ assert.equal(getSessionHolder("chatA"), "gamePlugin");
38
+ assert.equal(getSessionHolder("chatB"), "figurinhaPlugin");
39
+ });
40
+ test("a freed session can be acquired by a different plugin afterward", () => {
41
+ acquireSession("chat1", "gamePlugin");
42
+ releaseSession("chat1", "gamePlugin");
43
+ assert.equal(acquireSession("chat1", "figurinhaPlugin"), true);
44
+ assert.equal(getSessionHolder("chat1"), "figurinhaPlugin");
45
+ });
46
+ });
@@ -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, DEFAULT_MENU_CONFIG } 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, { ...DEFAULT_MENU_CONFIG, enabled: true }, 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,170 @@
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
+ days: String(defaults.notifyPeriodDays)
106
+ }));
107
+ }
108
+ stmts.insertHistory.run(id, entry.cmd);
109
+ }
110
+ const currentIds = new Set(byId.keys());
111
+ const persistedIds = stmts.allHistoryIds.all().map(r => r.id);
112
+ for (const id of persistedIds) {
113
+ if (currentIds.has(id))
114
+ continue;
115
+ const existing = stmts.getHistoryCmd.get(id);
116
+ if (!existing)
117
+ continue;
118
+ const spec = specById.get(id);
119
+ const notify = spec?.notifyChanges ?? defaults.notifyChanges;
120
+ if (notify) {
121
+ const periodMs = defaults.notifyPeriodDays * MS_PER_DAY;
122
+ stmts.upsertDeprecation.run(existing.cmd, id, null, now + periodMs, spec?.deprecatedMessage ?? null);
123
+ logger.warn(t("system.commandDeprecationRemoved", {
124
+ id,
125
+ old: existing.cmd,
126
+ days: String(defaults.notifyPeriodDays)
127
+ }));
128
+ }
129
+ stmts.deleteHistory.run(id);
130
+ }
131
+ }
132
+ /**
133
+ * Returns the active deprecation row for `cmdText`, or null if there is no
134
+ * deprecation or it has already expired.
135
+ */
136
+ export function getActiveDeprecation(cmdText) {
137
+ const row = stmts.getDeprecation.get(cmdText);
138
+ if (!row)
139
+ return null;
140
+ if (row.notify_until <= Date.now())
141
+ return null;
142
+ return row;
143
+ }
144
+ function interpolate(template, vars) {
145
+ return template.replace(/\{\{(\w+)\}\}/g, (_match, name) => Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : `{{${name}}}`);
146
+ }
147
+ /**
148
+ * Resolve the final user-facing message:
149
+ * 1. row.message (per-command override from yaml `deprecatedMessage`)
150
+ * 2. defaults.notifyMessage (global override)
151
+ * 3. built-in fallback ("Command \"{old}\" was renamed to \"{new}\"..." or
152
+ * "Command \"{old}\" has been removed" for removals)
153
+ */
154
+ export function formatDeprecationMessage(row, defaults) {
155
+ const vars = {
156
+ old: row.old_cmd,
157
+ new: row.new_cmd ?? "",
158
+ days: String(Math.max(0, Math.ceil((row.notify_until - Date.now()) / MS_PER_DAY))),
159
+ };
160
+ if (row.message && row.message.trim().length > 0) {
161
+ return interpolate(row.message, vars);
162
+ }
163
+ if (defaults.notifyMessage && defaults.notifyMessage.trim().length > 0) {
164
+ return interpolate(defaults.notifyMessage, vars);
165
+ }
166
+ const fallbackKey = row.new_cmd === null
167
+ ? "system.commandDeprecationFallbackRemoved"
168
+ : "system.commandDeprecationFallbackRenamed";
169
+ return interpolate(t(fallbackKey), vars);
170
+ }