@manybot/manybot 5.8.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 (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 +212 -38
  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,6 +5,14 @@ import { handleMessage } from "#drivers/baileys/messageHandler.js";
5
5
  import { pluginRegistry } from "#kernel/pluginLoader.js";
6
6
  import { buildCommandRegistry, __setRegistryForTests } from "#kernel/commandRegistry.js";
7
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";
8
16
  // ── Minimal spec builders (mirrors kernel/runCommand.test.ts conventions) ──
9
17
  function emptySpec(overrides) {
10
18
  return {
@@ -12,7 +20,8 @@ function emptySpec(overrides) {
12
20
  cmd: overrides.cmd ?? "task",
13
21
  aliases: overrides.aliases ?? [],
14
22
  plugin: overrides.plugin ?? "taskPlugin",
15
- function: overrides.function ?? "runFn",
23
+ functions: overrides.functions ?? ["runFn"],
24
+ loading: overrides.loading ?? null,
16
25
  text: overrides.text ?? null,
17
26
  desc: overrides.desc ?? null,
18
27
  category: overrides.category ?? null,
@@ -31,7 +40,8 @@ function emptySub(overrides) {
31
40
  id: overrides.id ?? "task::list",
32
41
  cmd: overrides.cmd ?? "list",
33
42
  aliases: overrides.aliases ?? [],
34
- function: overrides.function ?? null,
43
+ functions: overrides.functions ?? null,
44
+ loading: overrides.loading ?? null,
35
45
  desc: overrides.desc ?? null,
36
46
  manual: overrides.manual ?? null,
37
47
  arguments: overrides.arguments ?? [],
@@ -42,6 +52,11 @@ function emptySub(overrides) {
42
52
  // ── Mock WaContract — only the surface handleMessage's pipeline touches ────
43
53
  function createMockContract() {
44
54
  const sentTexts = [];
55
+ const reactions = [];
56
+ const presences = [];
57
+ const deletions = [];
58
+ const edits = [];
59
+ const sentHistory = [];
45
60
  let msgSeq = 0;
46
61
  const contract = {
47
62
  name: "baileys",
@@ -52,7 +67,18 @@ function createMockContract() {
52
67
  on: () => () => { },
53
68
  sendText: async (jid, text) => {
54
69
  sentTexts.push({ jid, text });
55
- return { id: `msg-${++msgSeq}`, chatId: jid, timestamp: Date.now() };
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() };
56
82
  },
57
83
  sendImage: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
58
84
  sendVideo: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
@@ -60,10 +86,18 @@ function createMockContract() {
60
86
  sendSticker: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
61
87
  sendDocument: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
62
88
  sendPoll: async () => ({ id: "x", chatId: "x", timestamp: Date.now() }),
63
- react: async () => { },
64
- deleteMessage: async () => { },
65
- editMessage: async () => { },
66
- sendPresenceUpdate: async () => { },
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
+ },
67
101
  readMessages: async () => { },
68
102
  onWhatsApp: async () => [{ exists: true }],
69
103
  getBusinessProfile: async () => null,
@@ -83,9 +117,12 @@ function createMockContract() {
83
117
  updateProfileStatus: async () => { },
84
118
  me: () => ({ id: "5511900000000@s.whatsapp.net" }),
85
119
  downloadMedia: async () => null,
86
- getHistory: async () => [],
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),
87
124
  };
88
- return { contract, sentTexts };
125
+ return { contract, sentTexts, reactions, presences, deletions, edits };
89
126
  }
90
127
  function makeBotMessage(overrides = {}) {
91
128
  return {
@@ -100,13 +137,30 @@ function makeBotMessage(overrides = {}) {
100
137
  ...overrides,
101
138
  };
102
139
  }
103
- function buildRegistry(specs) {
104
- return buildCommandRegistry(specs, pluginRegistry);
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
+ });
105
155
  }
106
156
  describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
107
157
  let store;
108
158
  let contract;
109
159
  let sentTexts;
160
+ let reactions;
161
+ let presences;
162
+ let deletions;
163
+ let edits;
110
164
  let runCalls;
111
165
  let listCalls;
112
166
  beforeEach(() => {
@@ -115,6 +169,10 @@ describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
115
169
  const mock = createMockContract();
116
170
  contract = mock.contract;
117
171
  sentTexts = mock.sentTexts;
172
+ reactions = mock.reactions;
173
+ presences = mock.presences;
174
+ deletions = mock.deletions;
175
+ edits = mock.edits;
118
176
  getDriverManager().register(contract, { isPrimary: true });
119
177
  runCalls = [];
120
178
  listCalls = [];
@@ -160,7 +218,7 @@ describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
160
218
  _resetDriverManagerForTests();
161
219
  });
162
220
  test("routes a matched subcommand to the subcommand handler, not the parent's, and replies", async () => {
163
- const spec = emptySpec({ subcommands: [emptySub({ function: "listFn" })] });
221
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
164
222
  __setRegistryForTests(buildRegistry([spec]));
165
223
  const msg = makeBotMessage({ body: "!task list" });
166
224
  await handleMessage(msg, contract, store);
@@ -175,8 +233,32 @@ describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
175
233
  assert.equal(sentTexts.length, 1);
176
234
  assert.equal(sentTexts[0].text, "done");
177
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
+ });
178
260
  test("a crash inside the matched command's handler is swallowed at the messageHandler boundary", async () => {
179
- const spec = emptySpec({ id: "task::crash", cmd: "crashcmd", function: "crashFn" });
261
+ const spec = emptySpec({ id: "task::crash", cmd: "crashcmd", functions: ["crashFn"] });
180
262
  __setRegistryForTests(buildRegistry([spec]));
181
263
  const msg = makeBotMessage({ body: "!crashcmd" });
182
264
  // The whole point of this session's fix: runCommand() now genuinely
@@ -188,7 +270,7 @@ describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
188
270
  assert.equal(sentTexts.length, 0, "no reply is sent for a crashed handler");
189
271
  });
190
272
  test("the message loop keeps working for the next message after a handler crash", async () => {
191
- const crashSpec = emptySpec({ id: "task::crash", cmd: "crashcmd", function: "crashFn" });
273
+ const crashSpec = emptySpec({ id: "task::crash", cmd: "crashcmd", functions: ["crashFn"] });
192
274
  __setRegistryForTests(buildRegistry([crashSpec]));
193
275
  await handleMessage(makeBotMessage({ body: "!crashcmd" }), contract, store);
194
276
  // Swap in a healthy command and confirm a later message still dispatches
@@ -200,4 +282,164 @@ describe("drivers/baileys/messageHandler — v6 runCommand dispatch", () => {
200
282
  assert.equal(sentTexts.length, 1);
201
283
  assert.equal(sentTexts[0].text, "done");
202
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
+ });
203
445
  });
@@ -40,6 +40,64 @@ test("Baileys sends messages with the chat's disappearing-message timer", async
40
40
  await contract.sendText("group@g.us", "group text");
41
41
  assert.equal((calls.at(-1)?.[2]).ephemeralExpiration, 604800);
42
42
  });
43
+ test("toBotMessage resolves LID/PN by suffix, not by field position (addressingMode 'lid')", async () => {
44
+ // Modern default WhatsApp addressing: `participant` is ALREADY the LID,
45
+ // `participantAlt` carries the PN companion — the reverse of the legacy
46
+ // "pn" mode. See https://baileys.wiki/concepts/jids.
47
+ const store = createStore();
48
+ const sock = { ev: new EventEmitter(), user: { id: "bot@s.whatsapp.net" } };
49
+ const { contract } = createBaileysAdapter({ sock, store });
50
+ const received = [];
51
+ contract.on("messages.upsert", (payload) => received.push(payload));
52
+ sock.ev.emit("messages.upsert", {
53
+ type: "notify",
54
+ messages: [{
55
+ key: {
56
+ remoteJid: "120363999999999999@g.us",
57
+ fromMe: false,
58
+ id: "MSG1",
59
+ participant: "98765@lid", // already LID
60
+ participantAlt: "5511999999999@s.whatsapp.net", // PN companion
61
+ addressingMode: "lid",
62
+ },
63
+ messageTimestamp: 1700000000,
64
+ pushName: "Alice",
65
+ message: { conversation: "oi" },
66
+ }],
67
+ });
68
+ const batch = received[0];
69
+ const msg = batch.messages[0];
70
+ assert.equal(msg.fromLid, "98765@lid", "fromLid must be the value that's actually @lid, regardless of which field it came from");
71
+ assert.equal(msg.fromPn, "5511999999999@s.whatsapp.net", "fromPn must be the value that's actually NOT @lid");
72
+ assert.equal(msg.participantAlt, "98765@lid", "participantAlt (consumed directly by getMsgSender) must also be suffix-verified");
73
+ });
74
+ test("toBotMessage resolves LID/PN by suffix, not by field position (legacy addressingMode 'pn')", async () => {
75
+ const store = createStore();
76
+ const sock = { ev: new EventEmitter(), user: { id: "bot@s.whatsapp.net" } };
77
+ const { contract } = createBaileysAdapter({ sock, store });
78
+ const received = [];
79
+ contract.on("messages.upsert", (payload) => received.push(payload));
80
+ sock.ev.emit("messages.upsert", {
81
+ type: "notify",
82
+ messages: [{
83
+ key: {
84
+ remoteJid: "120363999999999999@g.us",
85
+ fromMe: false,
86
+ id: "MSG2",
87
+ participant: "5511999999999@s.whatsapp.net", // PN
88
+ participantAlt: "98765@lid", // LID companion
89
+ addressingMode: "pn",
90
+ },
91
+ messageTimestamp: 1700000000,
92
+ pushName: "Bob",
93
+ message: { conversation: "oi" },
94
+ }],
95
+ });
96
+ const batch = received[0];
97
+ const msg = batch.messages[0];
98
+ assert.equal(msg.fromLid, "98765@lid");
99
+ assert.equal(msg.fromPn, "5511999999999@s.whatsapp.net");
100
+ });
43
101
  test("Baileys adapter getBusinessProfile handles success and failure", async () => {
44
102
  const store = createStore();
45
103
  const jid = "12345678@s.whatsapp.net";
@@ -279,3 +337,42 @@ test("Baileys adapter aggregatePollVotes returns empty array when no poll data",
279
337
  });
280
338
  assert.deepStrictEqual(result, []);
281
339
  });
340
+ test("Baileys adapter passes through the real group-participants.update shape (author/action, string[] participants) and feeds LID↔PN cache", async () => {
341
+ const store = createStore();
342
+ const sock = {
343
+ ev: new EventEmitter(),
344
+ user: { id: "bot@s.whatsapp.net" },
345
+ };
346
+ const { contract } = createBaileysAdapter({ sock, store });
347
+ const received = [];
348
+ contract.on("group-participants.update", (payload) => received.push(payload));
349
+ // Baileys v7 ships `participants` as GroupParticipant[] (Contact & { admin?… })
350
+ // — each entry carries `id` (LID form, the addressing mode the group uses),
351
+ // `lid?` (explicit LID alias), and `phoneNumber?` (PN form). The adapter
352
+ // projects to a flat JID list for the kernel, but also feeds the LID↔PN
353
+ // cache from the richer data while it has it. See BaileysEventMap.
354
+ sock.ev.emit("group-participants.update", {
355
+ id: "120363402117932687@g.us",
356
+ author: "99999@lid",
357
+ authorPn: "5516999999999@s.whatsapp.net",
358
+ participants: [
359
+ { id: "69119495901215@lid", phoneNumber: "5516111222333@s.whatsapp.net" },
360
+ { id: "69119495901216@lid", phoneNumber: "5516111222444@s.whatsapp.net" },
361
+ ],
362
+ action: "add",
363
+ });
364
+ assert.deepStrictEqual(received, [{
365
+ id: "120363402117932687@g.us",
366
+ author: "99999@lid",
367
+ participants: [
368
+ "69119495901215@lid",
369
+ "69119495901216@lid",
370
+ ],
371
+ action: "add",
372
+ }]);
373
+ // Passive LID↔PN cache filled from the richer payload — both directions
374
+ // (resolveJid returns the PN, resolvePn returns the LID).
375
+ assert.equal(store.resolveJid("69119495901215@lid"), "5516111222333@s.whatsapp.net");
376
+ assert.equal(store.resolvePn("5516111222444@s.whatsapp.net"), "69119495901216@lid");
377
+ assert.equal(store.resolveJid("99999@lid"), "5516999999999@s.whatsapp.net");
378
+ });
@@ -29,3 +29,29 @@ export function toWireJid(id) {
29
29
  const digits = trimmed.replace(/\D/g, "");
30
30
  return `${digits}@s.whatsapp.net`;
31
31
  }
32
+ /**
33
+ * Split a Baileys "primary/alt" JID pair (e.g. `key.participant` +
34
+ * `key.participantAlt`, or `key.remoteJid` + `key.remoteJidAlt`) into its
35
+ * LID and PN forms.
36
+ *
37
+ * Baileys only labels the primary field as the PN and the alt field as the
38
+ * LID under the legacy `addressingMode: "pn"`. Under the modern default
39
+ * `addressingMode: "lid"` the roles are reversed — the primary field IS
40
+ * already the LID, and the alt field carries the PN instead (see
41
+ * https://baileys.wiki/concepts/jids: "Group participant fields are
42
+ * typically LIDs; participantAlt carries the matching PN, and vice versa").
43
+ *
44
+ * Branching on `addressingMode` itself isn't reliable either — it's been
45
+ * observed flip-flopping for the same conversation across rc builds (see
46
+ * https://github.com/WhiskeySockets/Baileys/issues/1827). The one thing
47
+ * that's actually trustworthy is the JID suffix itself: whichever of the
48
+ * two values ends in "@lid" IS the LID, regardless of which field it came
49
+ * from or what addressingMode claims.
50
+ */
51
+ export function splitLidPn(primary, alt) {
52
+ const candidates = [primary, alt].filter((v) => !!v);
53
+ return {
54
+ lid: candidates.find(v => v.endsWith("@lid")),
55
+ pn: candidates.find(v => !v.endsWith("@lid")),
56
+ };
57
+ }
@@ -1,6 +1,6 @@
1
1
  import test, { describe } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { normalizeJid, denormalizeJid, toWireJid } from "#drivers/jid.js";
3
+ import { normalizeJid, denormalizeJid, toWireJid, splitLidPn } from "#drivers/jid.js";
4
4
  describe("drivers/jid", () => {
5
5
  describe("normalizeJid", () => {
6
6
  test("handles empty string or nullish input", () => {
@@ -37,4 +37,38 @@ describe("drivers/jid", () => {
37
37
  assert.equal(toWireJid("+55 (11) 99999-9999"), "5511999999999@s.whatsapp.net");
38
38
  });
39
39
  });
40
+ describe("splitLidPn", () => {
41
+ test("addressingMode 'pn' (legacy): primary is PN, alt is LID", () => {
42
+ const { lid, pn } = splitLidPn("5511999999999@s.whatsapp.net", "12345@lid");
43
+ assert.equal(lid, "12345@lid");
44
+ assert.equal(pn, "5511999999999@s.whatsapp.net");
45
+ });
46
+ test("addressingMode 'lid' (modern default): primary is LID, alt is PN — roles reversed", () => {
47
+ const { lid, pn } = splitLidPn("12345@lid", "5511999999999@s.whatsapp.net");
48
+ assert.equal(lid, "12345@lid");
49
+ assert.equal(pn, "5511999999999@s.whatsapp.net");
50
+ });
51
+ test("only primary present, and it's a LID", () => {
52
+ const { lid, pn } = splitLidPn("12345@lid", undefined);
53
+ assert.equal(lid, "12345@lid");
54
+ assert.equal(pn, undefined);
55
+ });
56
+ test("only primary present, and it's a PN", () => {
57
+ const { lid, pn } = splitLidPn("5511999999999@s.whatsapp.net", undefined);
58
+ assert.equal(lid, undefined);
59
+ assert.equal(pn, "5511999999999@s.whatsapp.net");
60
+ });
61
+ test("neither present", () => {
62
+ const { lid, pn } = splitLidPn(undefined, null);
63
+ assert.equal(lid, undefined);
64
+ assert.equal(pn, undefined);
65
+ });
66
+ test("group JID (@g.us) as primary with no alt is treated as pn-shaped, never as lid", () => {
67
+ // Not a real person's PN, but callers only ever consume `.lid` from
68
+ // this pairing for the group case — `.pn` is discarded there.
69
+ const { lid, pn } = splitLidPn("120363999999999999@g.us", undefined);
70
+ assert.equal(lid, undefined);
71
+ assert.equal(pn, "120363999999999999@g.us");
72
+ });
73
+ });
40
74
  });
@@ -42,27 +42,10 @@ function loadLocale(lang) {
42
42
  }
43
43
  }
44
44
  /**
45
- * Detects the OS locale without depending on a single env var, since LANG
46
- * isn't reliably set on macOS GUI sessions or Windows. Used as a fallback
47
- * when CONFIG.LANGUAGE isn't available yet (e.g. circular import during
48
- * config bootstrap) or isn't set.
49
- */
50
- function detectSystemLang() {
51
- try {
52
- const locale = Intl.DateTimeFormat().resolvedOptions().locale;
53
- if (locale)
54
- return locale.split("-")[0].toLowerCase();
55
- }
56
- catch {
57
- // Intl unavailable — fall through to env vars
58
- }
59
- const envLocale = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || process.env.LANGUAGE;
60
- if (envLocale)
61
- return envLocale.split(/[_.]/)[0].toLowerCase();
62
- return DEFAULT_LANG;
63
- }
64
- /**
65
- * Gets configured language or falls back to system locale, then English.
45
+ * Gets configured language or falls back to English. `CONFIG.LANGUAGE` is
46
+ * the single source of truth (set from manybot.toml, defaulted to "en")
47
+ * this never guesses from the OS locale, so bot output language doesn't
48
+ * depend on the host machine/CI environment it happens to run on.
66
49
  * @returns {string}
67
50
  */
68
51
  function getConfiguredLang() {
@@ -75,7 +58,7 @@ function getConfiguredLang() {
75
58
  // circular import while #config is still bootstrapping)
76
59
  }
77
60
  if (!lang) {
78
- lang = detectSystemLang();
61
+ lang = DEFAULT_LANG;
79
62
  }
80
63
  const filePath = path.join(LOCALES_DIR, `${lang}.json`);
81
64
  if (!fs.existsSync(filePath)) {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * kernel/chatOverrides.ts
3
+ *
4
+ * Per-chat overrides for the bot's global command prefix and language,
5
+ * set via `!config prefixo` / `!config idioma` (see coreCommands.ts).
6
+ * Both are stored under the "core" plugin namespace in settingsDb —
7
+ * the same storage plugins use via `ctx.settings`, keyed by the same
8
+ * `normalizeJid(msg.chatId)` that `buildApi()` uses to scope
9
+ * `ctx.settings` — so a value written here is read back under the
10
+ * exact same key it was written under.
11
+ *
12
+ * Kept as a standalone leaf module (only #config, jid utils, and
13
+ * settingsDb as deps) so it can be imported from anywhere in the
14
+ * dispatch pipeline (api/index.ts, messageHandler.ts, runCommand.ts,
15
+ * coreCommands.ts) without risking an import cycle.
16
+ */
17
+ import { getPluginSetting } from "./settingsDb.js";
18
+ import { normalizeJid } from "#drivers/jid.js";
19
+ import { CMD_PREFIX } from "#config";
20
+ const CORE_PLUGIN = "core";
21
+ /**
22
+ * Resolves the effective command prefix for a chat: the chat's saved
23
+ * override (`!config prefixo`) if one was set, else the global
24
+ * `CMD_PREFIX`.
25
+ *
26
+ * @param chatId - the *raw* chat id (e.g. `msg.chatId`), not yet
27
+ * normalized — this function normalizes it itself so callers don't
28
+ * have to know which normalization the settings layer expects.
29
+ */
30
+ export function getChatPrefix(chatId) {
31
+ const override = getPluginSetting(CORE_PLUGIN, normalizeJid(chatId), "chat_prefix");
32
+ return typeof override === "string" && override.length > 0 ? override : CMD_PREFIX;
33
+ }
34
+ /**
35
+ * Resolves the effective language code for a chat: the chat's saved
36
+ * override (`!config idioma`) if one was set, else `undefined` so the
37
+ * caller can fall back to the global language (`getCurrentLang()` /
38
+ * plain `t()`).
39
+ *
40
+ * @param chatId - the *raw* chat id (e.g. `msg.chatId`); normalized
41
+ * internally, same as {@link getChatPrefix}.
42
+ */
43
+ export function getChatLocale(chatId) {
44
+ const override = getPluginSetting(CORE_PLUGIN, normalizeJid(chatId), "chat_locale");
45
+ return typeof override === "string" && override.length > 0 ? override : undefined;
46
+ }