@manybot/manybot 5.7.0 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +28 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +91 -6
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/download/queue.js +13 -4
  9. package/dist/drivers/baileys/adapter.js +133 -15
  10. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  11. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  12. package/dist/drivers/baileys/api/index.js +384 -62
  13. package/dist/drivers/baileys/index.js +92 -36
  14. package/dist/drivers/baileys/loginPrompt.js +0 -2
  15. package/dist/drivers/baileys/messageHandler.js +344 -4
  16. package/dist/drivers/baileys/messageHandler.test.js +445 -0
  17. package/dist/drivers/baileysAdapter.test.js +378 -0
  18. package/dist/drivers/jid.js +26 -0
  19. package/dist/drivers/jid.test.js +74 -0
  20. package/dist/drivers/types.js +5 -5
  21. package/dist/i18n/index.js +20 -24
  22. package/dist/kernel/activeDriverSend.js +21 -0
  23. package/dist/kernel/activeDriverSend.test.js +89 -0
  24. package/dist/kernel/alerts.js +3 -9
  25. package/dist/kernel/chatOverrides.js +46 -0
  26. package/dist/kernel/chatOverrides.test.js +59 -0
  27. package/dist/kernel/chatSession.js +65 -0
  28. package/dist/kernel/chatSession.test.js +46 -0
  29. package/dist/kernel/commandAccess.js +66 -0
  30. package/dist/kernel/commandAccess.test.js +74 -0
  31. package/dist/kernel/commandDeprecation.js +170 -0
  32. package/dist/kernel/commandDeprecation.test.js +114 -0
  33. package/dist/kernel/commandMenu.js +357 -0
  34. package/dist/kernel/commandMenu.test.js +363 -0
  35. package/dist/kernel/commandPermissions.js +171 -0
  36. package/dist/kernel/commandPermissions.test.js +227 -0
  37. package/dist/kernel/commandRegistry.js +583 -0
  38. package/dist/kernel/commandRegistry.test.js +158 -0
  39. package/dist/kernel/commandsConfig.js +949 -0
  40. package/dist/kernel/commandsConfig.test.js +482 -0
  41. package/dist/kernel/contactAutoSave.js +6 -6
  42. package/dist/kernel/contactAutoSave.test.js +87 -0
  43. package/dist/kernel/coreCommands.js +62 -0
  44. package/dist/kernel/driverManager.js +10 -6
  45. package/dist/kernel/driverManager.test.js +90 -0
  46. package/dist/kernel/integrationMode.js +88 -0
  47. package/dist/kernel/integrationMode.test.js +95 -0
  48. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  49. package/dist/kernel/pluginApi.test.js +600 -0
  50. package/dist/kernel/pluginGuard.js +18 -13
  51. package/dist/kernel/pluginGuard.test.js +39 -0
  52. package/dist/kernel/pluginLoader.js +169 -11
  53. package/dist/kernel/pluginLoader.test.js +190 -0
  54. package/dist/kernel/runCommand.js +284 -0
  55. package/dist/kernel/runCommand.test.js +497 -0
  56. package/dist/kernel/sendFallbackGuard.js +19 -48
  57. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  58. package/dist/kernel/sendGuard.js +38 -42
  59. package/dist/kernel/sendGuard.test.js +102 -0
  60. package/dist/kernel/settingsDb.js +19 -5
  61. package/dist/kernel/statusServer.js +9 -2
  62. package/dist/kernel/statusServer.test.js +70 -0
  63. package/dist/kernel/testConfig.js +192 -0
  64. package/dist/kernel/testConfig.test.js +181 -0
  65. package/dist/kernel/updateCheck.js +33 -10
  66. package/dist/locales/en.json +77 -13
  67. package/dist/locales/es.json +77 -13
  68. package/dist/locales/pt.json +77 -13
  69. package/dist/logger/logger.js +23 -3
  70. package/dist/logger/logger.test.js +45 -0
  71. package/dist/main.js +5 -76
  72. package/dist/plugins/__manybot_integration__/index.js +184 -0
  73. package/dist/plugins/__manybot_integration__/index.test.js +218 -0
  74. package/dist/utils/phoneNumber.js +83 -0
  75. package/dist/utils/phoneNumber.test.js +53 -0
  76. package/package.json +76 -18
  77. package/dist/drivers/whatsmeow/client.js +0 -252
  78. package/dist/drivers/whatsmeow/index.js +0 -79
  79. package/dist/drivers/whatsmeow/installer.js +0 -86
  80. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  81. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
@@ -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
@@ -72,6 +73,22 @@ export function decodeContent(content) {
72
73
  type = "sticker";
73
74
  mimetype = m.stickerMessage.mimetype ?? undefined;
74
75
  }
