@manybot/manybot 5.8.0 → 5.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +15 -7
  2. package/dist/client/store.js +35 -1
  3. package/dist/download/queue.js +13 -4
  4. package/dist/drivers/baileys/adapter.js +75 -8
  5. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  6. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  7. package/dist/drivers/baileys/api/index.js +245 -51
  8. package/dist/drivers/baileys/index.js +30 -6
  9. package/dist/drivers/baileys/messageHandler.js +207 -21
  10. package/dist/drivers/baileys/messageHandler.test.js +256 -14
  11. package/dist/drivers/baileysAdapter.test.js +97 -0
  12. package/dist/drivers/jid.js +26 -0
  13. package/dist/drivers/jid.test.js +35 -1
  14. package/dist/i18n/index.js +5 -22
  15. package/dist/kernel/chatOverrides.js +46 -0
  16. package/dist/kernel/chatOverrides.test.js +59 -0
  17. package/dist/kernel/commandAccess.test.js +2 -2
  18. package/dist/kernel/commandDeprecation.js +4 -2
  19. package/dist/kernel/commandDeprecation.test.js +8 -1
  20. package/dist/kernel/commandMenu.js +91 -2
  21. package/dist/kernel/commandMenu.test.js +131 -2
  22. package/dist/kernel/commandPermissions.js +69 -23
  23. package/dist/kernel/commandPermissions.test.js +77 -9
  24. package/dist/kernel/commandRegistry.js +167 -43
  25. package/dist/kernel/commandRegistry.test.js +4 -2
  26. package/dist/kernel/commandsConfig.js +470 -38
  27. package/dist/kernel/commandsConfig.test.js +249 -3
  28. package/dist/kernel/contactAutoSave.js +6 -6
  29. package/dist/kernel/coreCommands.js +62 -0
  30. package/dist/kernel/pluginApi.test.js +20 -3
  31. package/dist/kernel/pluginGuard.js +5 -3
  32. package/dist/kernel/pluginLoader.js +73 -10
  33. package/dist/kernel/pluginLoader.test.js +111 -1
  34. package/dist/kernel/runCommand.js +57 -18
  35. package/dist/kernel/runCommand.test.js +269 -7
  36. package/dist/kernel/settingsDb.js +15 -2
  37. package/dist/kernel/testConfig.js +9 -0
  38. package/dist/locales/en.json +14 -1
  39. package/dist/locales/es.json +17 -4
  40. package/dist/locales/pt.json +18 -5
  41. package/dist/plugins/__manybot_integration__/index.js +33 -16
  42. package/dist/plugins/__manybot_integration__/index.test.js +42 -8
  43. package/dist/utils/phoneNumber.js +83 -0
  44. package/dist/utils/phoneNumber.test.js +53 -0
  45. package/package.json +4 -3
@@ -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,16 +1676,30 @@ 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
- * Resolve admin-supplied identifiers (bare phone number, @c.us,
1527
- * @s.whatsapp.net, or @lid) to the exact jid WhatsApp has on file for
1528
- * that participant *in this group*. A naive toWireJid() guess is only
1529
- * correct for pn-addressed groups — lid-addressed groups (increasingly
1530
- * common, e.g. when a member hides their phone number) only recognize
1531
- * that member by @lid, which a phone number typed by the admin can't
1532
- * produce on its own. Baileys' raw groupMetadata() participants carry
1533
- * `id` (whatever WA addresses them as here), `jid` (its best
1534
- * phone-number guess) and `lid` — check all three.
1683
+ * Resolve admin-supplied identifiers (bare phone number, bare LID,
1684
+ * @c.us, @s.whatsapp.net, or @lid) to the exact jid WhatsApp has on
1685
+ * file for that participant *in this group*. A naive toWireJid() guess
1686
+ * is only correct for pn-addressed groups — lid-addressed groups
1687
+ * (increasingly common, e.g. when a member hides their phone number)
1688
+ * only recognize that member by @lid, which a phone number typed by
1689
+ * the admin can't produce on its own. Baileys' raw groupMetadata()
1690
+ * participants carry `id` (whatever WA addresses them as here), `jid`
1691
+ * (its best phone-number guess) and `lid` — check all three.
1692
+ *
1693
+ * When the identifier has no explicit domain (the admin typed a bare
1694
+ * number), we don't know if it's a PN or a LID — toWireJid() would
1695
+ * silently guess PN, which never matches a participant that's only
1696
+ * addressable by @lid even when the digits are identical (e.g. typed
1697
+ * "118077710708981", the actual member is "118077710708981@lid"). In
1698
+ * that case, compare by digits alone against id/jid/lid instead of
1699
+ * forcing a domain. When the identifier *does* carry an explicit
1700
+ * domain (@c.us, @s.whatsapp.net, @lid), the domain is meaningful
1701
+ * (disambiguates a PN from a same-digit LID) and the original
1702
+ * wire/resolved comparison is used as before.
1535
1703
  *
