@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,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
|
+
});
|
package/dist/kernel/alerts.js
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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
|
+
}
|