@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.
- package/README.md +20 -3
- package/dist/client/banner.js +10 -0
- package/dist/client/banner.test.js +31 -0
- package/dist/client/store.js +56 -5
- package/dist/client/store.test.js +170 -0
- package/dist/config.js +28 -44
- package/dist/config.test.js +26 -0
- package/dist/drivers/baileys/adapter.js +58 -7
- package/dist/drivers/baileys/api/index.js +172 -24
- package/dist/drivers/baileys/index.js +62 -30
- package/dist/drivers/baileys/loginPrompt.js +0 -2
- package/dist/drivers/baileys/messageHandler.js +158 -4
- package/dist/drivers/baileys/messageHandler.test.js +203 -0
- package/dist/drivers/baileysAdapter.test.js +281 -0
- package/dist/drivers/jid.test.js +40 -0
- package/dist/drivers/types.js +5 -5
- package/dist/i18n/index.js +15 -2
- 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/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 +168 -0
- package/dist/kernel/commandDeprecation.test.js +107 -0
- package/dist/kernel/commandMenu.js +268 -0
- package/dist/kernel/commandMenu.test.js +234 -0
- package/dist/kernel/commandPermissions.js +125 -0
- package/dist/kernel/commandPermissions.test.js +159 -0
- package/dist/kernel/commandRegistry.js +459 -0
- package/dist/kernel/commandRegistry.test.js +156 -0
- package/dist/kernel/commandsConfig.js +517 -0
- package/dist/kernel/commandsConfig.test.js +236 -0
- package/dist/kernel/contactAutoSave.test.js +87 -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 +583 -0
- package/dist/kernel/pluginGuard.js +15 -12
- package/dist/kernel/pluginGuard.test.js +39 -0
- package/dist/kernel/pluginLoader.js +96 -1
- package/dist/kernel/pluginLoader.test.js +80 -0
- package/dist/kernel/runCommand.js +245 -0
- package/dist/kernel/runCommand.test.js +235 -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 +4 -3
- package/dist/kernel/statusServer.js +9 -2
- package/dist/kernel/statusServer.test.js +70 -0
- package/dist/kernel/testConfig.js +183 -0
- package/dist/kernel/testConfig.test.js +181 -0
- package/dist/kernel/updateCheck.js +33 -10
- package/dist/locales/en.json +64 -13
- package/dist/locales/es.json +64 -13
- package/dist/locales/pt.json +64 -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 +167 -0
- package/dist/plugins/__manybot_integration__/index.test.js +184 -0
- package/package.json +74 -17
- 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,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
|
+
}
|