@manybot/manybot 5.7.0 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +28 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +91 -6
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/download/queue.js +13 -4
  9. package/dist/drivers/baileys/adapter.js +133 -15
  10. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  11. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  12. package/dist/drivers/baileys/api/index.js +384 -62
  13. package/dist/drivers/baileys/index.js +92 -36
  14. package/dist/drivers/baileys/loginPrompt.js +0 -2
  15. package/dist/drivers/baileys/messageHandler.js +344 -4
  16. package/dist/drivers/baileys/messageHandler.test.js +445 -0
  17. package/dist/drivers/baileysAdapter.test.js +378 -0
  18. package/dist/drivers/jid.js +26 -0
  19. package/dist/drivers/jid.test.js +74 -0
  20. package/dist/drivers/types.js +5 -5
  21. package/dist/i18n/index.js +20 -24
  22. package/dist/kernel/activeDriverSend.js +21 -0
  23. package/dist/kernel/activeDriverSend.test.js +89 -0
  24. package/dist/kernel/alerts.js +3 -9
  25. package/dist/kernel/chatOverrides.js +46 -0
  26. package/dist/kernel/chatOverrides.test.js +59 -0
  27. package/dist/kernel/chatSession.js +65 -0
  28. package/dist/kernel/chatSession.test.js +46 -0
  29. package/dist/kernel/commandAccess.js +66 -0
  30. package/dist/kernel/commandAccess.test.js +74 -0
  31. package/dist/kernel/commandDeprecation.js +170 -0
  32. package/dist/kernel/commandDeprecation.test.js +114 -0
  33. package/dist/kernel/commandMenu.js +357 -0
  34. package/dist/kernel/commandMenu.test.js +363 -0
  35. package/dist/kernel/commandPermissions.js +171 -0
  36. package/dist/kernel/commandPermissions.test.js +227 -0
  37. package/dist/kernel/commandRegistry.js +583 -0
  38. package/dist/kernel/commandRegistry.test.js +158 -0
  39. package/dist/kernel/commandsConfig.js +949 -0
  40. package/dist/kernel/commandsConfig.test.js +482 -0
  41. package/dist/kernel/contactAutoSave.js +6 -6
  42. package/dist/kernel/contactAutoSave.test.js +87 -0
  43. package/dist/kernel/coreCommands.js +62 -0
  44. package/dist/kernel/driverManager.js +10 -6
  45. package/dist/kernel/driverManager.test.js +90 -0
  46. package/dist/kernel/integrationMode.js +88 -0
  47. package/dist/kernel/integrationMode.test.js +95 -0
  48. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  49. package/dist/kernel/pluginApi.test.js +600 -0
  50. package/dist/kernel/pluginGuard.js +18 -13
  51. package/dist/kernel/pluginGuard.test.js +39 -0
  52. package/dist/kernel/pluginLoader.js +169 -11
  53. package/dist/kernel/pluginLoader.test.js +190 -0
  54. package/dist/kernel/runCommand.js +284 -0
  55. package/dist/kernel/runCommand.test.js +497 -0
  56. package/dist/kernel/sendFallbackGuard.js +19 -48
  57. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  58. package/dist/kernel/sendGuard.js +38 -42
  59. package/dist/kernel/sendGuard.test.js +102 -0
  60. package/dist/kernel/settingsDb.js +19 -5
  61. package/dist/kernel/statusServer.js +9 -2
  62. package/dist/kernel/statusServer.test.js +70 -0
  63. package/dist/kernel/testConfig.js +192 -0
  64. package/dist/kernel/testConfig.test.js +181 -0
  65. package/dist/kernel/updateCheck.js +33 -10
  66. package/dist/locales/en.json +77 -13
  67. package/dist/locales/es.json +77 -13
  68. package/dist/locales/pt.json +77 -13
  69. package/dist/logger/logger.js +23 -3
  70. package/dist/logger/logger.test.js +45 -0
  71. package/dist/main.js +5 -76
  72. package/dist/plugins/__manybot_integration__/index.js +184 -0
  73. package/dist/plugins/__manybot_integration__/index.test.js +218 -0
  74. package/dist/utils/phoneNumber.js +83 -0
  75. package/dist/utils/phoneNumber.test.js +53 -0
  76. package/package.json +76 -18
  77. package/dist/drivers/whatsmeow/client.js +0 -252
  78. package/dist/drivers/whatsmeow/index.js +0 -79
  79. package/dist/drivers/whatsmeow/installer.js +0 -86
  80. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  81. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