1536
1704
  * Throws per-identifier if it doesn't match a current member, instead
1537
1705
  * of silently sending a guessed jid that WhatsApp rejects deep inside
@@ -1541,14 +1709,23 @@ function buildAdminApi(contract, store, chatJid) {
1541
1709
  const meta = await getGroupMetadataCached(contract, groupJid);
1542
1710
  const out = [];
1543
1711
  for (const id of identifiers) {
1712
+ const hasExplicitDomain = /@(s\.whatsapp\.net|lid|g\.us|c\.us)$/.test(id.trim());
1544
1713
  const wire = toWireJid(id);
1545
1714
  const resolved = normalizeJid(store.resolveJid(id));
1715
+ const idDigits = id.replace(/\D/g, "");
1546
1716
  const match = meta.participants.find((p) => {
1547
1717
  const pAny = p;
1548
- return (toWireJid(pAny.id) === wire ||
1549
- normalizeJid(store.resolveJid(pAny.id)) === resolved ||
1550
- (pAny.jid ? toWireJid(pAny.jid) === wire : false) ||
1551
- (pAny.lid ? toWireJid(pAny.lid) === wire : false));
1718
+ if (hasExplicitDomain) {
1719
+ return (toWireJid(pAny.id) === wire ||
1720
+ normalizeJid(store.resolveJid(pAny.id)) === resolved ||
1721
+ (pAny.jid ? toWireJid(pAny.jid) === wire : false) ||
1722
+ (pAny.lid ? toWireJid(pAny.lid) === wire : false));
1723
+ }
1724
+ // Bare number, no domain hint: match by digits against id/jid/lid,
1725
+ // instead of assuming a PN domain that may not be the one WhatsApp
1726
+ // actually uses for this participant (see PN-vs-LID note above).
1727
+ const cands = [pAny.id, pAny.jid, pAny.lid].filter((v) => !!v);
1728
+ return idDigits.length > 0 && cands.some((c) => c.replace(/\D/g, "") === idDigits);
1552
1729
  });
1553
1730
  if (!match) {
1554
1731
  throw new Error(t("driver.groupParticipantNotFound", { id, group: groupJid }));
@@ -1650,6 +1827,12 @@ function buildAdminApi(contract, store, chatJid) {
1650
1827
  async kick(memberIds) {
1651
1828
  requireChat();
1652
1829
  const users = await resolveTargets(chatJid, Array.isArray(memberIds) ? memberIds : [memberIds]);
1830
+ if (users.some((u) => {
1831
+ const n = normalizeJid(u);
1832
+ return (botJid && n === botJid) || (botLid && n === botLid);
1833
+ })) {
1834
+ throw new Error(t("driver.cannotKickSelf"));
1835
+ }
1653
1836
  return runParticipantsUpdate(chatJid, users, "remove");
1654
1837
  },
1655
1838
  /** @param {string|string[]} memberIds — JID (@s.whatsapp.net/@lid), this framework's @c.us form, or a bare phone number */
@@ -1924,7 +2107,9 @@ function buildPollApi(contract, store, rawJid, guardOptions, pluginName) {
1924
2107
  }
1925
2108
  // ── Base API (shared between setup and runtime) ───────────────────────────────
1926
2109
  function buildBaseApi(contract, store, pluginRegistry, pluginName) {
1927
- const botJid = contract.me().id ? jidNormalizedUser(contract.me().id) : null;
2110
+ const me = contract.me();
2111
+ const botJid = me.id ? jidNormalizedUser(me.id) : null;
2112
+ const botLid = me.lid ? normalizeJid(me.lid) : null;
1928
2113
  if (!botJid)
1929
2114
  logger.warn("[pluginApi] botId is null — socket may not be ready yet.");
1930
2115
  return {
@@ -1937,9 +2122,9 @@ function buildBaseApi(contract, store, pluginRegistry, pluginName) {
1937
2122
  scheduler: buildSchedulerApi(pluginName),
1938
2123
  plugins: buildPluginsApi(pluginRegistry),
1939
2124
  chats: buildChatsApi(store),
1940
- contacts: buildContactsApi(contract, store, botJid),
2125
+ contacts: buildContactsApi(contract, store, botJid, botLid),
1941
2126
  storage: buildStorageApi(pluginName),
1942
- botId: botJid,
2127
+ botId: botLid ?? botJid,
1943
2128
  commands: buildCommandsApi(),
1944
2129
  };
1945
2130
  }
@@ -2019,7 +2204,7 @@ function buildRunCommandFacet(msg, chat, contract, store, pluginRegistry, caller
2019
2204
  * @param {object} [params.guardOptions]
2020
2205
  */
2021
2206
  export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginName, guardOptions = {}, }) {
2022
- const prefix = CONFIG.CMD_PREFIX;
2207
+ const prefix = getChatPrefix(msg.chatId);
2023
2208
  const body = getMsgBody(msg);
2024
2209
  const rawArgs = body.trim().split(/\s+/);
2025
2210
  const first = rawArgs[0]?.toLowerCase() ?? "";
@@ -2027,7 +2212,7 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2027
2212
  const command = hasPrefix ? first.slice(prefix.length) : "";
2028
2213
  const rawJid = msg.chatId;
2029
2214
  const normJid = normalizeJid(rawJid);
2030
- const sender = getMsgSender(msg, store);
2215
+ const sender = getMsgSender(msg);
2031
2216
  const cooldown = (guardOptions.cooldown ?? true);
2032
2217
  const jitter = (guardOptions.jitter ?? true);
2033
2218
  bindGroupMetaInvalidation(contract);
@@ -2097,6 +2282,9 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2097
2282
  },
2098
2283
  /**
2099
2284
  * Check if a contact is an admin of this group.
2285
+ * Always fetches fresh (bypasses the group-metadata TTL cache) —
2286
+ * this gates the `admin:` command permission, so it cannot risk
2287
+ * serving a stale "still admin" result after a demote.
2100
2288
  * @param {string} contactId
2101
2289
  * @returns {Promise<boolean>}
2102
2290
  */
@@ -2104,7 +2292,7 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2104
2292
  if (!chat.isGroup)
2105
2293
  return false;
2106
2294
  try {
2107
- const meta = await getGroupMetadataCached(contract, rawJid);
2295
+ const meta = await getGroupMetadataFresh(contract, rawJid);
2108
2296
  return meta.participants.some(p => matchesParticipant([contactId], p.id) && (p.admin === "admin" || p.admin === "superadmin"));
2109
2297
  }
2110
2298
  catch {
@@ -2113,13 +2301,16 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2113
2301
  },
2114
2302
  /**
2115
2303
  * Check if the message sender is an admin of this group.
2304
+ * Always fetches fresh (bypasses the group-metadata TTL cache) — see
2305
+ * {@link isAdmin} for why: this gates the `botAdmin:`/`admin:`
2306
+ * permission checks in commandPermissions.ts.
2116
2307
  * @returns {Promise<boolean>}
2117
2308
  */
2118
2309
  async isSenderAdmin() {
2119
2310
  if (!chat.isGroup)
2120
2311
  return false;
2121
2312
  try {
2122
- const meta = await getGroupMetadataCached(contract, rawJid);
2313
+ const meta = await getGroupMetadataFresh(contract, rawJid);
2123
2314
  // The adapter's pre-extracted participant fields give us both the
2124
2315
  // LID and PN forms of the sender; match either against the group's
2125
2316
  // own participant list.
@@ -2132,6 +2323,9 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2132
2323
  },
2133
2324
  /**
2134
2325
  * Check if the bot is an admin of this group.
2326
+ * Always fetches fresh (bypasses the group-metadata TTL cache) — see
2327
+ * {@link isAdmin} for why: this gates the `botAdmin:` permission
2328
+ * check in commandPermissions.ts.
2135
2329
  * @returns {Promise<boolean>}
2136
2330
  */
2137
2331
  async isBotAdmin() {
@@ -2143,7 +2337,7 @@ export function buildApi({ msg, chat, contract, store, pluginRegistry, pluginNam
2143
2337
  if (!botCandidates.some(Boolean))
2144
2338
  return false;
2145
2339
  try {
2146
- const meta = await getGroupMetadataCached(contract, rawJid);
2340
+ const meta = await getGroupMetadataFresh(contract, rawJid);
2147
2341
  return meta.participants.some(p => matchesParticipant(botCandidates, p.id) && (p.admin === "admin" || p.admin === "superadmin"));
2148
2342
  }
2149
2343
  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
  },