@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,445 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test, beforeEach, afterEach } from "node:test";
3
+ import { createStore } from "#client/store.js";
4
+ import { handleMessage } from "#drivers/baileys/messageHandler.js";
5
+ import { pluginRegistry } from "#kernel/pluginLoader.js";
6
+ import { buildCommandRegistry, __setRegistryForTests } from "#kernel/commandRegistry.js";
7
+ import { getDriverManager, _resetDriverManagerForTests } from "#kernel/driverManager.js";
8
+ import { buildSettingsApi } from "#kernel/settingsDb.js";
9
+ import { CONFIG } from "#config";
10
+ // Pin the "no override" global language independently of whatever
11
+ // ~/.manybot/manybot.toml happens to say on the machine running the
12
+ // suite — CONFIG.LANGUAGE is read from the real config dir unless
13
+ // MANYBOT_CONFIG_DIR is set, and i18n's `currentLang` locks in on the
14
+ // first t()/tFor() call, so this must run before any test does.
15
+ CONFIG.LANGUAGE = "en";
16
+ // ── Minimal spec builders (mirrors kernel/runCommand.test.ts conventions) ──
17
+ function emptySpec(overrides) {
18
+ return {
19
+ id: overrides.id ?? "task::run",
20
+ cmd: overrides.cmd ?? "task",
21
+ aliases: overrides.aliases ?? [],
22
+ plugin: overrides.plugin ?? "taskPlugin",
23
+ functions: overrides.functions ?? ["runFn"],
24
+ loading: overrides.loading ?? null,
25
+ text: overrides.text ?? null,
26
+ desc: overrides.desc ?? null,
27
+ category: overrides.category ?? null,
28
+ group: overrides.group ?? null,
29
+ manual: overrides.manual ?? null,
30
+ deprecatedMessage: overrides.deprecatedMessage ?? null,
31
+ notifyChanges: overrides.notifyChanges ?? null,
32
+ permissions: overrides.permissions ?? null,
33
+ messages: overrides.messages ?? null,
34
+ arguments: overrides.arguments ?? [],
35
+ subcommands: overrides.subcommands ?? [],
36
+ };
37
+ }
38
+ function emptySub(overrides) {
39
+ return {
40
+ id: overrides.id ?? "task::list",
41
+ cmd: overrides.cmd ?? "list",
42
+ aliases: overrides.aliases ?? [],
43
+ functions: overrides.functions ?? null,
44
+ loading: overrides.loading ?? null,
45
+ desc: overrides.desc ?? null,
46
+ manual: overrides.manual ?? null,
47
+ arguments: overrides.arguments ?? [],
48
+ permissions: overrides.permissions ?? null,
49
+ messages: overrides.messages ?? null,
50
+ };
51
+ }
52
+ // ── Mock WaContract — only the surface handleMessage's pipeline touches ────
53
+ function createMockContract() {
54
+ const sentTexts = [];
55
+ const reactions = [];
56
+ const presences = [];
57
+ const deletions = [];
58
+ const edits = [];
59
+ const sentHistory = [];
60
+ let msgSeq = 0;
61
+ const contract = {
62
+ name: "baileys",
63
+ connect: async () => { },
64
+ disconnect: async () => { },
65
+ isReady: () => true,
66
+ resolveLid: async () => null,
67
+ on: () => () => { },
68
+ sendText: async (jid, text) => {
69
+ sentTexts.push({ jid, text });
70
+ const id = `msg-${++msgSeq}`;
71
+ sentHistory.push({
72
+ id,
73
+ chatId: jid,
74
+ fromMe: true,
75
+ contentHash: "hash",
76
+ timestamp: Date.now(),
77
+ type: "text",
78
+ body: text,
79
+ fromPn: "5511900000000@s.whatsapp.net",
80
+ });
81
+ return { id, chatId: jid, timestamp: Date.now() };
82
+ },
83
+ sendImage: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
84
+ sendVideo: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
85
+ sendAudio: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
86
+ sendSticker: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
87
+ sendDocument: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
88
+ sendPoll: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
89
+ react: async (_jid, _key, emoji) => {
90
+ reactions.push({ jid: _jid, emoji });
91
+ },
92
+ deleteMessage: async (_jid, _key, forEveryone) => {
93
+ deletions.push({ jid: _jid, forEveryone });
94
+ },
95
+ editMessage: async (_jid, key, text) => {
96
+ edits.push({ jid: _jid, id: key.id ?? "", text });
97
+ },
98
+ sendPresenceUpdate: async (state, jid) => {
99
+ presences.push({ state, jid });
100
+ },
101
+ readMessages: async () => { },
102
+ onWhatsApp: async () => [{ exists: true }],
103
+ getBusinessProfile: async () => null,
104
+ profilePictureUrl: async () => null,
105
+ fetchStatus: async () => null,
106
+ updateBlockStatus: async () => { },
107
+ addOrEditContact: async () => { },
108
+ removeContact: async () => { },
109
+ groupMetadata: async () => ({ subject: "Group", participants: [] }),
110
+ groupParticipantsUpdate: async () => [],
111
+ groupUpdateSubject: async () => { },
112
+ groupUpdateDescription: async () => { },
113
+ groupInviteCode: async () => "",
114
+ groupRevokeInvite: async () => "",
115
+ updateProfilePicture: async () => { },
116
+ updateProfileName: async () => { },
117
+ updateProfileStatus: async () => { },
118
+ me: () => ({ id: "5511900000000@s.whatsapp.net" }),
119
+ downloadMedia: async () => null,
120
+ // Mirrors real Baileys behavior: sendText's result lands in history
121
+ // immediately (sendFallbackGuard's verifyDelivery does a t=0 lookup
122
+ // here to confirm the send before the caller's await resolves).
123
+ getHistory: async (jid) => sentHistory.filter(m => m.chatId === jid),
124
+ };
125
+ return { contract, sentTexts, reactions, presences, deletions, edits };
126
+ }
127
+ function makeBotMessage(overrides = {}) {
128
+ return {
129
+ id: `in-${Math.random().toString(36).slice(2)}`,
130
+ chatId: "5511888888888@s.whatsapp.net",
131
+ fromMe: false,
132
+ contentHash: "hash",
133
+ timestamp: Date.now(),
134
+ type: "text",
135
+ body: "!task",
136
+ fromPn: "5511888888888@s.whatsapp.net",
137
+ ...overrides,
138
+ };
139
+ }
140
+ function buildRegistry(specs, menu) {
141
+ if (!menu)
142
+ return buildCommandRegistry(specs, pluginRegistry);
143
+ return buildCommandRegistry(specs, pluginRegistry, undefined, {
144
+ title: "ManyBot — Menu",
145
+ intro: "Help menu",
146
+ footer: null,
147
+ cmd: "help",
148
+ aliases: ["help", "menu"],
149
+ notFoundFallback: false,
150
+ welcomeMessage: null,
151
+ welcomeWindowDays: 3,
152
+ pageSize: 15,
153
+ ...menu,
154
+ });
155
+ }
156
+ describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
157
+ let store;
158
+ let contract;
159
+ let sentTexts;
160
+ let reactions;
161
+ let presences;
162
+ let deletions;
163
+ let edits;
164
+ let runCalls;
165
+ let listCalls;
166
+ beforeEach(() => {
167
+ _resetDriverManagerForTests();
168
+ store = createStore();
169
+ const mock = createMockContract();
170
+ contract = mock.contract;
171
+ sentTexts = mock.sentTexts;
172
+ reactions = mock.reactions;
173
+ presences = mock.presences;
174
+ deletions = mock.deletions;
175
+ edits = mock.edits;
176
+ getDriverManager().register(contract, { isPrimary: true });
177
+ runCalls = [];
178
+ listCalls = [];
179
+ const plugin = {
180
+ name: "taskPlugin",
181
+ status: "active",
182
+ run: null,
183
+ setup: null,
184
+ exports: {},
185
+ error: null,
186
+ guardOptions: {},
187
+ commands: {
188
+ runFn: {
189
+ cmd: "task",
190
+ aliases: [],
191
+ handler: async (ctx, input) => {
192
+ runCalls.push(input);
193
+ const c = ctx;
194
+ await c.send.text("done");
195
+ },
196
+ },
197
+ listFn: {
198
+ cmd: "list",
199
+ aliases: [],
200
+ handler: async (_ctx, input) => {
201
+ listCalls.push(input);
202
+ },
203
+ },
204
+ crashFn: {
205
+ cmd: "task",
206
+ aliases: [],
207
+ handler: async () => {
208
+ throw new Error("plugin exploded");
209
+ },
210
+ },
211
+ },
212
+ };
213
+ pluginRegistry.set("taskPlugin", plugin);
214
+ });
215
+ afterEach(() => {
216
+ pluginRegistry.delete("taskPlugin");
217
+ __setRegistryForTests(null);
218
+ _resetDriverManagerForTests();
219
+ });
220
+ test("routes a matched subcommand to the subcommand handler, not the parent's, and replies", async () => {
221
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
222
+ __setRegistryForTests(buildRegistry([spec]));
223
+ const msg = makeBotMessage({ body: "!task list" });
224
+ await handleMessage(msg, contract, store);
225
+ assert.equal(listCalls.length, 1, "subcommand handler was invoked");
226
+ assert.equal(runCalls.length, 0, "parent handler was not invoked for the subcommand token");
227
+ });
228
+ test("matched parent command dispatches through runCommand and sends its reply", async () => {
229
+ __setRegistryForTests(buildRegistry([emptySpec({})]));
230
+ const msg = makeBotMessage({ body: "!task" });
231
+ await handleMessage(msg, contract, store);
232
+ assert.equal(runCalls.length, 1, "parent handler ran");
233
+ assert.equal(sentTexts.length, 1);
234
+ assert.equal(sentTexts[0].text, "done");
235
+ });
236
+ // Regression test for the bug where "core" (ping/status/config/...)
237
+ // was never actually reachable from handleMessage(): it only ever
238
+ // existed in a *local* allPlugins map built inside commandRegistry.ts
239
+ // for registry-building purposes, never in the real pluginRegistry
240
+ // singleton the dispatch loop iterates — so runCommand() was never
241
+ // called for any core.* command, even though the registry itself
242
+ // resolved them fine (only !menu, which has its own separate dispatch
243
+ // path, worked). This must keep passing with 0 real plugins active.
244
+ test("a matched command sourced from the 'core' pseudo-plugin still dispatches and replies", async () => {
245
+ pluginRegistry.delete("taskPlugin"); // 0 real plugins active, same as the reported bug
246
+ const spec = emptySpec({
247
+ id: "core::ping",
248
+ cmd: "ping",
249
+ plugin: "core",
250
+ functions: ["ping"],
251
+ });
252
+ __setRegistryForTests(buildRegistry([spec]));
253
+ const msg = makeBotMessage({ body: "!ping" });
254
+ await handleMessage(msg, contract, store);
255
+ assert.equal(sentTexts.length, 1, "core.ping replied");
256
+ assert.equal(sentTexts[0].text, "pong");
257
+ // No restore needed: the next test's beforeEach() re-registers
258
+ // "taskPlugin" fresh regardless of what this test left behind.
259
+ });
260
+ test("a crash inside the matched command's handler is swallowed at the messageHandler boundary", async () => {
261
+ const spec = emptySpec({ id: "task::crash", cmd: "crashcmd", functions: ["crashFn"] });
262
+ __setRegistryForTests(buildRegistry([spec]));
263
+ const msg = makeBotMessage({ body: "!crashcmd" });
264
+ // The whole point of this session's fix: runCommand() now genuinely
265
+ // throws (rethrow: true) so Phase-8's fireAlert runs, but
266
+ // messageHandler.ts wraps that call in its own try/catch so the
267
+ // per-message plugin loop — and handleMessage() itself — must never
268
+ // propagate the error to the caller.
269
+ await assert.doesNotReject(() => handleMessage(msg, contract, store));
270
+ assert.equal(sentTexts.length, 0, "no reply is sent for a crashed handler");
271
+ });
272
+ test("the message loop keeps working for the next message after a handler crash", async () => {
273
+ const crashSpec = emptySpec({ id: "task::crash", cmd: "crashcmd", functions: ["crashFn"] });
274
+ __setRegistryForTests(buildRegistry([crashSpec]));
275
+ await handleMessage(makeBotMessage({ body: "!crashcmd" }), contract, store);
276
+ // Swap in a healthy command and confirm a later message still dispatches
277
+ // normally — the crash must not have left the plugin loop, the driver
278
+ // manager, or the registry in a broken state.
279
+ __setRegistryForTests(buildRegistry([emptySpec({})]));
280
+ await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
281
+ assert.equal(runCalls.length, 1);
282
+ assert.equal(sentTexts.length, 1);
283
+ assert.equal(sentTexts[0].text, "done");
284
+ });
285
+ // ── Loading indicator wiring ───────────────────────────────────────────
286
+ describe("loading indicator (v6 reaction/typing/spinner)", () => {
287
+ test("reaction: drops the icon then clears on success", async () => {
288
+ const spec = emptySpec({ loading: { type: "reaction", icon: "🛠" } });
289
+ __setRegistryForTests(buildRegistry([spec]));
290
+ await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
291
+ assert.equal(reactions.length, 2, "start + final reaction");
292
+ assert.equal(reactions[0].emoji, "🛠");
293
+ assert.equal(reactions[1].emoji, "");
294
+ });
295
+ test("reaction: replaces with onSuccess emoji on success", async () => {
296
+ const spec = emptySpec({ loading: { type: "reaction", icon: "⏳", onSuccess: "✅" } });
297
+ __setRegistryForTests(buildRegistry([spec]));
298
+ await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
299
+ assert.equal(reactions.at(-1)?.emoji, "✅");
300
+ });
301
+ test("typing: emits composing presence + paused on completion", async () => {
302
+ const spec = emptySpec({ loading: { type: "typing" } });
303
+ __setRegistryForTests(buildRegistry([spec]));
304
+ await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
305
+ assert.ok(presences.some(p => p.state === "composing"), "composing presence was emitted");
306
+ assert.ok(presences.some(p => p.state === "paused"), "paused presence was emitted at end");
307
+ });
308
+ test("none: never calls react or sendPresenceUpdate for the indicator", async () => {
309
+ const spec = emptySpec({ loading: { type: "none" } });
310
+ __setRegistryForTests(buildRegistry([spec]));
311
+ await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
312
+ // reaction is never called — the loading indicator itself is a no-op.
313
+ assert.equal(reactions.length, 0, "no reactions emitted");
314
+ // Presence still shows up from simulateState() inside ctx.send.text()
315
+ // (api/index.ts) humanizing every outbound send.
316
+ // A "none" loading spec must add zero presence calls on top of that.
317
+ assert.equal(presences.filter(p => p.state === "paused" && p.jid === "5511888888888@s.whatsapp.net").length, 1, "paused presence came only from simulateState path");
318
+ });
319
+ test("spinner: sends a frame message and edits it on completion", async () => {
320
+ const spec = emptySpec({
321
+ loading: {
322
+ type: "spinner",
323
+ frames: ["⏳", "⌛"],
324
+ intervalMs: 60000, // disable the interval so the test is deterministic
325
+ onSuccess: "ready",
326
+ },
327
+ });
328
+ __setRegistryForTests(buildRegistry([spec]));
329
+ await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
330
+ // The first sendText from the spinner carries the first frame.
331
+ const spinnerSent = sentTexts.find(s => s.text === "⏳");
332
+ assert.ok(spinnerSent, "spinner sent the first frame");
333
+ assert.ok(edits.some(e => e.text === "ready"), "spinner was edited with onSuccess text");
334
+ assert.equal(deletions.length, 0, "onSuccess set: spinner is edited, not deleted");
335
+ });
336
+ });
337
+ // ── Welcome message gating ──────────────────────────────────────────────
338
+ // Regression coverage for the false-positive welcome: history-sync
339
+ // replays the bot's own past outgoing messages as `messages.upsert`
340
+ // (fromMe=true) on every reconnect, and welcome must never fire for a
341
+ // group chat either — only for a genuinely incoming DM.
342
+ describe("welcome message gating", () => {
343
+ const WELCOME_TEXT = "Bem-vindo! Use {prefix}help.";
344
+ function buildWelcomeRegistry() {
345
+ return buildRegistry([emptySpec({})], {
346
+ welcomeMessage: WELCOME_TEXT,
347
+ welcomeWindowDays: 3,
348
+ });
349
+ }
350
+ // Each test uses its own sender so the shared (in-memory) settings DB
351
+ // never leaks "already seen" state between tests, and clears it
352
+ // up front regardless, mirroring kernel/commandMenu.test.ts.
353
+ function freshSender(id) {
354
+ const jid = `55110000${id}@s.whatsapp.net`;
355
+ buildSettingsApi("kernel", jid).delete("last_welcome_seen");
356
+ return { chatId: jid, fromPn: jid };
357
+ }
358
+ test("fires on a genuine first incoming DM", async () => {
359
+ const { chatId, fromPn } = freshSender("0001");
360
+ __setRegistryForTests(buildWelcomeRegistry());
361
+ await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "oi" }), contract, store);
362
+ assert.equal(sentTexts.length, 1, "welcome was sent");
363
+ assert.equal(sentTexts[0].text, "Bem-vindo! Use !help.");
364
+ });
365
+ test("does not fire for a history-sync replay of the bot's own message (fromMe=true)", async () => {
366
+ const { chatId, fromPn } = freshSender("0002");
367
+ __setRegistryForTests(buildWelcomeRegistry());
368
+ await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: true, body: "oi" }), contract, store);
369
+ assert.equal(sentTexts.length, 0, "no welcome sent for a fromMe message");
370
+ });
371
+ test("does not fire for a message received in a group chat", async () => {
372
+ const { fromPn } = freshSender("0003");
373
+ const groupChatId = "120363000000000000@g.us";
374
+ buildSettingsApi("kernel", groupChatId).delete("last_welcome_seen");
375
+ __setRegistryForTests(buildWelcomeRegistry());
376
+ await handleMessage(makeBotMessage({ chatId: groupChatId, fromPn, fromMe: false, body: "oi" }), contract, store);
377
+ assert.equal(sentTexts.length, 0, "no welcome sent for a group message");
378
+ });
379
+ test("does not fire twice within the welcome window for the same sender", async () => {
380
+ const { chatId, fromPn } = freshSender("0004");
381
+ __setRegistryForTests(buildWelcomeRegistry());
382
+ await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "oi" }), contract, store);
383
+ await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "oi de novo" }), contract, store);
384
+ assert.equal(sentTexts.length, 1, "welcome only sent once inside the window");
385
+ });
386
+ test("does not fire when the registry has no welcomeMessage configured", async () => {
387
+ const { chatId, fromPn } = freshSender("0005");
388
+ __setRegistryForTests(buildRegistry([emptySpec({})])); // default menu: welcomeMessage: null
389
+ await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "oi" }), contract, store);
390
+ assert.equal(sentTexts.length, 0, "no welcome sent when unconfigured");
391
+ });
392
+ test("does not fire for an offline-flush misclassified receipt message (empty body)", async () => {
393
+ const { chatId, fromPn } = freshSender("0006");
394
+ __setRegistryForTests(buildWelcomeRegistry());
395
+ await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "" }), contract, store);
396
+ assert.equal(sentTexts.length, 0, "no welcome sent for an empty body");
397
+ });
398
+ test("does not fire for an offline-flush delayed message (stale timestamp)", async () => {
399
+ const { chatId, fromPn } = freshSender("0007");
400
+ __setRegistryForTests(buildWelcomeRegistry());
401
+ const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
402
+ await handleMessage(makeBotMessage({ chatId, fromPn, fromMe: false, body: "oi", timestamp: twoHoursAgo }), contract, store);
403
+ assert.equal(sentTexts.length, 0, "no welcome sent for a stale message");
404
+ });
405
+ });
406
+ // ── Per-chat !config overrides (prefix / language) ─────────────────────
407
+ describe("per-chat overrides (!config prefixo / !config idioma)", () => {
408
+ // makeBotMessage()'s default chatId ("5511888888888@s.whatsapp.net")
409
+ // normalized the same way buildApi() scopes ctx.settings.
410
+ const normalizedChatId = "5511888888888@c.us";
411
+ afterEach(() => {
412
+ buildSettingsApi("core", normalizedChatId).deleteAll();
413
+ });
414
+ test("a saved prefix override changes which prefix the bot actually parses", async () => {
415
+ buildSettingsApi("core", normalizedChatId).set("chat_prefix", "#");
416
+ __setRegistryForTests(buildRegistry([emptySpec({})]));
417
+ await handleMessage(makeBotMessage({ body: "#task" }), contract, store);
418
+ assert.equal(runCalls.length, 1, "the new '#' prefix dispatches the command");
419
+ assert.equal(sentTexts[0].text, "done");
420
+ await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
421
+ assert.equal(runCalls.length, 1, "the old '!' prefix no longer matches for this chat");
422
+ });
423
+ test("without an override the global prefix still works", async () => {
424
+ __setRegistryForTests(buildRegistry([emptySpec({})]));
425
+ await handleMessage(makeBotMessage({ body: "!task" }), contract, store);
426
+ assert.equal(runCalls.length, 1);
427
+ assert.equal(sentTexts[0].text, "done");
428
+ });
429
+ test("a saved language override changes the language of a kernel-authored reply (unknown subcommand)", async () => {
430
+ buildSettingsApi("core", normalizedChatId).set("chat_locale", "pt");
431
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
432
+ __setRegistryForTests(buildRegistry([spec]));
433
+ await handleMessage(makeBotMessage({ body: "!task wat" }), contract, store);
434
+ assert.equal(sentTexts.length, 1);
435
+ assert.match(sentTexts[0].text, /Subcomando "wat" desconhecido/);
436
+ });
437
+ test("without an override the unknown-subcommand reply uses the global language", async () => {
438
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
439
+ __setRegistryForTests(buildRegistry([spec]));
440
+ await handleMessage(makeBotMessage({ body: "!task wat" }), contract, store);
441
+ assert.equal(sentTexts.length, 1);
442
+ assert.match(sentTexts[0].text, /Unknown subcommand "wat"/);
443
+ });
444
+ });
445
+ });