diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000000..96be166c8a8 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,74 @@ +name: E2E Tests + +on: + push: + branches: + - master + pull_request_target: + branches: + - master + +permissions: + contents: read + +jobs: + e2e: + name: E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + mock-server: + image: ghcr.io/whiskeysockets-devtools/bartender:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.BARTENDER_GHCR_TOKEN }} + ports: + - 8080:8080 + env: + CHATSTATE_TTL_SECS: "3" + ADV_SECRET_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + options: --log-driver none + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Setup Node.js and Corepack + uses: actions/setup-node@v4 + with: + node-version: 20.x + + - name: Enable Corepack and Set Yarn Version + run: | + corepack enable + corepack prepare yarn@4.x --activate + + - name: Restore Yarn Cache + uses: actions/cache@v4 + with: + path: .yarn/cache + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn install --immutable + + - name: Wait for mock server + run: | + for i in $(seq 1 30); do + if curl -sk https://localhost:8080/ > /dev/null 2>&1; then + echo "Mock server is ready" + exit 0 + fi + sleep 1 + done + echo "Mock server failed to become ready" + exit 1 + + - name: Run E2E tests + env: + SOCKET_URL: "wss://127.0.0.1:8080/ws/chat" + ADV_SECRET_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + run: yarn test:e2e diff --git a/package.json b/package.json index 3020b0ca0e3..238da626ccd 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "preinstall": "node ./engine-requirements.js", "release": "release-it", "test": "node --experimental-vm-modules ./node_modules/.bin/jest --testMatch '**/*.test.ts'", - "test:e2e": "node --experimental-vm-modules ./node_modules/.bin/jest --testMatch '**/*.test-e2e.ts'", + "test:e2e": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --experimental-vm-modules ./node_modules/.bin/jest --runInBand --forceExit --testMatch '**/*.test-e2e.ts'", "update:version": "tsx ./scripts/update-version.ts" }, "dependencies": { diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 09882e19a08..badf54f1dc1 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -135,7 +135,6 @@ export const MIN_PREKEY_COUNT = 5 export const INITIAL_PREKEY_COUNT = 812 export const UPLOAD_TIMEOUT = 30000 // 30 seconds -export const MIN_UPLOAD_INTERVAL = 5000 // 5 seconds minimum between uploads export const DEFAULT_CACHE_TTLS = { SIGNAL_STORE: 5 * 60, // 5 minutes diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index a9349265ccb..7a2a56e1744 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -52,12 +52,12 @@ import { type BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, + isHostedLidUser, + isHostedPnUser, isLidUser, isPnUser, jidDecode, jidNormalizedUser, - isHostedLidUser, - isHostedPnUser, reduceBinaryNodeToDictionary, S_WHATSAPP_NET } from '../WABinary' diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 5f7b80e04b0..eed751a1479 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -534,6 +534,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { }, authState?.creds?.me?.id || 'sendRetryRequest') } + // Mirrors WAWeb/Handle/PreKeyLow.js: skip a re-issued notification with the same stanza id. + const inFlightPreKeyLow = new Set() + /** * Fire-and-forget tctoken re-issuance after a peer's device identity changed. * Mirrors WAWebSendTcTokenWhenDeviceIdentityChange — runs in parallel with @@ -574,13 +577,23 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const handleEncryptNotification = async (node: BinaryNode) => { const from = node.attrs.from if (from === S_WHATSAPP_NET) { + const stanzaId = node.attrs.id + if (stanzaId && inFlightPreKeyLow.has(stanzaId)) { + return + } + const countChild = getBinaryNodeChild(node, 'count') const count = +countChild!.attrs.value! const shouldUploadMorePreKeys = count < MIN_PREKEY_COUNT logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count') if (shouldUploadMorePreKeys) { - await uploadPreKeys() + if (stanzaId) inFlightPreKeyLow.add(stanzaId) + try { + await uploadPreKeys() + } finally { + if (stanzaId) inFlightPreKeyLow.delete(stanzaId) + } } } else { const result = await handleIdentityChange(node, { @@ -1365,12 +1378,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } } - const errorMessage = msg?.messageStubParameters?.[0] || '' - const isPreKeyError = errorMessage.includes('PreKey') - - logger.debug(`[handleMessage] Attempting retry request for failed decryption`) + logger.debug('[handleMessage] Attempting retry request for failed decryption') - // Handle both pre-key and normal retries in single mutex + // WAWeb only retry-receipts here; server emits PreKeyLow if prekeys run low. await retryMutex.mutex(async () => { try { if (!ws.isOpen) { @@ -1378,34 +1388,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { return } - // Handle pre-key errors with upload and delay - if (isPreKeyError) { - logger.info({ error: errorMessage }, 'PreKey error detected, uploading and retrying') - - try { - logger.debug('Uploading pre-keys for error recovery') - await uploadPreKeys(5) - logger.debug('Waiting for server to process new pre-keys') - await delay(1000) - } catch (uploadErr) { - logger.error({ uploadErr }, 'Pre-key upload failed, proceeding with retry anyway') - } - } - const encNode = getBinaryNodeChild(node, 'enc') await sendRetryRequest(node, !encNode) if (retryRequestDelayMs) { await delay(retryRequestDelayMs) } } catch (err) { - logger.error({ err, isPreKeyError }, 'Failed to handle retry, attempting basic retry') - // Still attempt retry even if pre-key upload failed - try { - const encNode = getBinaryNodeChild(node, 'enc') - await sendRetryRequest(node, !encNode) - } catch (retryErr) { - logger.error({ retryErr }, 'Failed to send retry after error handling') - } + logger.error({ err }, 'Failed to send retry') } acked = true @@ -1490,14 +1479,14 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { status } - if (status === 'relaylatency') { - const latencyValue = infoChild.attrs.latency || infoChild.attrs['latency_ms'] || infoChild.attrs['latency-ms'] - const latencyMs = latencyValue ? Number(latencyValue) : undefined - if (Number.isFinite(latencyMs)) { - call.latencyMs = latencyMs - } - } - + if (status === 'relaylatency') { + const latencyValue = infoChild.attrs.latency || infoChild.attrs['latency_ms'] || infoChild.attrs['latency-ms'] + const latencyMs = latencyValue ? Number(latencyValue) : undefined + if (Number.isFinite(latencyMs)) { + call.latencyMs = latencyMs + } + } + if (status === 'offer') { call.isVideo = !!getBinaryNodeChild(infoChild, 'video') call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid'] @@ -1625,6 +1614,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { ) ignoreJid = !isNodeFromMe || isJidGroup(attrs.from) ? attrs.from : attrs.recipient } + if (ignoreJid && ignoreJid !== S_WHATSAPP_NET && shouldIgnoreJid(ignoreJid)) { await sendMessageAck(node, type === 'message' ? NACK_REASONS.UnhandledError : undefined) return diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index a95bc397d01..a330345bebf 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -17,6 +17,7 @@ import { assertMediaContent, bindWaitForEvent, decryptMediaRetryData, + DEF_MEDIA_HOST, encodeNewsletterMessage, encodeSignedDeviceIdentity, encodeWAMessage, @@ -109,11 +110,6 @@ export const makeMessagesSocket = (config: SocketConfig) => { useClones: false }) - const peerSessionsCache = new NodeCache({ - stdTTL: DEFAULT_CACHE_TTLS.USER_DEVICES, - useClones: false - }) - // Initialize message retry manager if enabled const messageRetryManager = enableRecentMessageCache ? new MessageRetryManager(logger, maxMsgRetryCount) : null @@ -121,6 +117,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { const encryptionMutex = makeKeyedMutex() let mediaConn: Promise + /** Per-socket media host; updated whenever media_conn is fetched. Defaults to the public WhatsApp host. */ + let mediaHost: string = DEF_MEDIA_HOST const refreshMediaConn = async (forceGet = false) => { const media = await mediaConn if (!media || forceGet || new Date().getTime() - media.fetchDate.getTime() > media.ttl * 1000) { @@ -146,6 +144,10 @@ export const makeMessagesSocket = (config: SocketConfig) => { fetchDate: new Date() } logger.debug('fetched media conn') + if (node.hosts[0]) { + mediaHost = node.hosts[0].hostname + } + return node })() } @@ -436,24 +438,15 @@ export const makeMessagesSocket = (config: SocketConfig) => { const assertSessions = async (jids: string[], force?: boolean) => { let didFetchNewSession = false - const uniqueJids = [...new Set(jids)] // Deduplicate JIDs + const uniqueJids = [...new Set(jids)] const jidsRequiringFetch: string[] = [] logger.debug({ jids }, 'assertSessions call with jids') - // Check peerSessionsCache and validate sessions using libsignal loadSession for (const jid of uniqueJids) { - const signalId = signalRepository.jidToSignalProtocolAddress(jid) - const cachedSession = peerSessionsCache.get(signalId) - if (cachedSession !== undefined) { - if (cachedSession && !force) { - continue // Session exists in cache - } - } else { + if (!force) { const sessionValidation = await signalRepository.validateSession(jid) - const hasSession = sessionValidation.exists - peerSessionsCache.set(signalId, hasSession) - if (hasSession && !force) { + if (sessionValidation.exists) { continue } } @@ -494,12 +487,6 @@ export const makeMessagesSocket = (config: SocketConfig) => { }) await parseAndInjectE2ESessions(result, signalRepository) didFetchNewSession = true - - // Cache fetched sessions using wire JIDs - for (const wireJid of wireJids) { - const signalId = signalRepository.jidToSignalProtocolAddress(wireJid) - peerSessionsCache.set(signalId, true) - } } return didFetchNewSession @@ -1235,6 +1222,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { sendReceipts, readMessages, refreshMediaConn, + // Function (not getter) so the spread in chats.ts preserves the live closure binding. + getMediaHost: () => mediaHost, waUploadToServer, fetchPrivacySettings, sendPeerDataOperationMessage, @@ -1268,7 +1257,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { } content.directPath = media.directPath - content.url = getUrlFromDirectPath(content.directPath!) + content.url = getUrlFromDirectPath(content.directPath!, mediaHost) logger.debug({ directPath: media.directPath, key: result.key }, 'media update successful') } catch (err: any) { diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index cd39f13a9a5..f9986e9e42b 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -8,7 +8,6 @@ import { DEF_TAG_PREFIX, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT, - MIN_UPLOAD_INTERVAL, NOISE_WA_HEADER, PROCESSABLE_HISTORY_TYPES, TimeMs, @@ -468,28 +467,18 @@ export const makeSocket = (config: SocketConfig) => { return +countChild.attrs.value! } - // Pre-key upload state management + // WAWeb has no time throttle here; the server drives uploads via PreKeyLow notifications. let uploadPreKeysPromise: Promise | null = null - let lastUploadTime = 0 /** generates and uploads a set of pre-keys to the server */ - const uploadPreKeys = async (count = MIN_PREKEY_COUNT, retryCount = 0) => { - // Check minimum interval (except for retries) - if (retryCount === 0) { - const timeSinceLastUpload = Date.now() - lastUploadTime - if (timeSinceLastUpload < MIN_UPLOAD_INTERVAL) { - logger.debug(`Skipping upload, only ${timeSinceLastUpload}ms since last upload`) - return - } - } - - // Prevent multiple concurrent uploads + const uploadPreKeys = async (count = MIN_PREKEY_COUNT) => { if (uploadPreKeysPromise) { logger.debug('Pre-key upload already in progress, waiting for completion') await uploadPreKeysPromise + return } - const uploadLogic = async () => { + const uploadLogic = async (retryCount: number): Promise => { logger.info({ count, retryCount }, 'uploading pre-keys') // Generate and save pre-keys atomically (prevents ID collisions on retry) @@ -498,23 +487,22 @@ export const makeSocket = (config: SocketConfig) => { const { update, node } = await getNextPreKeysNode({ creds, keys }, count) // Update credentials immediately to prevent duplicate IDs on retry ev.emit('creds.update', update) - return node // Only return node since update is already used + return node }, creds?.me?.id || 'upload-pre-keys') // Upload to server (outside transaction, can fail without affecting local keys) try { await query(node) logger.info({ count }, 'uploaded pre-keys successfully') - lastUploadTime = Date.now() } catch (uploadError) { logger.error({ uploadError: (uploadError as Error).toString(), count }, 'Failed to upload pre-keys to server') - // Exponential backoff retry (max 3 retries) + // Recurse into uploadLogic; calling uploadPreKeys would await its own in-flight promise. if (retryCount < 3) { const backoffDelay = Math.min(1000 * Math.pow(2, retryCount), 10000) logger.info(`Retrying pre-key upload in ${backoffDelay}ms`) await new Promise(resolve => setTimeout(resolve, backoffDelay)) - return uploadPreKeys(count, retryCount + 1) + return uploadLogic(retryCount + 1) } throw uploadError @@ -523,7 +511,7 @@ export const makeSocket = (config: SocketConfig) => { // Add timeout protection uploadPreKeysPromise = Promise.race([ - uploadLogic(), + uploadLogic(0), new Promise((_, reject) => setTimeout(() => reject(new Boom('Pre-key upload timeout', { statusCode: 408 })), UPLOAD_TIMEOUT) ) diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index eba8779c415..cfa3e89eba4 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -50,6 +50,8 @@ export type SocketConfig = { version: WAVersion /** override browser config */ browser: WABrowserDescription + /** Initial pushName carried in the registration ClientPayload (used by mock servers for deterministic phone assignment). */ + pushName?: string /** agent used for fetch requests -- uploading/downloading media */ fetchAgent?: Agent /** should the QR be printed in the terminal diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index af91ed5cf32..adead650433 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -500,7 +500,8 @@ export const encryptedStream = async ( } } -const DEF_HOST = 'mmg.whatsapp.net' +export const DEF_MEDIA_HOST = 'mmg.whatsapp.net' + const AES_CHUNK_SIZE = 16 const toSmallestChunkSize = (num: number) => { @@ -511,17 +512,19 @@ export type MediaDownloadOptions = { startByte?: number endByte?: number options?: RequestInit + /** Optional media host override; falls back to DEF_MEDIA_HOST when not provided. */ + host?: string } -export const getUrlFromDirectPath = (directPath: string) => `https://${DEF_HOST}${directPath}` +export const getUrlFromDirectPath = (directPath: string, host: string = DEF_MEDIA_HOST) => + `https://${host}${directPath}` export const downloadContentFromMessage = async ( { mediaKey, directPath, url }: DownloadableMessage, type: MediaType, opts: MediaDownloadOptions = {} ) => { - const isValidMediaUrl = url?.startsWith('https://mmg.whatsapp.net/') - const downloadUrl = isValidMediaUrl ? url : getUrlFromDirectPath(directPath!) + const downloadUrl = directPath ? getUrlFromDirectPath(directPath, opts.host) : url if (!downloadUrl) { throw new Boom('No valid media URL or directPath present in message', { statusCode: 400 }) } diff --git a/src/Utils/validate-connection.ts b/src/Utils/validate-connection.ts index ba130da3422..1fb451ae2d9 100644 --- a/src/Utils/validate-connection.ts +++ b/src/Utils/validate-connection.ts @@ -60,6 +60,10 @@ const getClientPayload = (config: SocketConfig) => { payload.webInfo = getWebInfo(config) + if (config.pushName) { + payload.pushName = config.pushName + } + return payload } diff --git a/src/__tests__/e2e/groups.test-e2e.ts b/src/__tests__/e2e/groups.test-e2e.ts new file mode 100644 index 00000000000..f0b923e6ca7 --- /dev/null +++ b/src/__tests__/e2e/groups.test-e2e.ts @@ -0,0 +1,98 @@ +import { jest } from '@jest/globals' +import { TestClient } from './helpers/test-client' + +jest.setTimeout(90_000) + +/** + * Create a group and wait until each participant receives the corresponding + * `groups.upsert` event. Without this barrier, an immediate sendMessage races + * the recipient's group materialization and arrives before the SKDM session + * is installable. + */ +const createGroup = async (creator: TestClient, subject: string, members: TestClient[]): Promise => { + const seenByMembers = members.map(m => m.waitForEvent('groups.upsert', g => g.some(x => x.subject === subject))) + const { id } = await creator.sock.groupCreate( + subject, + members.map(m => m.meJid) + ) + await Promise.all(seenByMembers) + return id +} + +describe('Groups', () => { + let alice: TestClient + let bob: TestClient + let charlie: TestClient + + beforeAll(async () => { + ;[alice, bob, charlie] = await Promise.all([TestClient.connect(), TestClient.connect(), TestClient.connect()]) + }) + + afterAll(async () => { + await Promise.all([alice?.cleanup(), bob?.cleanup(), charlie?.cleanup()]) + }) + + test('alice creates a group, sends a message, adds charlie, and everyone receives subsequent messages', async () => { + const groupJid = await createGroup(alice, 'E2E Test Group', [bob]) + + const text1 = `Hello group from A ${Date.now()}` + const bobReceives1 = bob.waitForText(text1, { remoteJid: groupJid }) + await alice.sock.sendMessage(groupJid, { text: text1 }) + await bobReceives1 + + const charlieSeesGroup = charlie.waitForEvent('groups.upsert', groups => groups.some(g => g.id === groupJid)) + const addResult = await alice.sock.groupParticipantsUpdate(groupJid, [charlie.meJid], 'add') + expect(addResult[0]?.status).toBe('200') + await charlieSeesGroup + + const text2 = `Welcome C ${Date.now()}` + const text2ReceivedByAll = Promise.all([ + bob.waitForText(text2, { remoteJid: groupJid }), + charlie.waitForText(text2, { remoteJid: groupJid }) + ]) + await alice.sock.sendMessage(groupJid, { text: text2 }) + await text2ReceivedByAll + + const text3 = `B says hi ${Date.now()}` + const text3ReceivedByAll = Promise.all([ + alice.waitForText(text3, { remoteJid: groupJid }), + charlie.waitForText(text3, { remoteJid: groupJid }) + ]) + await bob.sock.sendMessage(groupJid, { text: text3 }) + await text3ReceivedByAll + }) + + test('alice removes charlie from a group', async () => { + const groupJid = await createGroup(alice, 'Remove Test Group', [bob, charlie]) + + const removeResult = await alice.sock.groupParticipantsUpdate(groupJid, [charlie.meJid], 'remove') + expect(removeResult[0]?.status).toBe('200') + + const text = `After remove ${Date.now()}` + const bobReceives = bob.waitForText(text, { remoteJid: groupJid }) + await alice.sock.sendMessage(groupJid, { text }) + await bobReceives + }) + + test('alice promotes bob to admin and demotes back', async () => { + const groupJid = await createGroup(alice, 'Promote Test Group', [bob]) + + const promoteResult = await alice.sock.groupParticipantsUpdate(groupJid, [bob.meJid], 'promote') + expect(promoteResult[0]?.status).toBe('200') + + const demoteResult = await alice.sock.groupParticipantsUpdate(groupJid, [bob.meJid], 'demote') + expect(demoteResult[0]?.status).toBe('200') + }) + + test('groupMetadata reflects fresh participant list after an add', async () => { + const groupJid = await createGroup(alice, 'Cache Test Group', [bob]) + + const before = await alice.sock.groupMetadata(groupJid) + expect(before.participants.length).toBe(2) + + await alice.sock.groupParticipantsUpdate(groupJid, [charlie.meJid], 'add') + + const after = await alice.sock.groupMetadata(groupJid) + expect(after.participants.length).toBe(3) + }) +}) diff --git a/src/__tests__/e2e/helpers/mock-phone.ts b/src/__tests__/e2e/helpers/mock-phone.ts new file mode 100644 index 00000000000..3c354823693 --- /dev/null +++ b/src/__tests__/e2e/helpers/mock-phone.ts @@ -0,0 +1,41 @@ +import { request as httpRequest } from 'node:http' +import { request as httpsRequest } from 'node:https' + +const ADMIN_PATH = '/admin/mock-phone/scan-qr' + +const wsToHttp = (wsUrl: string): URL => { + const url = new URL(wsUrl) + url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:' + return url +} + +// bartender stopped auto-pairing; tests drive the scan over its admin endpoint. +export const postQrToMockPhone = (socketUrl: string, qr: string): Promise => { + const adminUrl = wsToHttp(socketUrl) + const isHttps = adminUrl.protocol === 'https:' + const request = isHttps ? httpsRequest : httpRequest + + return new Promise((resolve, reject) => { + const req = request( + { + host: adminUrl.hostname, + port: adminUrl.port, + path: ADMIN_PATH, + method: 'POST', + ...(isHttps ? { rejectUnauthorized: false } : {}) + }, + res => { + res.resume() + const status = res.statusCode ?? 0 + if (status >= 200 && status < 300) { + resolve() + } else { + reject(new Error(`mock-phone ${ADMIN_PATH} returned ${status}`)) + } + } + ) + req.on('error', reject) + req.write(qr) + req.end() + }) +} diff --git a/src/__tests__/e2e/helpers/test-client.ts b/src/__tests__/e2e/helpers/test-client.ts new file mode 100644 index 00000000000..d65360f1319 --- /dev/null +++ b/src/__tests__/e2e/helpers/test-client.ts @@ -0,0 +1,293 @@ +import { Boom } from '@hapi/boom' +import { randomUUID } from 'node:crypto' +import { rm } from 'node:fs/promises' +import { Agent } from 'node:https' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import P, { type Logger } from 'pino' +import makeWASocket, { + type BaileysEventMap, + DisconnectReason, + jidNormalizedUser, + makeCacheableSignalKeyStore, + type proto, + useMultiFileAuthState +} from '../../../index' +import { postQrToMockPhone } from './mock-phone' + +const DEFAULT_SOCKET_URL = 'wss://127.0.0.1:8080/ws/chat' +const DEFAULT_ADV_SECRET_KEY = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +const DEFAULT_TIMEOUT_MS = 30_000 + +export type Socket = ReturnType +export type WAMessageInfo = proto.IWebMessageInfo +export type MessagePredicate = (msg: WAMessageInfo) => boolean + +export interface TestClientOptions { + socketUrl?: string + /** Defaults to a unique tmp dir so concurrent clients don't collide. */ + authDir?: string + /** Bartender derives a deterministic phone number from this. Defaults to a unique value. */ + pushName?: string + advSecretKey?: string + logLevel?: P.Level + resolveTestGroup?: boolean + testGroupName?: string +} + +interface ResolvedConfig { + socketUrl: string + authDir: string + pushName?: string + advSecretKey: string + logger: Logger + agent: Agent +} + +const uniquePushName = (prefix = 'baileys-e2e') => `${prefix}-${randomUUID()}` + +const resolveConfig = (opts: TestClientOptions): ResolvedConfig => ({ + socketUrl: opts.socketUrl ?? process.env.SOCKET_URL ?? DEFAULT_SOCKET_URL, + authDir: opts.authDir ?? join(tmpdir(), `baileys-e2e-${randomUUID()}`), + pushName: opts.pushName, + advSecretKey: opts.advSecretKey ?? process.env.ADV_SECRET_KEY ?? DEFAULT_ADV_SECRET_KEY, + logger: P({ level: opts.logLevel ?? 'debug' }), + // self-signed mock-server cert + agent: new Agent({ rejectUnauthorized: false }) +}) + +const getDisconnectReason = (lastDisconnect: { error?: unknown } | undefined): DisconnectReason | undefined => { + const error = lastDisconnect?.error + if (error instanceof Boom) { + return error.output?.statusCode + } + + return undefined +} + +type AttemptResult = + | { kind: 'open'; meJid: string; meLid: string | undefined } + | { kind: 'reconnect'; reason: string } + | { kind: 'logged-out' } + | { kind: 'error'; error: Error } + +interface AttemptOutcome { + sock: Socket + saveCreds: () => Promise + result: AttemptResult +} + +const attemptConnect = async (config: ResolvedConfig): Promise => { + const { state, saveCreds } = await useMultiFileAuthState(config.authDir) + state.creds.advSecretKey = config.advSecretKey + + const sock = makeWASocket({ + auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, config.logger) }, + waWebSocketUrl: config.socketUrl, + pushName: config.pushName, + logger: config.logger, + agent: config.agent, + fetchAgent: config.agent + }) + sock.ev.on('creds.update', saveCreds) + + const result = await new Promise(resolve => { + let qrScanned = false + + sock.ev.on('connection.update', update => { + const { connection, lastDisconnect, qr } = update + + if (qr && !qrScanned) { + qrScanned = true + postQrToMockPhone(config.socketUrl, qr).catch(error => resolve({ kind: 'error', error: toError(error) })) + return + } + + if (connection === 'open') { + if (!sock.user?.id) { + resolve({ kind: 'error', error: new Error('socket reported open without user.id') }) + return + } + + resolve({ kind: 'open', meJid: jidNormalizedUser(sock.user.id), meLid: sock.user.lid }) + return + } + + if (connection === 'close') { + const reason = getDisconnectReason(lastDisconnect) + if (reason === DisconnectReason.loggedOut) { + resolve({ kind: 'logged-out' }) + return + } + + const label = reason !== undefined ? (DisconnectReason[reason] ?? String(reason)) : 'unknown' + resolve({ kind: 'reconnect', reason: label }) + } + }) + }) + + return { sock, saveCreds, result } +} + +const safeEnd = async (sock: Socket): Promise => { + try { + await sock.end(undefined) + } catch { + // already closed; nothing actionable in tests + } +} + +/** + * Generic event-listener-with-timeout. `subscribe` registers a listener that + * may call `emit(value)` once a match is found, and returns an unsubscribe + * function. The first emit (or the timeout) cleans up before settling. + */ +const waitWithTimeout = ( + subscribe: (emit: (value: T) => void) => () => void, + timeoutMs: number, + label: string +): Promise => + new Promise((resolve, reject) => { + let unsubscribe: () => void = () => {} + + const cleanup = () => { + clearTimeout(timer) + unsubscribe() + } + + const timer = setTimeout(() => { + cleanup() + reject(new Error(`Timed out after ${timeoutMs}ms waiting for ${label}`)) + }, timeoutMs) + + unsubscribe = subscribe(value => { + cleanup() + resolve(value) + }) + }) + +const toError = (value: unknown): Error => (value instanceof Error ? value : new Error(String(value))) + +const openConnection = async ( + config: ResolvedConfig +): Promise<{ sock: Socket; saveCreds: () => Promise; meJid: string; meLid?: string }> => { + for (;;) { + const { sock, saveCreds, result } = await attemptConnect(config) + + if (result.kind === 'open') { + return { sock, saveCreds, meJid: result.meJid, meLid: result.meLid } + } + + await safeEnd(sock) + + if (result.kind === 'logged-out') { + throw new Error('Logged out during e2e bring-up') + } + + if (result.kind === 'error') { + throw result.error + } + + // pairing-handoff close: Baileys terminates the noise socket once paired, we re-open + config.logger.debug({ reason: result.reason }, 'reconnecting after pairing handoff') + } +} + +/** + * Baileys socket wrapped with e2e ergonomics: QR autoscan via bartender admin, + * tmp auth dir per instance, and event/message wait helpers with timeouts. + * + * const tc = await TestClient.connect() + * await tc.sock.sendMessage(tc.meJid, { text: 'hi' }) + * await tc.waitForText('hi') + * await tc.cleanup() + */ +export class TestClient { + groupJid?: string + + private constructor( + readonly sock: Socket, + readonly meJid: string, + readonly meLid: string | undefined, + readonly config: ResolvedConfig, + private readonly saveCreds: () => Promise + ) {} + + static async connect(opts: TestClientOptions = {}): Promise { + const config = resolveConfig({ ...opts, pushName: opts.pushName ?? uniquePushName() }) + // stale auth would skip pairing and disconnect with logged-out + await rm(config.authDir, { recursive: true, force: true }) + + const { sock, saveCreds, meJid, meLid } = await openConnection(config) + const client = new TestClient(sock, meJid, meLid, config, saveCreds) + + if (opts.resolveTestGroup) { + await client.resolveTestGroup(opts.testGroupName) + } + + return client + } + + get pushName(): string | undefined { + return this.config.pushName + } + + async resolveTestGroup(name = 'Baileys Group Test'): Promise { + const groups = await this.sock.groupFetchAllParticipating() + this.groupJid = Object.values(groups).find(g => g.subject === name)?.id + return this.groupJid + } + + /** Wait for an event matching `predicate`, with a timeout that auto-detaches the listener. */ + waitForEvent( + event: K, + predicate: (data: BaileysEventMap[K]) => boolean, + timeoutMs = DEFAULT_TIMEOUT_MS + ): Promise { + return waitWithTimeout( + emit => { + const handler = (data: BaileysEventMap[K]) => { + if (predicate(data)) emit(data) + } + + this.sock.ev.on(event, handler) + return () => this.sock.ev.off(event, handler) + }, + timeoutMs, + String(event) + ) + } + + /** Wait for a single message inside `messages.upsert` matching `predicate`. */ + waitForMessage(predicate: MessagePredicate, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + return waitWithTimeout( + emit => { + const handler = ({ messages }: { messages: WAMessageInfo[] }) => { + const hit = messages.find(predicate) + if (hit) emit(hit) + } + + this.sock.ev.on('messages.upsert', handler) + return () => this.sock.ev.off('messages.upsert', handler) + }, + timeoutMs, + 'message' + ) + } + + waitForText(text: string, opts: { remoteJid?: string; timeoutMs?: number } = {}): Promise { + return this.waitForMessage( + msg => + (opts.remoteJid === undefined || msg.key?.remoteJid === opts.remoteJid) && + (msg.message?.conversation === text || msg.message?.extendedTextMessage?.text === text), + opts.timeoutMs + ) + } + + async cleanup(): Promise { + // In-flight signal session writes can race with cleanup; leave the tmp dir in place + // (it's recreated fresh in connect()) so late writes don't ENOENT the test runner. + this.sock.ev.off('creds.update', this.saveCreds) + await safeEnd(this.sock) + } +} diff --git a/src/__tests__/e2e/messaging.test-e2e.ts b/src/__tests__/e2e/messaging.test-e2e.ts new file mode 100644 index 00000000000..c4efca71c18 --- /dev/null +++ b/src/__tests__/e2e/messaging.test-e2e.ts @@ -0,0 +1,67 @@ +import { jest } from '@jest/globals' +import { proto } from '../../index' +import { TestClient } from './helpers/test-client' + +jest.setTimeout(60_000) + +describe('Messaging (DM)', () => { + let alice: TestClient + let bob: TestClient + + beforeAll(async () => { + ;[alice, bob] = await Promise.all([TestClient.connect(), TestClient.connect()]) + }) + + afterAll(async () => { + await Promise.all([alice?.cleanup(), bob?.cleanup()]) + }) + + test('alice sends a text message to bob', async () => { + const text = `Hello Bob ${Date.now()}` + const received = bob.waitForText(text) + + await alice.sock.sendMessage(bob.meJid, { text }) + const msg = await received + + expect(msg.key?.fromMe).toBe(false) + expect(msg.message?.conversation || msg.message?.extendedTextMessage?.text).toBe(text) + }) + + test('alice and bob exchange messages bidirectionally', async () => { + const fromAlice = `Hello Bob, this is Alice ${Date.now()}` + const aliceToBob = bob.waitForText(fromAlice) + await alice.sock.sendMessage(bob.meJid, { text: fromAlice }) + await aliceToBob + + const fromBob = `Hello Alice, this is Bob ${Date.now()}` + const bobToAlice = alice.waitForText(fromBob) + await bob.sock.sendMessage(alice.meJid, { text: fromBob }) + await bobToAlice + }) + + test('alice revokes a sent message and bob receives the protocol message', async () => { + const text = `This will be revoked ${Date.now()}` + const received = bob.waitForText(text) + + const sent = await alice.sock.sendMessage(bob.meJid, { text }) + await received + + const revokeReceived = bob.waitForMessage( + m => m.message?.protocolMessage?.type === proto.Message.ProtocolMessage.Type.REVOKE + ) + await alice.sock.sendMessage(bob.meJid, { delete: sent!.key }) + + const revoke = await revokeReceived + expect(revoke.message?.protocolMessage?.key?.id).toBe(sent!.key.id) + }) + + test('received message carries the sender push name', async () => { + const text = `Hello with push name ${Date.now()}` + const received = bob.waitForText(text) + + await alice.sock.sendMessage(bob.meJid, { text }) + const msg = await received + + expect(msg.pushName).toBe(alice.pushName) + }) +}) diff --git a/src/__tests__/e2e/send-receive-message.test-e2e.ts b/src/__tests__/e2e/send-receive-message.test-e2e.ts index 5bebde609ec..cf2c07ad109 100644 --- a/src/__tests__/e2e/send-receive-message.test-e2e.ts +++ b/src/__tests__/e2e/send-receive-message.test-e2e.ts @@ -1,644 +1,259 @@ -import { Boom } from '@hapi/boom' import { jest } from '@jest/globals' import { readFileSync } from 'node:fs' -import P from 'pino' -import makeWASocket, { - DisconnectReason, - downloadContentFromMessage, - downloadMediaMessage, - jidNormalizedUser, - proto, - toBuffer, - useMultiFileAuthState, - type WAMessage -} from '../../index' - -jest.setTimeout(30_000) +import { downloadContentFromMessage, downloadMediaMessage, proto, toBuffer, type WAMessage } from '../../index' +import { TestClient } from './helpers/test-client' + +jest.setTimeout(60_000) describe('E2E Tests', () => { - let sock: ReturnType - let meJid: string | undefined - let meLid: string | undefined - let groupJid: string | undefined + let tc: TestClient beforeAll(async () => { - const { state, saveCreds } = await useMultiFileAuthState('baileys_auth_info') - const logger = P({ level: 'silent' }) - - sock = makeWASocket({ - auth: state, - logger - }) - - sock.ev.on('creds.update', saveCreds) - - await new Promise((resolve, reject) => { - sock.ev.on('connection.update', update => { - const { connection, lastDisconnect } = update - if (connection === 'open') { - meJid = jidNormalizedUser(sock.user?.id) - meLid = sock.user?.lid - - sock - .groupFetchAllParticipating() - .then(groups => { - const group = Object.values(groups).find(g => g.subject === 'Baileys Group Test') - if (group) { - groupJid = group.id - console.log(`Found test group "${group.subject}" with JID: ${groupJid}`) - } - - resolve() - }) - .catch(reject) - } else if (connection === 'close') { - const reason = (lastDisconnect?.error as Boom)?.output?.statusCode - if (reason === DisconnectReason.loggedOut) { - console.error('Logged out, please delete the baileys_auth_info_e2e folder and re-run the test') - } - - if (lastDisconnect?.error) { - reject(new Error(`Connection closed: ${DisconnectReason[reason] || 'unknown'}`)) - } - } - }) - }) + tc = await TestClient.connect({ resolveTestGroup: true }) }) afterAll(async () => { - if (sock) { - await sock.end(undefined) - } + await tc?.cleanup() }) test('should send a message', async () => { - const messageContent = `E2E Test Message ${Date.now()}` - const sentMessage = await sock.sendMessage(meJid!, { text: messageContent }) + const text = `E2E Test Message ${Date.now()}` + const sent = await tc.sock.sendMessage(tc.meJid, { text }) - expect(sentMessage).toBeDefined() - console.log('Sent message:', sentMessage!.key.id) - expect(sentMessage!.key.id).toBeTruthy() - expect(sentMessage!.message?.extendedTextMessage?.text || sentMessage!.message?.conversation).toBe(messageContent) + expect(sent).toBeDefined() + expect(sent!.key.id).toBeTruthy() + expect(sent!.message?.extendedTextMessage?.text || sent!.message?.conversation).toBe(text) }) test('should edit a message', async () => { - const messageContent = `E2E Test Message to Edit ${Date.now()}` - const sentMessage = await sock.sendMessage(meJid!, { text: messageContent }) + const original = `E2E Test Message to Edit ${Date.now()}` + const sent = await tc.sock.sendMessage(tc.meJid, { text: original }) - expect(sentMessage).toBeDefined() - console.log('Sent message to edit:', sentMessage!.key.id) + const newText = `E2E Edited Message ${Date.now()}` + const edited = await tc.sock.sendMessage(tc.meJid, { text: newText, edit: sent!.key }) - const newContent = `E2E Edited Message ${Date.now()}` - const editedMessage = await sock.sendMessage(meJid!, { - text: newContent, - edit: sentMessage!.key - }) - - expect(editedMessage).toBeDefined() - console.log('Edited message response:', editedMessage!.key.id) - - expect(editedMessage!.message?.protocolMessage?.type).toBe(proto.Message.ProtocolMessage.Type.MESSAGE_EDIT) - const editedContent = editedMessage!.message?.protocolMessage?.editedMessage - expect(editedContent?.extendedTextMessage?.text || editedContent?.conversation).toBe(newContent) + expect(edited!.message?.protocolMessage?.type).toBe(proto.Message.ProtocolMessage.Type.MESSAGE_EDIT) + const editedContent = edited!.message?.protocolMessage?.editedMessage + expect(editedContent?.extendedTextMessage?.text || editedContent?.conversation).toBe(newText) }) test('should react to a message', async () => { - const messageContent = `E2E Test Message to React to ${Date.now()}` - const sentMessage = await sock.sendMessage(meJid!, { text: messageContent }) + const sent = await tc.sock.sendMessage(tc.meJid, { text: `E2E Test React ${Date.now()}` }) + const reaction = await tc.sock.sendMessage(tc.meJid, { react: { text: '👍', key: sent!.key } }) - expect(sentMessage).toBeDefined() - console.log('Sent message to react to:', sentMessage!.key.id) - - const reaction = '👍' - const reactionMessage = await sock.sendMessage(meJid!, { - react: { - text: reaction, - key: sentMessage!.key - } - }) - - expect(reactionMessage).toBeDefined() - console.log('Sent reaction:', reactionMessage!.key.id) - - expect(reactionMessage!.message?.reactionMessage?.text).toBe(reaction) - expect(reactionMessage!.message?.reactionMessage?.key?.id).toBe(sentMessage!.key.id) + expect(reaction!.message?.reactionMessage?.text).toBe('👍') + expect(reaction!.message?.reactionMessage?.key?.id).toBe(sent!.key.id) }) test('should remove a reaction from a message', async () => { - const messageContent = `E2E Test Message to Remove Reaction from ${Date.now()}` - const sentMessage = await sock.sendMessage(meJid!, { text: messageContent }) - - expect(sentMessage).toBeDefined() - console.log('Sent message to remove reaction from:', sentMessage!.key.id) - - await sock.sendMessage(meJid!, { - react: { - text: '😄', - key: sentMessage!.key - } - }) - - const removeReactionMessage = await sock.sendMessage(meJid!, { - react: { - text: '', - key: sentMessage!.key - } - }) + const sent = await tc.sock.sendMessage(tc.meJid, { text: `E2E Test Remove React ${Date.now()}` }) + await tc.sock.sendMessage(tc.meJid, { react: { text: '😄', key: sent!.key } }) + const removed = await tc.sock.sendMessage(tc.meJid, { react: { text: '', key: sent!.key } }) - expect(removeReactionMessage).toBeDefined() - console.log('Sent remove reaction:', removeReactionMessage!.key.id) - - expect(removeReactionMessage!.message?.reactionMessage?.text).toBe('') - expect(removeReactionMessage!.message?.reactionMessage?.key?.id).toBe(sentMessage!.key.id) + expect(removed!.message?.reactionMessage?.text).toBe('') + expect(removed!.message?.reactionMessage?.key?.id).toBe(sent!.key.id) }) test('should delete a message', async () => { - const messageContent = `E2E Test Message to Delete ${Date.now()}` - const sentMessage = await sock.sendMessage(meJid!, { text: messageContent }) - - expect(sentMessage).toBeDefined() - console.log('Sent message to delete:', sentMessage!.key.id) - - const deleteMessage = await sock.sendMessage(meJid!, { - delete: sentMessage!.key - }) - - expect(deleteMessage).toBeDefined() - console.log('Sent delete message command:', deleteMessage!.key.id) + const sent = await tc.sock.sendMessage(tc.meJid, { text: `E2E Test Delete ${Date.now()}` }) + const del = await tc.sock.sendMessage(tc.meJid, { delete: sent!.key }) - expect(deleteMessage!.message?.protocolMessage?.type).toBe(proto.Message.ProtocolMessage.Type.REVOKE) - expect(deleteMessage!.message?.protocolMessage?.key?.id).toBe(sentMessage!.key.id) + expect(del!.message?.protocolMessage?.type).toBe(proto.Message.ProtocolMessage.Type.REVOKE) + expect(del!.message?.protocolMessage?.key?.id).toBe(sent!.key.id) }) test('should forward a message', async () => { - const messageContent = `E2E Test Message to Forward ${Date.now()}` - const sentMessage = await sock.sendMessage(meJid!, { - text: messageContent - }) - - expect(sentMessage).toBeDefined() - console.log('Sent message to forward:', sentMessage!.key.id) - - const forwardedMessage = await sock.sendMessage(meJid!, { - forward: sentMessage! - }) - - expect(forwardedMessage).toBeDefined() - console.log('Forwarded message:', forwardedMessage!.key.id) + const text = `E2E Test Forward ${Date.now()}` + const sent = await tc.sock.sendMessage(tc.meJid, { text }) + const forwarded = await tc.sock.sendMessage(tc.meJid, { forward: sent! }) - const content = forwardedMessage!.message?.extendedTextMessage?.text || forwardedMessage!.message?.conversation - expect(content).toBe(messageContent) - expect(forwardedMessage!.key.id).not.toBe(sentMessage!.key.id) + const content = forwarded!.message?.extendedTextMessage?.text || forwarded!.message?.conversation + expect(content).toBe(text) + expect(forwarded!.key.id).not.toBe(sent!.key.id) }) test('should send an image message', async () => { - const image = readFileSync('./Media/cat.jpeg') - const sentMessage = await sock.sendMessage(meJid!, { - image: image, + const sent = await tc.sock.sendMessage(tc.meJid, { + image: readFileSync('./Media/cat.jpeg'), caption: 'E2E Test Image' }) - - expect(sentMessage).toBeDefined() - console.log('Sent image message:', sentMessage!.key.id) - expect(sentMessage!.message?.imageMessage).toBeDefined() - expect(sentMessage!.message?.imageMessage?.caption).toBe('E2E Test Image') + expect(sent!.message?.imageMessage?.caption).toBe('E2E Test Image') }) test('should send a video message with a thumbnail', async () => { - const video = readFileSync('./Media/ma_gif.mp4') - const sentMessage = await sock.sendMessage(meJid!, { - video: video, + const sent = await tc.sock.sendMessage(tc.meJid, { + video: readFileSync('./Media/ma_gif.mp4'), caption: 'E2E Test Video' }) - - expect(sentMessage).toBeDefined() - console.log('Sent video message:', sentMessage!.key.id) - expect(sentMessage!.message?.videoMessage).toBeDefined() - expect(sentMessage!.message?.videoMessage?.caption).toBe('E2E Test Video') + expect(sent!.message?.videoMessage?.caption).toBe('E2E Test Video') }) test('should send a PTT (push-to-talk) audio message', async () => { - const audio = readFileSync('./Media/sonata.mp3') - const sentMessage = await sock.sendMessage(meJid!, { - audio: audio, + const sent = await tc.sock.sendMessage(tc.meJid, { + audio: readFileSync('./Media/sonata.mp3'), ptt: true, mimetype: 'audio/mp4' }) - - expect(sentMessage).toBeDefined() - console.log('Sent PTT audio message:', sentMessage!.key.id) - expect(sentMessage!.message?.audioMessage).toBeDefined() - expect(sentMessage!.message?.audioMessage?.ptt).toBe(true) + expect(sent!.message?.audioMessage?.ptt).toBe(true) }) test('should send a document message', async () => { - const document = readFileSync('./Media/ma_gif.mp4') - const sentMessage = await sock.sendMessage(meJid!, { - document: document, + const sent = await tc.sock.sendMessage(tc.meJid, { + document: readFileSync('./Media/ma_gif.mp4'), mimetype: 'application/pdf', fileName: 'E2E Test Document.pdf' }) - - expect(sentMessage).toBeDefined() - console.log('Sent document message:', sentMessage!.key.id) - expect(sentMessage!.message?.documentMessage).toBeDefined() - expect(sentMessage!.message?.documentMessage?.fileName).toBe('E2E Test Document.pdf') + expect(sent!.message?.documentMessage?.fileName).toBe('E2E Test Document.pdf') }) test('should send a sticker message', async () => { - const sticker = readFileSync('./Media/cat.jpeg') - const sentMessage = await sock.sendMessage(meJid!, { - sticker: sticker - }) - - expect(sentMessage).toBeDefined() - console.log('Sent sticker message:', sentMessage!.key.id) - expect(sentMessage!.message?.stickerMessage).toBeDefined() + const sent = await tc.sock.sendMessage(tc.meJid, { sticker: readFileSync('./Media/cat.jpeg') }) + expect(sent!.message?.stickerMessage).toBeDefined() }) - test('should send a poll message and receive a vote', async () => { - const poll = { - name: 'E2E Test Poll', - values: ['Option 1', 'Option 2', 'Option 3'], - selectableCount: 1 - } - const sentPoll = await sock.sendMessage(meJid!, { poll }) - - expect(sentPoll).toBeDefined() - console.log('Sent poll message:', sentPoll!.key.id) - expect(sentPoll?.message?.pollCreationMessageV3).toBeDefined() - expect(sentPoll?.message?.pollCreationMessageV3?.name).toBe('E2E Test Poll') + test('should send a poll creation message', async () => { + const sent = await tc.sock.sendMessage(tc.meJid, { + poll: { name: 'E2E Test Poll', values: ['Option 1', 'Option 2', 'Option 3'], selectableCount: 1 } + }) + expect(sent?.message?.pollCreationMessageV3?.name).toBe('E2E Test Poll') }) test('should send a contact (vCard) message', async () => { - const vcard = - 'BEGIN:VCARD\n' + - 'VERSION:3.0\n' + - 'FN:E2E Test Contact\n' + - 'ORG:Baileys Tests;\n' + - 'TEL;type=CELL;type=VOICE;waid=1234567890:+1 234-567-890\n' + + const vcard = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'FN:E2E Test Contact', + 'ORG:Baileys Tests;', + 'TEL;type=CELL;type=VOICE;waid=1234567890:+1 234-567-890', 'END:VCARD' - const sentMessage = await sock.sendMessage(meJid!, { - contacts: { - displayName: 'E2E Test Contact', - contacts: [{ vcard }] - } + ].join('\n') + const sent = await tc.sock.sendMessage(tc.meJid, { + contacts: { displayName: 'E2E Test Contact', contacts: [{ vcard }] } }) - - expect(sentMessage).toBeDefined() - console.log('Sent contact message:', sentMessage!.key.id) - expect(sentMessage!.message?.contactMessage).toBeDefined() - expect(sentMessage!.message?.contactMessage?.vcard).toContain('FN:E2E Test Contact') + expect(sent!.message?.contactMessage?.vcard).toContain('FN:E2E Test Contact') }) test('should send and download an image message', async () => { - const image = readFileSync('./Media/cat.jpeg') const caption = 'E2E Test Image Download Success' - - let listener: ((data: { messages: proto.IWebMessageInfo[] }) => void) | undefined - let timeoutId: NodeJS.Timeout | undefined - - try { - const receivedMsgPromise = new Promise((resolve, reject) => { - listener = ({ messages }) => { - const msg = messages.find(m => m.message?.imageMessage?.caption === caption) - if (msg) { - resolve(msg) - } - } - - timeoutId = setTimeout(() => { - reject(new Error('Timed out waiting for the image message to be received')) - }, 30_000) - - sock.ev.on('messages.upsert', listener) - }) - - await sock.sendMessage(meJid!, { - image: image, - caption: caption - }) - - const receivedMsg = await receivedMsgPromise - - clearTimeout(timeoutId) - timeoutId = undefined - - console.log('Received image message, attempting to download...') - - const buffer = await downloadMediaMessage( - receivedMsg as WAMessage, - 'buffer', - {}, - { - logger: sock.logger, - reuploadRequest: m => sock.updateMediaMessage(m) - } - ) - - expect(Buffer.isBuffer(buffer)).toBe(true) - expect(buffer.length).toBeGreaterThan(0) - - console.log('Successfully downloaded the image.') - } finally { - if (listener) { - sock.ev.off('messages.upsert', listener) + const received = tc.waitForMessage(m => m.message?.imageMessage?.caption === caption) + + await tc.sock.sendMessage(tc.meJid, { image: readFileSync('./Media/cat.jpeg'), caption }) + const msg = await received + + const buffer = await downloadMediaMessage( + msg as WAMessage, + 'buffer', + { host: tc.sock.getMediaHost() }, + { + logger: tc.sock.logger, + reuploadRequest: m => tc.sock.updateMediaMessage(m) } + ) - if (timeoutId) { - clearTimeout(timeoutId) - } - } + expect(Buffer.isBuffer(buffer)).toBe(true) + expect(buffer.length).toBeGreaterThan(0) }) test('should send and download an image message via LID', async () => { - console.log(`Testing with self-LID: ${meLid}`) - - const image = readFileSync('./Media/cat.jpeg') + expect(tc.meLid).toBeDefined() const caption = 'E2E Test LID Image Download' - - let listener: ((data: { messages: proto.IWebMessageInfo[] }) => void) | undefined - let timeoutId: NodeJS.Timeout | undefined - - try { - const receivedMsgPromise = new Promise((resolve, reject) => { - listener = ({ messages }) => { - const msg = messages.find(m => m.message?.imageMessage?.caption === caption) - if (msg) { - resolve(msg) - } - } - - timeoutId = setTimeout(() => { - reject(new Error('Timed out waiting for the LID image message to be received')) - }, 30_000) - - sock.ev.on('messages.upsert', listener) - }) - - await sock.sendMessage(meLid!, { - image: image, - caption: caption - }) - - const receivedMsg = await receivedMsgPromise - clearTimeout(timeoutId) - timeoutId = undefined - - console.log('Received LID image message, attempting to download...') - - const buffer = await downloadMediaMessage( - receivedMsg as WAMessage, - 'buffer', - {}, - { - logger: sock.logger, - reuploadRequest: m => sock.updateMediaMessage(m) - } - ) - - expect(Buffer.isBuffer(buffer)).toBe(true) - expect(buffer.length).toBeGreaterThan(0) - - console.log('Successfully downloaded the image sent via LID.') - } finally { - if (listener) { - sock.ev.off('messages.upsert', listener) + const received = tc.waitForMessage(m => m.message?.imageMessage?.caption === caption) + + await tc.sock.sendMessage(tc.meLid!, { image: readFileSync('./Media/cat.jpeg'), caption }) + const msg = await received + + const buffer = await downloadMediaMessage( + msg as WAMessage, + 'buffer', + { host: tc.sock.getMediaHost() }, + { + logger: tc.sock.logger, + reuploadRequest: m => tc.sock.updateMediaMessage(m) } + ) - if (timeoutId) { - clearTimeout(timeoutId) - } - } + expect(buffer.length).toBeGreaterThan(0) }) test('should send and download an image using the low-level downloadContentFromMessage', async () => { - const image = readFileSync('./Media/cat.jpeg') const caption = 'E2E Test Low-Level Download' + const received = tc.waitForMessage(m => m.message?.imageMessage?.caption === caption) - let listener: ((data: { messages: proto.IWebMessageInfo[] }) => void) | undefined - let timeoutId: NodeJS.Timeout | undefined - - try { - const receivedMsgPromise = new Promise((resolve, reject) => { - listener = ({ messages }) => { - const msg = messages.find(m => m.message?.imageMessage?.caption === caption) - if (msg) { - resolve(msg) - } - } - - timeoutId = setTimeout(() => { - reject(new Error('Timed out waiting for the low-level test message')) - }, 30_000) - - sock.ev.on('messages.upsert', listener) - }) + await tc.sock.sendMessage(tc.meJid, { image: readFileSync('./Media/cat.jpeg'), caption }) + const msg = await received - await sock.sendMessage(meJid!, { - image: image, - caption: caption - }) + const imageMessage = msg.message?.imageMessage + expect(imageMessage).toBeDefined() - const receivedMsg = await receivedMsgPromise - clearTimeout(timeoutId) - timeoutId = undefined - - console.log('Received message for low-level download test, preparing to download...') - - const imageMessage = receivedMsg.message?.imageMessage - expect(imageMessage).toBeDefined() - - const downloadable: proto.Message.IImageMessage = { - url: imageMessage!.url, - mediaKey: imageMessage!.mediaKey, - directPath: imageMessage!.directPath - } - - const stream = await downloadContentFromMessage(downloadable, 'image') - const buffer = await toBuffer(stream) - - expect(Buffer.isBuffer(buffer)).toBe(true) - expect(buffer.length).toBeGreaterThan(0) - - console.log('Successfully downloaded the image using downloadContentFromMessage.') - } finally { - if (listener) { - sock.ev.off('messages.upsert', listener) - } - - if (timeoutId) { - clearTimeout(timeoutId) - } - } + const stream = await downloadContentFromMessage( + { url: imageMessage!.url, mediaKey: imageMessage!.mediaKey, directPath: imageMessage!.directPath }, + 'image', + { host: tc.sock.getMediaHost() } + ) + const buffer = await toBuffer(stream) + expect(buffer.length).toBeGreaterThan(0) }) test('should download a quoted image message using downloadContentFromMessage', async () => { - const image = readFileSync('./Media/cat.jpeg') - const originalCaption = 'This is the original media message' - const commandText = '-download' - - let imageListener: ((data: { messages: proto.IWebMessageInfo[] }) => void) | undefined - let commandListener: ((data: { messages: proto.IWebMessageInfo[] }) => void) | undefined - let timeoutId: NodeJS.Timeout | undefined - - try { - console.log('Sending initial image message...') - const receivedImagePromise = new Promise((resolve, reject) => { - imageListener = ({ messages }) => { - const msg = messages.find(m => m.message?.imageMessage?.caption === originalCaption) - if (msg) resolve(msg) - } - - sock.ev.on('messages.upsert', imageListener) - timeoutId = setTimeout(() => reject(new Error('Timed out waiting for initial image message')), 30_000) - }) - - const sentImageMessage = await sock.sendMessage(meJid!, { - image: image, - caption: originalCaption - }) - await receivedImagePromise - clearTimeout(timeoutId) - timeoutId = undefined - - if (imageListener) { - sock.ev.off('messages.upsert', imageListener) - } - - console.log('Initial image message sent and received.') - - console.log('Sending command message as a reply...') - const receivedCommandPromise = new Promise((resolve, reject) => { - commandListener = ({ messages }) => { - const msg = messages.find(m => m.message?.extendedTextMessage?.text === commandText) - if (msg) resolve(msg) - } - - sock.ev.on('messages.upsert', commandListener) - timeoutId = setTimeout(() => reject(new Error('Timed out waiting for command message')), 30_000) - }) - - await sock.sendMessage(meJid!, { text: commandText }, { quoted: sentImageMessage }) - const receivedCommandMessage = await receivedCommandPromise - clearTimeout(timeoutId) - timeoutId = undefined - console.log('Command message received.') - - console.log('Extracting quoted message and attempting download...') - - const quotedMessage = receivedCommandMessage.message?.extendedTextMessage?.contextInfo?.quotedMessage - expect(quotedMessage).toBeDefined() - - const quotedImage = quotedMessage!.imageMessage - expect(quotedImage).toBeDefined() - - const downloadable: proto.Message.IImageMessage = { - url: quotedImage!.url, - mediaKey: quotedImage!.mediaKey, - directPath: quotedImage!.directPath - } - - const stream = await downloadContentFromMessage(downloadable, 'image') - const buffer = await toBuffer(stream) - - expect(Buffer.isBuffer(buffer)).toBe(true) - expect(buffer.length).toBeGreaterThan(0) - - console.log('Successfully downloaded quoted image using downloadContentFromMessage.') - } finally { - if (imageListener) sock.ev.off('messages.upsert', imageListener) - if (commandListener) sock.ev.off('messages.upsert', commandListener) - if (timeoutId) clearTimeout(timeoutId) - } + const caption = 'This is the original media message' + const command = '-download' + + const imageReceived = tc.waitForMessage(m => m.message?.imageMessage?.caption === caption) + const sentImage = await tc.sock.sendMessage(tc.meJid, { image: readFileSync('./Media/cat.jpeg'), caption }) + await imageReceived + + const commandReceived = tc.waitForText(command) + await tc.sock.sendMessage(tc.meJid, { text: command }, { quoted: sentImage }) + const commandMsg = await commandReceived + + const quoted = commandMsg.message?.extendedTextMessage?.contextInfo?.quotedMessage + expect(quoted?.imageMessage).toBeDefined() + + const stream = await downloadContentFromMessage( + { + url: quoted!.imageMessage!.url, + mediaKey: quoted!.imageMessage!.mediaKey, + directPath: quoted!.imageMessage!.directPath + }, + 'image', + { host: tc.sock.getMediaHost() } + ) + const buffer = await toBuffer(stream) + expect(buffer.length).toBeGreaterThan(0) }) test('should download a quoted videos message within a group', async () => { - if (!groupJid) { + if (!tc.groupJid) { console.warn('⚠️ Skipping group test because "Baileys Group Test" was not found.') return } - const video = readFileSync('./Media/ma_gif.mp4') - const originalCaption = 'This is the original media message for the group test' - const commandText = '-download group' - - let videoListener: ((data: { messages: proto.IWebMessageInfo[] }) => void) | undefined - let commandListener: ((data: { messages: proto.IWebMessageInfo[] }) => void) | undefined - let timeoutId: NodeJS.Timeout | undefined - - try { - console.log(`Sending initial video message to group ${groupJid}...`) - const receivedVideoPromise = new Promise((resolve, reject) => { - videoListener = ({ messages }) => { - const msg = messages.find( - m => m.key!.remoteJid === groupJid && m.message?.videoMessage?.caption === originalCaption - ) - if (msg) resolve(msg) - } - - sock.ev.on('messages.upsert', videoListener) - timeoutId = setTimeout(() => reject(new Error('Timed out waiting for initial group image message')), 30_000) - }) - - const sentVideoMessage = await sock.sendMessage(groupJid, { - video: video, - caption: originalCaption - }) - await receivedVideoPromise - clearTimeout(timeoutId) - timeoutId = undefined - if (videoListener) sock.ev.off('messages.upsert', videoListener) - console.log('Initial group image message sent and received.') - - console.log('Sending command message as a reply in the group...') - const receivedCommandPromise = new Promise((resolve, reject) => { - commandListener = ({ messages }) => { - const msg = messages.find( - m => m.key!.remoteJid === groupJid && m.message?.extendedTextMessage?.text === commandText - ) - if (msg) resolve(msg) - } - - sock.ev.on('messages.upsert', commandListener) - timeoutId = setTimeout(() => reject(new Error('Timed out waiting for group command message')), 30_000) - }) - - await sock.sendMessage(groupJid, { text: commandText }, { quoted: sentVideoMessage }) - const receivedCommandMessage = await receivedCommandPromise - clearTimeout(timeoutId) - timeoutId = undefined - console.log('Group command message received.') - - console.log('Extracting quoted message from group chat and attempting download...') - - const quotedMessage = receivedCommandMessage.message?.extendedTextMessage?.contextInfo?.quotedMessage - expect(quotedMessage).toBeDefined() - - console.log('quotedMessage', JSON.stringify(quotedMessage, null, 2)) - - const quotedVideo = quotedMessage!.videoMessage - expect(quotedVideo).toBeDefined() - - console.log('quotedVideo', JSON.stringify(quotedVideo, null, 2)) - - const downloadable: proto.Message.IVideoMessage = { - url: quotedVideo!.url, - mediaKey: quotedVideo!.mediaKey, - directPath: quotedVideo!.directPath - } - - const stream = await downloadContentFromMessage(downloadable, 'video') - const buffer = await toBuffer(stream) - - expect(Buffer.isBuffer(buffer)).toBe(true) - expect(buffer.length).toBeGreaterThan(0) - - console.log('Successfully downloaded quoted image from group message.') - } finally { - if (videoListener) sock.ev.off('messages.upsert', videoListener) - if (commandListener) sock.ev.off('messages.upsert', commandListener) - if (timeoutId) clearTimeout(timeoutId) - } + const caption = 'This is the original media message for the group test' + const command = '-download group' + + const videoReceived = tc.waitForMessage( + m => m.key?.remoteJid === tc.groupJid && m.message?.videoMessage?.caption === caption + ) + const sentVideo = await tc.sock.sendMessage(tc.groupJid, { video: readFileSync('./Media/ma_gif.mp4'), caption }) + await videoReceived + + const commandReceived = tc.waitForText(command, { remoteJid: tc.groupJid }) + await tc.sock.sendMessage(tc.groupJid, { text: command }, { quoted: sentVideo }) + const commandMsg = await commandReceived + + const quoted = commandMsg.message?.extendedTextMessage?.contextInfo?.quotedMessage + expect(quoted?.videoMessage).toBeDefined() + + const stream = await downloadContentFromMessage( + { + url: quoted!.videoMessage!.url, + mediaKey: quoted!.videoMessage!.mediaKey, + directPath: quoted!.videoMessage!.directPath + }, + 'video', + { host: tc.sock.getMediaHost() } + ) + const buffer = await toBuffer(stream) + expect(buffer.length).toBeGreaterThan(0) }) })