@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
package/README.md CHANGED
@@ -1,6 +1,3 @@
1
- > [!WARNING]
2
- > Versions 5.6.x and 5.7.x are UNSTABLE because of management error. Please use 5.5.4 instead.
3
-
4
1
  <div align="center">
5
2
 
6
3
  ![ManyBot Logo](logo.png)
@@ -66,12 +63,23 @@ npm test
66
63
  # Run all verification gates (typecheck + lint + unit tests)
67
64
  npm run check
68
65
 
69
- # Run real WhatsApp integration tests (requires TEST_CHAT and live session)
70
- TEST_CHAT="5516999999999" npm run test:integration
66
+ # Run real WhatsApp integration tests against a live account.
67
+ # Requires: TEST_CHAT, MANYBOT_RUN_WHATSAPP_TESTS=1, and a saved
68
+ # WhatsApp session in your CONFIG_DIR. The bare variant below
69
+ # always skips every test (no live socket). Use `:local` to boot
70
+ # the bot first — it preloads src/main.ts so the driver connects
71
+ # before the test runner kicks in.
72
+ TEST_CHAT="5516999999999" MANYBOT_RUN_WHATSAPP_TESTS=1 npm run test:integration:local
73
+
74
+ # Manual smoke probe: connects, runs a few marker round-trips, and
75
+ # prints the IContact shape (id / number / numberRaw / numberPretty /
76
+ # country / countryCallingCode) returned by contacts.get(...) — useful
77
+ # for verifying the LID-aware contact API against your real account
78
+ # without the full test runner overhead.
79
+ TEST_CHAT="5516999999999" MANYBOT_RUN_WHATSAPP_TESTS=1 \
80
+ node --import ./src/main.ts scripts/probe-contacts.mjs
71
81
  ```
72
82
 
73
- For full details on the test architecture and API coverage classification, see [API_TEST_PLAN.md](API_TEST_PLAN.md).
74
-
75
83
  ## Contributing
76
84
 
77
85
  All kinds of contributions are welcome:
@@ -18,6 +18,7 @@
18
18
  * @whiskeysockets/baileys import line never leaks past the driver
19
19
  * boundary.
20
20
  */
21
+ import { normalizeJid } from "#drivers/jid.js";
21
22
  // ── Factory ───────────────────────────────────────────────────────────────────
22
23
  /**
23
24
  * Create a new BotStore instance.
@@ -30,9 +31,23 @@ export function createStore() {
30
31
  // @lid JID → traditional @s.whatsapp.net JID, learned from contact and
31
32
  // message-key pairs (Baileys exposes both forms during the LID rollout).
32
33
  const lidMap = new Map();
34
+ // traditional @s.whatsapp.net JID → @lid JID, reverse mapping for lookups.
35
+ const pnMap = new Map();
33
36
  function learnLid(lid, pn) {
34
- if (lid && pn && lid.endsWith("@lid") && !pn.endsWith("@lid"))
37
+ if (lid && pn && lid.endsWith("@lid") && !pn.endsWith("@lid")) {
38
+ // lidMap keeps the wire-format value as given — resolveJid()
39
+ // consumers (e.g. adapter.ts building self/participant candidates
40
+ // via jidNormalizedUser) expect the raw "@s.whatsapp.net" shape.
35
41
  lidMap.set(lid, pn);
42
+ // pnMap's key is canonicalized to ManyBot's internal "@c.us" form
43
+ // so a lookup matches regardless of which format the caller has on
44
+ // hand — callers deep in the driver (adapter.ts) query it with raw
45
+ // wire JIDs, while callers in the plugin-facing API (index.ts) query
46
+ // it with already-normalized JIDs. Without this, pn entries learned
47
+ // in one format silently never matched a resolvePn() call made in
48
+ // the other.
49
+ pnMap.set(normalizeJid(pn), lid);
50
+ }
36
51
  }
37
52
  function resolveJid(jid) {
38
53
  if (!jid || !jid.endsWith("@lid"))
@@ -40,8 +55,16 @@ export function createStore() {
40
55
  return lidMap.get(jid) ?? jid;
41
56
  }
42
57
  function forgetLid(lid) {
58
+ const pn = lidMap.get(lid);
59
+ if (pn)
60
+ pnMap.delete(normalizeJid(pn));
43
61
  lidMap.delete(lid);
44
62
  }
63
+ function resolvePn(pn) {
64
+ if (!pn)
65
+ return null;
66
+ return pnMap.get(normalizeJid(pn)) ?? null;
67
+ }
45
68
  // Coerce the various shapes Baileys delivers `ephemeralExpiration` in
46
69
  // (string-numbered from the wire, `null` when the timer was cleared,
47
70
  // or absent entirely) into a plain finite number. Anything we can't
@@ -223,6 +246,7 @@ export function createStore() {
223
246
  chats: [...chatsMap.values()],
224
247
  contacts: { ...contacts },
225
248
  lidMap: [...lidMap.entries()],
249
+ pnMap: [...pnMap.entries()],
226
250
  };
227
251
  }
228
252
  function hydrate(snapshot) {
@@ -231,6 +255,15 @@ export function createStore() {
231
255
  for (const [id, contact] of Object.entries(snapshot.contacts ?? {})) {
232
256
  contacts[id] = { ...contacts[id], ...contact };
233
257
  }
258
+ // learnLid() always mirrors into both lidMap and pnMap, so replaying
259
+ // snapshot.lidMap alone fully reconstructs pnMap too — every pnMap
260
+ // entry has a corresponding lidMap entry, they're never written
261
+ // independently. snapshot.pnMap itself is redundant to replay here:
262
+ // it exists only so `toJSON()`'s output is self-describing/inspectable,
263
+ // and re-learning from it a second time would re-derive pnMap's key
264
+ // (already-canonicalized "@c.us" form) as if it were lidMap's raw
265
+ // wire-format value, corrupting the wire-format invariant lidMap's
266
+ // consumers (e.g. adapter.ts) depend on.
234
267
  for (const [lid, pn] of snapshot.lidMap ?? [])
235
268
  learnLid(lid, pn);
236
269
  }
@@ -244,6 +277,7 @@ export function createStore() {
244
277
  resolveJid,
245
278
  learnLid,
246
279
  forgetLid,
280
+ resolvePn,
247
281
  setChatEphemeralExpiration,
248
282
  bind,
249
283
  toJSON,
@@ -10,6 +10,9 @@
10
10
  * Usage:
11
11
  * import { enqueue } from "#download";
12
12
  * enqueue(async () => { ... all plugin logic ... }, onError);
13
+ *
14
+ * `errorFn` is optional — if omitted, a failure is still logged via
15
+ * `logger.warn` so it's never silently swallowed.
13
16
  */
14
17
  import { logger } from "#logger";
15
18
  import { t } from "#i18n";
@@ -18,7 +21,8 @@ let processing = false;
18
21
  /**
19
22
  * Add a job to the queue and start processing if idle.
20
23
  * @param workFn All plugin logic — runs exclusively until resolved.
21
- * @param errorFn Called with the thrown error if workFn rejects.
24
+ * @param errorFn Called with the thrown error if workFn rejects. If omitted,
25
+ * the error is logged via `logger.warn` instead.
22
26
  */
23
27
  export function enqueue(workFn, errorFn) {
24
28
  queue.push({ workFn, errorFn });
@@ -41,9 +45,14 @@ async function processJob({ workFn, errorFn }) {
41
45
  catch (e) {
42
46
  const err = e instanceof Error ? e : new Error(String(e));
43
47
  logger.error(t("system.downloadJobFailed", { message: err.message }));
44
- try {
45
- await errorFn(err instanceof Error ? err : new Error(String(err)));
48
+ if (errorFn) {
49
+ try {
50
+ await errorFn(err);
51
+ }
52
+ catch { }
53
+ }
54
+ else {
55
+ logger.warn(t("system.downloadJobNoErrorFn"));
46
56
  }
47
- catch { }
48
57
  }
49
58
  }
@@ -25,6 +25,7 @@
25
25
  import { normalizeMessageContent, downloadMediaMessage, jidNormalizedUser, decryptPollVote as baileysDecryptPollVote, getAggregateVotesInPollMessage, } from "@whiskeysockets/baileys";
26
26
  import { createHash } from "node:crypto";
27
27
  import { logger } from "#logger";
28
+ import { splitLidPn } from "#drivers/jid.js";
28
29
  /**
29
30
  * Classify a Baileys message-content payload (`WAMessageContent`) into
30
31
  * the neutral `(type, body, mimetype)` triple used by `BotMessage`. Pure
@@ -180,7 +181,17 @@ export function createBaileysAdapter(initial) {
180
181
  }
181
182
  return hash.digest("hex");
182
183
  }
183
- /** Translate a Baileys WAMessage into the neutral BotMessage envelope. */
184
+ /** Normalize a mentioned JID: convert PN JIDs to LID when a mapping is known. */
185
+ function normalizeMentionedJid(jid) {
186
+ if (!jid)
187
+ return jid;
188
+ // Already LID — pass through.
189
+ if (jid.endsWith("@lid"))
190
+ return jid;
191
+ // Phone-based JID: look up the LID in the reverse map.
192
+ const lid = store.resolvePn(jid);
193
+ return lid ?? jid;
194
+ }
184
195
  function toBotMessage(msg) {
185
196
  const m = normalizeMessageContent(msg.message) ?? undefined;
186
197
  const { type, body, mimetype } = decodeContent(msg.message);
@@ -195,6 +206,11 @@ export function createBaileysAdapter(initial) {
195
206
  m?.buttonsMessage?.contextInfo ??
196
207
  undefined;
197
208
  const ciTyped = contextInfo;
209
+ // See splitLidPn() — `key.participant`/`key.remoteJid` are only the PN
210
+ // form under legacy `addressingMode: "pn"`; under the modern default
211
+ // "lid" mode they're already the LID and the *Alt field carries the PN.
212
+ const participantIds = splitLidPn(key.participant, key.participantAlt);
213
+ const remoteJidIds = splitLidPn(key.remoteJid, key.remoteJidAlt);
198
214
  return {
199
215
  id: msg.key.id ?? "",
200
216
  chatId: msg.key.remoteJid ?? "",
@@ -205,17 +221,23 @@ export function createBaileysAdapter(initial) {
205
221
  body,
206
222
  mimetype: mimetype ?? undefined,
207
223
  pushName: msg.pushName,
208
- mentionedJid: ciTyped?.mentionedJid ?? undefined,
224
+ mentionedJid: ciTyped?.mentionedJid?.map(normalizeMentionedJid) ?? undefined,
209
225
  quotedKey: ciTyped?.stanzaId ? {
210
226
  id: ciTyped.stanzaId,
211
227
  remoteJid: msg.key.remoteJid ?? undefined,
212
228
  fromMe: false,
213
229
  participant: ciTyped.participant ?? undefined,
214
230
  } : undefined,
215
- fromLid: key.participantAlt,
216
- fromPn: key.participant,
217
- participantAlt: key.participantAlt,
218
- remoteJidAlt: key.remoteJidAlt,
231
+ // Resolve LID/PN by actual JID suffix, not by field position — see
232
+ // splitLidPn() for why `key.participantAlt` can't be trusted to
233
+ // always be the LID. `participantAlt`/`remoteJidAlt` below are
234
+ // reassigned to the suffix-verified LID (or `undefined` if neither
235
+ // candidate is one), so every existing consumer that reads them
236
+ // directly gets the corrected value for free.
237
+ fromLid: participantIds.lid,
238
+ fromPn: participantIds.pn,
239
+ participantAlt: participantIds.lid,
240
+ remoteJidAlt: remoteJidIds.lid,
219
241
  // Driver-specific escape hatches:
220
242
  // - pollEncKeyRaw: poll-decryption key for vote decryption
221
243
  // - contextInfo: full IContextInfo (incl. embedded quotedMessage)
@@ -478,6 +500,16 @@ export function createBaileysAdapter(initial) {
478
500
  if (rawDuration !== undefined) {
479
501
  store.setChatEphemeralExpiration(jid, Number(rawDuration) || 0);
480
502
  }
503
+ // Baileys v7's GroupMetadata carries per-participant `id` (LID, the
504
+ // addressing mode the group uses) AND `phoneNumber` (the traditional
505
+ // PN form). Feed the LID↔PN store for any participant that carries
506
+ // both — passive cache, never a network call.
507
+ for (const p of meta.participants) {
508
+ const lid = p.id;
509
+ const phone = p.phoneNumber;
510
+ if (lid && phone)
511
+ store.learnLid(lid, phone);
512
+ }
481
513
  return {
482
514
  subject: meta.subject,
483
515
  participants: meta.participants.map(p => ({
@@ -727,8 +759,37 @@ export function createBaileysAdapter(initial) {
727
759
  emit("contacts.update", { updates: arg.map(contactSummary) });
728
760
  });
729
761
  register("group-participants.update", (arg) => {
730
- const { id, participants } = arg;
731
- emit("group-participants.update", { id, participants });
762
+ // Baileys v7 ships `participants` as GroupParticipant[] (Contact & { admin… }),
763
+ // carrying `id`, `lid?`, and `phoneNumber?` per entry. The kernel-public
764
+ // contract still uses `string[]` of JIDs (one per participant), so we
765
+ // project `id` down here — but we also take the opportunity to feed the
766
+ // LID↔PN store from the richer data while we have it: when a single
767
+ // payload delivers both forms of the same identity, that's the cleanest
768
+ // mapping we can ever hope to learn.
769
+ const a = arg;
770
+ const jids = a.participants.map(p => jidNormalizedUser(p.id));
771
+ for (const p of a.participants) {
772
+ // v7 sometimes provides both forms; sometimes only one. Use whichever
773
+ // is available — learnLid() filters out incomplete or non-LID inputs.
774
+ // The richest case is `lid` + `phoneNumber` both explicit; otherwise
775
+ // we accept `lid`+`id` or `id`+`phoneNumber` (Baileys commonly puts
776
+ // the LID in `id` and the PN in `phoneNumber` on group metadata).
777
+ if (p.lid && p.phoneNumber)
778
+ store.learnLid(p.lid, p.phoneNumber);
779
+ else if (p.lid && p.id)
780
+ store.learnLid(p.lid, p.id);
781
+ else if (p.id && p.phoneNumber)
782
+ store.learnLid(p.id, p.phoneNumber);
783
+ }
784
+ // authorPn (the inviter's PN) is the same source of truth — feed it too.
785
+ if (a.author && a.authorPn)
786
+ store.learnLid(a.author, a.authorPn);
787
+ emit("group-participants.update", {
788
+ id: a.id,
789
+ author: a.author,
790
+ participants: jids,
791
+ action: a.action,
792
+ });
732
793
  });
733
794
  register("groups.upsert", (arg) => {
734
795
  const groups = arg;
@@ -738,7 +799,13 @@ export function createBaileysAdapter(initial) {
738
799
  emit("groups.update", { updates: arg });
739
800
  });
740
801
  register("group.join-request", (arg) => {
802
+ // Baileys v7 emits `participantPn` (and `authorPn`) on the join-request
803
+ // payload — feed the LID↔PN store from those while we have both sides.
741
804
  const a = arg;
805
+ if (a.participant && a.participantPn)
806
+ store.learnLid(a.participant, a.participantPn);
807
+ if (a.author && a.authorPn)
808
+ store.learnLid(a.author, a.authorPn);
742
809
  emit("group.join-request", {
743
810
  id: a.id,
744
811
  author: a.author,
@@ -0,0 +1,261 @@
1
+ /**
2
+ * src/drivers/baileys/api/contacts.integration.test.ts
3
+ *
4
+ * Real-WhatsApp integration tests for the LID-aware contact API surface
5
+ * (`IContact.id` is LID-or-null, `number`/`numberRaw`/`numberPretty`/
6
+ * `country`/`countryCallingCode` populated by libphonenumber-js).
7
+ *
8
+ * Exercises the full driver stack end-to-end:
9
+ * - The integration plugin (gated by `MANYBOT_RUN_WHATSAPP_TESTS=1`)
10
+ * drives real sends + listens for the test marker's echo.
11
+ * - Each test sends a uniquely-prefixed message into the configured
12
+ * `TEST_CHAT`, waits for the round-trip, then reads
13
+ * `ctx.contacts.get(...)` from a freshly-built plugin context and
14
+ * asserts the new shape is honored against a live WhatsApp account.
15
+ *
16
+ * All tests skip when integration mode is not fully ready (no opt-in,
17
+ * no `TEST_CHAT`, or no saved WhatsApp session). Skipped tests do not
18
+ * count as failures — they're informational.
19
+ *
20
+ * Requires:
21
+ * - `MANYBOT_RUN_WHATSAPP_TESTS=1`
22
+ * - `TEST_CHAT="<phone>"` (env or manybot.toml) — MUST be an
23
+ * individual chat (a bare phone number, or a JID ending in
24
+ * `@s.whatsapp.net`/`@c.us`/`@lid`), NOT a group (`@g.us`).
25
+ * Every assertion below reads per-person contact fields (LID,
26
+ * E.164 number, numberPretty, country, countryCallingCode) —
27
+ * `normalizeContact()` (src/drivers/baileys/api/index.ts) never
28
+ * populates any of those for a group JID, since a group has no
29
+ * LID or phone number of its own. Sending/receiving still works
30
+ * fine against a group (the round-trip itself doesn't care), so
31
+ * a group `TEST_CHAT` will pass the integration-mode gate and
32
+ * even echo markers successfully, then fail every shape
33
+ * assertion below with `expected: /@lid$/, actual: "...@g.us"`
34
+ * (and `null` for number/country/etc.) — if you see that
35
+ * specific failure pattern, point TEST_CHAT at a DM instead.
36
+ * - A logged-in WhatsApp session (creds.json + app-state-sync-… in
37
+ * CONFIG_DIR, same as a normal run).
38
+ * - Either:
39
+ * - `npm run test:integration:local` — boots `src/main.ts` first
40
+ * so the bot connects and the integration plugin is registered;
41
+ * - a one-shot manual run with the integration plugin loaded by
42
+ * the caller (see scripts/probe-contacts.mjs).
43
+ *
44
+ * `npm run test:integration` (without `:local`) is supported but every
45
+ * test will skip because no live socket exists in that mode.
46
+ */
47
+ import test, { describe } from "node:test";
48
+ import assert from "node:assert/strict";
49
+ import { loadIntegrationPlugin, pluginRegistry, getGlobalKernelRefs, } from "#kernel/pluginLoader.js";
50
+ import { getIntegrationModeStatus, INTEGRATION_PLUGIN_NAME } from "#kernel/integrationMode.js";
51
+ import { logger } from "#logger";
52
+ // ── Integration-mode gate (resolved at top level) ────────────────────────────
53
+ //
54
+ // Two distinct gates need to pass before a test runs:
55
+ //
56
+ // 1. Configuration gate (`getIntegrationModeStatus().ready`):
57
+ // MANYBOT_RUN_WHATSAPP_TESTS=1 is set AND TEST_CHAT is configured
58
+ // (env or manybot.toml). This is what `npm run test:integration`
59
+ // auto-enables via the script's env prefix.
60
+ //
61
+ // 2. Live-socket gate (`getGlobalKernelRefs() !== null`):
62
+ // The Baileys driver has actually connected at least once so a
63
+ // `WaContract` is available for real sends. With
64
+ // `npm run test:integration:local` (which boots main.ts first),
65
+ // this resolves as soon as the bot connects. With the bare
66
+ // `npm run test:integration` (no main.ts preload), it never
67
+ // resolves and every test skips — which is correct.
68
+ //
69
+ // We resolve both gates up-front (top-level await) instead of in a
70
+ // `before()` hook so the `{ skip: !integrationReady }` option on
71
+ // each `test()` call is honored by the runner — an early-return
72
+ // inside the test body looks like `pass: N` in CI output, which is
73
+ // confusing when scanning for "did this actually run?".
74
+ let integrationReady = false;
75
+ let integrationSkipReason = "not evaluated";
76
+ let integrationChat = null;
77
+ let kernelRefs = null;
78
+ async function evaluateGates() {
79
+ const status = await getIntegrationModeStatus();
80
+ const configReady = status.ready;
81
+ const configReason = status.reason ?? "";
82
+ integrationChat = status.chat;
83
+ // Make sure the integration plugin is registered — its `setup()`
84
+ // populates `api.testChat` and `api.waitForMarker`, both needed by
85
+ // the test helpers below. When the bot is already running (the
86
+ // `:local` variant), `loadPlugins`/`setupPlugins` ran during boot
87
+ // and called `loadIntegrationPlugin()` for us; `loadIntegrationPlugin`
88
+ // is idempotent so calling it again here is safe.
89
+ if (configReady) {
90
+ try {
91
+ await loadIntegrationPlugin();
92
+ }
93
+ catch (e) {
94
+ logger.warn(`[contacts.integration] loadIntegrationPlugin failed: ${e.message}`);
95
+ }
96
+ }
97
+ // With `npm run test:integration:local`, main.ts is preloaded and
98
+ // begins connecting as soon as it's imported — the test runner may
99
+ // execute this file before the connection has resolved. Poll for
100
+ // the global refs for up to 60s before giving up; this lets
101
+ // contributors run the suite immediately after `node main.ts` and
102
+ // have it work without manual timing.
103
+ const SOCKET_WAIT_MS = 60_000;
104
+ const deadline = Date.now() + SOCKET_WAIT_MS;
105
+ while (Date.now() < deadline) {
106
+ kernelRefs = getGlobalKernelRefs();
107
+ if (kernelRefs)
108
+ break;
109
+ await new Promise((r) => setTimeout(r, 250));
110
+ }
111
+ const socketReady = kernelRefs !== null;
112
+ integrationReady = configReady && socketReady;
113
+ integrationSkipReason = !configReady
114
+ ? configReason
115
+ : `bot did not connect within ${SOCKET_WAIT_MS / 1000}s — check the main.ts preload output for connection errors`;
116
+ if (integrationReady) {
117
+ logger.info(`[contacts.integration] integration mode ready — chat=${integrationChat} ` +
118
+ `kernelRefs=${!!kernelRefs}`);
119
+ }
120
+ else {
121
+ logger.warn(`[contacts.integration] integration mode NOT ready — ${integrationSkipReason}. ` +
122
+ `Every test in this file will skip.`);
123
+ }
124
+ }
125
+ // Resolve the gates up-front. Top-level await pauses module
126
+ // initialization until `evaluateGates()` resolves, so by the time any
127
+ // `test()` call below is reached the `integrationReady` flag is
128
+ // stable. This is what lets us pass `{ skip: !integrationReady }` to
129
+ // each test — `node:test` honors the flag at test-definition time.
130
+ await evaluateGates();
131
+ function getIntegrationApi() {
132
+ const entry = pluginRegistry.get(INTEGRATION_PLUGIN_NAME);
133
+ if (!entry || entry.status !== "active")
134
+ return null;
135
+ return entry.exports;
136
+ }
137
+ const ROUND_TRIP_TIMEOUT_MS = 30_000;
138
+ /**
139
+ * Send a marker-prefixed message into the test chat and wait for an
140
+ * inbound message with the same marker — exercises the round-trip
141
+ * (BotStore + adapter + listener + contact store feeding) that the
142
+ * LID/PN resolution depends on.
143
+ */
144
+ async function sendAndAwaitEcho(api, contract, marker, opts) {
145
+ await contract.sendText(api.testChat, marker, opts);
146
+ await api.waitForMarker(marker, ROUND_TRIP_TIMEOUT_MS);
147
+ }
148
+ // ── Tests ────────────────────────────────────────────────────────────────────
149
+ //
150
+ // All tests below pass `{ skip: !integrationReady }` so they report
151
+ // as `skipped` in CI output (not `pass`) when the integration suite
152
+ // can't actually run. The `if (!integrationReady) return;` guard at
153
+ // the top of each test body is belt-and-braces — if the skip flag is
154
+ // ever ignored (e.g. someone wires this suite into a different
155
+ // runner), the test still no-ops instead of crashing on a null
156
+ // `integrationChat`.
157
+ describe("contacts.integration — gate", () => {
158
+ test("integration mode reports ready (sanity)", { skip: !integrationReady }, () => {
159
+ assert.ok(integrationSkipReason, "skip reason must explain the skip");
160
+ assert.equal(integrationReady, true, "integration mode must be ready for this suite to run");
161
+ assert.ok(integrationChat, "integration chat must be configured when ready");
162
+ assert.ok(kernelRefs, "live WaContract must be available when integration mode is ready");
163
+ });
164
+ });
165
+ describe("contacts.integration — IContact shape on a live WhatsApp account", () => {
166
+ test("id is the LID form (preferred canonical identifier)", { skip: !integrationReady }, async () => {
167
+ const api = getIntegrationApi();
168
+ assert.ok(api && kernelRefs, "integration api + kernel refs must be available");
169
+ const marker = `ICONTACT-ID-${Date.now()}-`;
170
+ await sendAndAwaitEcho(api, kernelRefs.contract, marker);
171
+ const me = await (await import("#drivers/baileys/api/index.js")).buildContactsApi(kernelRefs.contract, kernelRefs.store, null).get(api.testChat);
172
+ assert.ok(me, "expected contacts.get to resolve the test chat");
173
+ assert.match(me.id ?? "", /@lid$/, `expected id to be @lid form, got ${me.id}`);
174
+ });
175
+ test("number is canonical E.164 with leading +", { skip: !integrationReady }, async () => {
176
+ const api = getIntegrationApi();
177
+ assert.ok(api && kernelRefs);
178
+ const marker = `ICONTACT-NUMBER-${Date.now()}-`;
179
+ await sendAndAwaitEcho(api, kernelRefs.contract, marker);
180
+ const me = await (await import("#drivers/baileys/api/index.js")).buildContactsApi(kernelRefs.contract, kernelRefs.store, null).get(api.testChat);
181
+ assert.match(me.number ?? "", /^\+\d+$/, `expected E.164 with +, got ${me.number}`);
182
+ });
183
+ test("numberPretty is internationally formatted", { skip: !integrationReady }, async () => {
184
+ const api = getIntegrationApi();
185
+ assert.ok(api && kernelRefs);
186
+ const marker = `ICONTACT-PRETTY-${Date.now()}-`;
187
+ await sendAndAwaitEcho(api, kernelRefs.contract, marker);
188
+ const me = await (await import("#drivers/baileys/api/index.js")).buildContactsApi(kernelRefs.contract, kernelRefs.store, null).get(api.testChat);
189
+ assert.ok((me.numberPretty ?? "").startsWith("+"), `expected pretty form to start with +, got ${me.numberPretty}`);
190
+ });
191
+ test("country is ISO alpha-2", { skip: !integrationReady }, async () => {
192
+ const api = getIntegrationApi();
193
+ assert.ok(api && kernelRefs);
194
+ const marker = `ICONTACT-COUNTRY-${Date.now()}-`;
195
+ await sendAndAwaitEcho(api, kernelRefs.contract, marker);
196
+ const me = await (await import("#drivers/baileys/api/index.js")).buildContactsApi(kernelRefs.contract, kernelRefs.store, null).get(api.testChat);
197
+ assert.match(me.country ?? "", /^[A-Z]{2}$/, `expected ISO alpha-2, got ${me.country}`);
198
+ });
199
+ test("countryCallingCode is the ITU dial code", { skip: !integrationReady }, async () => {
200
+ const api = getIntegrationApi();
201
+ assert.ok(api && kernelRefs);
202
+ const marker = `ICONTACT-CC-${Date.now()}-`;
203
+ await sendAndAwaitEcho(api, kernelRefs.contract, marker);
204
+ const me = await (await import("#drivers/baileys/api/index.js")).buildContactsApi(kernelRefs.contract, kernelRefs.store, null).get(api.testChat);
205
+ assert.match(me.countryCallingCode ?? "", /^\d{1,4}$/, `expected ITU dial code, got ${me.countryCallingCode}`);
206
+ });
207
+ });
208
+ describe("contacts.integration — LID↔PN cache populated by a real round-trip", () => {
209
+ test("after one round-trip the @s.whatsapp.net form resolves back to LID", { skip: !integrationReady }, async () => {
210
+ const api = getIntegrationApi();
211
+ assert.ok(api && kernelRefs);
212
+ const marker = `PNMAP-CACHE-${Date.now()}-`;
213
+ await sendAndAwaitEcho(api, kernelRefs.contract, marker);
214
+ const lid = kernelRefs.store.resolvePn(api.testChat);
215
+ assert.ok(lid, `expected pnMap to have learned the LID for ${api.testChat}`);
216
+ assert.match(lid, /@lid$/, `expected resolved LID, got ${lid}`);
217
+ });
218
+ test("contacts.get(normalizedPn) returns LID-backed IContact after cache warmup", { skip: !integrationReady }, async () => {
219
+ const api = getIntegrationApi();
220
+ assert.ok(api && kernelRefs);
221
+ const marker = `GET-PN-RESOLVES-LID-${Date.now()}-`;
222
+ await sendAndAwaitEcho(api, kernelRefs.contract, marker);
223
+ const me = await (await import("#drivers/baileys/api/index.js")).buildContactsApi(kernelRefs.contract, kernelRefs.store, null).get(api.testChat);
224
+ assert.match(me.id ?? "", /@lid$/, "expected LID form after cache warmup");
225
+ });
226
+ });
227
+ describe("contacts.integration — mentionedJid is normalized to LID form", () => {
228
+ test("sending a real mention succeeds and the store learns a LID↔PN pair", { skip: !integrationReady }, async () => {
229
+ const api = getIntegrationApi();
230
+ assert.ok(api && kernelRefs);
231
+ const marker = `MENTION-LID-${Date.now()}-`;
232
+ // WaContract.sendText() already accepts `mentions` — no need to reach
233
+ // into Baileys internals to send a real mention (see waContract.ts).
234
+ // Modern WhatsApp delivers contextInfo.mentionedJid in @lid form
235
+ // already (confirmed against WhiskeySockets/Baileys#1683/#1667), so
236
+ // there's no PN→LID "resolution" for a mention to teach — this test
237
+ // just proves the send round-trips and, if the account has a phone
238
+ // number mapping learned along the way (e.g. via the contact/message
239
+ // sync that accompanies any real WhatsApp exchange), the store
240
+ // reflects it.
241
+ await sendAndAwaitEcho(api, kernelRefs.contract, marker, { mentions: [api.testChat] });
242
+ const bodies = api.recentBodies();
243
+ assert.ok(bodies.some((b) => b.startsWith(marker)), `expected recent bodies to include "${marker}" — got ${JSON.stringify(bodies)}`);
244
+ const lidKeys = Object.keys(kernelRefs.store.contacts).filter((k) => k.endsWith("@lid"));
245
+ assert.ok(lidKeys.length > 0, `expected store.contacts to contain at least one @lid key after mention round-trip — got ${JSON.stringify(Object.keys(kernelRefs.store.contacts))}`);
246
+ });
247
+ });
248
+ // ── Live-harness readiness probe ────────────────────────────────────────────
249
+ //
250
+ // Single test that fails (rather than skips) when the harness is
251
+ // missing a piece — useful so a contributor who has TEST_CHAT + a
252
+ // session can immediately see what's still left to wire. Skips
253
+ // cleanly when integration mode isn't ready at all.
254
+ describe("contacts.integration — harness readiness", () => {
255
+ test("integration plugin is registered and active", { skip: !integrationReady }, () => {
256
+ const entry = pluginRegistry.get(INTEGRATION_PLUGIN_NAME);
257
+ assert.ok(entry, `expected "${INTEGRATION_PLUGIN_NAME}" to be registered in pluginRegistry`);
258
+ assert.equal(entry.status, "active", `expected plugin status "active", got "${entry.status}"`);
259
+ assert.ok(entry.exports, "expected integration plugin exports to be populated");
260
+ });
261
+ });