76
+ else if (m?.templateMessage) {
77
+ type = "text";
78
+ const tpl = m.templateMessage.hydratedTemplate ?? m.templateMessage.hydratedFourRowTemplate;
79
+ const buttonUrls = tpl?.hydratedButtons?.map((b) => b.urlButton?.url).filter(Boolean).join(" ") ?? "";
80
+ body = [tpl?.hydratedContentText ?? "", buttonUrls].filter(Boolean).join(" ");
81
+ }
82
+ else if (m?.interactiveMessage) {
83
+ type = "text";
84
+ const buttonParams = m.interactiveMessage.nativeFlowMessage?.buttons
85
+ ?.map((b) => b.buttonParamsJson).filter(Boolean).join(" ") ?? "";
86
+ body = [m.interactiveMessage.body?.text ?? "", buttonParams].filter(Boolean).join(" ");
87
+ }
88
+ else if (m?.buttonsMessage) {
89
+ type = "text";
90
+ body = [m.buttonsMessage.contentText ?? "", m.buttonsMessage.footerText ?? ""].filter(Boolean).join(" ");
91
+ }
75
92
  return { type, body, mimetype };
76
93
  }
77
94
  export function createBaileysAdapter(initial) {
@@ -115,6 +132,29 @@ export function createBaileysAdapter(initial) {
115
132
  // which throws if `.message` is missing.
116
133
  return { quoted: { key: toFlatKey(quoted), message: inner } };
117
134
  }
135
+ /**
136
+ * Merge `quoted` reply-citation with the chat's current
137
+ * `ephemeralExpiration` so every outgoing send in a chat with a
138
+ * disappearing-message timer inherits it automatically. Returns
139
+ * `undefined` when there's nothing to set, so callers can pass it
140
+ * through as the third argument to `sock.sendMessage` without
141
+ * conditionally building the options object.
142
+ *
143
+ * The timer is read off the in-memory store, which is populated by
144
+ * `chats.upsert` / `chats.update` / `messages.upsert` (via the
145
+ * `ephemeralMessage` envelope) and from `groupMetadata().ephemeralDuration`
146
+ * in this same adapter — i.e. everything WhatsApp tells us about the
147
+ * chat's timer ends up there, and the send path just reads it back.
148
+ */
149
+ function buildSendOpts(jid, quoted) {
150
+ const quotedOpts = buildQuotedOpts(quoted);
151
+ const timer = store.chats.get(jid)?.ephemeralExpiration;
152
+ if (!timer)
153
+ return quotedOpts;
154
+ if (!quotedOpts)
155
+ return { ephemeralExpiration: timer };
156
+ return { ...quotedOpts, ephemeralExpiration: timer };
157
+ }
118
158
  /**
119
159
  * Translate a neutral `BotQuotedRef` into the FLAT key shape Baileys
120
160
  * expects for `react`/`delete`/`edit`/`readMessages` (proto.IMessageKey
@@ -141,7 +181,17 @@ export function createBaileysAdapter(initial) {
141
181
  }
142
182
  return hash.digest("hex");
143
183
  }
144
- /** 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
+ }
145
195
  function toBotMessage(msg) {
146
196
  const m = normalizeMessageContent(msg.message) ?? undefined;
147
197
  const { type, body, mimetype } = decodeContent(msg.message);
@@ -151,8 +201,16 @@ export function createBaileysAdapter(initial) {
151
201
  m?.videoMessage?.contextInfo ??
152
202
  m?.audioMessage?.contextInfo ??
153
203
  m?.documentMessage?.contextInfo ??
204
+ m?.templateMessage?.contextInfo ??
205
+ m?.interactiveMessage?.contextInfo ??
206
+ m?.buttonsMessage?.contextInfo ??
154
207
  undefined;
155
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);
156
214
  return {
157
215
  id: msg.key.id ?? "",
158
216
  chatId: msg.key.remoteJid ?? "",
@@ -163,17 +221,23 @@ export function createBaileysAdapter(initial) {
163
221
  body,
164
222
  mimetype: mimetype ?? undefined,
165
223
  pushName: msg.pushName,
166
- mentionedJid: ciTyped?.mentionedJid ?? undefined,
224
+ mentionedJid: ciTyped?.mentionedJid?.map(normalizeMentionedJid) ?? undefined,
167
225
  quotedKey: ciTyped?.stanzaId ? {
168
226
  id: ciTyped.stanzaId,
169
227
  remoteJid: msg.key.remoteJid ?? undefined,
170
228
  fromMe: false,
171
229
  participant: ciTyped.participant ?? undefined,
172
230
  } : undefined,
173
- fromLid: key.participantAlt,
174
- fromPn: key.participant,
175
- participantAlt: key.participantAlt,
176
- 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,
177
241
  // Driver-specific escape hatches:
178
242
  // - pollEncKeyRaw: poll-decryption key for vote decryption
179
243
  // - contextInfo: full IContextInfo (incl. embedded quotedMessage)
@@ -295,7 +359,7 @@ export function createBaileysAdapter(initial) {
295
359
  async sendText(jid, text, opts) {
296
360
  const content = { text };
297
361
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
298
- const sendOpts = buildQuotedOpts(opts?.quoted);
362
+ const sendOpts = buildSendOpts(jid, opts?.quoted);
299
363
  if (opts?.mentions?.length)
300
364
  content.mentions = opts.mentions;
301
365
  const ref = await sock.sendMessage(jid, content, sendOpts);
@@ -309,7 +373,7 @@ export function createBaileysAdapter(initial) {
309
373
  content.viewOnce = true;
310
374
  if (opts?.mentions?.length)
311
375
  content.mentions = opts.mentions;
312
- const ref = await sock.sendMessage(jid, content, buildQuotedOpts(opts?.quoted));
376
+ const ref = await sock.sendMessage(jid, content, buildSendOpts(jid, opts?.quoted));
313
377
  return toSentRef(ref, jid);
314
378
  },
315
379
  async sendVideo(jid, buffer, opts) {
@@ -322,7 +386,7 @@ export function createBaileysAdapter(initial) {
322
386
  content.gifPlayback = true;
323
387
  if (opts?.mentions?.length)
324
388
  content.mentions = opts.mentions;
325
- const ref = await sock.sendMessage(jid, content, buildQuotedOpts(opts?.quoted));
389
+ const ref = await sock.sendMessage(jid, content, buildSendOpts(jid, opts?.quoted));
326
390
  return toSentRef(ref, jid);
327
391
  },
328
392
  async sendAudio(jid, buffer, opts) {
@@ -332,20 +396,20 @@ export function createBaileysAdapter(initial) {
332
396
  content.ptt = true;
333
397
  if (opts?.viewOnce)
334
398
  content.viewOnce = true;
335
- const ref = await sock.sendMessage(jid, content, buildQuotedOpts(opts?.quoted));
399
+ const ref = await sock.sendMessage(jid, content, buildSendOpts(jid, opts?.quoted));
336
400
  return toSentRef(ref, jid);
337
401
  },
338
402
  async sendSticker(jid, buffer, opts) {
339
- const ref = await sock.sendMessage(jid, { sticker: buffer }, buildQuotedOpts(opts?.quoted));
403
+ const ref = await sock.sendMessage(jid, { sticker: buffer }, buildSendOpts(jid, opts?.quoted));
340
404
  return toSentRef(ref, jid);
341
405
  },
342
406
  async sendDocument(jid, buffer, filename, mimetype, opts) {
343
- const ref = await sock.sendMessage(jid, { document: buffer, mimetype, fileName: filename }, buildQuotedOpts(opts?.quoted));
407
+ const ref = await sock.sendMessage(jid, { document: buffer, mimetype, fileName: filename }, buildSendOpts(jid, opts?.quoted));
344
408
  return toSentRef(ref, jid);
345
409
  },
346
410
  async sendPoll(jid, opts) {
347
411
  const poll = { name: opts.name, values: opts.values, selectableCount: opts.selectableCount ?? 1 };
348
- const ref = await sock.sendMessage(jid, { poll }, buildQuotedOpts(opts.quoted));
412
+ const ref = await sock.sendMessage(jid, { poll }, buildSendOpts(jid, opts.quoted));
349
413
  return toSentRef(ref, jid);
350
414
  },
351
415
  async react(jid, target, emoji) {
@@ -427,6 +491,25 @@ export function createBaileysAdapter(initial) {
427
491
  // ── groups ─────────────────────────────────────────────────────────────
428
492
  async groupMetadata(jid) {
429
493
  const meta = await sock.groupMetadata(jid);
494
+ // WhatsApp reports the group's current disappearing-message
495
+ // timer as `ephemeralDuration` on the metadata payload — promote
496
+ // it to the in-memory store so the next send to this group
497
+ // picks it up automatically (and so the timer survives a
498
+ // snapshot round-trip without us re-fetching metadata).
499
+ const rawDuration = meta.ephemeralDuration;
500
+ if (rawDuration !== undefined) {
501
+ store.setChatEphemeralExpiration(jid, Number(rawDuration) || 0);
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
+ }
430
513
  return {
431
514
  subject: meta.subject,
432
515
  participants: meta.participants.map(p => ({
@@ -676,8 +759,37 @@ export function createBaileysAdapter(initial) {
676
759
  emit("contacts.update", { updates: arg.map(contactSummary) });
677
760
  });
678
761
  register("group-participants.update", (arg) => {
679
- const { id, participants } = arg;
680
- 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
+ });
681
793
  });
682
794
  register("groups.upsert", (arg) => {
683
795
  const groups = arg;
@@ -687,7 +799,13 @@ export function createBaileysAdapter(initial) {
687
799
  emit("groups.update", { updates: arg });
688
800
  });
689
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.
690
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);
691
809
  emit("group.join-request", {
692
810
  id: a.id,
693
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
+ });