@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.
Files changed (45) hide show
  1. package/README.md +15 -7
  2. package/dist/client/store.js +35 -1
  3. package/dist/download/queue.js +13 -4
  4. package/dist/drivers/baileys/adapter.js +75 -8
  5. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  6. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  7. package/dist/drivers/baileys/api/index.js +245 -51
  8. package/dist/drivers/baileys/index.js +30 -6
  9. package/dist/drivers/baileys/messageHandler.js +207 -21
  10. package/dist/drivers/baileys/messageHandler.test.js +256 -14
  11. package/dist/drivers/baileysAdapter.test.js +97 -0
  12. package/dist/drivers/jid.js +26 -0
  13. package/dist/drivers/jid.test.js +35 -1
  14. package/dist/i18n/index.js +5 -22
  15. package/dist/kernel/chatOverrides.js +46 -0
  16. package/dist/kernel/chatOverrides.test.js +59 -0
  17. package/dist/kernel/commandAccess.test.js +2 -2
  18. package/dist/kernel/commandDeprecation.js +4 -2
  19. package/dist/kernel/commandDeprecation.test.js +8 -1
  20. package/dist/kernel/commandMenu.js +91 -2
  21. package/dist/kernel/commandMenu.test.js +131 -2
  22. package/dist/kernel/commandPermissions.js +69 -23
  23. package/dist/kernel/commandPermissions.test.js +77 -9
  24. package/dist/kernel/commandRegistry.js +167 -43
  25. package/dist/kernel/commandRegistry.test.js +4 -2
  26. package/dist/kernel/commandsConfig.js +470 -38
  27. package/dist/kernel/commandsConfig.test.js +249 -3
  28. package/dist/kernel/contactAutoSave.js +6 -6
  29. package/dist/kernel/coreCommands.js +62 -0
  30. package/dist/kernel/pluginApi.test.js +20 -3
  31. package/dist/kernel/pluginGuard.js +5 -3
  32. package/dist/kernel/pluginLoader.js +73 -10
  33. package/dist/kernel/pluginLoader.test.js +111 -1
  34. package/dist/kernel/runCommand.js +57 -18
  35. package/dist/kernel/runCommand.test.js +269 -7
  36. package/dist/kernel/settingsDb.js +15 -2
  37. package/dist/kernel/testConfig.js +9 -0
  38. package/dist/locales/en.json +14 -1
  39. package/dist/locales/es.json +17 -4
  40. package/dist/locales/pt.json +18 -5
  41. package/dist/plugins/__manybot_integration__/index.js +33 -16
  42. package/dist/plugins/__manybot_integration__/index.test.js +42 -8
  43. package/dist/utils/phoneNumber.js +83 -0
  44. package/dist/utils/phoneNumber.test.js +53 -0
  45. 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, undefined, categories);
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
- function: "optOut",
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, CMD_PREFIX);
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, botAdmin, admin, and cooldown.
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. scope
10
- * 3. blacklist
11
- * 4. whitelist
12
- * 5. botAdmin
13
- * 6. admin
14
- * 7. cooldown (only consumed if all prior checks pass)
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. Owner check
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 || !matchId(ctx.senderId, OWNER_NUMBER)) {
81
+ if (!OWNER_NUMBER || !matchesSender(ctx.sender, [OWNER_NUMBER])) {
48
82
  return { allowed: false, message: msgs.ownerOnly };
49
83
  }
50
84
  }
51
- // 2. Scope check (group | dm | any)
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
- // 3. Blacklist check
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.wrongScope };
102
+ return { allowed: false, message: msgs.blacklist };
63
103
  }
64
104
  }
65
105
  if (perms.blacklist.users.length > 0) {
66
- if (matchesAny(ctx.senderId, perms.blacklist.users)) {
67
- return { allowed: false, message: msgs.wrongScope };
106
+ if (matchesSender(ctx.sender, perms.blacklist.users)) {
107
+ return { allowed: false, message: msgs.blacklist };
68
108
  }
69
109
  }
70
110
  }
71
- // 4. Whitelist check (defined list and outside it -> deny)
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 (!matchesAny(ctx.senderId, perms.whitelist.users)) {
124
+ if (!matchesSender(ctx.sender, perms.whitelist.users)) {
85
125
  return { allowed: false, message: msgs.wrongScope };
86
126
  }
87
127
  }
88
128
  }
89
- // 5. botAdmin check
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.botNotAdmin };
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
- // 6. admin check
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.senderNotAdmin };
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
- // 7. Cooldown check (only consumed if all other checks pass)
155
+ // 9. Cooldown check (only consumed if all other checks pass)
110
156
  if (perms.cooldownSeconds > 0) {
111
- const key = `${entry.id}:${ctx.senderId}`;
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;