@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.
- package/README.md +28 -3
- package/dist/client/banner.js +10 -0
- package/dist/client/banner.test.js +31 -0
- package/dist/client/store.js +91 -6
- package/dist/client/store.test.js +170 -0
- package/dist/config.js +28 -44
- package/dist/config.test.js +26 -0
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +133 -15
- package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
- package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
- package/dist/drivers/baileys/api/index.js +384 -62
- package/dist/drivers/baileys/index.js +92 -36
- package/dist/drivers/baileys/loginPrompt.js +0 -2
- package/dist/drivers/baileys/messageHandler.js +344 -4
- package/dist/drivers/baileys/messageHandler.test.js +445 -0
- package/dist/drivers/baileysAdapter.test.js +378 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +74 -0
- package/dist/drivers/types.js +5 -5
- package/dist/i18n/index.js +20 -24
- package/dist/kernel/activeDriverSend.js +21 -0
- package/dist/kernel/activeDriverSend.test.js +89 -0
- package/dist/kernel/alerts.js +3 -9
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/chatSession.js +65 -0
- package/dist/kernel/chatSession.test.js +46 -0
- package/dist/kernel/commandAccess.js +66 -0
- package/dist/kernel/commandAccess.test.js +74 -0
- package/dist/kernel/commandDeprecation.js +170 -0
- package/dist/kernel/commandDeprecation.test.js +114 -0
- package/dist/kernel/commandMenu.js +357 -0
- package/dist/kernel/commandMenu.test.js +363 -0
- package/dist/kernel/commandPermissions.js +171 -0
- package/dist/kernel/commandPermissions.test.js +227 -0
- package/dist/kernel/commandRegistry.js +583 -0
- package/dist/kernel/commandRegistry.test.js +158 -0
- package/dist/kernel/commandsConfig.js +949 -0
- package/dist/kernel/commandsConfig.test.js +482 -0
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/contactAutoSave.test.js +87 -0
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/driverManager.js +10 -6
- package/dist/kernel/driverManager.test.js +90 -0
- package/dist/kernel/integrationMode.js +88 -0
- package/dist/kernel/integrationMode.test.js +95 -0
- package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
- package/dist/kernel/pluginApi.test.js +600 -0
- package/dist/kernel/pluginGuard.js +18 -13
- package/dist/kernel/pluginGuard.test.js +39 -0
- package/dist/kernel/pluginLoader.js +169 -11
- package/dist/kernel/pluginLoader.test.js +190 -0
- package/dist/kernel/runCommand.js +284 -0
- package/dist/kernel/runCommand.test.js +497 -0
- package/dist/kernel/sendFallbackGuard.js +19 -48
- package/dist/kernel/sendFallbackGuard.test.js +80 -0
- package/dist/kernel/sendGuard.js +38 -42
- package/dist/kernel/sendGuard.test.js +102 -0
- package/dist/kernel/settingsDb.js +19 -5
- package/dist/kernel/statusServer.js +9 -2
- package/dist/kernel/statusServer.test.js +70 -0
- package/dist/kernel/testConfig.js +192 -0
- package/dist/kernel/testConfig.test.js +181 -0
- package/dist/kernel/updateCheck.js +33 -10
- package/dist/locales/en.json +77 -13
- package/dist/locales/es.json +77 -13
- package/dist/locales/pt.json +77 -13
- package/dist/logger/logger.js +23 -3
- package/dist/logger/logger.test.js +45 -0
- package/dist/main.js +5 -76
- package/dist/plugins/__manybot_integration__/index.js +184 -0
- package/dist/plugins/__manybot_integration__/index.test.js +218 -0
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +76 -18
- package/dist/drivers/whatsmeow/client.js +0 -252
- package/dist/drivers/whatsmeow/index.js +0 -79
- package/dist/drivers/whatsmeow/installer.js +0 -86
- package/dist/drivers/whatsmeow/supervisor.js +0 -328
- 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
|
-
/**
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 =
|
|
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,
|
|
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,
|
|
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,
|
|
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 },
|
|
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 },
|
|
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 },
|
|
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
|
-
|
|
680
|
-
|
|
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
|
+
});
|