@manybot/manybot 5.8.0 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -7
- package/dist/client/store.js +35 -1
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +75 -8
- 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 +212 -38
- package/dist/drivers/baileys/index.js +30 -6
- package/dist/drivers/baileys/messageHandler.js +207 -21
- package/dist/drivers/baileys/messageHandler.test.js +256 -14
- package/dist/drivers/baileysAdapter.test.js +97 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +35 -1
- package/dist/i18n/index.js +5 -22
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/commandAccess.test.js +2 -2
- package/dist/kernel/commandDeprecation.js +4 -2
- package/dist/kernel/commandDeprecation.test.js +8 -1
- package/dist/kernel/commandMenu.js +91 -2
- package/dist/kernel/commandMenu.test.js +131 -2
- package/dist/kernel/commandPermissions.js +69 -23
- package/dist/kernel/commandPermissions.test.js +77 -9
- package/dist/kernel/commandRegistry.js +167 -43
- package/dist/kernel/commandRegistry.test.js +4 -2
- package/dist/kernel/commandsConfig.js +470 -38
- package/dist/kernel/commandsConfig.test.js +249 -3
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/pluginApi.test.js +20 -3
- package/dist/kernel/pluginGuard.js +5 -3
- package/dist/kernel/pluginLoader.js +73 -10
- package/dist/kernel/pluginLoader.test.js +111 -1
- package/dist/kernel/runCommand.js +57 -18
- package/dist/kernel/runCommand.test.js +269 -7
- package/dist/kernel/settingsDb.js +15 -2
- package/dist/kernel/testConfig.js +9 -0
- package/dist/locales/en.json +14 -1
- package/dist/locales/es.json +17 -4
- package/dist/locales/pt.json +18 -5
- package/dist/plugins/__manybot_integration__/index.js +33 -16
- package/dist/plugins/__manybot_integration__/index.test.js +42 -8
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- 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
|

|
|
@@ -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
|
|
70
|
-
TEST_CHAT=
|
|
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:
|
package/dist/client/store.js
CHANGED
|
@@ -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,
|
package/dist/download/queue.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
731
|
-
|
|
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
|
+
});
|