@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
@@ -13,10 +13,12 @@ import { decodeContent } from "#drivers/baileys/adapter.js";
13
13
  import { logger } from "#logger";
14
14
  import { t, createPluginT, reloadTranslations, getCurrentLang } from "#i18n";
15
15
  import { CONFIG, CONFIG_DIR } from "#config";
16
+ import { getChatPrefix } from "#kernel/chatOverrides.js";
16
17
  import { enqueue } from "#download";
17
18
  import { waitForEditSlot } from "#kernel/sendGuard.js";
18
19
  import { schedule, cancelPlugin } from "#kernel/scheduler.js";
19
20
  import { emptyFolder } from "#utils/file.js";
21
+ import { parsePhone } from "#utils/phoneNumber.js";
20
22
  import { normalizeJid, denormalizeJid, toWireJid } from "#drivers/jid.js";
21
23
  import { mkdirSync } from "fs";
22
24
  import { readFile, writeFile, unlink, mkdtemp, rm } from "fs/promises";
@@ -146,14 +148,35 @@ function msgIsGif(msg, store) {
146
148
  return !!raw.message?.videoMessage?.gifPlayback;
147
149
  }
148
150
  /** Sender JID — group participant or DM remote JID, normalized. */
149
- function getMsgSender(msg, store) {
150
- // Prefer the @s.whatsapp.net form when we know it (Baileys-advisor split
151
- // exposes both forms on incoming messages). The adapter fills
152
- // `fromPn` when known; fall back to `chatId` for DMs.
153
- const participant = msg._raw?.key?.participant
154
- ?? msg.fromPn;
155
- const raw = participant ?? msg.chatId;
156
- return normalizeJid(store.resolveJid(normalizeJid(raw)));
151
+ function getMsgSender(msg) {
152
+ // LID-canonical, matching normalizeContact()'s `id` and every other
153
+ // participant lookup in this file (`participantAlt ?? fromLid ?? fromPn
154
+ // ?? null`). `participantAlt` carries the alt-LID on group messages;
155
+ // `remoteJidAlt` carries it on DMs (Baileys puts the DM alt-LID on the
156
+ // key's remoteJidAlt, not participantAlt — there's no participant field
157
+ // at all in a 1:1 chat). When the account is already LID-addressed
158
+ // (addressingMode "lid"), `chatId` itself IS the @lid form and
159
+ // `remoteJidAlt` may hold the PN alt instead — check chatId first so
160
+ // that case isn't missed. No PN fallback: null is the correct, expected
161
+ // value when no LID has been learned for this contact yet, not a
162
+ // reason to silently hand back a legacy @c.us identity.
163
+ const dmLid = msg.chatId?.endsWith("@lid") ? msg.chatId : msg.remoteJidAlt;
164
+ const lid = msg.participantAlt ?? msg.fromLid ?? dmLid ?? null;
165
+ return lid ? normalizeJid(lid) : null;
166
+ }
167
+ /**
168
+ * PN companion of getMsgSender(), for the few internal consumers (command
169
+ * permissions today) that still need to match against phone-number-based
170
+ * config the way it's always been written. Never exposed on ctx.msg —
171
+ * `sender` there is LID-canonical only, per Baileys' own "migrate to LID,
172
+ * don't try to restore PN" guidance.
173
+ */
174
+ function getMsgSenderPn(msg) {
175
+ if (msg.fromPn)
176
+ return normalizeJid(msg.fromPn);
177
+ if (msg.chatId && !msg.chatId.endsWith("@lid"))
178
+ return normalizeJid(msg.chatId);
179
+ return null;
157
180
  }
158
181
  /** Quoted-message metadata as the rest of the api uses it. */
159
182
  function getQuotedContext(msg) {
@@ -222,6 +245,25 @@ async function getGroupMetadataCached(contract, jid) {
222
245
  groupMetaCache.set(jid, { meta, at: Date.now() });
223
246
  return meta;
224
247
  }
248
+ /**
249
+ * Same fetch as {@link getGroupMetadataCached}, but always hits the network
250
+ * and never reads the TTL cache — used for admin permission checks
251
+ * (`isAdmin`/`isSenderAdmin`), which gate the `admin:`/`botAdmin:` command
252
+ * permission and so cannot tolerate the up-to-5-minute staleness window.
253
+ * Invalidation on `group-participants.update` closes most of that window,
254
+ * but Baileys' own event-buffer bugs during reconnects (see the welcome-
255
+ * message gating fix) can drop the event entirely — leaving a demoted
256
+ * admin able to keep running admin-only commands until the TTL expires,
257
+ * or indefinitely if no later promote/demote re-triggers invalidation.
258
+ * The result is still written into the shared cache so unrelated cached
259
+ * reads (`getParticipants()`, chat name) benefit from the fresh fetch too.
260
+ */
261
+ async function getGroupMetadataFresh(contract, jid) {
262
+ const sock = rawSocketOf(contract);
263
+ const meta = await sock.groupMetadata(jid);
264
+ groupMetaCache.set(jid, { meta, at: Date.now() });
265
+ return meta;
266
+ }
225
267
  let groupMetaInvalidationBound = false;
226
268
  function bindGroupMetaInvalidation(contract) {
227
269
  if (groupMetaInvalidationBound)
@@ -236,6 +278,14 @@ function bindGroupMetaInvalidation(contract) {
236
278
  groupMetaCache.delete(u.id);
237
279
  });
238
280
  }
281
+ /** Test-only: clears the group-metadata cache and re-arms invalidation
282
+ * binding so each test starts isolated (module-level singletons otherwise
283
+ * persist for the life of the process). Not for production use. */
284
+ export function __resetGroupMetaCacheForTests() {
285
+ groupMetaCache.clear();
286
+ groupNameCache.clear();
287
+ groupMetaInvalidationBound = false;
288
+ }
239
289
  /**
240
290
  * Build a WAChat adapter from a BotMessage + store.
241
291
  * Exposed for use in messageHandler.ts.
@@ -355,7 +405,9 @@ function buildConfigApi() {
355
405
  * @param {any} [defaultValue]
356
406
  */
357
407
  get(key, defaultValue = null) {
358
- return CONFIG[key] ?? defaultValue;
408
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `get<T>` is a
409
+ // type-level convenience for callers; CONFIG itself is untyped.
410
+ return (CONFIG[key] ?? defaultValue);
359
411
  },
360
412
  };