@@ -0,0 +1,482 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, beforeEach, describe, test } from "node:test";
3
+ import fs from "fs/promises";
4
+ import os from "os";
5
+ import path from "path";
6
+ const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "manybot-commands-config-"));
7
+ process.env.MANYBOT_CONFIG_DIR = configDir;
8
+ const { loadCommandsConfig, parseLocalizedString, resolveFileRef } = await import("#kernel/commandsConfig.js");
9
+ const commandsFile = path.join(configDir, "commands.yaml");
10
+ beforeEach(async () => {
11
+ await fs.rm(commandsFile, { force: true });
12
+ });
13
+ after(async () => {
14
+ await fs.rm(configDir, { recursive: true, force: true });
15
+ });
16
+ describe("kernel/commandsConfig", () => {
17
+ test("parses localized strings and preserves only usable values", () => {
18
+ assert.equal(parseLocalizedString(" hello "), "hello");
19
+ assert.equal(parseLocalizedString(" "), null);
20
+ assert.deepEqual(parseLocalizedString({ en: " Hello ", pt: " Olá ", invalid: 3 }), {
21
+ en: "Hello",
22
+ pt: "Olá",
23
+ });
24
+ assert.equal(parseLocalizedString([]), null);
25
+ });
26
+ test("returns null when commands.yaml does not exist", async () => {
27
+ assert.equal(await loadCommandsConfig(), null);
28
+ });
29
+ test("uses defaults for an empty YAML document", async () => {
30
+ await fs.writeFile(commandsFile, "null\n", "utf8");
31
+ const config = await loadCommandsConfig();
32
+ assert.ok(config);
33
+ assert.equal(config.defaults.notifyChanges, true);
34
+ assert.equal(config.defaults.notifyPeriodDays, 7);
35
+ assert.equal(config.menu.enabled, false);
36
+ assert.deepEqual(config.menu.aliases, ["help", "man", "menu", "bot", "?"]);
37
+ assert.deepEqual(config.specs, []);
38
+ });
39
+ test("rejects malformed and non-object YAML roots", async () => {
40
+ await fs.writeFile(commandsFile, "commands: [unterminated", "utf8");
41
+ assert.equal(await loadCommandsConfig(), null);
42
+ await fs.writeFile(commandsFile, "- not\n- a mapping\n", "utf8");
43
+ assert.equal(await loadCommandsConfig(), null);
44
+ });
45
+ test("loads defaults, menu, categories, manuals, file references and command permissions", async () => {
46
+ await fs.writeFile(path.join(configDir, "reply.txt"), "Reply from file", "utf8");
47
+ await fs.writeFile(path.join(configDir, "manual-pt.txt"), "Manual em português", "utf8");
48
+ await fs.writeFile(commandsFile, `
49
+ defaults:
50
+ notifyChanges: false
51
+ notifyPeriodDays: 12.8
52
+ notifyMessage: " Command changed "
53
+ permissions:
54
+ admin: true
55
+ scope: GROUP
56
+ cooldownSeconds: 4
57
+ whitelist:
58
+ groups: [ " group@g.us ", 1 ]
59
+ users: [ " user@c.us " ]
60
+ messages:
61
+ cooldown: " Wait "
62
+ menu:
63
+ title: { en: " Commands ", pt: " Comandos " }
64
+ intro: " Intro "
65
+ footer: " Footer "
66
+ aliases: [ help, " ? ", 1 ]
67
+ notFoundFallback: true
68
+ categories:
69
+ fun:
70
+ label: { en: " Fun " }
71
+ order: 2
72
+ uncategorized: {}
73
+ manuals:
74
+ greeting: "file: manual-pt.txt"
75
+ hello:
76
+ cmd: " hello "
77
+ aliases: [ hi, " oi ", 3 ]
78
+ plugin: " sample "
79
+ function: " greet "
80
+ text: "file: reply.txt"
81
+ desc: { en: " Say hello ", pt: " Diga oi " }
82
+ category: " fun "
83
+ manual: { pt: "file: manual-pt.txt", en: " Plain manual " }
84
+ deprecatedMessage: " Old command "
85
+ notifyChanges: false
86
+ permissions:
87
+ botAdmin: true
88
+ owner: false
89
+ scope: dm
90
+ cooldownSeconds: 0
91
+ blacklist:
92
+ users: [ blocked@c.us ]
93
+ messages:
94
+ ownerOnly: " Owners only "
95
+ invalid: "not a command"
96
+ missingCmd:
97
+ aliases: [ no ]
98
+ `, "utf8");
99
+ const config = await loadCommandsConfig();
100
+ assert.ok(config);
101
+ assert.deepEqual(config.defaults, {
102
+ notifyChanges: false,
103
+ notifyPeriodDays: 12,
104
+ notifyMessage: "Command changed",
105
+ permissions: {
106
+ admin: true,
107
+ botAdmin: undefined,
108
+ owner: undefined,
109
+ scope: "group",
110
+ cooldownSeconds: 4,
111
+ whitelist: { groups: ["group@g.us"], users: ["user@c.us"] },
112
+ blacklist: undefined,
113
+ dono: undefined,
114
+ allowedChats: undefined,
115
+ groupOnly: undefined,
116
+ dmOnly: undefined,
117
+ whitelistGroups: undefined,
118
+ blacklistUsers: undefined,
119
+ hiddenOutsideScope: undefined,
120
+ },
121
+ messages: {
122
+ botNotAdmin: undefined,
123
+ senderNotAdmin: undefined,
124
+ ownerOnly: undefined,
125
+ donoOnly: undefined,
126
+ wrongScope: undefined,
127
+ cooldown: "Wait",
128
+ blacklist: undefined,
129
+ allowedChats: undefined,
130
+ },
131
+ loading: null,
132
+ });
133
+ assert.deepEqual(config.menu, {
134
+ enabled: true,
135
+ title: { en: "Commands", pt: "Comandos" },
136
+ intro: "Intro",
137
+ footer: "Footer",
138
+ cmd: "menu",
139
+ aliases: ["help", "?"],
140
+ notFoundFallback: true,
141
+ suggestSimilar: false,
142
+ suggestMaxDistance: 2,
143
+ welcomeMessage: null,
144
+ welcomeWindowDays: 3,
145
+ pageSize: 15,
146
+ });
147
+ assert.deepEqual(config.categories, {
148
+ fun: { label: { en: "Fun" }, order: 2, scope: null, hiddenInScope: null },
149
+ uncategorized: { label: "uncategorized", order: 999, scope: null, hiddenInScope: null },
150
+ });
151
+ assert.deepEqual(config.manuals, { greeting: "Manual em português" });
152
+ assert.equal(config.specs.length, 1);
153
+ assert.deepEqual(config.specs[0], {
154
+ id: "hello",
155
+ cmd: "hello",
156
+ aliases: ["hi", "oi"],
157
+ plugin: "sample",
158
+ functions: ["greet"],
159
+ loading: null,
160
+ text: "Reply from file",
161
+ desc: { en: "Say hello", pt: "Diga oi" },
162
+ category: "fun",
163
+ group: null,
164
+ manual: { pt: "Manual em português", en: "Plain manual" },
165
+ deprecatedMessage: "Old command",
166
+ notifyChanges: false,
167
+ permissions: {
168
+ admin: undefined,
169
+ botAdmin: true,
170
+ owner: false,
171
+ scope: "dm",
172
+ cooldownSeconds: 0,
173
+ whitelist: undefined,
174
+ blacklist: { groups: undefined, users: ["blocked@c.us"] },
175
+ dono: undefined,
176
+ allowedChats: undefined,
177
+ groupOnly: undefined,
178
+ dmOnly: undefined,
179
+ whitelistGroups: undefined,
180
+ blacklistUsers: undefined,
181
+ hiddenOutsideScope: undefined,
182
+ },
183
+ messages: {
184
+ botNotAdmin: undefined,
185
+ senderNotAdmin: undefined,
186
+ ownerOnly: "Owners only",
187
+ donoOnly: undefined,
188
+ wrongScope: undefined,
189
+ cooldown: undefined,
190
+ blacklist: undefined,
191
+ allowedChats: undefined,
192
+ },
193
+ arguments: [],
194
+ subcommands: [],
195
+ });
196
+ });
197
+ test("keeps an unreadable file reference as the original text", async () => {
198
+ assert.equal(await resolveFileRef("file: missing.txt"), "file: missing.txt");
199
+ assert.deepEqual(await resolveFileRef({ en: "file: missing.txt", pt: "Texto" }), {
200
+ en: "file: missing.txt",
201
+ pt: "Texto",
202
+ });
203
+ });
204
+ test("import: merges top-level sections from auxiliary files", async () => {
205
+ await fs.writeFile(path.join(configDir, "menu.yaml"), `
206
+ menu:
207
+ title: "Imported Menu"
208
+ aliases: ["ajuda"]
209
+ `, "utf8");
210
+ await fs.writeFile(path.join(configDir, "manual.yaml"), `
211
+ manuals:
212
+ hello: "Imported manual"
213
+ `, "utf8");
214
+ await fs.writeFile(commandsFile, `
215
+ import:
216
+ - menu.yaml
217
+ - manual.yaml
218
+ hello:
219
+ cmd: hello
220
+ plugin: sample
221
+ function: greet
222
+ `, "utf8");
223
+ const config = await loadCommandsConfig();
224
+ assert.ok(config);
225
+ assert.equal(config.menu.title, "Imported Menu");
226
+ assert.deepEqual(config.menu.aliases, ["ajuda"]);
227
+ assert.deepEqual(config.manuals, { hello: "Imported manual" });
228
+ assert.equal(config.specs.length, 1);
229
+ assert.equal(config.specs[0].id, "hello");
230
+ });
231
+ test("import: accepts a single path (not wrapped in a list)", async () => {
232
+ await fs.writeFile(path.join(configDir, "menu.yaml"), `
233
+ menu:
234
+ title: "Solo Import"
235
+ `, "utf8");
236
+ await fs.writeFile(commandsFile, `
237
+ import: menu.yaml
238
+ `, "utf8");
239
+ const config = await loadCommandsConfig();
240
+ assert.ok(config);
241
+ assert.equal(config.menu.title, "Solo Import");
242
+ });
243
+ test("import: a key already owned by the main file or an earlier import is kept, not overwritten", async () => {
244
+ await fs.writeFile(path.join(configDir, "menu.yaml"), `
245
+ menu:
246
+ title: "Should be ignored"
247
+ `, "utf8");
248
+ await fs.writeFile(commandsFile, `
249
+ import:
250
+ - menu.yaml
251
+ menu:
252
+ title: "Main file wins"
253
+ `, "utf8");
254
+ const config = await loadCommandsConfig();
255
+ assert.ok(config);
256
+ assert.equal(config.menu.title, "Main file wins");
257
+ });
258
+ test("import: a missing or malformed import file is skipped without failing the whole load", async () => {
259
+ await fs.writeFile(path.join(configDir, "broken.yaml"), "not: [a, valid\n", "utf8");
260
+ await fs.writeFile(commandsFile, `
261
+ import:
262
+ - does-not-exist.yaml
263
+ - broken.yaml
264
+ hello:
265
+ cmd: hello
266
+ plugin: sample
267
+ function: greet
268
+ `, "utf8");
269
+ const config = await loadCommandsConfig();
270
+ assert.ok(config);
271
+ assert.equal(config.specs.length, 1);
272
+ assert.equal(config.specs[0].id, "hello");
273
+ });
274
+ // ── loading: snake_case flat-form props (reference yaml uses these) ──────
275
+ describe("loading: snake_case flat-form props", () => {
276
+ test("loading_presets accepts on_success/on_error (reaction) and interval_ms (spinner) — reference yaml's exact shape", async () => {
277
+ await fs.writeFile(commandsFile, `
278
+ loading_presets:
279
+ padrao:
280
+ type: reaction
281
+ icon: "⏳"
282
+ on_success: "✅"
283
+ on_error: "❌"
284
+ spinner_classico:
285
+ type: spinner
286
+ frames: ["⠋", "⠙"]
287
+ interval_ms: 1000
288
+ on_success: "✅ Pronto!"
289
+ on_error: "Erro: {erro}"
290
+ loading: padrao
291
+ `, "utf8");
292
+ const config = await loadCommandsConfig();
293
+ assert.ok(config);
294
+ assert.deepEqual(config.loadingPresets.padrao, {
295
+ type: "reaction",
296
+ icon: "⏳",
297
+ onSuccess: "✅",
298
+ onError: "❌",
299
+ });
300
+ assert.deepEqual(config.loadingPresets.spinner_classico, {
301
+ type: "spinner",
302
+ frames: ["⠋", "⠙"],
303
+ intervalMs: 1000,
304
+ onSuccess: "✅ Pronto!",
305
+ onError: "Erro: {erro}",
306
+ });
307
+ });
308
+ test("camelCase and snake_case forms are equivalent, not additive (last one parsed wins, neither is required)", async () => {
309
+ await fs.writeFile(commandsFile, `
310
+ loading_presets:
311
+ onlyCamel:
312
+ type: reaction
313
+ onSuccess: "camel"
314
+ onlySnake:
315
+ type: reaction
316
+ on_success: "snake"
317
+ `, "utf8");
318
+ const config = await loadCommandsConfig();
319
+ assert.ok(config);
320
+ assert.equal(config.loadingPresets.onlyCamel.onSuccess, "camel");
321
+ assert.equal(config.loadingPresets.onlySnake.onSuccess, "snake");
322
+ });
323
+ test("an actually-unknown property for the declared type is still fatal (malformed config)", async () => {
324
+ await fs.writeFile(commandsFile, `
325
+ loading_presets:
326
+ bad:
327
+ type: reaction
328
+ frames: ["not", "valid", "for", "reaction"]
329
+ `, "utf8");
330
+ const config = await loadCommandsConfig();
331
+ assert.ok(config);
332
+ assert.equal(config.loadingPresets.bad, undefined, "malformed preset is dropped, not silently accepted");
333
+ });
334
+ });
335
+ // ── top-level `loading:` global default overlays onto defaults.loading ──
336
+ test("top-level loading: <preset-name> overlays onto defaults.loading, same as notify_*/permission_messages", async () => {
337
+ await fs.writeFile(commandsFile, `
338
+ loading_presets:
339
+ padrao:
340
+ type: reaction
341
+ icon: "⏳"
342
+ loading: padrao
343
+ `, "utf8");
344
+ const config = await loadCommandsConfig();
345
+ assert.ok(config);
346
+ assert.deepEqual(config.defaults.loading, { type: "reaction", icon: "⏳" });
347
+ });
348
+ test("top-level loading: accepts an inline spec, not just a preset name", async () => {
349
+ await fs.writeFile(commandsFile, `
350
+ loading:
351
+ type: typing
352
+ `, "utf8");
353
+ const config = await loadCommandsConfig();
354
+ assert.ok(config);
355
+ assert.deepEqual(config.defaults.loading, { type: "typing" });
356
+ });
357
+ test("top-level loading: wins over defaults.loading when both are present (overlay semantics)", async () => {
358
+ await fs.writeFile(commandsFile, `
359
+ defaults:
360
+ loading:
361
+ type: typing
362
+ loading:
363
+ type: none
364
+ `, "utf8");
365
+ const config = await loadCommandsConfig();
366
+ assert.ok(config);
367
+ assert.deepEqual(config.defaults.loading, { type: "none" });
368
+ });
369
+ // ── plugin: registry-key normalization ───────────────────────────────
370
+ // The caller passes the active pluginRegistry's keys via
371
+ // loadCommandsConfig({ validPluginKeys }). parseEntry uses them to
372
+ // resolve shorthand `plugin: <name>` entries to the canonical
373
+ // `owner/repo` key (and to leave fully-qualified entries alone).
374
+ describe("plugin: registry-key normalization", () => {
375
+ test("keeps an exact owner/repo key verbatim", async () => {
376
+ await fs.writeFile(commandsFile, `
377
+ hello:
378
+ cmd: hello
379
+ plugin: synt-xerror/welcome
380
+ functions: [greet]
381
+ `, "utf8");
382
+ const config = await loadCommandsConfig(new Set(["synt-xerror/welcome", "de/welcome-test"]));
383
+ assert.ok(config);
384
+ assert.equal(config.specs.length, 1);
385
+ assert.equal(config.specs[0].plugin, "synt-xerror/welcome");
386
+ assert.deepEqual(config.specs[0].functions, ["greet"]);
387
+ });
388
+ test("resolves a bare name to the unique matching owner/repo key", async () => {
389
+ await fs.writeFile(commandsFile, `
390
+ hello:
391
+ cmd: hello
392
+ plugin: welcome
393
+ functions: [greet]
394
+ `, "utf8");
395
+ const config = await loadCommandsConfig(new Set(["synt-xerror/welcome", "de/welcome-test"]));
396
+ assert.ok(config);
397
+ assert.equal(config.specs[0].plugin, "synt-xerror/welcome");
398
+ });
399
+ test("splits the inline owner/repo.fn form before normalization", async () => {
400
+ await fs.writeFile(commandsFile, `
401
+ hello:
402
+ cmd: hello
403
+ plugin: synt-xerror/welcome.ping
404
+ `, "utf8");
405
+ const config = await loadCommandsConfig(new Set(["synt-xerror/welcome", "de/welcome-test"]));
406
+ assert.ok(config);
407
+ assert.equal(config.specs[0].plugin, "synt-xerror/welcome");
408
+ assert.deepEqual(config.specs[0].functions, ["ping"]);
409
+ });
410
+ test("splits the inline bare-name.fn form before normalization", async () => {
411
+ await fs.writeFile(commandsFile, `
412
+ hello:
413
+ cmd: hello
414
+ plugin: welcome.ping
415
+ `, "utf8");
416
+ const config = await loadCommandsConfig(new Set(["synt-xerror/welcome"]));
417
+ assert.ok(config);
418
+ assert.equal(config.specs[0].plugin, "synt-xerror/welcome");
419
+ assert.deepEqual(config.specs[0].functions, ["ping"]);
420
+ });
421
+ test("splits core.fn items in functions lists", async () => {
422
+ await fs.writeFile(commandsFile, `
423
+ hello:
424
+ cmd: hello
425
+ functions: [core.greet, core.reply]
426
+ `, "utf8");
427
+ const config = await loadCommandsConfig(new Set(["core"]));
428
+ assert.ok(config);
429
+ assert.equal(config.specs[0].plugin, "core");
430
+ assert.deepEqual(config.specs[0].functions, ["greet", "reply"]);
431
+ });
432
+ test("splits canonical owner/plugin.fn items in subcommands", async () => {
433
+ await fs.writeFile(commandsFile, `
434
+ figurinha:
435
+ cmd: f
436
+ subcommands:
437
+ - cmd: criar
438
+ functions: [synt-xerror/figurinha.validarMidia, synt-xerror/figurinha.criarFigurinha]
439
+ `, "utf8");
440
+ const config = await loadCommandsConfig(new Set(["synt-xerror/figurinha"]));
441
+ assert.ok(config);
442
+ assert.equal(config.specs[0].plugin, "synt-xerror/figurinha");
443
+ assert.deepEqual(config.specs[0].subcommands[0].functions, [
444
+ "validarMidia",
445
+ "criarFigurinha",
446
+ ]);
447
+ });
448
+ test("keeps an unknown bare name verbatim (caller surfaces the miss)", async () => {
449
+ await fs.writeFile(commandsFile, `
450
+ hello:
451
+ cmd: hello
452
+ plugin: unknown-plugin
453
+ functions: [greet]
454
+ `, "utf8");
455
+ const config = await loadCommandsConfig(new Set(["synt-xerror/welcome", "de/welcome-test"]));
456
+ assert.ok(config);
457
+ assert.equal(config.specs[0].plugin, "unknown-plugin");
458
+ });
459
+ test("keeps a name verbatim when more than one owner matches (ambiguity surfaces at dispatch)", async () => {
460
+ await fs.writeFile(commandsFile, `
461
+ hello:
462
+ cmd: hello
463
+ plugin: shared
464
+ functions: [greet]
465
+ `, "utf8");
466
+ const config = await loadCommandsConfig(new Set(["alice/shared", "bob/shared"]));
467
+ assert.ok(config);
468
+ assert.equal(config.specs[0].plugin, "shared");
469
+ });
470
+ test("without validPluginKeys the value is used verbatim (legacy path)", async () => {
471
+ await fs.writeFile(commandsFile, `
472
+ hello:
473
+ cmd: hello
474
+ plugin: welcome
475
+ functions: [greet]
476
+ `, "utf8");
477
+ const config = await loadCommandsConfig();
478
+ assert.ok(config);
479
+ assert.equal(config.specs[0].plugin, "welcome");
480
+ });
481
+ });
482
+ });
@@ -75,10 +75,10 @@ function randomInt(min, max) {
75
75
  * failure would be mistaken for a real save and never retried.
76
76
  */
77
77
  async function addContact(contract, jid, name) {
78
- // `jid` here is this framework's internal "@c.us" form (see
79
- // getMsgSender()/normalizeJid()) Baileys needs the real wire JID or
80
- // it silently no-ops instead of throwing, which is exactly how this
81
- // went unnoticed before.
78
+ // `jid` here is LID-canonical (see getMsgSender()) Baileys needs the
79
+ // real wire JID or it silently no-ops instead of throwing, which is
80
+ // exactly how this went unnoticed before. toWireJid() passes @lid
81
+ // through unchanged, so no extra handling is needed here.
82
82
  const wireJid = toWireJid(jid);
83
83
  try {
84
84
  await contract.addOrEditContact(wireJid, {
@@ -101,7 +101,7 @@ async function addContact(contract, jid, name) {
101
101
  *
102
102
  * @param {WaContract} contract
103
103
  * @param {BotMessage} msg — driver-neutral incoming message envelope
104
- * @param {string} senderJid — normalized sender JID (never a group JID)
104
+ * @param {string|null} senderJid — normalized sender JID (LID-canonical; never a group JID), or null when no LID is known yet for this contact — those messages are skipped, since there's no stable id to track progress against
105
105
  * @param {boolean} isGroup — whether this message came from a group chat
106
106
  * @param {boolean} triggeredBot — true if this message invoked the bot
107
107
  * (command prefix). Ignored outside groups.
@@ -109,7 +109,7 @@ async function addContact(contract, jid, name) {
109
109
  export async function trackIncomingForContactSave(contract, msg, senderJid, isGroup, triggeredBot) {
110
110
  try {
111
111
  const pushName = msg.pushName?.trim();
112
- if (!pushName || senderJid.endsWith("@g.us"))
112
+ if (!pushName || !senderJid || senderJid.endsWith("@g.us"))
113
113
  return;
114
114
  let s = getSenderState(senderJid);
115
115
  // Completing a refresh takes priority over new-save logic, but in
@@ -0,0 +1,87 @@
1
+ import test, { describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { trackIncomingForContactSave } from "#kernel/contactAutoSave.js";
4
+ function createMockContract() {
5
+ const saved = [];
6
+ return {
7
+ savedContacts: saved,
8
+ addOrEditContact: async (jid, info) => {
9
+ saved.push({ jid, name: info.fullName || "" });
10
+ },
11
+ name: "baileys",
12
+ isReady: () => true,
13
+ connect: async () => { },
14
+ disconnect: async () => { },
15
+ me: () => ({ id: "123@c.us" }),
16
+ sendText: async () => ({ id: "msg", chatId: "123@c.us", timestamp: Date.now() }),
17
+ sendImage: async () => ({ id: "img", chatId: "123@c.us", timestamp: Date.now() }),
18
+ sendVideo: async () => ({ id: "vid", chatId: "123@c.us", timestamp: Date.now() }),
19
+ sendAudio: async () => ({ id: "aud", chatId: "123@c.us", timestamp: Date.now() }),
20
+ sendDocument: async () => ({ id: "doc", chatId: "123@c.us", timestamp: Date.now() }),
21
+ sendSticker: async () => ({ id: "stk", chatId: "123@c.us", timestamp: Date.now() }),
22
+ sendLocation: async () => ({ id: "loc", chatId: "123@c.us", timestamp: Date.now() }),
23
+ sendContact: async () => ({ id: "cnt", chatId: "123@c.us", timestamp: Date.now() }),
24
+ sendReaction: async () => { },
25
+ sendPoll: async () => ({ id: "pll", chatId: "123@c.us", timestamp: Date.now() }),
26
+ react: async () => { },
27
+ deleteMessage: async () => { },
28
+ editMessage: async () => { },
29
+ sendPresenceUpdate: async () => { },
30
+ readMessages: async () => { },
31
+ onWhatsApp: async () => null,
32
+ getBusinessProfile: async () => null,
33
+ profilePictureUrl: async () => null,
34
+ fetchStatus: async () => null,
35
+ updateBlockStatus: async () => { },
36
+ removeContact: async () => { },
37
+ groupMetadata: async () => ({ subject: "Test Group", participants: [] }),
38
+ groupParticipantsUpdate: async () => [],
39
+ groupUpdateSubject: async () => { },
40
+ groupUpdateDescription: async () => { },
41
+ groupInviteCode: async () => "",
42
+ groupRevokeInvite: async () => "",
43
+ updateProfilePicture: async () => { },
44
+ updateProfileName: async () => { },
45
+ updateProfileStatus: async () => { },
46
+ downloadMedia: async () => null,
47
+ on: () => () => { },
48
+ };
49
+ }
50
+ describe("kernel/contactAutoSave", () => {
51
+ test("accumulates DM messages and triggers addOrEditContact when target reached", async () => {
52
+ const contract = createMockContract();
53
+ const jid = "5511999991111@c.us";
54
+ const msg = {
55
+ id: "1",
56
+ chatId: jid,
57
+ fromMe: false,
58
+ timestamp: Date.now(),
59
+ text: "hello",
60
+ pushName: "Alice",
61
+ raw: {},
62
+ };
63
+ // Send up to 6 DM messages (max target range is 3-6)
64
+ for (let i = 0; i < 6; i++) {
65
+ await trackIncomingForContactSave(contract, msg, jid, false, false);
66
+ }
67
+ assert.ok(contract.savedContacts.length > 0);
68
+ assert.equal(contract.savedContacts[0].name, "Alice");
69
+ });
70
+ test("ignores silent group messages (triggeredBot = false)", async () => {
71
+ const contract = createMockContract();
72
+ const jid = "5511999992222@c.us";
73
+ const msg = {
74
+ id: "2",
75
+ chatId: "group1@g.us",
76
+ fromMe: false,
77
+ timestamp: Date.now(),
78
+ text: "just chatting",
79
+ pushName: "Bob",
80
+ raw: {},
81
+ };
82
+ for (let i = 0; i < 10; i++) {
83
+ await trackIncomingForContactSave(contract, msg, jid, true, false);
84
+ }
85
+ assert.equal(contract.savedContacts.length, 0);
86
+ });
87
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * coreCommands.ts
3
+ *
4
+ * Built-in command handlers live in the kernel namespace, independently of
5
+ * the external plugin registry and manyplug configuration.
6
+ */
7
+ // Mirrors src/locales/*.json — the only languages with a translation file.
8
+ const AVAILABLE_LOCALES = ["pt", "en", "es"];
9
+ const handlers = {
10
+ ping: async (ctx) => {
11
+ await ctx.send.text("pong");
12
+ },
13
+ status: async (ctx) => {
14
+ await ctx.send.text("ManyBot está online.");
15
+ },
16
+ // `!configurar` / `!config` / `!cfg` (no subcommand) — shows the
17
+ // current per-chat overrides. Persisted via ctx.settings, which
18
+ // (per pluginApi.ts) is already scoped to plugin "core" + the
19
+ // current chat — same storage pattern already used for
20
+ // last_welcome_seen in commandMenu.ts.
21
+ setChatConfig: async (ctx) => {
22
+ const pctx = ctx;
23
+ const prefix = pctx.settings.get("chat_prefix", null);
24
+ const locale = pctx.settings.get("chat_locale", null);
25
+ await pctx.send.text("⚙️ Configuração deste chat:\n" +
26
+ `• Prefixo: ${prefix ?? "(padrão)"}\n` +
27
+ `• Idioma: ${locale ?? "(padrão)"}\n\n` +
28
+ "Use !config prefixo <novo> ou !config idioma <pt|en|es> para alterar.");
29
+ },
30
+ // `!config prefixo <novo>`
31
+ setChatPrefix: async (ctx, input) => {
32
+ const pctx = ctx;
33
+ const value = input?.args?.[0];
34
+ if (!value || value.length > 5) {
35
+ await pctx.send.text("Uso: !config prefixo <novo prefixo> (até 5 caracteres)");
36
+ return;
37
+ }
38
+ pctx.settings.set("chat_prefix", value);
39
+ await pctx.send.text(`✅ Prefixo salvo como "${value}" para este chat.\n` +
40
+ `A partir de agora, use "${value}" em vez do prefixo padrão neste chat.`);
41
+ },
42
+ // `!config idioma <pt|en|es>`
43
+ setChatLocale: async (ctx, input) => {
44
+ const pctx = ctx;
45
+ const value = input?.args?.[0]?.toLowerCase();
46
+ if (!value || !AVAILABLE_LOCALES.includes(value)) {
47
+ await pctx.send.text(`Uso: !config idioma <${AVAILABLE_LOCALES.join("|")}>`);
48
+ return;
49
+ }
50
+ pctx.settings.set("chat_locale", value);
51
+ await pctx.send.text(`✅ Idioma salvo como "${value}" para este chat.\n` +
52
+ "As mensagens do sistema de comandos (menu, permissões, avisos de uso) passam a usar esse idioma neste chat.");
53
+ },
54
+ };
55
+ export function resolveCoreCommandHandler(name) {
56
+ return handlers[name] ?? (async () => {
57
+ throw new Error(`Core handler "${name}" is not registered`);
58
+ });
59
+ }
60
+ export function registerCoreCommand(name, handler) {
61
+ handlers[name] = handler;
62
+ }