@manybot/manybot 5.8.0 → 5.9.1
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 +15 -7
- package/dist/client/store.js +35 -1
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +75 -8
- 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 +245 -51
- package/dist/drivers/baileys/index.js +30 -6
- package/dist/drivers/baileys/messageHandler.js +207 -21
- package/dist/drivers/baileys/messageHandler.test.js +256 -14
- package/dist/drivers/baileysAdapter.test.js +97 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +35 -1
- package/dist/i18n/index.js +5 -22
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/commandAccess.test.js +2 -2
- package/dist/kernel/commandDeprecation.js +4 -2
- package/dist/kernel/commandDeprecation.test.js +8 -1
- package/dist/kernel/commandMenu.js +91 -2
- package/dist/kernel/commandMenu.test.js +131 -2
- package/dist/kernel/commandPermissions.js +69 -23
- package/dist/kernel/commandPermissions.test.js +77 -9
- package/dist/kernel/commandRegistry.js +167 -43
- package/dist/kernel/commandRegistry.test.js +4 -2
- package/dist/kernel/commandsConfig.js +470 -38
- package/dist/kernel/commandsConfig.test.js +249 -3
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/pluginApi.test.js +20 -3
- package/dist/kernel/pluginGuard.js +5 -3
- package/dist/kernel/pluginLoader.js +73 -10
- package/dist/kernel/pluginLoader.test.js +111 -1
- package/dist/kernel/runCommand.js +57 -18
- package/dist/kernel/runCommand.test.js +269 -7
- package/dist/kernel/settingsDb.js +15 -2
- package/dist/kernel/testConfig.js +9 -0
- package/dist/locales/en.json +14 -1
- package/dist/locales/es.json +17 -4
- package/dist/locales/pt.json +18 -5
- package/dist/plugins/__manybot_integration__/index.js +33 -16
- package/dist/plugins/__manybot_integration__/index.test.js +42 -8
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +4 -3
|
@@ -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
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test, { describe } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { exists, desc, manual, list, isMenuAlias } from "#kernel/commandAccess.js";
|
|
4
|
-
import { buildCommandRegistry } from "#kernel/commandRegistry.js";
|
|
4
|
+
import { buildCommandRegistry, DEFAULT_MENU_CONFIG } from "#kernel/commandRegistry.js";
|
|
5
5
|
function createTestRegistry() {
|
|
6
6
|
const plugins = new Map([
|
|
7
7
|
[
|
|
@@ -31,7 +31,7 @@ function createTestRegistry() {
|
|
|
31
31
|
const categories = {
|
|
32
32
|
utils: { label: { pt: "Utilitários", en: "Utilities" }, order: 1 },
|
|
33
33
|
};
|
|
34
|
-
return buildCommandRegistry(null, plugins, undefined,
|
|
34
|
+
return buildCommandRegistry(null, plugins, undefined, { ...DEFAULT_MENU_CONFIG, enabled: true }, categories);
|
|
35
35
|
}
|
|
36
36
|
describe("kernel/commandAccess", () => {
|
|
37
37
|
test("exists() is true for cmd and alias, false for unknown", () => {
|
|
@@ -101,7 +101,8 @@ export function syncCommandHistory(byId, defaults, specs) {
|
|
|
101
101
|
logger.warn(t("system.commandDeprecationRenamed", {
|
|
102
102
|
id,
|
|
103
103
|
old: prevCmd,
|
|
104
|
-
new: entry.cmd
|
|
104
|
+
new: entry.cmd,
|
|
105
|
+
days: String(defaults.notifyPeriodDays)
|
|
105
106
|
}));
|
|
106
107
|
}
|
|
107
108
|
stmts.insertHistory.run(id, entry.cmd);
|
|
@@ -121,7 +122,8 @@ export function syncCommandHistory(byId, defaults, specs) {
|
|
|
121
122
|
stmts.upsertDeprecation.run(existing.cmd, id, null, now + periodMs, spec?.deprecatedMessage ?? null);
|
|
122
123
|
logger.warn(t("system.commandDeprecationRemoved", {
|
|
123
124
|
id,
|
|
124
|
-
old: existing.cmd
|
|
125
|
+
old: existing.cmd,
|
|
126
|
+
days: String(defaults.notifyPeriodDays)
|
|
125
127
|
}));
|
|
126
128
|
}
|
|
127
129
|
stmts.deleteHistory.run(id);
|
|
@@ -13,6 +13,8 @@ function createMockEntry(id, cmd) {
|
|
|
13
13
|
source: "plugin",
|
|
14
14
|
pluginName: "testPlugin",
|
|
15
15
|
function: null,
|
|
16
|
+
functions: [],
|
|
17
|
+
loading: null,
|
|
16
18
|
handler: null,
|
|
17
19
|
text: null,
|
|
18
20
|
permissions: {
|
|
@@ -23,11 +25,15 @@ function createMockEntry(id, cmd) {
|
|
|
23
25
|
cooldownSeconds: 0,
|
|
24
26
|
whitelist: null,
|
|
25
27
|
blacklist: null,
|
|
28
|
+
dono: null,
|
|
29
|
+
allowedChats: null,
|
|
30
|
+
hiddenOutsideScope: null,
|
|
26
31
|
messages: {},
|
|
27
32
|
},
|
|
28
33
|
arguments: [],
|
|
29
34
|
subcommands: {},
|
|
30
35
|
categoryHiddenInScope: null,
|
|
36
|
+
hiddenOutsideScope: null,
|
|
31
37
|
};
|
|
32
38
|
}
|
|
33
39
|
describe("kernel/commandDeprecation", () => {
|
|
@@ -81,7 +87,8 @@ describe("kernel/commandDeprecation", () => {
|
|
|
81
87
|
const specs1 = [{
|
|
82
88
|
id: "plugin::optOut",
|
|
83
89
|
plugin: "plugin",
|
|
84
|
-
|
|
90
|
+
functions: ["optOut"],
|
|
91
|
+
loading: null,
|
|
85
92
|
cmd: "alpha",
|
|
86
93
|
aliases: [],
|
|
87
94
|
desc: null,
|
|
@@ -6,13 +6,54 @@
|
|
|
6
6
|
import { CMD_PREFIX } from "#config";
|
|
7
7
|
import { getCurrentLang, tFor } from "#i18n";
|
|
8
8
|
import { buildSettingsApi } from "./settingsDb.js";
|
|
9
|
+
/** Maximum age (ms) of an incoming message for the welcome to fire.
|
|
10
|
+
* Matches `MAX_MESSAGE_AGE_SECONDS` in `drivers/baileys/index.ts` so the
|
|
11
|
+
* welcome can never fire on a message the driver would have dropped as
|
|
12
|
+
* stale. See `checkAndTriggerWelcomeMessage` for the full rationale. */
|
|
13
|
+
const MAX_WELCOME_AGE_MS = 60 * 1000;
|
|
9
14
|
/**
|
|
10
15
|
* Checks if the user should be shown the welcome message,
|
|
11
16
|
* and marks them as seen if so.
|
|
17
|
+
*
|
|
18
|
+
* Two extra gates protect against the "ghost welcome" failure mode where
|
|
19
|
+
* a welcome fires out of the blue for a sender the bot never heard from:
|
|
20
|
+
*
|
|
21
|
+
* - The incoming message must have non-empty text. Baileys 7.x has a
|
|
22
|
+
* documented offline-flush bug where, after a reconnect, the
|
|
23
|
+
* event-buffer can reclassify receipts as `messages.upsert` events
|
|
24
|
+
* (the buffer interleaves message and receipt frames during the
|
|
25
|
+
* replay window). Those events arrive with `fromMe=false`, in a
|
|
26
|
+
* real DM, with `body` empty — every other condition for a welcome
|
|
27
|
+
* fires, but they aren't real messages. Requiring a non-empty body
|
|
28
|
+
* filters this entire class of synthetic upserts.
|
|
29
|
+
*
|
|
30
|
+
* - The incoming message's `timestamp` must be within
|
|
31
|
+
* `MAX_WELCOME_AGE_MS` of `now`. The Baileys 7.x line also has
|
|
32
|
+
* widely-reported cases where the event-buffer delivers
|
|
33
|
+
* `messages.upsert` events minutes, hours, or even days after they
|
|
34
|
+
* originally occurred (they sit in the buffer during a long
|
|
35
|
+
* disconnect and are drained in order on reconnect). A sender
|
|
36
|
+
* whose first-ever contact with the bot was days ago is not a
|
|
37
|
+
* "first-time" sender in any meaningful sense — the welcome is for
|
|
38
|
+
* a real, live first contact, not a delayed replay. This mirrors
|
|
39
|
+
* `isMessageStale()` in `drivers/baileys/index.ts` (60s), which
|
|
40
|
+
* is the same threshold the driver itself uses to drop stale
|
|
41
|
+
* events before they reach the handler. We keep the threshold the
|
|
42
|
+
* same here so the two stay in sync: if the driver decided the
|
|
43
|
+
* message is "real-time enough" to dispatch, the welcome fires;
|
|
44
|
+
* if the driver would have dropped it, we never reach this path.
|
|
12
45
|
*/
|
|
13
|
-
export function checkAndTriggerWelcomeMessage(userId, registry, lang) {
|
|
46
|
+
export function checkAndTriggerWelcomeMessage(userId, registry, msg, lang, prefix = CMD_PREFIX) {
|
|
14
47
|
if (!registry.menu.welcomeMessage)
|
|
15
48
|
return null;
|
|
49
|
+
if (!msg || !msg.body || msg.body.trim() === "")
|
|
50
|
+
return null;
|
|
51
|
+
const msgTsMs = msg.timestamp ?? 0;
|
|
52
|
+
if (msgTsMs > 0) {
|
|
53
|
+
const ageMs = Date.now() - msgTsMs;
|
|
54
|
+
if (ageMs > MAX_WELCOME_AGE_MS)
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
16
57
|
const settings = buildSettingsApi("kernel", userId);
|
|
17
58
|
const lastSeen = settings.get("last_welcome_seen");
|
|
18
59
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -22,7 +63,7 @@ export function checkAndTriggerWelcomeMessage(userId, registry, lang) {
|
|
|
22
63
|
const rawMsg = resolveLocalizedString(registry.menu.welcomeMessage, lang);
|
|
23
64
|
if (!rawMsg)
|
|
24
65
|
return null;
|
|
25
|
-
return rawMsg.replace(/\{prefix\}/g,
|
|
66
|
+
return rawMsg.replace(/\{prefix\}/g, prefix);
|
|
26
67
|
}
|
|
27
68
|
return null;
|
|
28
69
|
}
|
|
@@ -79,6 +120,8 @@ export function renderOverview(registry, lang, page, scope) {
|
|
|
79
120
|
return true;
|
|
80
121
|
if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
|
|
81
122
|
return false;
|
|
123
|
+
if (e.hiddenOutsideScope && e.hiddenOutsideScope !== "any" && e.hiddenOutsideScope !== scope)
|
|
124
|
+
return false;
|
|
82
125
|
return true;
|
|
83
126
|
})
|
|
84
127
|
.sort((a, b) => a.cmd.localeCompare(b.cmd));
|
|
@@ -106,6 +149,8 @@ export function renderOverview(registry, lang, page, scope) {
|
|
|
106
149
|
return true;
|
|
107
150
|
if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
|
|
108
151
|
return false;
|
|
152
|
+
if (e.hiddenOutsideScope && e.hiddenOutsideScope !== "any" && e.hiddenOutsideScope !== scope)
|
|
153
|
+
return false;
|
|
109
154
|
return true;
|
|
110
155
|
})
|
|
111
156
|
.sort((a, b) => a.cmd.localeCompare(b.cmd));
|
|
@@ -131,6 +176,8 @@ export function renderOverview(registry, lang, page, scope) {
|
|
|
131
176
|
return true;
|
|
132
177
|
if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
|
|
133
178
|
return false;
|
|
179
|
+
if (e.hiddenOutsideScope && e.hiddenOutsideScope !== "any" && e.hiddenOutsideScope !== scope)
|
|
180
|
+
return false;
|
|
134
181
|
return true;
|
|
135
182
|
});
|
|
136
183
|
const startIdx = page ? (page - 1) * pageSize : 0;
|
|
@@ -191,6 +238,8 @@ export function renderCategory(registry, categoryKey, lang, scope) {
|
|
|
191
238
|
// If entry has a specific scope defined and it doesn't match the requested scope, exclude it
|
|
192
239
|
if (e.categoryHiddenInScope && e.categoryHiddenInScope === scope)
|
|
193
240
|
return false;
|
|
241
|
+
if (e.hiddenOutsideScope && e.hiddenOutsideScope !== "any" && e.hiddenOutsideScope !== scope)
|
|
242
|
+
return false;
|
|
194
243
|
return true;
|
|
195
244
|
})
|
|
196
245
|
.sort((a, b) => a.cmd.localeCompare(b.cmd));
|
|
@@ -235,8 +284,48 @@ export function renderManual(entry, registry, lang) {
|
|
|
235
284
|
}
|
|
236
285
|
return parts.join("\n").trim();
|
|
237
286
|
}
|
|
287
|
+
function levenshtein(a, b) {
|
|
288
|
+
const m = a.length, n = b.length;
|
|
289
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
290
|
+
for (let i = 0; i <= m; i++)
|
|
291
|
+
dp[i][0] = i;
|
|
292
|
+
for (let j = 0; j <= n; j++)
|
|
293
|
+
dp[0][j] = j;
|
|
294
|
+
for (let i = 1; i <= m; i++) {
|
|
295
|
+
for (let j = 1; j <= n; j++) {
|
|
296
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
297
|
+
? dp[i - 1][j - 1]
|
|
298
|
+
: 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return dp[m][n];
|
|
302
|
+
}
|
|
303
|
+
/** Closest known cmd/alias to `invocation` within `maxDistance`, or null. */
|
|
304
|
+
function findClosestInvocation(invocation, registry, maxDistance) {
|
|
305
|
+
let best = null;
|
|
306
|
+
let bestDist = Infinity;
|
|
307
|
+
for (const known of registry.byInvocation.keys()) {
|
|
308
|
+
const dist = levenshtein(invocation, known);
|
|
309
|
+
if (dist < bestDist) {
|
|
310
|
+
bestDist = dist;
|
|
311
|
+
best = known;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return best !== null && bestDist > 0 && bestDist <= maxDistance ? best : null;
|
|
315
|
+
}
|
|
238
316
|
export function renderNotFound(invocation, registry, lang) {
|
|
239
317
|
const menuCmd = registry.menu.cmd;
|
|
318
|
+
if (registry.menu.suggestSimilar) {
|
|
319
|
+
const suggestion = findClosestInvocation(invocation, registry, registry.menu.suggestMaxDistance);
|
|
320
|
+
if (suggestion) {
|
|
321
|
+
return tFor(lang, "system.commandNotFoundSuggestion", {
|
|
322
|
+
cmd: invocation,
|
|
323
|
+
suggestion,
|
|
324
|
+
prefix: CMD_PREFIX,
|
|
325
|
+
menuCmd
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
240
329
|
return tFor(lang, "system.commandNotFound", { cmd: invocation, prefix: CMD_PREFIX, menuCmd });
|
|
241
330
|
}
|
|
242
331
|
export function handleMenuCommand(command, rawArgs, registry, lang, scope) {
|
|
@@ -46,6 +46,8 @@ function createTestRegistry() {
|
|
|
46
46
|
cmd: "help",
|
|
47
47
|
aliases: ["help", "menu"],
|
|
48
48
|
notFoundFallback: false,
|
|
49
|
+
suggestSimilar: false,
|
|
50
|
+
suggestMaxDistance: 2,
|
|
49
51
|
welcomeMessage: { pt: "Bem-vindo ao bot! Use {prefix}help para o menu.", en: "Welcome! Use {prefix}help." },
|
|
50
52
|
welcomeWindowDays: 3,
|
|
51
53
|
pageSize: 2,
|
|
@@ -115,6 +117,8 @@ describe("kernel/commandMenu", () => {
|
|
|
115
117
|
cmd: "help",
|
|
116
118
|
aliases: ["help"],
|
|
117
119
|
notFoundFallback: false,
|
|
120
|
+
suggestSimilar: false,
|
|
121
|
+
suggestMaxDistance: 2,
|
|
118
122
|
welcomeMessage: null,
|
|
119
123
|
welcomeWindowDays: 3,
|
|
120
124
|
pageSize: 2,
|
|
@@ -192,6 +196,8 @@ describe("kernel/commandMenu", () => {
|
|
|
192
196
|
cmd: "help",
|
|
193
197
|
aliases: ["help"],
|
|
194
198
|
notFoundFallback: false,
|
|
199
|
+
suggestSimilar: false,
|
|
200
|
+
suggestMaxDistance: 2,
|
|
195
201
|
welcomeMessage: null,
|
|
196
202
|
welcomeWindowDays: 3,
|
|
197
203
|
pageSize: 1,
|
|
@@ -223,12 +229,135 @@ describe("kernel/commandMenu", () => {
|
|
|
223
229
|
const userId = "test_user_welcome_1";
|
|
224
230
|
const settings = buildSettingsApi("kernel", userId);
|
|
225
231
|
settings.delete("last_welcome_seen");
|
|
226
|
-
const msg = checkAndTriggerWelcomeMessage(userId, registry, "pt");
|
|
232
|
+
const msg = checkAndTriggerWelcomeMessage(userId, registry, { body: "oi", timestamp: Date.now() }, "pt");
|
|
227
233
|
assert.ok(msg);
|
|
228
234
|
assert.match(msg, /Bem-vindo ao bot! Use !help para o menu\./);
|
|
229
235
|
// Immediately checking again returns null
|
|
230
|
-
const secondCheck = checkAndTriggerWelcomeMessage(userId, registry, "pt");
|
|
236
|
+
const secondCheck = checkAndTriggerWelcomeMessage(userId, registry, { body: "oi", timestamp: Date.now() }, "pt");
|
|
231
237
|
assert.equal(secondCheck, null);
|
|
232
238
|
});
|
|
239
|
+
test("does not fire when the incoming message has no body (offline-flush receipt misclassification)", () => {
|
|
240
|
+
const registry = createTestRegistry();
|
|
241
|
+
const userId = "test_user_welcome_empty_body";
|
|
242
|
+
buildSettingsApi("kernel", userId).delete("last_welcome_seen");
|
|
243
|
+
const msg = checkAndTriggerWelcomeMessage(userId, registry, { body: "", timestamp: Date.now() });
|
|
244
|
+
assert.equal(msg, null, "no welcome for a body-less message");
|
|
245
|
+
const msg2 = checkAndTriggerWelcomeMessage(userId, registry, { body: " ", timestamp: Date.now() });
|
|
246
|
+
assert.equal(msg2, null, "no welcome for a whitespace-only body either");
|
|
247
|
+
});
|
|
248
|
+
test("does not fire when the incoming message is older than the freshness window (offline-flush delayed replay)", () => {
|
|
249
|
+
const registry = createTestRegistry();
|
|
250
|
+
const userId = "test_user_welcome_stale_replay";
|
|
251
|
+
buildSettingsApi("kernel", userId).delete("last_welcome_seen");
|
|
252
|
+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
253
|
+
const msg = checkAndTriggerWelcomeMessage(userId, registry, { body: "oi", timestamp: twoHoursAgo });
|
|
254
|
+
assert.equal(msg, null, "no welcome for a 2-hour-old replay");
|
|
255
|
+
});
|
|
256
|
+
test("does fire for a fresh in-window message (control case for the new gates)", () => {
|
|
257
|
+
const registry = createTestRegistry();
|
|
258
|
+
const userId = "test_user_welcome_fresh";
|
|
259
|
+
buildSettingsApi("kernel", userId).delete("last_welcome_seen");
|
|
260
|
+
const msg = checkAndTriggerWelcomeMessage(userId, registry, { body: "oi", timestamp: Date.now() });
|
|
261
|
+
assert.ok(msg, "welcome fires for a current, non-empty message");
|
|
262
|
+
});
|
|
263
|
+
test("does not fire when the msg envelope is missing (defensive — caller must always pass one)", () => {
|
|
264
|
+
const registry = createTestRegistry();
|
|
265
|
+
const userId = "test_user_welcome_no_envelope";
|
|
266
|
+
buildSettingsApi("kernel", userId).delete("last_welcome_seen");
|
|
267
|
+
const msg = checkAndTriggerWelcomeMessage(userId, registry);
|
|
268
|
+
assert.equal(msg, null);
|
|
269
|
+
});
|
|
270
|
+
test("uses the prefix override in the {prefix} placeholder instead of the global default", () => {
|
|
271
|
+
const registry = createTestRegistry();
|
|
272
|
+
const userId = "test_user_welcome_custom_prefix";
|
|
273
|
+
buildSettingsApi("kernel", userId).delete("last_welcome_seen");
|
|
274
|
+
const msg = checkAndTriggerWelcomeMessage(userId, registry, { body: "oi", timestamp: Date.now() }, "pt", "#");
|
|
275
|
+
assert.ok(msg);
|
|
276
|
+
assert.match(msg, /Use #help para o menu\./);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
describe("hiddenOutsideScope (group/dm suppression)", () => {
|
|
280
|
+
function buildScopeRegistry() {
|
|
281
|
+
const noop = async () => { };
|
|
282
|
+
const plugins = new Map([
|
|
283
|
+
[
|
|
284
|
+
"scopePlugin",
|
|
285
|
+
{
|
|
286
|
+
name: "scopePlugin",
|
|
287
|
+
status: "active",
|
|
288
|
+
manifest: { name: "scopePlugin", version: "1.0.0" },
|
|
289
|
+
commands: {
|
|
290
|
+
groupOnlyFn: {
|
|
291
|
+
cmd: "g",
|
|
292
|
+
aliases: [],
|
|
293
|
+
desc: "Group-only command",
|
|
294
|
+
category: "utils",
|
|
295
|
+
permissions: { groupOnly: true },
|
|
296
|
+
handler: noop,
|
|
297
|
+
},
|
|
298
|
+
dmOnlyFn: {
|
|
299
|
+
cmd: "d",
|
|
300
|
+
aliases: [],
|
|
301
|
+
desc: "DM-only command",
|
|
302
|
+
category: "utils",
|
|
303
|
+
permissions: { dmOnly: true },
|
|
304
|
+
handler: noop,
|
|
305
|
+
},
|
|
306
|
+
bothFn: {
|
|
307
|
+
cmd: "b",
|
|
308
|
+
aliases: [],
|
|
309
|
+
desc: "Both-scopes command",
|
|
310
|
+
category: "utils",
|
|
311
|
+
handler: noop,
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
]);
|
|
317
|
+
const menu = {
|
|
318
|
+
title: "Scope",
|
|
319
|
+
intro: null,
|
|
320
|
+
footer: null,
|
|
321
|
+
cmd: "help",
|
|
322
|
+
aliases: ["help"],
|
|
323
|
+
notFoundFallback: false,
|
|
324
|
+
suggestSimilar: false,
|
|
325
|
+
suggestMaxDistance: 2,
|
|
326
|
+
welcomeMessage: null,
|
|
327
|
+
welcomeWindowDays: 3,
|
|
328
|
+
pageSize: 10,
|
|
329
|
+
};
|
|
330
|
+
return buildCommandRegistry(null, plugins, undefined, menu, {
|
|
331
|
+
utils: { label: "Utils", order: 1 },
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
test("hides group-only commands from DM overview", () => {
|
|
335
|
+
const registry = buildScopeRegistry();
|
|
336
|
+
const dmOverview = renderOverview(registry, "en", undefined, "dm");
|
|
337
|
+
assert.doesNotMatch(dmOverview, /!g\b/);
|
|
338
|
+
assert.match(dmOverview, /!d\b/);
|
|
339
|
+
assert.match(dmOverview, /!b\b/);
|
|
340
|
+
});
|
|
341
|
+
test("hides DM-only commands from group overview", () => {
|
|
342
|
+
const registry = buildScopeRegistry();
|
|
343
|
+
const groupOverview = renderOverview(registry, "en", undefined, "group");
|
|
344
|
+
assert.match(groupOverview, /!g\b/);
|
|
345
|
+
assert.doesNotMatch(groupOverview, /!d\b/);
|
|
346
|
+
assert.match(groupOverview, /!b\b/);
|
|
347
|
+
});
|
|
348
|
+
test("no-scope overview shows all commands", () => {
|
|
349
|
+
const registry = buildScopeRegistry();
|
|
350
|
+
const overview = renderOverview(registry, "en");
|
|
351
|
+
assert.match(overview, /!g\b/);
|
|
352
|
+
assert.match(overview, /!d\b/);
|
|
353
|
+
assert.match(overview, /!b\b/);
|
|
354
|
+
});
|
|
355
|
+
test("renderCategory respects hiddenOutsideScope", () => {
|
|
356
|
+
const registry = buildScopeRegistry();
|
|
357
|
+
const dmCategory = renderCategory(registry, "utils", "en", "dm");
|
|
358
|
+
assert.ok(dmCategory);
|
|
359
|
+
assert.doesNotMatch(dmCategory, /!g\b/);
|
|
360
|
+
assert.match(dmCategory, /!d\b/);
|
|
361
|
+
});
|
|
233
362
|
});
|
|
234
363
|
});
|
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
* commandPermissions.ts
|
|
3
3
|
*
|
|
4
4
|
* Permission engine for ManyBot commands.
|
|
5
|
-
* Checks owner, scope, blacklist, whitelist,
|
|
5
|
+
* Checks dono, owner, scope, allowed_chats, blacklist, whitelist,
|
|
6
|
+
* botAdmin, admin, and cooldown.
|
|
6
7
|
*
|
|
7
8
|
* Order of evaluation:
|
|
8
|
-
* 1. owner
|
|
9
|
-
* 2.
|
|
10
|
-
* 3.
|
|
11
|
-
* 4.
|
|
12
|
-
* 5.
|
|
13
|
-
* 6.
|
|
14
|
-
* 7.
|
|
9
|
+
* 1. dono (specific owner JID; otherwise falls through to owner)
|
|
10
|
+
* 2. owner
|
|
11
|
+
* 3. scope
|
|
12
|
+
* 4. allowed_chats (closed list of JIDs; deny outside)
|
|
13
|
+
* 5. blacklist
|
|
14
|
+
* 6. whitelist
|
|
15
|
+
* 7. botAdmin
|
|
16
|
+
* 8. admin
|
|
17
|
+
* 9. cooldown (only consumed if all prior checks pass)
|
|
15
18
|
*/
|
|
16
19
|
import { OWNER_NUMBER } from "#config";
|
|
17
20
|
import { normalizeJid } from "#drivers/jid.js";
|
|
@@ -37,38 +40,75 @@ export function matchId(targetId, candidate) {
|
|
|
37
40
|
return false;
|
|
38
41
|
}
|
|
39
42
|
export function matchesAny(targetId, candidates) {
|
|
43
|
+
if (!targetId)
|
|
44
|
+
return false;
|
|
40
45
|
return candidates.some(candidate => matchId(targetId, candidate));
|
|
41
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Matches a sender against a list of configured ids (numbers or JIDs),
|
|
49
|
+
* trying both the LID and PN forms — config today is written in phone
|
|
50
|
+
* numbers, but a sender whose PN mapping isn't known yet only has a LID
|
|
51
|
+
* (or, rarely, only a PN when no LID has been learned). Either form
|
|
52
|
+
* matching is enough.
|
|
53
|
+
*/
|
|
54
|
+
export function matchesSender(sender, candidates) {
|
|
55
|
+
return matchesAny(sender.lid, candidates) || matchesAny(sender.pn, candidates);
|
|
56
|
+
}
|
|
57
|
+
/** Stable per-sender identity for cooldown/state keys — prefers LID (canonical), falls back to PN. */
|
|
58
|
+
function senderKey(sender) {
|
|
59
|
+
return sender.lid ?? sender.pn ?? "unknown";
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Cooldown reset key. Format: `<pluginName>:<subId|cmd>` so distinct
|
|
63
|
+
* plugin-provided commands don't share buckets even when registered
|
|
64
|
+
* under the same cmd, and the same command under two cmd names doesn't
|
|
65
|
+
* either. Subcommands reuse the parent's plugin/cmd unless overridden.
|
|
66
|
+
*/
|
|
67
|
+
function cooldownKey(entry) {
|
|
68
|
+
return `${entry.pluginName ?? "text"}:${entry.cmd}`;
|
|
69
|
+
}
|
|
42
70
|
export async function checkPermission(entry, ctx) {
|
|
43
71
|
const perms = entry.permissions;
|
|
44
72
|
const msgs = perms.messages;
|
|
45
|
-
// 1.
|
|
73
|
+
// 1. dono check (specific owner JID, overrides global OWNER_NUMBER)
|
|
74
|
+
if (perms.dono) {
|
|
75
|
+
if (!matchesSender(ctx.sender, [perms.dono])) {
|
|
76
|
+
return { allowed: false, message: msgs.donoOnly };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 2. Owner check
|
|
46
80
|
if (perms.owner) {
|
|
47
|
-
if (!OWNER_NUMBER || !
|
|
81
|
+
if (!OWNER_NUMBER || !matchesSender(ctx.sender, [OWNER_NUMBER])) {
|
|
48
82
|
return { allowed: false, message: msgs.ownerOnly };
|
|
49
83
|
}
|
|
50
84
|
}
|
|
51
|
-
//
|
|
85
|
+
// 3. Scope check (group | dm | any)
|
|
52
86
|
if (perms.scope === "group" && !ctx.isGroup) {
|
|
53
87
|
return { allowed: false, message: msgs.wrongScope };
|
|
54
88
|
}
|
|
55
89
|
if (perms.scope === "dm" && ctx.isGroup) {
|
|
56
90
|
return { allowed: false, message: msgs.wrongScope };
|
|
57
91
|
}
|
|
58
|
-
//
|
|
92
|
+
// 4. allowed_chats check (closed list of JIDs the command may run in)
|
|
93
|
+
if (perms.allowedChats && perms.allowedChats.length > 0) {
|
|
94
|
+
if (!matchesAny(ctx.chatId, perms.allowedChats)) {
|
|
95
|
+
return { allowed: false, message: msgs.allowedChats };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 5. Blacklist check
|
|
59
99
|
if (perms.blacklist) {
|
|
60
100
|
if (ctx.isGroup && perms.blacklist.groups.length > 0) {
|
|
61
101
|
if (matchesAny(ctx.chatId, perms.blacklist.groups)) {
|
|
62
|
-
return { allowed: false, message: msgs.
|
|
102
|
+
return { allowed: false, message: msgs.blacklist };
|
|
63
103
|
}
|
|
64
104
|
}
|
|
65
105
|
if (perms.blacklist.users.length > 0) {
|
|
66
|
-
if (
|
|
67
|
-
return { allowed: false, message: msgs.
|
|
106
|
+
if (matchesSender(ctx.sender, perms.blacklist.users)) {
|
|
107
|
+
return { allowed: false, message: msgs.blacklist };
|
|
68
108
|
}
|
|
69
109
|
}
|
|
70
110
|
}
|
|
71
|
-
//
|
|
111
|
+
// 6. Whitelist check (defined list and outside it -> deny)
|
|
72
112
|
if (perms.whitelist) {
|
|
73
113
|
const hasGroupList = perms.whitelist.groups.length > 0;
|
|
74
114
|
const hasUserList = perms.whitelist.users.length > 0;
|
|
@@ -81,34 +121,40 @@ export async function checkPermission(entry, ctx) {
|
|
|
81
121
|
return { allowed: false, message: msgs.wrongScope };
|
|
82
122
|
}
|
|
83
123
|
if (hasUserList) {
|
|
84
|
-
if (!
|
|
124
|
+
if (!matchesSender(ctx.sender, perms.whitelist.users)) {
|
|
85
125
|
return { allowed: false, message: msgs.wrongScope };
|
|
86
126
|
}
|
|
87
127
|
}
|
|
88
128
|
}
|
|
89
|
-
//
|
|
129
|
+
// 7. botAdmin check
|
|
90
130
|
if (perms.botAdmin) {
|
|
131
|
+
// Admin status only exists inside a group — outside one, this is a
|
|
132
|
+
// scope mismatch, not "the bot isn't admin", so use wrongScope
|
|
133
|
+
// rather than botNotAdmin (which would misleadingly imply the check
|
|
134
|
+
// was actually evaluated).
|
|
91
135
|
if (!ctx.isGroup) {
|
|
92
|
-
return { allowed: false, message: msgs.
|
|
136
|
+
return { allowed: false, message: msgs.wrongScope };
|
|
93
137
|
}
|
|
94
138
|
const isBotAdmin = await ctx.isBotAdmin();
|
|
95
139
|
if (!isBotAdmin) {
|
|
96
140
|
return { allowed: false, message: msgs.botNotAdmin };
|
|
97
141
|
}
|
|
98
142
|
}
|
|
99
|
-
//
|
|
143
|
+
// 8. admin check
|
|
100
144
|
if (perms.admin) {
|
|
145
|
+
// Same reasoning as botAdmin above: no group, no admin concept, so
|
|
146
|
+
// this is wrongScope, not senderNotAdmin.
|
|
101
147
|
if (!ctx.isGroup) {
|
|
102
|
-
return { allowed: false, message: msgs.
|
|
148
|
+
return { allowed: false, message: msgs.wrongScope };
|
|
103
149
|
}
|
|
104
150
|
const isSenderAdmin = await ctx.isSenderAdmin();
|
|
105
151
|
if (!isSenderAdmin) {
|
|
106
152
|
return { allowed: false, message: msgs.senderNotAdmin };
|
|
107
153
|
}
|
|
108
154
|
}
|
|
109
|
-
//
|
|
155
|
+
// 9. Cooldown check (only consumed if all other checks pass)
|
|
110
156
|
if (perms.cooldownSeconds > 0) {
|
|
111
|
-
const key = `${entry
|
|
157
|
+
const key = `${cooldownKey(entry)}:${senderKey(ctx.sender)}`;
|
|
112
158
|
const now = Date.now();
|
|
113
159
|
const lastUsed = cooldownMap.get(key) ?? 0;
|
|
114
160
|
const elapsedSeconds = (now - lastUsed) / 1000;
|