@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
@@ -5,8 +5,10 @@ import os from "os";
5
5
  import path from "path";
6
6
  const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "manybot-plugin-loader-"));
7
7
  process.env.MANYBOT_CONFIG_DIR = configDir;
8
- const { cleanupPlugins, loadPlugin, pluginRegistry, reloadPlugin } = await import("#kernel/pluginLoader.js");
8
+ const { cleanupPlugins, loadPlugin, loadPlugins, pluginRegistry, reloadCommandRegistry, reloadPlugin } = await import("#kernel/pluginLoader.js");
9
+ const { getCommandRegistry } = await import("#kernel/commandRegistry.js");
9
10
  const pluginsDir = path.join(configDir, "plugins");
11
+ const commandsFile = path.join(configDir, "commands.yaml");
10
12
  async function writePlugin(name, manifest, source) {
11
13
  const dir = path.join(pluginsDir, name);
12
14
  await fs.mkdir(dir, { recursive: true });
@@ -14,10 +16,22 @@ async function writePlugin(name, manifest, source) {
14
16
  if (source !== undefined)
15
17
  await fs.writeFile(path.join(dir, "index.js"), source, "utf8");
16
18
  }
19
+ async function waitFor(label, read, timeoutMs = 3000) {
20
+ const deadline = Date.now() + timeoutMs;
21
+ while (Date.now() < deadline) {
22
+ const value = read();
23
+ if (value !== undefined)
24
+ return value;
25
+ await new Promise((r) => setTimeout(r, 50));
26
+ }
27
+ throw new Error(`timed out waiting for ${label}`);
28
+ }
17
29
  beforeEach(async () => {
18
30
  await cleanupPlugins();
19
31
  pluginRegistry.clear();
20
32
  await fs.rm(pluginsDir, { recursive: true, force: true });
33
+ await fs.rm(commandsFile, { force: true });
34
+ await fs.rm(path.join(configDir, "menu.yaml"), { force: true });
21
35
  });
22
36
  after(async () => {
23
37
  await cleanupPlugins();
@@ -78,3 +92,99 @@ describe("kernel/pluginLoader", () => {
78
92
  assert.equal(plugin?.errorCount, 0);
79
93
  });
80
94
  });
95
+ describe("kernel/pluginLoader — commands.yaml hot reload", () => {
96
+ test("reloadCommandRegistry re-reads commands.yaml and rebuilds the registry", async () => {
97
+ await writePlugin("reloaddummy", '{"main":"index.js"}', `
98
+ export default async function run() {}
99
+ `);
100
+ await loadPlugins(["reloaddummy"]);
101
+ await fs.writeFile(commandsFile, `
102
+ defaults:
103
+ notifyChanges: false
104
+ helloReload:
105
+ cmd: hello
106
+ plugin: reloaddummy
107
+ desc: "First version"
108
+ functions: []
109
+ `, "utf8");
110
+ await reloadCommandRegistry();
111
+ const registry = getCommandRegistry();
112
+ assert.ok(registry, "registry should be initialized after reload");
113
+ assert.equal(registry.byInvocation.get("hello"), "helloReload");
114
+ assert.equal(registry.byId.get("helloReload")?.desc, "First version");
115
+ assert.equal(registry.defaults.notifyChanges, false);
116
+ await fs.writeFile(commandsFile, `
117
+ helloReload:
118
+ cmd: hello
119
+ plugin: reloaddummy
120
+ desc: "Second version"
121
+ functions: []
122
+ `, "utf8");
123
+ await reloadCommandRegistry();
124
+ const registry2 = getCommandRegistry();
125
+ assert.ok(registry2);
126
+ assert.equal(registry2.byId.get("helloReload")?.desc, "Second version");
127
+ assert.equal(registry2.defaults.notifyChanges, true, "defaults should reset to built-in when omitted");
128
+ });
129
+ test("config watcher reloads the registry when commands.yaml is edited", async () => {
130
+ await writePlugin("watchdummy", '{"main":"index.js"}', `
131
+ export default async function run() {}
132
+ `);
133
+ await loadPlugins(["watchdummy"]);
134
+ assert.equal(getCommandRegistry()?.byInvocation.get("watch"), undefined);
135
+ await fs.writeFile(commandsFile, `
136
+ helloWatch:
137
+ cmd: watch
138
+ plugin: watchdummy
139
+ desc: "Watcher picks this up"
140
+ functions: []
141
+ `, "utf8");
142
+ const registry = await waitFor("registry to pick up commands.yaml change", () => {
143
+ const r = getCommandRegistry();
144
+ return r?.byId.get("helloWatch")?.desc === "Watcher picks this up" ? r : undefined;
145
+ });
146
+ assert.equal(registry.byInvocation.get("watch"), "helloWatch");
147
+ });
148
+ test("config watcher reloads the registry when an imported YAML file is edited", async () => {
149
+ await writePlugin("importdummy", '{"main":"index.js"}', `
150
+ export default async function run() {}
151
+ `);
152
+ await fs.writeFile(path.join(configDir, "menu.yaml"), `
153
+ helloImport:
154
+ cmd: importcmd
155
+ plugin: importdummy
156
+ desc: "From menu.yaml (first)"
157
+ functions: []
158
+ `, "utf8");
159
+ await fs.writeFile(commandsFile, `import: menu.yaml\n`, "utf8");
160
+ await loadPlugins(["importdummy"]);
161
+ const first = await waitFor("initial import to be picked up", () => {
162
+ const r = getCommandRegistry();
163
+ return r?.byId.get("helloImport")?.desc === "From menu.yaml (first)" ? r : undefined;
164
+ });
165
+ assert.equal(first.byInvocation.get("importcmd"), "helloImport");
166
+ await fs.writeFile(path.join(configDir, "menu.yaml"), `
167
+ helloImport:
168
+ cmd: importcmd
169
+ plugin: importdummy
170
+ desc: "From menu.yaml (second)"
171
+ functions: []
172
+ `, "utf8");
173
+ const second = await waitFor("imported yaml change to be picked up", () => {
174
+ const r = getCommandRegistry();
175
+ return r?.byId.get("helloImport")?.desc === "From menu.yaml (second)" ? r : undefined;
176
+ });
177
+ assert.equal(second.byInvocation.get("importcmd"), "helloImport");
178
+ });
179
+ test("config watcher ignores non-yaml/non-toml files in PATHS.HOME", async () => {
180
+ await writePlugin("ignoredummy", '{"main":"index.js"}', `
181
+ export default async function run() {}
182
+ `);
183
+ await loadPlugins(["ignoredummy"]);
184
+ const registryBefore = getCommandRegistry();
185
+ await fs.writeFile(path.join(configDir, "README.md"), "unrelated", "utf8");
186
+ await new Promise((r) => setTimeout(r, 800));
187
+ const registryAfter = getCommandRegistry();
188
+ assert.equal(registryAfter, registryBefore, "registry reference must be stable when no relevant file changed");
189
+ });
190
+ });
@@ -23,13 +23,16 @@
23
23
  * `for (plugin of pluginRegistry) await runPlugin(...)` loop.
24
24
  */
25
25
  import { logger } from "#logger";
26
- import { t } from "#i18n";
26
+ import { tFor } from "#i18n";
27
27
  import { CMD_PREFIX } from "#config";
28
28
  import { fireAlert } from "./alerts.js";
29
29
  import { runPlugin } from "./pluginGuard.js";
30
30
  import { checkPermission } from "./commandPermissions.js";
31
31
  import { getCommandRegistry } from "./commandRegistry.js";
32
32
  import { pluginRegistry, resolvePluginCommandHandler } from "./pluginLoader.js";
33
+ import { STOP_CHAIN } from "./commandsConfig.js";
34
+ import { resolveCoreCommandHandler } from "./coreCommands.js";
35
+ import { getChatLocale, getChatPrefix } from "./chatOverrides.js";
33
36
  function flatten(target) {
34
37
  if (target.kind === "sub") {
35
38
  return {
@@ -89,8 +92,13 @@ export function resolveDispatch(command, rawArgs) {
89
92
  * through to the legacy run loop.
90
93
  */
91
94
  export async function runCommand(opts) {
92
- const { resolution, pluginName, ctx, reply } = opts;
95
+ const { resolution, pluginName, ctx, reply, chatId } = opts;
93
96
  const { target } = resolution;
97
+ // Resolved once per dispatch: this chat's `!config` overrides (or the
98
+ // global defaults when none were set) for every kernel-authored reply
99
+ // below (unknown-sub hint, missing-argument usage, ...).
100
+ const lang = chatId ? getChatLocale(chatId) : undefined;
101
+ const prefix = chatId ? getChatPrefix(chatId) : CMD_PREFIX;
94
102
  if (target.kind === "none") {
95
103
  return { status: "no_dispatch", sentReply: null, suggestedReply: null };
96
104
  }
@@ -99,7 +107,7 @@ export async function runCommand(opts) {
99
107
  // handler — same convention as a CLI tool with an unknown subcommand.
100
108
  if (resolution.unmatchedSubToken && target.kind === "parent") {
101
109
  const validSubs = Object.keys(target.entry.subcommands);
102
- const help = t("commandRun.unknownSubcommand", {
110
+ const help = tFor(lang, "commandRun.unknownSubcommand", {
103
111
  sub: resolution.unmatchedSubToken,
104
112
  cmd: target.entry.cmd,
105
113
  valid: validSubs.join(", ") || "(none)",
@@ -112,7 +120,7 @@ export async function runCommand(opts) {
112
120
  const perm = await checkPermission(permEntry, {
113
121
  isGroup: ctx.chat.isGroup,
114
122
  chatId: ctx.chat.id,
115
- senderId: ctx.msg.sender,
123
+ sender: { lid: ctx.msg.sender, pn: ctx.msg.senderPn },
116
124
  isSenderAdmin: () => ctx.chat.isSenderAdmin(),
117
125
  isBotAdmin: () => ctx.chat.isBotAdmin(),
118
126
  });
@@ -127,8 +135,8 @@ export async function runCommand(opts) {
127
135
  // + auto-generated usage before invoking.
128
136
  const requiredCount = flat.arguments.filter(a => a.required).length;
129
137
  if (requiredCount > flat.args.length) {
130
- const usage = renderUsage(target);
131
- const msg = `${t("commandRun.missingRequiredArg")}\n\n${usage}`;
138
+ const usage = renderUsage(target, prefix);
139
+ const msg = `${tFor(lang, "commandRun.missingRequiredArg")}\n\n${usage}`;
132
140
  await reply.text(msg);
133
141
  return { status: "argument_missing", sentReply: msg, suggestedReply: msg };
134
142
  }
@@ -139,19 +147,50 @@ export async function runCommand(opts) {
139
147
  // text-only command handled below (caller will handle the fixed-text path).
140
148
  return { status: "no_dispatch", sentReply: null, suggestedReply: null };
141
149
  }
142
- // The actual handler invocation is delegated to runPlugin (which
143
- // enforces timeout + 3-strikes). Pass the resolved sub-function
144
- // name via the unified entrypoint so handlers can be the same
145
- // function whether called as the parent or as a sub.
146
- const fnName = target.kind === "sub" ? target.sub.function : (target.entry.function ?? target.entry.cmd);
150
+ // Function chain dispatch v6 reference yaml allows a command to
151
+ // declare `functions: [a, b, c]` and have them run top-to-bottom.
152
+ // Each function receives the same `(ctx, { args, subcommand })`
153
+ // shape; a function may short-circuit the rest of the chain by
154
+ // returning the sentinel `STOP_CHAIN` (exported from
155
+ // `commandsConfig.ts`). Anything else (void/undefined/regular value)
156
+ // lets the chain continue. Empty chain → "no_dispatch" (parent is
157
+ // metadata-only, e.g. a sub-container with no override).
158
+ const fnNames = target.kind === "sub" ? target.sub.functions : target.entry.functions;
159
+ if (fnNames.length === 0) {
160
+ return { status: "no_dispatch", sentReply: null, suggestedReply: null };
161
+ }
162
+ const subId = target.kind === "sub" ? target.sub.cmd : undefined;
163
+ const input = { args: flat.args, subcommand: subId };
164
+ if (pluginName === "core") {
165
+ for (const fnName of fnNames) {
166
+ const handler = resolveCoreCommandHandler(fnName);
167
+ if (!handler) {
168
+ logger.warn(`[runCommand] Core namespace does not expose handler "${fnName}"`);
169
+ continue;
170
+ }
171
+ const result = await handler(ctx, input);
172
+ if (result === STOP_CHAIN)
173
+ break;
174
+ }
175
+ return { status: "executed", sentReply: null, suggestedReply: null };
176
+ }
147
177
  const plugin = lookupPlugin(pluginName);
148
- const handler = plugin ? resolvePluginCommandHandler(plugin.commands?.[fnName]) : null;
149
- if (!plugin || !handler) {
150
- const msg = `Plugin "${pluginName}" does not expose handler for !${flat.name}`;
178
+ if (!plugin) {
179
+ const msg = `Plugin "${pluginName}" is not active`;
151
180
  logger.warn(`[runCommand] ${msg}`);
152
181
  return { status: "no_dispatch", sentReply: null, suggestedReply: msg };
153
182
  }
154
- await runPlugin(plugin, ctx, handler, { args: flat.args, subcommand: target.kind === "sub" ? target.sub.cmd : undefined }, { rethrow: true });
183
+ for (const fnName of fnNames) {
184
+ const handler = resolvePluginCommandHandler(plugin.commands?.[fnName]);
185
+ if (!handler) {
186
+ const msg = `Plugin "${pluginName}" does not expose handler for !${flat.name}.${fnName}`;
187
+ logger.warn(`[runCommand] ${msg}`);
188
+ continue;
189
+ }
190
+ const result = await runPlugin(plugin, ctx, handler, input, { rethrow: true });
191
+ if (result === STOP_CHAIN)
192
+ break;
193
+ }
155
194
  return { status: "executed", sentReply: null, suggestedReply: null };
156
195
  }
157
196
  catch (e) {
@@ -216,13 +255,13 @@ function lookupPlugin(pluginName) {
216
255
  * !<cmd>[ <sub>] [--<arg1> ...]
217
256
  * Built from the declared `arguments:` block.
218
257
  */
219
- export function renderUsage(target) {
258
+ export function renderUsage(target, prefix = CMD_PREFIX) {
220
259
  if (target.kind === "none")
221
260
  return "";
222
261
  const flat = flatten(target);
223
262
  const head = target.kind === "sub"
224
- ? `${CMD_PREFIX}${target.parent.cmd} ${target.sub.cmd}`
225
- : `${CMD_PREFIX}${flat.entry.cmd}`;
263
+ ? `${prefix}${target.parent.cmd} ${target.sub.cmd}`
264
+ : `${prefix}${flat.entry.cmd}`;
226
265
  const headClean = head.replace(/\s+$/, "");
227
266
  if (flat.arguments.length === 0)
228
267
  return headClean;
@@ -3,13 +3,23 @@ import assert from "node:assert/strict";
3
3
  import { resolveDispatch, runCommand, renderUsage } from "#kernel/runCommand.js";
4
4
  import { buildCommandRegistry, __setRegistryForTests } from "#kernel/commandRegistry.js";
5
5
  import { pluginRegistry } from "#kernel/pluginLoader.js";
6
+ import { STOP_CHAIN } from "#kernel/commandsConfig.js";
7
+ import { buildSettingsApi } from "#kernel/settingsDb.js";
8
+ import { CONFIG } from "#config";
9
+ // Pin the "no override" global language independently of whatever
10
+ // ~/.manybot/manybot.toml happens to say on the machine running the
11
+ // suite — CONFIG.LANGUAGE is read from the real config dir unless
12
+ // MANYBOT_CONFIG_DIR is set, and i18n's `currentLang` locks in on the
13
+ // first t()/tFor() call, so this must run before any test does.
14
+ CONFIG.LANGUAGE = "en";
6
15
  function emptySpec(overrides) {
7
16
  return {
8
17
  id: overrides.id ?? "todo::add",
9
18
  cmd: overrides.cmd ?? "todo",
10
19
  aliases: overrides.aliases ?? [],
11
20
  plugin: overrides.plugin ?? "todoPlugin",
12
- function: overrides.function ?? "addFn",
21
+ functions: overrides.functions ?? ["addFn"],
22
+ loading: overrides.loading ?? null,
13
23
  text: overrides.text ?? null,
14
24
  desc: overrides.desc ?? null,
15
25
  category: overrides.category ?? null,
@@ -28,7 +38,8 @@ function emptySub(overrides) {
28
38
  id: overrides.id ?? "todo::list",
29
39
  cmd: overrides.cmd ?? "list",
30
40
  aliases: overrides.aliases ?? [],
31
- function: overrides.function ?? null,
41
+ functions: overrides.functions ?? null,
42
+ loading: overrides.loading ?? null,
32
43
  desc: overrides.desc ?? null,
33
44
  manual: overrides.manual ?? null,
34
45
  arguments: overrides.arguments ?? [],
@@ -71,6 +82,11 @@ function registerTodoPlugin() {
71
82
  throw new Error("boom");
72
83
  },
73
84
  },
85
+ gateFn: {
86
+ cmd: "gate",
87
+ aliases: [],
88
+ handler: async () => STOP_CHAIN,
89
+ },
74
90
  },
75
91
  };
76
92
  pluginRegistry.set("todoPlugin", plugin);
@@ -113,7 +129,7 @@ describe("kernel/runCommand", () => {
113
129
  }
114
130
  });
115
131
  test("resolveDispatch: kind sub when a declared subcommand token matches", () => {
116
- const spec = emptySpec({ subcommands: [emptySub({ function: "listFn" })] });
132
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
117
133
  __setRegistryForTests(buildRegistry([spec]));
118
134
  const { target } = resolveDispatch("todo", "list");
119
135
  assert.equal(target.kind, "sub");
@@ -122,7 +138,7 @@ describe("kernel/runCommand", () => {
122
138
  }
123
139
  });
124
140
  test("resolveDispatch: unmatchedSubToken falls through to parent", () => {
125
- const spec = emptySpec({ subcommands: [emptySub({ function: "listFn" })] });
141
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
126
142
  __setRegistryForTests(buildRegistry([spec]));
127
143
  const { target, unmatchedSubToken } = resolveDispatch("todo", "wat now");
128
144
  assert.equal(target.kind, "parent");
@@ -144,7 +160,7 @@ describe("kernel/runCommand", () => {
144
160
  assert.equal(replies.length, 0);
145
161
  });
146
162
  test("runCommand: routes to the subcommand handler, not the parent's", async () => {
147
- const spec = emptySpec({ subcommands: [emptySub({ function: "listFn" })] });
163
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
148
164
  __setRegistryForTests(buildRegistry([spec]));
149
165
  const resolution = resolveDispatch("todo", "list");
150
166
  const result = await runCommand({
@@ -158,7 +174,7 @@ describe("kernel/runCommand", () => {
158
174
  assert.equal(addCalls.length, 0);
159
175
  });
160
176
  test("runCommand: unmatchedSubToken replies with a usage hint and does not dispatch", async () => {
161
- const spec = emptySpec({ subcommands: [emptySub({ function: "listFn" })] });
177
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
162
178
  __setRegistryForTests(buildRegistry([spec]));
163
179
  const resolution = resolveDispatch("todo", "wat");
164
180
  const replies = [];
@@ -199,7 +215,7 @@ describe("kernel/runCommand", () => {
199
215
  assert.equal(addCalls.length, 0);
200
216
  });
201
217
  test("runCommand: re-throws on handler crash after firing the alert", async () => {
202
- const spec = emptySpec({ id: "todo::crash", cmd: "crashcmd", function: "crashFn" });
218
+ const spec = emptySpec({ id: "todo::crash", cmd: "crashcmd", functions: ["crashFn"] });
203
219
  __setRegistryForTests(buildRegistry([spec]));
204
220
  const resolution = resolveDispatch("crashcmd", "");
205
221
  await assert.rejects(() => runCommand({
@@ -232,4 +248,250 @@ describe("kernel/runCommand", () => {
232
248
  const usage = renderUsage({ kind: "none" });
233
249
  assert.equal(usage, "");
234
250
  });
251
+ test("renderUsage: accepts a prefix override", () => {
252
+ const spec = emptySpec({
253
+ arguments: [{ name: "item", type: "quoted_text", required: true }],
254
+ });
255
+ __setRegistryForTests(buildRegistry([spec]));
256
+ const { target } = resolveDispatch("todo", "");
257
+ const usage = renderUsage(target, "#");
258
+ assert.match(usage, /^#todo /);
259
+ });
260
+ describe("per-chat overrides (prefix / language via !config)", () => {
261
+ const overrideChatId = "5511988887777@c.us"; // already-normalized form
262
+ beforeEach(() => {
263
+ buildSettingsApi("core", overrideChatId).deleteAll();
264
+ });
265
+ afterEach(() => {
266
+ buildSettingsApi("core", overrideChatId).deleteAll();
267
+ });
268
+ test("missing-argument usage message uses the chat's saved prefix override", async () => {
269
+ buildSettingsApi("core", overrideChatId).set("chat_prefix", "#");
270
+ const spec = emptySpec({ arguments: [{ name: "item", type: "quoted_text", required: true }] });
271
+ __setRegistryForTests(buildRegistry([spec]));
272
+ const resolution = resolveDispatch("todo", "");
273
+ const replies = [];
274
+ const result = await runCommand({
275
+ pluginName: "todoPlugin",
276
+ ctx: fakeCtx(),
277
+ resolution,
278
+ reply: { text: (t) => replies.push(t) },
279
+ chatId: overrideChatId,
280
+ });
281
+ assert.equal(result.status, "argument_missing");
282
+ assert.match(replies[0], /#todo/, "usage line should use the chat's saved prefix, not the global one");
283
+ });
284
+ test("missing-argument usage message falls back to the global prefix when no override is set", async () => {
285
+ const spec = emptySpec({ arguments: [{ name: "item", type: "quoted_text", required: true }] });
286
+ __setRegistryForTests(buildRegistry([spec]));
287
+ const resolution = resolveDispatch("todo", "");
288
+ const replies = [];
289
+ await runCommand({
290
+ pluginName: "todoPlugin",
291
+ ctx: fakeCtx(),
292
+ resolution,
293
+ reply: { text: (t) => replies.push(t) },
294
+ chatId: overrideChatId,
295
+ });
296
+ assert.match(replies[0], /!todo/);
297
+ });
298
+ test("unknown-subcommand message uses the chat's saved language override", async () => {
299
+ buildSettingsApi("core", overrideChatId).set("chat_locale", "pt");
300
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
301
+ __setRegistryForTests(buildRegistry([spec]));
302
+ const resolution = resolveDispatch("todo", "wat");
303
+ const replies = [];
304
+ await runCommand({
305
+ pluginName: "todoPlugin",
306
+ ctx: fakeCtx(),
307
+ resolution,
308
+ reply: { text: (t) => replies.push(t) },
309
+ chatId: overrideChatId,
310
+ });
311
+ assert.match(replies[0], /Subcomando "wat" desconhecido/);
312
+ });
313
+ test("unknown-subcommand message falls back to the global language when no override is set", async () => {
314
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
315
+ __setRegistryForTests(buildRegistry([spec]));
316
+ const resolution = resolveDispatch("todo", "wat");
317
+ const replies = [];
318
+ await runCommand({
319
+ pluginName: "todoPlugin",
320
+ ctx: fakeCtx(),
321
+ resolution,
322
+ reply: { text: (t) => replies.push(t) },
323
+ chatId: overrideChatId,
324
+ });
325
+ assert.match(replies[0], /Unknown subcommand "wat"/);
326
+ });
327
+ test("omitting chatId behaves exactly like an unset override (backward compatible)", async () => {
328
+ const spec = emptySpec({ arguments: [{ name: "item", type: "quoted_text", required: true }] });
329
+ __setRegistryForTests(buildRegistry([spec]));
330
+ const resolution = resolveDispatch("todo", "");
331
+ const replies = [];
332
+ const result = await runCommand({
333
+ pluginName: "todoPlugin",
334
+ ctx: fakeCtx(),
335
+ resolution,
336
+ reply: { text: (t) => replies.push(t) },
337
+ });
338
+ assert.equal(result.status, "argument_missing");
339
+ assert.match(replies[0], /!todo/);
340
+ });
341
+ });
342
+ describe("functions: chain + STOP_CHAIN", () => {
343
+ test("runs every function in the chain in declared order", async () => {
344
+ let second = false;
345
+ let third = false;
346
+ const plugin = {
347
+ name: "chainPlugin",
348
+ status: "active",
349
+ run: null,
350
+ setup: null,
351
+ exports: {},
352
+ error: null,
353
+ guardOptions: {},
354
+ commands: {
355
+ firstFn: { cmd: "todo", aliases: [], handler: async () => { second = true; } },
356
+ secondFn: { cmd: "todo", aliases: [], handler: async () => {
357
+ assert.equal(second, true, "second handler ran before first finished");
358
+ third = true;
359
+ } },
360
+ thirdFn: { cmd: "todo", aliases: [], handler: async () => {
361
+ assert.equal(third, true, "third handler ran before second finished");
362
+ } },
363
+ },
364
+ };
365
+ pluginRegistry.set("chainPlugin", plugin);
366
+ const specs = [
367
+ {
368
+ id: "chainPlugin::firstFn",
369
+ cmd: "chain",
370
+ aliases: [],
371
+ plugin: "chainPlugin",
372
+ functions: ["firstFn", "secondFn", "thirdFn"],
373
+ loading: null,
374
+ text: null,
375
+ desc: null,
376
+ category: null,
377
+ group: null,
378
+ manual: null,
379
+ deprecatedMessage: null,
380
+ notifyChanges: null,
381
+ permissions: null,
382
+ messages: null,
383
+ arguments: [],
384
+ subcommands: [],
385
+ },
386
+ ];
387
+ __setRegistryForTests(buildCommandRegistry(specs, pluginRegistry));
388
+ const resolution = resolveDispatch("chain", "");
389
+ const result = await runCommand({
390
+ pluginName: "chainPlugin",
391
+ ctx: fakeCtx(),
392
+ resolution,
393
+ reply: { text: () => { } },
394
+ });
395
+ assert.equal(result.status, "executed");
396
+ assert.equal(third, true);
397
+ pluginRegistry.delete("chainPlugin");
398
+ });
399
+ test("STOP_CHAIN short-circuits the rest of the chain", async () => {
400
+ let secondRan = false;
401
+ const plugin = {
402
+ name: "stopPlugin",
403
+ status: "active",
404
+ run: null,
405
+ setup: null,
406
+ exports: {},
407
+ error: null,
408
+ guardOptions: {},
409
+ commands: {
410
+ firstFn: { cmd: "stop", aliases: [], handler: async () => STOP_CHAIN },
411
+ secondFn: { cmd: "stop", aliases: [], handler: async () => { secondRan = true; } },
412
+ },
413
+ };
414
+ pluginRegistry.set("stopPlugin", plugin);
415
+ const specs = [
416
+ {
417
+ id: "stopPlugin::firstFn",
418
+ cmd: "stop",
419
+ aliases: [],
420
+ plugin: "stopPlugin",
421
+ functions: ["firstFn", "secondFn"],
422
+ loading: null,
423
+ text: null,
424
+ desc: null,
425
+ category: null,
426
+ group: null,
427
+ manual: null,
428
+ deprecatedMessage: null,
429
+ notifyChanges: null,
430
+ permissions: null,
431
+ messages: null,
432
+ arguments: [],
433
+ subcommands: [],
434
+ },
435
+ ];
436
+ __setRegistryForTests(buildCommandRegistry(specs, pluginRegistry));
437
+ const resolution = resolveDispatch("stop", "");
438
+ const result = await runCommand({
439
+ pluginName: "stopPlugin",
440
+ ctx: fakeCtx(),
441
+ resolution,
442
+ reply: { text: () => { } },
443
+ });
444
+ assert.equal(result.status, "executed");
445
+ assert.equal(secondRan, false);
446
+ pluginRegistry.delete("stopPlugin");
447
+ });
448
+ test("chain throw propagates and stops the chain", async () => {
449
+ let secondRan = false;
450
+ const plugin = {
451
+ name: "throwPlugin",
452
+ status: "active",
453
+ run: null,
454
+ setup: null,
455
+ exports: {},
456
+ error: null,
457
+ guardOptions: {},
458
+ commands: {
459
+ firstFn: { cmd: "throw", aliases: [], handler: async () => { throw new Error("mid"); } },
460
+ secondFn: { cmd: "throw", aliases: [], handler: async () => { secondRan = true; } },
461
+ },
462
+ };
463
+ pluginRegistry.set("throwPlugin", plugin);
464
+ const specs = [
465
+ {
466
+ id: "throwPlugin::firstFn",
467
+ cmd: "throw",
468
+ aliases: [],
469
+ plugin: "throwPlugin",
470
+ functions: ["firstFn", "secondFn"],
471
+ loading: null,
472
+ text: null,
473
+ desc: null,
474
+ category: null,
475
+ group: null,
476
+ manual: null,
477
+ deprecatedMessage: null,
478
+ notifyChanges: null,
479
+ permissions: null,
480
+ messages: null,
481
+ arguments: [],
482
+ subcommands: [],
483
+ },
484
+ ];
485
+ __setRegistryForTests(buildCommandRegistry(specs, pluginRegistry));
486
+ const resolution = resolveDispatch("throw", "");
487
+ await assert.rejects(() => runCommand({
488
+ pluginName: "throwPlugin",
489
+ ctx: fakeCtx(),
490
+ resolution,
491
+ reply: { text: () => { } },
492
+ }), /mid/);
493
+ assert.equal(secondRan, false);
494
+ pluginRegistry.delete("throwPlugin");
495
+ });
496
+ });
235
497
  });
@@ -94,6 +94,17 @@ function dbDelete(pluginName, chatId, key) {
94
94
  function dbDeleteAll(pluginName, chatId) {
95
95
  stmts.deleteAll.run(pluginName, chatId);
96
96
  }
97
+ /**
98
+ * Direct read of a single (plugin, chat, key) setting, bypassing the
99
+ * `ctx.settings` scoped-accessor pattern. For call sites that need a
100
+ * value before a `PluginContext` exists yet — e.g. resolving the
101
+ * per-chat command prefix while still parsing the incoming message,
102
+ * or resolving the per-chat language from outside the "core" plugin's
103
+ * own context. See `kernel/chatOverrides.ts`.
104
+ */
105
+ export function getPluginSetting(pluginName, chatId, key) {
106
+ return dbGet(pluginName, chatId, key);
107
+ }
97
108
  // ── Scoped accessor factory ───────────────────────────────────────────────────
98
109
  /**
99
110
  * Returns a settings accessor for a specific (pluginName, chatId) pair.
@@ -106,9 +117,11 @@ function scopedAccessor(pluginName, chatId) {
106
117
  * @param {string} key
107
118
  * @param {*} [defaultValue]
108
119
  */
109
- get(key, defaultValue = undefined) {
120
+ get(key, defaultValue) {
110
121
  const val = dbGet(pluginName, chatId, key);
111
- return val !== undefined ? val : defaultValue;
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `get<T>` is a
123
+ // type-level convenience for callers; the store itself is untyped JSON.
124
+ return (val !== undefined ? val : defaultValue);
112
125
  },
113
126
  /**
114
127
  * Get all settings for this chat as a plain object.
@@ -12,6 +12,15 @@
12
12
  * c. otherwise absent (`chat === null`) — integration tests skip
13
13
  * with an explanatory message instead of crashing.
14
14
  *
15
+ * This module accepts any chat shape `normalizeTestChat()` allows,
16
+ * including a group (`@g.us`) — it has no opinion on what a given
17
+ * test file actually needs. Individual suites are the ones with
18
+ * that requirement: contacts.integration.test.ts, for instance,
19
+ * needs an individual chat (a phone number, or a JID ending in
20
+ * `@s.whatsapp.net`/`@c.us`/`@lid`) because it asserts on
21
+ * per-person contact fields (LID, number, country) that a group
22
+ * simply doesn't have — see that file's own header for details.
23
+ *
15
24
  * 2. `runWhatsApp` — explicit opt-in flag (env `MANYBOT_RUN_WHATSAPP_TESTS=1`).
16
25
  * A saved WhatsApp session plus a `TEST_CHAT` is NOT enough to fire real
17
26
  * messages; this is the single, deliberate signal that the operator