361
413
  }
@@ -463,13 +515,72 @@ function mentionDisplayName(jid) {
463
515
  * Build a normalized contact object from a JID and optional store metadata.
464
516
  * isBusiness is resolved via contract.getBusinessProfile(jid) — it resolves
465
517
  * to a profile only for WhatsApp Business accounts, undefined otherwise.
518
+ *
519
+ * `id` invariant: the returned ID is the LID form whenever the bot can
520
+ * guarantee it (input was a `@lid`, or we resolved a `@s.whatsapp.net`
521
+ * JID through the LID↔PN cache). When the input is a phone-based JID
522
+ * and we don't have a LID mapping yet, `id` is `null` — handing back a
523
+ * PN in that case would be a lie about the user's preferred identity
524
+ * (LID is now the internal addressing mode WhatsApp uses; see MANYBOT-7
525
+ * and the Baileys 7 LID rollout).
526
+ *
527
+ * Phone fields (`number`/`numberRaw`/`numberPretty`/`country`/
528
+ * `countryCallingCode`) are populated by {@link parsePhone} once a PN
529
+ * form is known. If we have neither a PN nor a resolvable LID, every
530
+ * phone field stays `null`.
531
+ *
532
+ * Groups keep their `@g.us` JID as `id` — they're not subject to the
533
+ * LID/PN distinction and never have a phone number to parse.
534
+ *
466
535
  * @param {string} jid
467
536
  * @param {RawStoreContact} [info]
468
537
  * @param {string|null} [botJid]
469
538
  * @param {WaContract} [contract]
539
+ * @param {string|null} [botLid]
470
540
  */
471
- async function normalizeContact(jid, info, botJid, contract) {
472
- const number = jid.split("@")[0];
541
+ async function normalizeContact(jid, info, botJid, contract, store, botLid) {
542
+ // Compute the canonical ID form (LID for users, group JID for groups,
543
+ // null when we can't pin a LID) and the resolved PN form (if any) in
544
+ // one pass — both are derived from the same LID↔PN cache + protocol-level
545
+ // resolver lookups, and a successful PN resolution feeds learnLid() so
546
+ // the next call is fully synchronous.
547
+ const isGroup = jid.endsWith("@g.us");
548
+ let resolvedId = isGroup ? jid : null;
549
+ let resolvedPn = null;
550
+ if (!isGroup) {
551
+ if (jid.endsWith("@lid")) {
552
+ // Input is already LID — that's the canonical ID; look up the PN.
553
+ resolvedId = jid;
554
+ const cached = store.resolveJid(jid);
555
+ if (cached && cached !== jid) {
556
+ resolvedPn = cached;
557
+ }
558
+ else if (contract?.resolveLid) {
559
+ try {
560
+ const fresh = await contract.resolveLid(jid);
561
+ if (fresh) {
562
+ store.learnLid(jid, fresh);
563
+ resolvedPn = fresh;
564
+ }
565
+ }
566
+ catch { /* fall through to null — caller decides */ }
567
+ }
568
+ }
569
+ else if (jid.endsWith("@s.whatsapp.net") || jid.endsWith("@c.us")) {
570
+ // Input is PN. Prefer the LID form for `id`; cache lookup is sync.
571
+ const lid = store.resolvePn(jid);
572
+ if (lid) {
573
+ resolvedId = lid;
574
+ resolvedPn = normalizeJid(jid);
575
+ }
576
+ else {
577
+ // No LID mapping yet — id stays null per the invariant.
578
+ resolvedPn = normalizeJid(jid);
579
+ }
580
+ }
581
+ // Anything else (status broadcast, newsletter, meta AI…) — id = null.
582
+ }
583
+ const phone = parsePhone(resolvedPn);
473
584
  let isBusiness = false;
474
585
  // We already have a contact record for this jid (learned from a real
475
586
  // contacts.upsert or an actual message from them) — that alone proves
@@ -478,7 +589,7 @@ async function normalizeContact(jid, info, botJid, contract) {
478
589
  // raw @lid we've never resolved to a phone number (returns a false
479
590
  // "doesn't exist" instead of throwing).
480
591
  let isWAAccount = Boolean(info);
481
- if (!isWAAccount && contract && !jid.endsWith("@g.us")) {
592
+ if (!isWAAccount && contract && !isGroup) {
482
593
  try {
483
594
  const results = await contract.onWhatsApp(jid);
484
595
  isWAAccount = Boolean(results?.[0]?.exists);
@@ -492,9 +603,9 @@ async function normalizeContact(jid, info, botJid, contract) {
492
603
  // which we can't verify this way) — nothing backs this contact.
493
604
  // Matches the old whatsapp-web.js contract: getContactById() threw /
494
605
  // resolved to null for an unknown ID instead of returning a hollow object.
495
- if (!jid.endsWith("@g.us") && !isWAAccount)
606
+ if (!isGroup && !isWAAccount)
496
607
  return null;
497
- if (contract && !jid.endsWith("@g.us")) {
608
+ if (contract && !isGroup) {
498
609
  try {
499
610
  isBusiness = Boolean(await contract.getBusinessProfile(jid));
500
611
  }
@@ -503,8 +614,12 @@ async function normalizeContact(jid, info, botJid, contract) {
503
614
  }
504
615
  }
505
616
  return {
506
- id: jid,
507
- number,
617
+ id: resolvedId,
618
+ number: phone.number,
619
+ numberRaw: phone.numberRaw,
620
+ numberPretty: phone.numberPretty,
621
+ country: phone.country,
622
+ countryCallingCode: phone.countryCallingCode,
508
623
  pushname: info?.notify ?? null,
509
624
  name: info?.name ?? info?.verifiedName ?? null,
510
625
  // Baileys' Contact type has no "short name" equivalent (that's a
@@ -513,11 +628,25 @@ async function normalizeContact(jid, info, botJid, contract) {
513
628
  isBusiness,
514
629
  isEnterprise: false,
515
630
  isBlocked: false,
516
- isMe: botJid ? jid === normalizeJid(botJid) : false,
631
+ isMe: (botJid && jid === normalizeJid(botJid)) || (botLid && jid === normalizeJid(botLid)) || false,
517
632
  isWAAccount,
518
- isUser: !jid.endsWith("@g.us"),
519
- isGroup: jid.endsWith("@g.us"),
520
- mention: { text: `@${mentionDisplayName(jid)}`, mentions: [toWireJid(jid)] },
633
+ isUser: !isGroup,
634
+ isGroup,
635
+ // Mention text: for `@lid` contacts WhatsApp always renders the raw
636
+ // LID digits (privacy — it never reveals the phone number here, even
637
+ // when we've resolved one internally), matching mobile's "no saved
638
+ // contact" fallback. Only PN-native contacts get the pretty/raw phone
639
+ // form.
640
+ mention: {
641
+ text: resolvedId?.endsWith("@lid")
642
+ ? `@${mentionDisplayName(resolvedId)}`
643
+ : phone.numberPretty
644
+ ? `@${phone.numberPretty}`
645
+ : phone.numberRaw
646
+ ? `@${phone.numberRaw}`
647
+ : `@${mentionDisplayName(jid)}`,
648
+ mentions: [toWireJid(jid)],
649
+ },
521
650
  };
522
651
  }
523
652
  // ── Chats API ─────────────────────────────────────────────────────────────────
@@ -558,7 +687,15 @@ function buildChatsApi(store) {
558
687
  };
559
688
  }
560
689
  // ── Contact API ───────────────────────────────────────────────────────────────
561
- function buildContactsApi(contract, store, botJid) {
690
+ export function buildContactsApi(contract, store, botJid, botLid) {
691
+ // Teach the store the bot's own LID↔PN mapping proactively — it's
692
+ // known synchronously from contract.me() and doesn't need a resolveLid()
693
+ // round trip or a prior sync to have taught the store this pairing
694
+ // (e.g. contacts.get() on the bot's own id before any message from the
695
+ // bot's own account arrived to populate it via message handling).
696
+ // Without this, self-lookups keep every phone-number field null.
697
+ if (botLid && botJid)
698
+ store.learnLid(botLid, botJid);
562
699
  return {
563
700
  /**
564
701
  * Get a normalized contact object by JID.
@@ -627,7 +764,8 @@ function buildContactsApi(contract, store, botJid) {
627
764
  if (botJid &&
628
765
  normalizedContactId.endsWith("@lid") &&
629
766
  resolved === normalizeJid(botJid) &&
630
- normalizedContactId !== normalizeJid(botJid)) {
767
+ normalizedContactId !== normalizeJid(botJid) &&
768
+ !(botLid && normalizedContactId === normalizeJid(botLid))) {
631
769
  logger.warn(`[contacts.get] "${contactId}" resolved to the bot's own JID — discarding stale lidMap entry, treating as unresolved.`);
632
770
  store.forgetLid(normalizedContactId);
633
771
  resolved = normalizedContactId;
@@ -650,7 +788,7 @@ function buildContactsApi(contract, store, botJid) {
650
788
  notify: resolvedInfo?.notify ?? raw?.notify,
651
789
  verifiedName: resolvedInfo?.verifiedName ?? raw?.verifiedName,
652
790
  } : undefined;
653
- return normalizeContact(resolved, info, botJid, contract);
791
+ return normalizeContact(resolved, info, botJid, contract, store, botLid);
654
792
  },
655
793
  /**
656
794
  * Get the profile picture URL of a contact.
@@ -738,20 +876,33 @@ function makeHistoryArray(entries, store) {
738
876
  const arr = entries;
739
877
  arr.last = (n) => makeHistoryArray(typeof n === "number" ? entries.slice(-n) : entries.slice(), store);
740
878
  arr.from = (senderId) => {
741
- const target = normalizeJid(store.resolveJid(normalizeJid(senderId)));
879
+ // `e.sender` is LID-canonical (see getMsgSender()) — resolve a
880
+ // phone-number input to its LID before comparing. If it's already
881
+ // `@lid`, pass through unchanged. If no LID mapping is known for a
882
+ // PN input, there's nothing to match against (filters to empty),
883
+ // same as e.sender being null for that contact.
884
+ const normalized = normalizeJid(senderId.trim());
885
+ const target = normalized.endsWith("@lid") ? normalized : store.resolvePn(normalized);
886
+ if (!target)
887
+ return makeHistoryArray([], store);
742
888
  return makeHistoryArray(entries.filter((e) => e.sender === target), store);
743
889
  };
744
890
  return arr;
745
891
  }
746
892
  export function buildMessageContext(msg, contract, store, guardOptions = {}) {
747
893
  const body = getMsgBody(msg);
748
- const prefix = CONFIG.CMD_PREFIX;
894
+ const prefix = getChatPrefix(msg.chatId);
749
895
  const rawArgs = body.trim().split(/\s+/);
750
896
  const first = rawArgs[0]?.toLowerCase() ?? "";
751
897
  const hasPrefix = first.startsWith(prefix);
752
898
  const command = hasPrefix ? first.slice(prefix.length) : "";
753
899
  const rawJid = msg.chatId;
754
- const sender = getMsgSender(msg, store);
900
+ const sender = getMsgSender(msg);
901
+ const senderPn = getMsgSenderPn(msg);
902
+ // Best-known identity for anything that still needs a non-null jid
903
+ // internally (contact lookup, the sender-name fallback below) — prefers
904
+ // LID, falls back to PN, and finally the chat itself so it's never empty.
905
+ const contactId = sender ?? senderPn ?? msg.chatId;
755
906
  const cooldown = guardOptions.cooldown ?? true;
756
907
  const jitter = guardOptions.jitter ?? true;
757
908
  const contextInfo = getContextInfo(msg);
@@ -829,7 +980,8 @@ export function buildMessageContext(msg, contract, store, guardOptions = {}) {
829
980
  type: getMsgType(msg),
830
981
  fromMe: msg.fromMe,
831
982
  sender,
832
- senderName: msg.pushName ?? sender.replace(/(:\d+)?@.*$/, ""),
983
+ senderPn,
984
+ senderName: msg.pushName ?? contactId.replace(/(:\d+)?@.*$/, ""),
833
985
  command,
834
986
  args: rawArgs.slice(1),
835
987
  is(cmd) {
@@ -837,6 +989,7 @@ export function buildMessageContext(msg, contract, store, guardOptions = {}) {
837
989
  },
838
990
  hasMedia: msgHasMedia(msg),
839
991
  isGif: msgIsGif(msg, store),
992
+ mentionedJid: msg.mentionedJid ?? [],
840
993
  async downloadMedia(opts = {}) {
841
994
  try {
842
995
  // contract.downloadMedia handles reupload internally via the
@@ -909,12 +1062,13 @@ export function buildMessageContext(msg, contract, store, guardOptions = {}) {
909
1062
  * @returns {Promise<object|null>} null if the sender can't be confirmed as a real WhatsApp account.
910
1063
  */
911
1064
  async getContact() {
912
- const info = store.contacts[sender]
913
- ?? store.contacts[denormalizeJid(sender)]
1065
+ const info = store.contacts[contactId]
1066
+ ?? store.contacts[denormalizeJid(contactId)]
914
1067
  ?? store.contacts[store.resolveJid(msg.fromPn ?? "")]
915
1068
  ?? store.contacts[denormalizeJid(store.resolveJid(msg.fromPn ?? ""))];
916
1069
  const botJid = contract.me().id ? jidNormalizedUser(contract.me().id) : null;
917
- return normalizeContact(sender, info, botJid, contract);
1070
+ const botLid = contract.me().lid ? normalizeJid(contract.me().lid) : null;
1071
+ return normalizeContact(contactId, info, botJid, contract, store, botLid);
918
1072
  },
919
1073
  };
920
1074
  }
@@ -1522,6 +1676,9 @@ async function resolveMentionJids(contract, store, jid, mentions) {
1522
1676
  return Array.from(new Set(result.filter(Boolean)));
1523
1677
  }
1524
1678
  function buildAdminApi(contract, store, chatJid) {
1679
+ const me = contract.me();
1680
+ const botJid = me.id ? normalizeJid(jidNormalizedUser(me.id)) : null;
1681
+ const botLid = me.lid ? normalizeJid(me.lid) : null;
1525
1682
  /**
1526
1683
  * Resolve admin-supplied identifiers (bare phone number, @c.us,
1527
1684
  * @s.whatsapp.net, or @lid) to the exact jid WhatsApp has on file for
@@ -1650,6 +1807,12 @@ function buildAdminApi(contract, store, chatJid) {
1650
1807
  async kick(memberIds) {
1651
1808
  requireChat();
1652
1809
  const users = await resolveTargets(chatJid, Array.isArray(memberIds) ? memberIds : [memberIds]);
1810
+ if (users.some((u) => {
1811
+ const n = normalizeJid(u);
1812
+ return (botJid && n === botJid) || (botLid && n === botLid);
1813
+ })) {
1814
+ throw new Error(t("driver.cannotKickSelf"));
1815
+ }
1653
1816
  return runParticipantsUpdate(chatJid, users, "remove");
1654
1817
  },
1655
1818
  /** @param {string|string[]} memberIds — JID (@s.whatsapp.net/@lid), this framework's @c.us form, or a bare phone number */
@@ -1924,7 +2087,9 @@ function buildPollApi(contract, store, rawJid, guardOptions, pluginName) {
1924
2087
  }
1925
2088
  // ── Base API (shared between setup and runtime) ───────────────────────────────
1926
2089
  function buildBaseApi(contract, store, pluginRegistry, pluginName) {
1927
- const botJid = contract.me().id ? jidNormalizedUser(contract.me().id) : null;
2090
+ const me = contract.me();
2091
+ const botJid = me.id ? jidNormalizedUser(me.id) : null;
2092
+ const botLid = me.lid ? normalizeJid(me.lid) : null;
1928
2093
  if (!botJid)
1929
2094
  logger.warn("[pluginApi] botId is null — socket may not be ready yet.");
1930
2095
  return {
@@ -1937,9 +2102,9 @@ function buildBaseApi(contract, store, pluginRegistry, pluginName) {
1937
2102
  scheduler: buildSchedulerApi(pluginName),
1938
2103
  plugins: buildPluginsApi(pluginRegistry),
1939
2104
  chats: buildChatsApi(store),
1940
- contacts: buildContactsApi(contract, store, botJid),
2105
+ contacts: buildContactsApi(contract, store, botJid, botLid),
1941
2106
  storage: buildStorageApi(pluginName),
1942
- botId: botJid,
2107
+ botId: botLid ?? botJid,
1943
2108
  commands: buildCommandsApi(),
1944
2109
  };
1945
2110
  }
@@ -2019,7 +2184,7 @@ function buildRunCommandFacet(msg, chat, contract, store, pluginRegistry, caller
2019
2184
  * @param {object} [params.guardOptions]
2020
2185
  */
2021
2186
  export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginName, guardOptions = {}, }) {
2022
- const prefix = CONFIG.CMD_PREFIX;
2187
+ const prefix = getChatPrefix(msg.chatId);
2023
2188
  const body = getMsgBody(msg);
2024
2189
  const rawArgs = body.trim().split(/\s+/);
2025
2190
  const first = rawArgs[0]?.toLowerCase() ?? "";
@@ -2027,7 +2192,7 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2027
2192
  const command = hasPrefix ? first.slice(prefix.length) : "";
2028
2193
  const rawJid = msg.chatId;
2029
2194
  const normJid = normalizeJid(rawJid);
2030
- const sender = getMsgSender(msg, store);
2195
+ const sender = getMsgSender(msg);
2031
2196
  const cooldown = (guardOptions.cooldown ?? true);
2032
2197
  const jitter = (guardOptions.jitter ?? true);
2033
2198
  bindGroupMetaInvalidation(contract);
@@ -2097,6 +2262,9 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2097
2262
  },
2098
2263
  /**
2099
2264
  * Check if a contact is an admin of this group.
2265
+ * Always fetches fresh (bypasses the group-metadata TTL cache) —
2266
+ * this gates the `admin:` command permission, so it cannot risk
2267
+ * serving a stale "still admin" result after a demote.
2100
2268
  * @param {string} contactId
2101
2269
  * @returns {Promise<boolean>}
2102
2270
  */
@@ -2104,7 +2272,7 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2104
2272
  if (!chat.isGroup)
2105
2273
  return false;
2106
2274
  try {
2107
- const meta = await getGroupMetadataCached(contract, rawJid);
2275
+ const meta = await getGroupMetadataFresh(contract, rawJid);
2108
2276
  return meta.participants.some(p => matchesParticipant([contactId], p.id) && (p.admin === "admin" || p.admin === "superadmin"));
2109
2277
  }
2110
2278
  catch {
@@ -2113,13 +2281,16 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2113
2281
  },
2114
2282
  /**
2115
2283
  * Check if the message sender is an admin of this group.
2284
+ * Always fetches fresh (bypasses the group-metadata TTL cache) — see
2285
+ * {@link isAdmin} for why: this gates the `botAdmin:`/`admin:`
2286
+ * permission checks in commandPermissions.ts.
2116
2287
  * @returns {Promise<boolean>}
2117
2288
  */
2118
2289
  async isSenderAdmin() {
2119
2290
  if (!chat.isGroup)
2120
2291
  return false;
2121
2292
  try {
2122
- const meta = await getGroupMetadataCached(contract, rawJid);
2293
+ const meta = await getGroupMetadataFresh(contract, rawJid);
2123
2294
  // The adapter's pre-extracted participant fields give us both the
2124
2295
  // LID and PN forms of the sender; match either against the group's
2125
2296
  // own participant list.
@@ -2132,6 +2303,9 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2132
2303
  },
2133
2304
  /**
2134
2305
  * Check if the bot is an admin of this group.
2306
+ * Always fetches fresh (bypasses the group-metadata TTL cache) — see
2307
+ * {@link isAdmin} for why: this gates the `botAdmin:` permission
2308
+ * check in commandPermissions.ts.
2135
2309
  * @returns {Promise<boolean>}
2136
2310
  */
2137
2311
  async isBotAdmin() {
@@ -2143,7 +2317,7 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2143
2317
  if (!botCandidates.some(Boolean))
2144
2318
  return false;
2145
2319
  try {
2146
- const meta = await getGroupMetadataCached(contract, rawJid);
2320
+ const meta = await getGroupMetadataFresh(contract, rawJid);
2147
2321
  return meta.participants.some(p => matchesParticipant(botCandidates, p.id) && (p.admin === "admin" || p.admin === "superadmin"));
2148
2322
  }
2149
2323
  catch {
@@ -22,8 +22,9 @@
22
22
  import { createSocket, AUTH_DIR, store as sharedStore } from "./sdk/baileysSock.js";
23
23
  import { createBaileysAdapter } from "./adapter.js";
24
24
  import { handleMessage } from "./messageHandler.js";
25
- import { normalizeJid } from "#drivers/jid.js";
26
- import { loadPlugins, setupPlugins } from "#kernel/pluginLoader.js";
25
+ import { normalizeJid, splitLidPn } from "#drivers/jid.js";
26
+ import { loadPlugins, setupPlugins, loadIntegrationPlugin } from "#kernel/pluginLoader.js";
27
+ import { isIntegrationOptIn } from "#kernel/integrationMode.js";
27
28
  import { runContactRefreshSweep } from "#kernel/contactAutoSave.js";
28
29
  import { registerAlertSockProvider, sendAlert } from "#kernel/alerts.js";
29
30
  import { startUpdateCheckSchedule, stopUpdateCheckSchedule } from "#kernel/updateCheck.js";
@@ -211,6 +212,24 @@ async function startBot() {
211
212
  pluginsReady = true;
212
213
  await loadPlugins(PLUGINS);
213
214
  await setupPlugins(contract, store);
215
+ // Opt-in: when the operator is running the integration test
216
+ // suite (`MANYBOT_RUN_WHATSAPP_TESTS=1`), also load the
217
+ // reserved integration plugin so its public API
218
+ // (`waitForMarker`, `testChat`, ...) is available for the
219
+ // test harness. Done after setupPlugins() so the integration
220
+ // plugin's own setup() can subscribe to events already wired
221
+ // up by the regular plugins. Production runs (no opt-in flag)
222
+ // are completely unaffected — `loadIntegrationPlugin()` itself
223
+ // throws on missing opt-in, so we gate it here too for an
224
+ // early, clean skip.
225
+ if (isIntegrationOptIn()) {
226
+ try {
227
+ await loadIntegrationPlugin();
228
+ }
229
+ catch (e) {
230
+ logger.warn(`[baileys] integration plugin failed to load: ${e.message}`);
231
+ }
232
+ }
214
233
  }
215
234
  startCacheAutosave(store);
216
235
  startContactRefreshSweep(contract);
@@ -595,6 +614,11 @@ export function toBotMessage(msg) {
595
614
  m?.interactiveMessage?.contextInfo ??
596
615
  m?.buttonsMessage?.contextInfo ??
597
616
  undefined;
617
+ // See splitLidPn() in #drivers/jid.js — `key.participant` is only the PN
618
+ // form under legacy `addressingMode: "pn"`; under the modern default
619
+ // "lid" mode it's already the LID and `key.participantAlt` carries the
620
+ // PN instead. Resolve by JID suffix, not by field position.
621
+ const participantIds = splitLidPn(key.participant, key.participantAlt);
598
622
  return {
599
623
  id: msg.key?.id ?? "",
600
624
  chatId: normalizeJid(msg.key?.remoteJid ?? ""),
@@ -612,10 +636,10 @@ export function toBotMessage(msg) {
612
636
  fromMe: false,
613
637
  participant: contextInfo.participant ?? undefined,
614
638
  } : undefined,
615
- fromLid: key.participantAlt,
616
- fromPn: key.participant,
617
- participantAlt: key.participantAlt,
618
- remoteJidAlt: key.remoteJidAlt,
639
+ fromLid: participantIds.lid,
640
+ fromPn: participantIds.pn,
641
+ participantAlt: participantIds.lid,
642
+ remoteJidAlt: splitLidPn(key.remoteJid, key.remoteJidAlt).lid,
619
643
  _raw: {
620
644
  pollEncKeyRaw: m?.messageContextInfo?.messageSecret ?? undefined,
621
645
  },