diff --git a/.github/workflows/update-version.yml b/.github/workflows/update-version.yml index 9637bc85..2ccfeb82 100644 --- a/.github/workflows/update-version.yml +++ b/.github/workflows/update-version.yml @@ -2,9 +2,15 @@ name: Update WhatsApp Version on: schedule: - # Run on the 1st of every month at 02:00 UTC - - cron: '0 0 * * 0' + # Run daily at 06:00 UTC (when WhatsApp typically deploys updates) + - cron: '0 6 * * *' workflow_dispatch: + inputs: + force: + description: 'Force create PR even if version unchanged (useful for re-triggering)' + required: false + default: 'false' + type: boolean permissions: contents: write @@ -16,10 +22,10 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Node and Corepack - uses: actions/setup-node@v3.6.0 + uses: actions/setup-node@v4 with: node-version: 20.x @@ -29,7 +35,7 @@ jobs: corepack prepare yarn@4.x --activate - name: Restore Yarn Cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: yarn-cache with: path: .yarn/cache @@ -42,23 +48,52 @@ jobs: - name: Update WhatsApp Web Version id: update_version - run: yarn update:version + run: | + # Run the update script, capture exit code + # Exit codes: 0=success, 1=error, 2=fetch failed (fallback used) + set +e + yarn update:version + EXIT_CODE=$? + set -e + + echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT + + # Only fail on fatal errors (exit code 1) + if [ $EXIT_CODE -eq 1 ]; then + echo "::error::Script failed with fatal error" + exit 1 + fi + + # Exit code 2 means fetch failed but fallback was used - this is a warning + if [ $EXIT_CODE -eq 2 ]; then + echo "::warning::Could not fetch latest version from WhatsApp servers" + fi - name: Check for changes id: check_changes run: | + FORCE="${{ inputs.force }}" + if git diff --quiet; then echo "has_changes=false" >> $GITHUB_OUTPUT + if [ "$FORCE" == "true" ]; then + echo "force_mode=true" >> $GITHUB_OUTPUT + echo "::notice::Force mode enabled - will create PR even without changes" + else + echo "force_mode=false" >> $GITHUB_OUTPUT + fi else echo "has_changes=true" >> $GITHUB_OUTPUT + echo "force_mode=false" >> $GITHUB_OUTPUT fi - name: Create Pull Request - if: steps.check_changes.outputs.has_changes == 'true' + if: steps.check_changes.outputs.has_changes == 'true' || steps.check_changes.outputs.force_mode == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | BRANCH_NAME="update-version/stable" + FORCE="${{ inputs.force }}" # Configure git git config user.name "github-actions[bot]" @@ -67,22 +102,61 @@ jobs: # Check if branch exists and delete it git push origin --delete "$BRANCH_NAME" 2>/dev/null || true - # Create new branch, commit, and push + # Create new branch git checkout -b "$BRANCH_NAME" - git add src/Defaults/baileys-version.json src/Defaults/index.ts src/Utils/generics.ts - git commit -m "chore: update WhatsApp Web version" + + # Only add the single source of truth file + git add src/Defaults/baileys-version.json + + # Create commit (allow empty if force mode) + if [ "$FORCE" == "true" ] && git diff --cached --quiet; then + git commit --allow-empty -m "chore: force WhatsApp Web version check" + else + git commit -m "chore: update WhatsApp Web version" + fi + git push origin "$BRANCH_NAME" # Create PR using GitHub CLI - gh pr create \ - --title "chore: update WhatsApp Web version" \ - --body "Automated WhatsApp Web version update. + PR_BODY="Automated WhatsApp Web version update. This PR updates the WhatsApp Web client revision to the latest version fetched from \`web.whatsapp.com\`. - **Files updated:** - - \`src/Defaults/baileys-version.json\` - - \`src/Defaults/index.ts\` - - \`src/Utils/generics.ts\`" \ + **Single source of truth:** + - \`src/Defaults/baileys-version.json\` (other files import from here) + + **Update frequency:** Daily at 06:00 UTC" + + if [ "$FORCE" == "true" ]; then + PR_BODY="$PR_BODY + + > **Note:** This PR was created with force mode enabled." + fi + + gh pr create \ + --title "chore: update WhatsApp Web version" \ + --body "$PR_BODY" \ --base master \ --head "$BRANCH_NAME" || echo "PR already exists or could not be created" + + - name: Summary + run: | + echo "### WhatsApp Version Update" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + EXIT_CODE="${{ steps.update_version.outputs.exit_code }}" + HAS_CHANGES="${{ steps.check_changes.outputs.has_changes }}" + FORCE_MODE="${{ steps.check_changes.outputs.force_mode }}" + + if [ "$EXIT_CODE" == "2" ]; then + echo "āš ļø Warning: Could not fetch latest version from WhatsApp servers" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + if [ "$HAS_CHANGES" == "true" ]; then + echo "āœ… New version detected and PR created" >> $GITHUB_STEP_SUMMARY + elif [ "$FORCE_MODE" == "true" ]; then + echo "šŸ”„ Force mode: PR created (no version change)" >> $GITHUB_STEP_SUMMARY + else + echo "ā„¹ļø Already up to date" >> $GITHUB_STEP_SUMMARY + fi diff --git a/scripts/update-version.ts b/scripts/update-version.ts index f540f9d3..d0952071 100644 --- a/scripts/update-version.ts +++ b/scripts/update-version.ts @@ -1,40 +1,258 @@ #!/usr/bin/env node /** - * Script to update WhatsApp Web version across the codebase. - * Fetches the latest version from web.whatsapp.com and updates: - * - src/Defaults/baileys-version.json - * - src/Defaults/index.ts - * - src/Utils/generics.ts + * Script to update WhatsApp Web version with robust error handling. + * Fetches the latest version from web.whatsapp.com with: + * - Retry with exponential backoff + * - Multiple source endpoints for redundancy + * - Version validation before updating + * + * Updates: src/Defaults/baileys-version.json (SINGLE SOURCE OF TRUTH) * * Usage: yarn update:version + * + * Environment variables: + * - WA_MIN_REVISION: Minimum valid revision number (default: 1000000000) + * - WA_MAX_REVISION: Maximum valid revision number (default: 9999999999) + * + * Exit codes: + * - 0: Success (version updated or already up to date) + * - 1: Fatal error (script failure) + * - 2: Fetch failed (all sources failed, fallback version used) */ -import { readFileSync, writeFileSync } from 'fs' +import { readFileSync, writeFileSync, appendFileSync } from 'fs' import { dirname, join } from 'path' import { fileURLToPath } from 'url' -import { fetchLatestWaWebVersion } from '../src/Utils/generics.ts' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const ROOT_DIR = join(__dirname, '..') +const VERSION_FILE_PATH = join(ROOT_DIR, 'src/Defaults/baileys-version.json') -function updateBaileysVersionJson(version: [number, number, number]): boolean { - const filePath = join(ROOT_DIR, 'src/Defaults/baileys-version.json') - const content = { - version - } +type WAVersion = [number, number, number] + +interface VersionResult { + version: WAVersion + isLatest: boolean + source?: string + error?: unknown +} + +/** + * Configuration for the version update script. + * + * Revision bounds explanation: + * - WhatsApp Web uses a numeric "client_revision" that increments with each release + * - As of 2024, revisions are in the 10-digit range (e.g., 1032141294) + * - minRevision (1000000000) ensures we don't accept clearly invalid small numbers + * that could result from parsing errors + * - maxRevision (9999999999) is the maximum 10-digit number, providing an upper sanity check + * + * These bounds can be overridden via environment variables if WhatsApp changes their + * versioning scheme in the future. + */ +const CONFIG = { + maxRetries: 3, + retryDelayMs: 2000, // Base delay, will be multiplied by attempt number + requestTimeoutMs: 10000, + // Version sanity checks - can be overridden via environment variables + minRevision: parseInt(process.env.WA_MIN_REVISION || '1000000000', 10), + maxRevision: parseInt(process.env.WA_MAX_REVISION || '9999999999', 10), +} + +/** + * Sleep for specified milliseconds + */ +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Fetch with timeout + */ +async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) try { - const currentContent = readFileSync(filePath, 'utf-8') + const response = await fetch(url, { + ...options, + signal: controller.signal + }) + return response + } finally { + clearTimeout(timeout) + } +} + +/** + * Validates that a revision number looks reasonable. + * WhatsApp Web revisions are typically 10-digit numbers. + * + * @param revision - The revision number to validate + * @returns true if the revision is within expected bounds + */ +function isValidRevision(revision: number): boolean { + return ( + Number.isInteger(revision) && + revision >= CONFIG.minRevision && + revision <= CONFIG.maxRevision + ) +} + +/** + * Fetches version from WhatsApp Web Service Worker (primary source) + */ +async function fetchFromServiceWorker(): Promise { + const url = 'https://web.whatsapp.com/sw.js' + const headers = { + 'sec-fetch-site': 'none', + 'sec-fetch-mode': 'navigate', + 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'accept': '*/*', + 'accept-language': 'en-US,en;q=0.9', + } + + const response = await fetchWithTimeout(url, { method: 'GET', headers }, CONFIG.requestTimeoutMs) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.text() + + // Try multiple patterns for robustness + const patterns = [ + /\\?"client_revision\\?":\s*(\d+)/, + /"client_revision":\s*(\d+)/, + /client_revision["']?\s*:\s*(\d+)/, + ] + + for (const regex of patterns) { + const match = data.match(regex) + if (match?.[1]) { + const revision = parseInt(match[1], 10) + if (isValidRevision(revision)) { + return { + version: [2, 3000, revision] as WAVersion, + isLatest: true, + source: 'sw.js' + } + } + } + } + + throw new Error('Could not find valid client_revision in sw.js') +} + +/** + * Fetches version from WhatsApp Web bootstrap (backup source) + */ +async function fetchFromBootstrap(): Promise { + const url = 'https://web.whatsapp.com/' + const headers = { + 'sec-fetch-site': 'none', + 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'accept': 'text/html,application/xhtml+xml', + } + + const response = await fetchWithTimeout(url, { method: 'GET', headers }, CONFIG.requestTimeoutMs) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.text() + + // Look for version in HTML/JS bootstrap + const patterns = [ + /\"client_revision\":(\d+)/, + /clientRevision['":\s]+(\d+)/i, + ] + + for (const regex of patterns) { + const match = data.match(regex) + if (match?.[1]) { + const revision = parseInt(match[1], 10) + if (isValidRevision(revision)) { + return { + version: [2, 3000, revision] as WAVersion, + isLatest: true, + source: 'bootstrap' + } + } + } + } + + throw new Error('Could not find valid client_revision in bootstrap page') +} + +/** + * Fetches the latest WhatsApp Web version with retry and fallback + */ +async function fetchLatestWaWebVersion(): Promise { + // Read current version as fallback + const currentContent = readFileSync(VERSION_FILE_PATH, 'utf-8') + const fallbackVersion = JSON.parse(currentContent).version as WAVersion + + const sources = [ + { name: 'Service Worker', fetch: fetchFromServiceWorker }, + { name: 'Bootstrap Page', fetch: fetchFromBootstrap }, + ] + + const errors: string[] = [] + + for (const source of sources) { + for (let attempt = 1; attempt <= CONFIG.maxRetries; attempt++) { + try { + console.log(` Attempting ${source.name} (attempt ${attempt}/${CONFIG.maxRetries})...`) + const result = await source.fetch() + console.log(` āœ“ Success from ${source.name}`) + return result + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + errors.push(`${source.name} attempt ${attempt}: ${errorMsg}`) + console.log(` āœ— ${source.name} failed: ${errorMsg}`) + + if (attempt < CONFIG.maxRetries) { + const delay = CONFIG.retryDelayMs * attempt + console.log(` Waiting ${delay}ms before retry...`) + await sleep(delay) + } + } + } + } + + console.log('\n⚠ All sources failed, using fallback version') + return { + version: fallbackVersion, + isLatest: false, + error: { message: 'All fetch attempts failed', details: errors } + } +} + +function updateBaileysVersionJson(version: WAVersion, source?: string): boolean { + const content = { version } + + try { + const currentContent = readFileSync(VERSION_FILE_PATH, 'utf-8') const currentVersion = JSON.parse(currentContent).version as number[] - if (currentVersion[0] === version[0] && currentVersion[1] === version[1] && currentVersion[2] === version[2]) { + if ( + currentVersion[0] === version[0] && + currentVersion[1] === version[1] && + currentVersion[2] === version[2] + ) { console.log(`āœ“ baileys-version.json already up to date`) return false } - writeFileSync(filePath, JSON.stringify(content) + '\n') + writeFileSync(VERSION_FILE_PATH, JSON.stringify(content) + '\n') console.log(`āœ“ Updated baileys-version.json: [${currentVersion.join(', ')}] → [${version.join(', ')}]`) + if (source) { + console.log(` Source: ${source}`) + } + console.log(` (index.ts and generics.ts automatically use the new version via import)`) return true } catch (error) { console.error(`āœ— Failed to update baileys-version.json:`, error) @@ -42,105 +260,65 @@ function updateBaileysVersionJson(version: [number, number, number]): boolean { } } -function updateGenerics(version: [number, number, number]): boolean { - const filePath = join(ROOT_DIR, 'src/Utils/generics.ts') - - try { - const content = readFileSync(filePath, 'utf-8') - const versionRegex = /const baileysVersion = \[(\d+),\s*(\d+),\s*(\d+)\]/ - const match = content.match(versionRegex) - - if (!match) { - throw new Error('Could not find baileysVersion declaration in generics.ts') - } - - const currentVersion = [+match[1]!, +match[2]!, +match[3]!] - - if (currentVersion[0] === version[0] && currentVersion[1] === version[1] && currentVersion[2] === version[2]) { - console.log(`āœ“ src/Utils/generics.ts already up to date`) - return false - } - - const newContent = content.replace( - versionRegex, - `const baileysVersion = [${version[0]}, ${version[1]}, ${version[2]}]` - ) - - writeFileSync(filePath, newContent) - console.log(`āœ“ Updated src/Utils/generics.ts: [${currentVersion.join(', ')}] → [${version.join(', ')}]`) - return true - } catch (error) { - console.error(`āœ— Failed to update src/Utils/generics.ts:`, error) - throw error - } -} - -function updateIndex(version: [number, number, number]): boolean { - const filePath = join(ROOT_DIR, 'src/Defaults/index.ts') - - try { - const content = readFileSync(filePath, 'utf-8') - const versionRegex = /const version = \[(\d+),\s*(\d+),\s*(\d+)\]/ - const match = content.match(versionRegex) - - if (!match) { - throw new Error('Could not find version declaration in index.ts') - } - - const currentVersion = [+match[1]!, +match[2]!, +match[3]!] - - if (currentVersion[0] === version[0] && currentVersion[1] === version[1] && currentVersion[2] === version[2]) { - console.log(`āœ“ src/Defaults/index.ts already up to date`) - return false - } - - const newContent = content.replace(versionRegex, `const version = [${version[0]}, ${version[1]}, ${version[2]}]`) - - writeFileSync(filePath, newContent) - console.log(`āœ“ Updated src/Defaults/index.ts: [${currentVersion.join(', ')}] → [${version.join(', ')}]`) - return true - } catch (error) { - console.error(`āœ— Failed to update src/Defaults/index.ts:`, error) - throw error - } -} - async function main() { + console.log('╔════════════════════════════════════════════════╗') + console.log('ā•‘ WhatsApp Web Version Update Script ā•‘') + console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\n') + + // Log configuration + console.log('Configuration:') + console.log(` Min revision: ${CONFIG.minRevision}`) + console.log(` Max revision: ${CONFIG.maxRevision}`) + console.log(` Max retries: ${CONFIG.maxRetries}`) + console.log(` Request timeout: ${CONFIG.requestTimeoutMs}ms\n`) + console.log('Fetching latest WhatsApp Web version...\n') const result = await fetchLatestWaWebVersion() - if (!result.isLatest) { - console.error('Failed to fetch latest version:', result.error) - process.exit(1) + console.log('') + if (result.isLatest) { + console.log(`Latest version: [${result.version.join(', ')}]`) + if (result.source) { + console.log(`Source: ${result.source}\n`) + } + } else { + console.log(`⚠ Using fallback version: [${result.version.join(', ')}]`) + console.log(`Reason: ${JSON.stringify(result.error)}\n`) } - console.log(`Latest version: [${result.version.join(', ')}]\n`) - - const updates = [ - updateBaileysVersionJson(result.version), - updateGenerics(result.version), - updateIndex(result.version) - ] - - const hasUpdates = updates.some(Boolean) + const hasUpdates = updateBaileysVersionJson(result.version, result.source) console.log('') if (hasUpdates) { + console.log('═══════════════════════════════════════════════') console.log('Version update complete!') - // Set GitHub Actions output if running in CI + console.log('═══════════════════════════════════════════════') if (process.env.GITHUB_OUTPUT) { - const { appendFileSync } = await import('fs') appendFileSync(process.env.GITHUB_OUTPUT, `updated=true\n`) appendFileSync(process.env.GITHUB_OUTPUT, `version=${result.version.join('.')}\n`) + appendFileSync(process.env.GITHUB_OUTPUT, `source=${result.source || 'fallback'}\n`) } } else { - console.log('All files are already up to date.') + console.log('Already up to date. No changes needed.') if (process.env.GITHUB_OUTPUT) { - const { appendFileSync } = await import('fs') appendFileSync(process.env.GITHUB_OUTPUT, `updated=false\n`) } } + + // Exit with code 2 if fetch failed (Fix #3) + // This allows CI to distinguish between: + // - 0: Success + // - 1: Script error + // - 2: Fetch failed but fallback used + if (!result.isLatest) { + console.log('\n⚠ Warning: Could not fetch latest version from WhatsApp servers') + console.log('Exit code 2: Fetch failed, fallback version used') + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `fetch_failed=true\n`) + } + process.exit(2) + } } main().catch(error => { diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 0637cac0..846a8108 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -3,8 +3,10 @@ import { makeLibSignalRepository } from '../Signal/libsignal' import type { AuthenticationState, SocketConfig, WAVersion } from '../Types' import { Browsers } from '../Utils/browser-utils' import logger from '../Utils/logger' +// Single source of truth for WhatsApp Web version - imported from JSON +import baileysVersionData from './baileys-version.json' with { type: 'json' } -const version = [2, 3000, 1032141294] +const version = baileysVersionData.version export const UNAUTHORIZED_CODES = [401, 403, 419] @@ -45,8 +47,12 @@ export const PROCESSABLE_HISTORY_TYPES = [ proto.HistorySync.HistorySyncType.INITIAL_STATUS_V3 ] +// 6 hours in milliseconds +const SIX_HOURS_MS = 6 * 60 * 60 * 1000 + export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { version: version as WAVersion, + versionCheckIntervalMs: SIX_HOURS_MS, browser: Browsers.macOS('Chrome'), waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', connectTimeoutMs: 20_000, @@ -70,7 +76,8 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { generateHighQualityLinkPreview: false, enableAutoSessionRecreation: true, enableRecentMessageCache: true, - // Enable CTWA (Click-to-WhatsApp) ads message recovery by default + // Enable automatic recovery of Click-to-WhatsApp ads messages + // These arrive as "placeholder messages" and need to be requested from the phone enableCTWARecovery: true, options: {}, appStateMacVerification: { diff --git a/src/Socket/index.ts b/src/Socket/index.ts index 3ee2d850..8862579b 100644 --- a/src/Socket/index.ts +++ b/src/Socket/index.ts @@ -1,7 +1,37 @@ import { DEFAULT_CONNECTION_CONFIG } from '../Defaults' -import type { UserFacingSocketConfig } from '../Types' +import type { UserFacingSocketConfig, WAVersion } from '../Types' +import { getCachedVersion, refreshVersionCache, clearVersionCache, getVersionCacheStatus } from '../Utils/version-cache' +import type { VersionCacheLogger } from '../Utils/version-cache' import { makeCommunitiesSocket } from './communities' +/** + * Adapts Baileys logger to VersionCacheLogger interface + */ +const createCacheLogger = (logger: any): VersionCacheLogger | undefined => { + if (!logger) return undefined + return { + info: (obj: unknown, msg?: string) => logger.info(obj, msg), + debug: (obj: unknown, msg?: string) => logger.debug(obj, msg), + warn: (obj: unknown, msg?: string) => logger.warn(obj, msg) + } +} + +/** + * Compares two WhatsApp versions + * @returns true if versions are different + */ +const versionsAreDifferent = (v1: WAVersion, v2: WAVersion): boolean => { + return v1[0] !== v2[0] || v1[1] !== v2[1] || v1[2] !== v2[2] +} + +/** + * Checks if a version change is critical (major or minor version changed) + */ +const isCriticalVersionChange = (oldVersion: WAVersion, newVersion: WAVersion): boolean => { + // Major version change (index 0) or minor version change (index 1) + return oldVersion[0] !== newVersion[0] || oldVersion[1] !== newVersion[1] +} + // export the last socket layer const makeWASocket = (config: UserFacingSocketConfig) => { const newConfig = { @@ -19,4 +49,190 @@ const makeWASocket = (config: UserFacingSocketConfig) => { return makeCommunitiesSocket(newConfig) } +/** + * Creates a WhatsApp socket connection with automatic version fetching + * and periodic version checks (soft update - transparent to user). + * + * Features: + * - **Shared cache**: 150 connections = 1 request (not 150) + * - **Persistent cache**: Survives server restarts + * - Fetches latest version on connect (uses cache if valid) + * - Checks for new versions every 6 hours (configurable) + * - Updates version on next natural reconnection (transparent) + * - Emits 'version.update' event when new version is detected + * + * @example + * ```typescript + * // All connections share the same cached version + * const sock = await makeWASocketAutoVersion({ + * auth: state, + * versionCheckIntervalMs: 6 * 60 * 60 * 1000 // 6 hours (default) + * }) + * + * // Listen for version updates + * sock.ev.on('version.update', ({ currentVersion, newVersion, isCritical }) => { + * console.log(`New version detected: ${newVersion.join('.')}`) + * // Version will be used on next reconnection automatically + * }) + * ``` + */ +export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) => { + const mergedConfig = { + ...DEFAULT_CONNECTION_CONFIG, + ...config + } + + const logger = mergedConfig.logger + const cacheLogger = createCacheLogger(logger) + const checkIntervalMs = mergedConfig.versionCheckIntervalMs + + // Track version separately to avoid mutating config (Fix #7) + let trackedVersion: WAVersion = [...mergedConfig.version] as WAVersion + let versionCheckInterval: ReturnType | null = null + let isSocketClosed = false + + /** + * Cleans up the version check interval + */ + const cleanupInterval = () => { + if (versionCheckInterval) { + clearInterval(versionCheckInterval) + versionCheckInterval = null + logger?.debug('Stopped periodic version check') + } + } + + // Fetch latest version using SHARED CACHE + // 150 connections starting = 1 request (deduplication) + try { + const { version, fromCache, age } = await getCachedVersion({ + cacheTtlMs: checkIntervalMs, + logger: cacheLogger + }) + + logger?.info( + { + version, + fromCache, + ageMinutes: fromCache ? Math.round(age / 60000) : 0 + }, + fromCache + ? 'Using cached WhatsApp Web version' + : 'Fetched fresh WhatsApp Web version' + ) + + mergedConfig.version = version + trackedVersion = [...version] as WAVersion + } catch (error) { + logger?.warn( + { error, fallbackVersion: mergedConfig.version }, + 'Error fetching version, using bundled version' + ) + } + + // Create the socket + const sock = makeWASocket(mergedConfig) + + // Listen for connection close to cleanup interval (Fix #1, #6) + // This handles both explicit sock.end() and internal disconnections + sock.ev.on('connection.update', (update) => { + if (update.connection === 'close') { + isSocketClosed = true + cleanupInterval() + } else if (update.connection === 'open') { + isSocketClosed = false + } + }) + + // Setup periodic version check if interval > 0 + if (checkIntervalMs > 0) { + logger?.info( + { intervalHours: checkIntervalMs / (60 * 60 * 1000) }, + 'Starting periodic version check' + ) + + versionCheckInterval = setInterval(async () => { + // Skip if socket is closed (Fix #8 - race condition) + if (isSocketClosed) { + cleanupInterval() + return + } + + try { + logger?.debug('Checking for WhatsApp Web version update...') + + // Check cache status first + const cacheStatus = getVersionCacheStatus(checkIntervalMs) + + // Only refresh if cache is expired (one socket refreshes, others use cache) + let newVersion: WAVersion + let fetchSuccess = true + + if (cacheStatus.isExpired) { + logger?.debug('Cache expired, refreshing...') + const result = await refreshVersionCache({ logger: cacheLogger }) + newVersion = result.version + fetchSuccess = result.success + + // Don't update to fallback version on transient network errors + if (!fetchSuccess) { + logger?.warn( + { fallbackVersion: result.version }, + 'Failed to fetch latest version, keeping current version' + ) + return // Skip version update on fetch failure + } + } else if (cacheStatus.version) { + // Cache still valid, use cached version directly (no file I/O) + newVersion = cacheStatus.version + } else { + // No cache available, skip this check + return + } + + // Double-check socket is still open after async operation (Fix #8) + if (isSocketClosed) { + cleanupInterval() + return + } + + if (versionsAreDifferent(trackedVersion, newVersion)) { + const isCritical = isCriticalVersionChange(trackedVersion, newVersion) + const previousVersion = trackedVersion + + logger?.info( + { + currentVersion: previousVersion, + newVersion: newVersion, + isCritical + }, + 'New WhatsApp Web version detected! Will use on next reconnection.' + ) + + // Update tracked version for next reconnection (Fix #7) + trackedVersion = [...newVersion] as WAVersion + + // Emit event for user to handle (only if socket still open) + if (!isSocketClosed) { + sock.ev.emit('version.update', { + currentVersion: previousVersion, + newVersion: newVersion, + isCritical + }) + } + } else { + logger?.debug({ version: trackedVersion }, 'Version is up to date') + } + } catch (error) { + logger?.warn({ error }, 'Error checking for version update') + } + }, checkIntervalMs) + } + + return sock +} + +// Export cache utilities for manual control +export { getCachedVersion, refreshVersionCache, clearVersionCache, getVersionCacheStatus } + export default makeWASocket diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 4727d2e7..4be626e9 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -4,6 +4,7 @@ import { randomBytes } from 'crypto' import Long from 'long' import { proto } from '../../WAProto/index.js' import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT } from '../Defaults' +import { metrics, recordMessageReceived, recordHistorySyncMessages, recordMessageRetry, recordMessageFailure } from '../Utils/prometheus-metrics.js' import type { GroupParticipant, MessageReceiptType, @@ -74,7 +75,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { getMessage, shouldIgnoreJid, enableAutoSessionRecreation, - enableCTWARecovery = true + enableCTWARecovery } = config const sock = makeMessagesSocket(config) const { @@ -408,11 +409,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { if (messageRetryManager.hasExceededMaxRetries(msgId)) { logger.debug({ msgId }, 'reached retry limit with new retry manager, clearing') messageRetryManager.markRetryFailed(msgId) + recordMessageFailure('retry', 'max_retries_reached') return } // Increment retry count using new system const retryCount = messageRetryManager.incrementRetryCount(msgId) + recordMessageRetry('retry') // Use the new retry count for the rest of the logic const key = `${msgId}:${msgKey?.participant}` @@ -424,11 +427,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { if (retryCount >= maxMsgRetryCount) { logger.debug({ retryCount, msgId }, 'reached retry limit, clearing') await msgRetryCache.del(key) + recordMessageFailure('retry', 'max_retries_reached') return } retryCount += 1 await msgRetryCache.set(key, retryCount) + recordMessageRetry('retry') } const key = `${msgId}:${msgKey?.participant}` @@ -1234,32 +1239,47 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { await decrypt() // message failed to decrypt if (msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') { - // Handle missing keys - no recovery possible + // Handle "Missing keys" - standard decryption failure, just ACK if (msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT) { return sendMessageAck(node) } - // Handle CTWA (Click-to-WhatsApp) ads messages - // These messages arrive as "Message absent from node" because Meta's ads endpoint - // doesn't encrypt messages for linked devices (multi-device architecture) - // We request the original message from the primary phone via PDO + // Handle "Message absent from node" - likely a CTWA (Click-to-WhatsApp) ads message + // These messages are only encrypted for the primary phone, not linked devices + // We need to request the message content from the phone via PDO (Peer Data Operation) if (msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT) { if (enableCTWARecovery && msg.key) { + const startTime = Date.now() logger.info( { msgId: msg.key.id, remoteJid: msg.key.remoteJid }, 'CTWA: Message absent from node detected, requesting placeholder resend from phone' ) + try { + metrics.ctwaRecoveryRequests.inc({ status: 'requested' }) + const requestId = await requestPlaceholderResend(msg.key) - if (requestId === 'RESOLVED') { - logger.info( - { msgId: msg.key.id }, - 'CTWA: Message was received while waiting to request resend' - ) - } else if (requestId) { + if (requestId) { logger.debug( { msgId: msg.key.id, requestId }, - 'CTWA: Placeholder resend requested successfully' + 'CTWA: Placeholder resend request sent successfully' + ) + // Note: The actual message will be emitted via 'messages.upsert' + // when the PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE is processed + // in process-message.ts:399-421 + } else if (requestId === 'RESOLVED') { + // Message was received while we were waiting + logger.debug( + { msgId: msg.key.id }, + 'CTWA: Message received during resend delay' + ) + metrics.ctwaMessagesRecovered.inc() + metrics.ctwaRecoveryLatency.observe(Date.now() - startTime) + } else { + // Already requested (duplicate request prevented by cache) + logger.debug( + { msgId: msg.key.id }, + 'CTWA: Resend already requested, skipping duplicate' ) } } catch (error) { @@ -1267,8 +1287,15 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { { error, msgId: msg.key.id }, 'CTWA: Failed to request placeholder resend' ) + metrics.ctwaRecoveryFailures.inc({ reason: 'request_failed' }) } + } else { + logger.debug( + { msgId: msg.key?.id, enableCTWARecovery }, + 'CTWA recovery disabled or missing key, skipping placeholder resend' + ) } + return sendMessageAck(node) } @@ -1364,6 +1391,18 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { if (msg.key.id && msg.key.remoteJid) { logMessageReceived(msg.key.id, msg.key.remoteJid) } + + // Record message received metric + const msgContent = msg.message + const msgType = msgContent?.conversation ? 'text' + : msgContent?.imageMessage ? 'image' + : msgContent?.videoMessage ? 'video' + : msgContent?.audioMessage ? 'audio' + : msgContent?.documentMessage ? 'document' + : msgContent?.stickerMessage ? 'sticker' + : msgContent?.reactionMessage ? 'reaction' + : 'other' + recordMessageReceived(msgType) }) } catch (error) { logger.error({ error, node: binaryNodeToString(node) }, 'error in handling message') diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index d95102c5..ae2be48d 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -56,6 +56,7 @@ import { S_WHATSAPP_NET } from '../WABinary' import { USyncQuery, USyncUser } from '../WAUSync' +import { recordMessageSent, recordMessageRetry, recordMessageFailure } from '../Utils/prometheus-metrics' import { makeNewsletterSocket } from './newsletter' export const makeMessagesSocket = (config: SocketConfig) => { @@ -595,6 +596,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { const nodes = (await Promise.all(encryptionPromises)).filter(node => node !== null) as BinaryNode[] if (recipientJids.length > 0 && nodes.length === 0) { + recordMessageFailure('send', 'encryption_failed') throw new Boom('All encryptions failed', { statusCode: 500 }) } @@ -1038,6 +1040,17 @@ export const makeMessagesSocket = (config: SocketConfig) => { // Log with [BAILEYS] prefix logMessageSent(msgId, destinationJid) + // Record message sent metric + const msgType = message.conversation ? 'text' + : message.imageMessage ? 'image' + : message.videoMessage ? 'video' + : message.audioMessage ? 'audio' + : message.documentMessage ? 'document' + : message.stickerMessage ? 'sticker' + : message.reactionMessage ? 'reaction' + : 'other' + recordMessageSent(msgType) + // Add message to retry cache if enabled if (messageRetryManager && !participant) { messageRetryManager.addRecentMessage(destinationJid, msgId, message) diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index 08f45ac4..2d3b9f2c 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -54,6 +54,12 @@ import { jidEncode, S_WHATSAPP_NET } from '../WABinary' +import { + recordConnectionError, + recordConnectionAttempt, + incrementActiveConnections, + decrementActiveConnections +} from '../Utils/prometheus-metrics' import { BinaryInfo } from '../WAM/BinaryInfo.js' import { USyncQuery, USyncUser } from '../WAUSync/' import { WebSocketClient } from './Client' @@ -730,6 +736,45 @@ export const makeSocket = (config: SocketConfig) => { closed = true logger.info({ trace: error?.stack }, error ? 'connection errored' : 'connection closed') + // Record connection error metric + if (error) { + const statusCode = (error as Boom)?.output?.statusCode || 0 + let errorType = 'unknown' + switch (statusCode) { + case DisconnectReason.connectionClosed: + errorType = 'connection_closed' + break + case DisconnectReason.connectionLost: + errorType = 'connection_lost' + break + case DisconnectReason.connectionReplaced: + errorType = 'connection_replaced' + break + case DisconnectReason.timedOut: + errorType = 'timed_out' + break + case DisconnectReason.loggedOut: + errorType = 'logged_out' + break + case DisconnectReason.badSession: + errorType = 'bad_session' + break + case DisconnectReason.restartRequired: + errorType = 'restart_required' + break + case DisconnectReason.multideviceMismatch: + errorType = 'multidevice_mismatch' + break + default: + errorType = `error_${statusCode}` + } + recordConnectionError(errorType) + recordConnectionAttempt('failure') + } + + // Decrement active connections + decrementActiveConnections() + clearInterval(keepAliveReq) clearTimeout(qrTimer) @@ -1044,6 +1089,10 @@ export const makeSocket = (config: SocketConfig) => { ev.emit('connection.update', { connection: 'open' }) + // Record successful connection metrics + recordConnectionAttempt('success') + incrementActiveConnections() + if (node.attrs.lid && authState.creds.me?.id) { const myLID = node.attrs.lid process.nextTick(async () => { diff --git a/src/Types/Events.ts b/src/Types/Events.ts index bf0d1d07..18c2fba8 100644 --- a/src/Types/Events.ts +++ b/src/Types/Events.ts @@ -107,6 +107,18 @@ export type BaileysEventMap = { /** Settings and actions sync events */ 'chats.lock': { id: string; locked: boolean } + /** + * Emitted when a new WhatsApp Web version is detected. + * The new version will be used on the next reconnection (soft update). + */ + 'version.update': { + /** Previous version */ + currentVersion: [number, number, number] + /** New version detected */ + newVersion: [number, number, number] + /** Whether the update is critical (major version change) */ + isCritical: boolean + } 'settings.update': | { setting: 'unarchiveChats'; value: boolean } | { setting: 'locale'; value: string } diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index 0c7fa51a..36161e4d 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -49,6 +49,17 @@ export type SocketConfig = { logger: ILogger /** version to connect with */ version: WAVersion + /** + * Interval in milliseconds to check for new WhatsApp Web versions. + * When a new version is detected, it will be used on the next reconnection. + * Set to 0 to disable periodic checks. + * + * Note: This option is only used by `makeWASocketAutoVersion()`. + * The standard `makeWASocket()` does not perform automatic version checks. + * + * @default 21600000 (6 hours) + */ + versionCheckIntervalMs: number /** override browser config */ browser: WABrowserDescription /** agent used for fetch requests -- uploading/downloading media */ @@ -107,6 +118,21 @@ export type SocketConfig = { /** Enable recent message caching for retry handling */ enableRecentMessageCache: boolean + /** + * Enable automatic recovery of Click-to-WhatsApp (CTWA) ads messages. + * + * When enabled, messages from Facebook/Instagram ads that arrive as + * "placeholder messages" (Message absent from node) will be automatically + * recovered by requesting resend from the primary phone device. + * + * This is necessary because Meta's ads endpoint doesn't encrypt messages + * for linked devices - they only arrive on the primary phone. + * + * @default true + * @see https://github.com/WhiskeySockets/Baileys/issues/1723 + */ + enableCTWARecovery: boolean + /** * Returns if a jid should be ignored, * no event for that jid will be triggered. diff --git a/src/Utils/chat-utils.ts b/src/Utils/chat-utils.ts index 04cd8547..c03bd3c2 100644 --- a/src/Utils/chat-utils.ts +++ b/src/Utils/chat-utils.ts @@ -264,7 +264,7 @@ export const decodeSyncdMutations = async ( }) } - return mutationKeys(keyEnc.keyData!) + return await mutationKeys(keyEnc.keyData!) } } diff --git a/src/Utils/circuit-breaker.ts b/src/Utils/circuit-breaker.ts index fc20e135..143f0729 100644 --- a/src/Utils/circuit-breaker.ts +++ b/src/Utils/circuit-breaker.ts @@ -380,6 +380,11 @@ export class CircuitBreaker extends EventEmitter { this.options.onOpen() this.emit('open') + // Record circuit breaker trip metric + if (this.options.collectMetrics) { + metrics.circuitBreakerTrips?.inc({ name: this.options.name }) + } + // Schedule transition to HALF_OPEN this.resetTimer = setTimeout(() => { this.transitionTo('half-open') diff --git a/src/Utils/event-buffer.ts b/src/Utils/event-buffer.ts index 15a0f47c..64aec7b2 100644 --- a/src/Utils/event-buffer.ts +++ b/src/Utils/event-buffer.ts @@ -61,7 +61,7 @@ export function loadBufferConfig(): BufferConfig { maxBufferSize: parseInt(process.env.BAILEYS_BUFFER_MAX_SIZE || '5000', 10), flushDebounceMs: parseInt(process.env.BAILEYS_BUFFER_FLUSH_DEBOUNCE_MS || '100', 10), enableAdaptiveTimeout: process.env.BAILEYS_BUFFER_ADAPTIVE_TIMEOUT !== 'false', - enableMetrics: process.env.BAILEYS_BUFFER_METRICS === 'true', + enableMetrics: process.env.BAILEYS_BUFFER_METRICS === 'true' || process.env.BAILEYS_PROMETHEUS_ENABLED === 'true', lruCleanupRatio: parseFloat(process.env.BAILEYS_BUFFER_LRU_CLEANUP_RATIO || '0.2'), bufferWarnThreshold: parseFloat(process.env.BAILEYS_BUFFER_WARN_THRESHOLD || '0.8') } @@ -329,6 +329,31 @@ class AdaptiveTimeoutCalculator { } return 'balanced' } + + /** + * Get current event rate in events per second + */ + getEventRate(): number { + if (this.eventTimestamps.length < 2) { + return 0 + } + const oldest = this.eventTimestamps[0]! + const newest = this.eventTimestamps[this.eventTimestamps.length - 1]! + const timeSpan = newest - oldest + if (timeSpan === 0) return 0 + return (this.eventTimestamps.length / timeSpan) * 1000 + } + + /** + * Check if system is healthy based on current mode + * Conservative mode with low event rate is healthy + * Aggressive mode might indicate high load + */ + isHealthy(): boolean { + const mode = this.getMode() + // System is considered healthy if not in aggressive mode (high load) + return mode !== 'aggressive' + } } // ============================================================================ @@ -410,9 +435,9 @@ export const makeEventBuffer = ( } } - const recordFlushMetrics = (eventCount: number, forced: boolean) => { + const recordFlushMetrics = (eventCount: number, forced: boolean, cacheSize: number) => { if (metricsModule) { - metricsModule.recordBufferFlush(eventCount, forced) + metricsModule.recordBufferFlush(eventCount, forced, cacheSize) } } @@ -449,6 +474,10 @@ export const makeEventBuffer = ( currentSize: currentEventCount, maxSize: config.maxBufferSize }) + // Record overflow metric + if (metricsModule) { + metricsModule.recordBufferOverflow() + } flush(true) return true } @@ -482,6 +511,10 @@ export const makeEventBuffer = ( removed: removed.length, remaining: historyCache.size }) + // Record metrics for cache cleanup + if (metricsModule) { + metricsModule.recordCacheCleanup(removed.length) + } } } @@ -553,7 +586,12 @@ export const makeEventBuffer = ( stats.historyCacheSize = historyCache.size // Record metrics - recordFlushMetrics(eventCount, force) + recordFlushMetrics(eventCount, force, historyCache.size) + + // Update adaptive metrics + if (config.enableAdaptiveTimeout && metricsModule) { + metricsModule.updateAdaptiveMetrics(adaptiveTimeout.getEventRate(), adaptiveTimeout.isHealthy()) + } // Log with [BAILEYS] prefix - use getMode() to avoid duplicating mode calculation logic const flushDuration = Date.now() - flushStartTime @@ -599,11 +637,16 @@ export const makeEventBuffer = ( } logger.debug('Destroying event buffer') + const hadPendingFlush = isBuffering destroyed = true // Flush any remaining events - if (isBuffering) { + if (hadPendingFlush) { flush(true) + // Record final flush metric + if (metricsModule) { + metricsModule.recordBufferFinalFlush() + } } // Clear all timers @@ -616,6 +659,11 @@ export const makeEventBuffer = ( // Remove all event listeners ev.removeAllListeners() + // Record buffer destroyed metric + if (metricsModule) { + metricsModule.recordBufferDestroyed('normal', hadPendingFlush) + } + logger.debug('Event buffer destroyed successfully') } diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index 60788954..ce0a69c8 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -1,7 +1,10 @@ import { Boom } from '@hapi/boom' import { createHash, randomBytes } from 'crypto' import { proto } from '../../WAProto/index.js' -const baileysVersion = [2, 3000, 1032141294] +// Single source of truth for WhatsApp Web version - imported from JSON +import baileysVersionData from '../Defaults/baileys-version.json' with { type: 'json' } + +const baileysVersion = baileysVersionData.version import type { BaileysEventEmitter, BaileysEventMap, diff --git a/src/Utils/index.ts b/src/Utils/index.ts index 9e4af329..1826cf29 100644 --- a/src/Utils/index.ts +++ b/src/Utils/index.ts @@ -33,5 +33,8 @@ export * from './cache-utils' export * from './circuit-breaker' export * from './retry-utils' +// Version management +export * from './version-cache' + // Event streaming export * from './baileys-event-stream' diff --git a/src/Utils/process-message.ts b/src/Utils/process-message.ts index 969ec3f2..b1743cca 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -16,6 +16,7 @@ import type { WAMessage, WAMessageKey } from '../Types' +import { metrics, recordHistorySyncMessages } from './prometheus-metrics.js' import { WAMessageStubType } from '../Types' import { getContentType, normalizeMessageContent } from '../Utils/messages' import { @@ -352,6 +353,11 @@ const processMessage = async ( isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined, peerDataRequestSessionId: histNotification.peerDataRequestSessionId }) + + // Record history sync metrics + if (data.messages?.length) { + recordHistorySyncMessages(data.messages.length) + } } break @@ -402,11 +408,24 @@ const processMessage = async ( await placeholderResendCache?.del(response.stanzaId!) // TODO: IMPLEMENT HISTORY SYNC ETC (sticker uploads etc.). const { peerDataOperationResult } = response + let recoveredCount = 0 for (const result of peerDataOperationResult!) { const { placeholderMessageResendResponse: retryResponse } = result //eslint-disable-next-line max-depth if (retryResponse) { const webMessageInfo = proto.WebMessageInfo.decode(retryResponse.webMessageInfoBytes!) + + // Track CTWA message recovery success + recoveredCount++ + logger?.info( + { + msgId: webMessageInfo.key?.id, + remoteJid: webMessageInfo.key?.remoteJid, + requestId: response.stanzaId + }, + 'CTWA: Successfully recovered message via placeholder resend' + ) + // wait till another upsert event is available, don't want it to be part of the PDO response message // TODO: parse through proper message handling utilities (to add relevant key fields) ev.emit('messages.upsert', { @@ -416,6 +435,16 @@ const processMessage = async ( }) } } + + // Update metrics for recovered messages + if (recoveredCount > 0) { + metrics.ctwaMessagesRecovered.inc(recoveredCount) + metrics.ctwaRecoveryRequests.inc({ status: 'success' }) + logger?.debug( + { recoveredCount, requestId: response.stanzaId }, + 'CTWA: Placeholder resend response processed' + ) + } } break diff --git a/src/Utils/prometheus-metrics.ts b/src/Utils/prometheus-metrics.ts index 619edebc..c24a43fe 100644 --- a/src/Utils/prometheus-metrics.ts +++ b/src/Utils/prometheus-metrics.ts @@ -95,6 +95,31 @@ export function getConfiguredPrefix(): string { return configuredPrefix } +/** + * Check if a metric with the given name already exists in the custom registry + */ +function metricExists(name: string): boolean { + const fullName = getFullMetricName(name) + try { + const existing = customRegistry.getSingleMetric(fullName) + return existing !== undefined + } catch { + return false + } +} + +/** + * Get an existing metric from the custom registry + */ +function getExistingMetric(name: string): T | undefined { + const fullName = getFullMetricName(name) + try { + return customRegistry.getSingleMetric(fullName) as T | undefined + } catch { + return undefined + } +} + // ============================================ // Configuration // ============================================ @@ -1051,9 +1076,11 @@ export class SystemMetricsCollector { new Gauge('system_load_average', 'System load average', ['period']) ) - // Event loop lag + // Event loop lag - use different name to avoid conflict with collectDefaultMetrics + // prom-client's collectDefaultMetrics creates nodejs_eventloop_lag_seconds + // We use system_eventloop_lag_seconds to avoid duplicate registration this.eventLoopLag = registry.register( - new Histogram('nodejs_eventloop_lag_seconds', 'Event loop lag in seconds', [], DEFAULT_LATENCY_BUCKETS) + new Histogram('system_eventloop_lag_seconds', 'Event loop lag in seconds', [], DEFAULT_LATENCY_BUCKETS) ) // Initialize CPU baseline @@ -1364,6 +1391,12 @@ export const metrics = { connectionLatency: baileysMetrics.register( new Histogram('connection_latency_ms', 'Connection establishment latency in ms', [], [100, 250, 500, 1000, 2500, 5000, 10000]) ), + activeConnections: baileysMetrics.register( + new Gauge('active_connections', 'Number of active WhatsApp connections') + ), + connectionErrors: baileysMetrics.register( + new Counter('connection_errors_total', 'Total connection errors', ['error_type']) + ), // ========== Message Metrics ========== messagesSent: baileysMetrics.register( @@ -1385,6 +1418,24 @@ export const metrics = { new Gauge('messages_queued', 'Current number of messages in queue', ['priority']) ), + // ========== CTWA (Click-to-WhatsApp Ads) Metrics ========== + /** + * Messages from Facebook/Instagram ads that arrive as placeholder messages + * and are recovered via PDO (Peer Data Operation) request + */ + ctwaRecoveryRequests: baileysMetrics.register( + new Counter('ctwa_recovery_requests_total', 'Total CTWA placeholder resend requests', ['status']) + ), + ctwaMessagesRecovered: baileysMetrics.register( + new Counter('ctwa_messages_recovered_total', 'Total CTWA messages successfully recovered') + ), + ctwaRecoveryLatency: baileysMetrics.register( + new Histogram('ctwa_recovery_latency_ms', 'CTWA message recovery latency in ms', [], [500, 1000, 2000, 3000, 5000, 8000, 10000]) + ), + ctwaRecoveryFailures: baileysMetrics.register( + new Counter('ctwa_recovery_failures_total', 'Total CTWA recovery failures', ['reason']) + ), + // ========== Media Metrics ========== mediaUploads: baileysMetrics.register( new Counter('media_uploads_total', 'Total media uploads', ['type', 'status']) @@ -1427,6 +1478,18 @@ export const metrics = { eventsProcessed: baileysMetrics.register( new Counter('events_processed_total', 'Total events processed from buffer', ['event_type']) ), + bufferDestroyed: baileysMetrics.register( + new Counter('buffer_destroyed_total', 'Total buffers destroyed', ['reason', 'had_pending_flush']) + ), + bufferFinalFlush: baileysMetrics.register( + new Counter('buffer_final_flush_total', 'Total final flushes during destruction') + ), + bufferCacheCleanup: baileysMetrics.register( + new Counter('buffer_cache_cleanup_total', 'Total cache cleanup operations') + ), + bufferCacheSize: baileysMetrics.register( + new Gauge('buffer_cache_size', 'Current buffer cache size') + ), // ========== Adaptive Flush Metrics ========== adaptiveFlushInterval: baileysMetrics.register( @@ -1444,6 +1507,12 @@ export const metrics = { adaptiveFlushEfficiency: baileysMetrics.register( new Gauge('adaptive_flush_efficiency_percent', 'Flush efficiency percentage') ), + adaptiveHealthStatus: baileysMetrics.register( + new Gauge('adaptive_health_status', 'Adaptive system health status (0=unhealthy, 1=healthy)') + ), + adaptiveEventRate: baileysMetrics.register( + new Gauge('adaptive_event_rate', 'Current event rate per second') + ), // ========== Error Metrics ========== errors: baileysMetrics.register( @@ -1729,31 +1798,19 @@ export function trackOperation( // Event Buffer Metrics Integration // ============================================ -// Event buffer metrics (lazy initialized) +// Event buffer metrics (lazy initialized) - for detailed buffer tracking +// Note: bufferFlushes is NOT here - use metrics.bufferFlushes from main metrics object let eventBufferMetrics: { - eventsBuffered: Counter | null - bufferFlushes: Counter | null bufferFlushEvents: Histogram | null bufferCurrentSize: Gauge | null bufferPeakSize: Gauge | null bufferHistoryCacheSize: Gauge | null - bufferOverflows: Counter | null bufferLruCleanups: Counter | null } | null = null function getEventBufferMetrics() { if (!eventBufferMetrics) { eventBufferMetrics = { - eventsBuffered: new Counter( - 'events_buffered_total', - 'Total number of events buffered', - ['event_type'] - ), - bufferFlushes: new Counter( - 'buffer_flushes_total', - 'Total number of buffer flushes', - ['forced'] - ), bufferFlushEvents: new Histogram( 'buffer_flush_events', 'Number of events per buffer flush', @@ -1772,24 +1829,11 @@ function getEventBufferMetrics() { 'buffer_history_cache_size', 'Current size of history cache' ), - bufferOverflows: new Counter( - 'buffer_overflows_total', - 'Total number of buffer overflows detected' - ), bufferLruCleanups: new Counter( 'buffer_lru_cleanups_total', 'Total number of LRU cache cleanups performed' ) } - // Register all metrics - if (eventBufferMetrics.eventsBuffered) baileysMetrics.register(eventBufferMetrics.eventsBuffered) - if (eventBufferMetrics.bufferFlushes) baileysMetrics.register(eventBufferMetrics.bufferFlushes) - if (eventBufferMetrics.bufferFlushEvents) baileysMetrics.register(eventBufferMetrics.bufferFlushEvents) - if (eventBufferMetrics.bufferCurrentSize) baileysMetrics.register(eventBufferMetrics.bufferCurrentSize) - if (eventBufferMetrics.bufferPeakSize) baileysMetrics.register(eventBufferMetrics.bufferPeakSize) - if (eventBufferMetrics.bufferHistoryCacheSize) baileysMetrics.register(eventBufferMetrics.bufferHistoryCacheSize) - if (eventBufferMetrics.bufferOverflows) baileysMetrics.register(eventBufferMetrics.bufferOverflows) - if (eventBufferMetrics.bufferLruCleanups) baileysMetrics.register(eventBufferMetrics.bufferLruCleanups) } return eventBufferMetrics } @@ -1800,7 +1844,7 @@ function getEventBufferMetrics() { */ export function recordEventBuffered(eventType: string, count: number = 1): void { try { - const metrics = getEventBufferMetrics() + // Use the main metrics object which has eventsBuffered with label ['event_type'] metrics.eventsBuffered?.inc({ event_type: eventType }, count) } catch { // Metrics not initialized, ignore silently @@ -1811,12 +1855,23 @@ export function recordEventBuffered(eventType: string, count: number = 1): void * Record a buffer flush operation * Used by event-buffer.ts for metrics integration */ -export function recordBufferFlush(eventCount: number, forced: boolean): void { +export function recordBufferFlush(eventCount: number, forced: boolean, historyCacheSize?: number): void { try { - const metrics = getEventBufferMetrics() - metrics.bufferFlushes?.inc({ forced: forced ? 'true' : 'false' }) - metrics.bufferFlushEvents?.observe({}, eventCount) - metrics.bufferCurrentSize?.set({}, 0) // Reset after flush + // Use the main metrics object which has the correct labels ['type', 'reason'] + metrics.bufferFlushes?.inc({ type: 'event', reason: forced ? 'forced' : 'normal' }) + + // Update buffer cache size if provided + if (typeof historyCacheSize === 'number') { + metrics.bufferCacheSize?.set({}, historyCacheSize) + } + + // Also update the lazy-loaded event buffer metrics for detailed tracking + const ebMetrics = getEventBufferMetrics() + ebMetrics.bufferFlushEvents?.observe({}, eventCount) + ebMetrics.bufferCurrentSize?.set({}, 0) // Reset after flush + if (typeof historyCacheSize === 'number') { + ebMetrics.bufferHistoryCacheSize?.set({}, historyCacheSize) + } } catch { // Metrics not initialized, ignore silently } @@ -1834,10 +1889,197 @@ export function updateBufferStatistics(stats: { lruCleanups: number }): void { try { - const metrics = getEventBufferMetrics() - metrics.bufferCurrentSize?.set({}, stats.currentSize) - metrics.bufferPeakSize?.set({}, stats.peakSize) - metrics.bufferHistoryCacheSize?.set({}, stats.historyCacheSize) + const ebMetrics = getEventBufferMetrics() + ebMetrics.bufferCurrentSize?.set({}, stats.currentSize) + ebMetrics.bufferPeakSize?.set({}, stats.peakSize) + ebMetrics.bufferHistoryCacheSize?.set({}, stats.historyCacheSize) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Record a cache cleanup operation + * Used by event-buffer.ts when LRU cleanup is performed + */ +export function recordCacheCleanup(removedCount: number): void { + try { + metrics.bufferCacheCleanup?.inc({}) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Record a buffer overflow event + * Used by event-buffer.ts when buffer exceeds max size + */ +export function recordBufferOverflow(): void { + try { + metrics.bufferOverflows?.inc({ type: 'event' }) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Record a connection error + * Used by socket.ts when connection fails + */ +export function recordConnectionError(errorType: string): void { + try { + metrics.connectionErrors?.inc({ error_type: errorType }) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Record buffer destruction + * Used by event-buffer.ts when buffer is destroyed + */ +export function recordBufferDestroyed(reason: string, hadPendingFlush: boolean): void { + try { + metrics.bufferDestroyed?.inc({ reason, had_pending_flush: hadPendingFlush ? 'true' : 'false' }) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Record final flush during buffer destruction + * Used by event-buffer.ts when buffer flushes remaining events on destroy + */ +export function recordBufferFinalFlush(): void { + try { + metrics.bufferFinalFlush?.inc({}) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Update adaptive system metrics + * Used by event-buffer.ts to report adaptive timeout health and event rate + */ +export function updateAdaptiveMetrics(eventRate: number, isHealthy: boolean): void { + try { + metrics.adaptiveEventRate?.set({}, eventRate) + metrics.adaptiveHealthStatus?.set({}, isHealthy ? 1 : 0) + } catch { + // Metrics not initialized, ignore silently + } +} + +// ============================================ +// WhatsApp Connection & Message Metrics +// ============================================ + +/** + * Increment active connections gauge + */ +export function incrementActiveConnections(): void { + try { + metrics.activeConnections?.inc({}) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Decrement active connections gauge + */ +export function decrementActiveConnections(): void { + try { + metrics.activeConnections?.dec({}) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Set active connections to specific value + */ +export function setActiveConnections(count: number): void { + try { + metrics.activeConnections?.set({}, count) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Record a connection attempt + */ +export function recordConnectionAttempt(status: 'success' | 'failure'): void { + try { + metrics.connectionAttempts?.inc({ status }) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Record a message sent + */ +export function recordMessageSent(type: string = 'text'): void { + try { + metrics.messagesSent?.inc({ type }) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Record a message received + */ +export function recordMessageReceived(type: string = 'text'): void { + try { + metrics.messagesReceived?.inc({ type }) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Record a message retry attempt + */ +export function recordMessageRetry(type: string = 'text'): void { + try { + metrics.messageRetries?.inc({ type }) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Record a message failure + */ +export function recordMessageFailure(type: string = 'text', reason: string = 'unknown'): void { + try { + metrics.messageFailures?.inc({ type, reason }) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Update messages queued gauge + */ +export function setMessagesQueued(count: number, priority: string = 'normal'): void { + try { + metrics.messagesQueued?.set({ priority }, count) + } catch { + // Metrics not initialized, ignore silently + } +} + +/** + * Record history sync messages + */ +export function recordHistorySyncMessages(count: number = 1): void { + try { + metrics.historySyncMessages?.inc({}, count) } catch { // Metrics not initialized, ignore silently } @@ -1862,12 +2104,59 @@ export function getMetricsManager(config?: Partial): PrometheusMe return globalMetricsManager } +/** + * Initialize metrics with labels to zero values + * This ensures they appear in Prometheus output even before first increment + */ +function initializeMetricsWithLabels(): void { + try { + // Buffer destroyed metric + metrics.bufferDestroyed?.inc({ reason: 'normal', had_pending_flush: 'true' }, 0) + metrics.bufferDestroyed?.inc({ reason: 'normal', had_pending_flush: 'false' }, 0) + metrics.bufferDestroyed?.inc({ reason: 'error', had_pending_flush: 'true' }, 0) + metrics.bufferDestroyed?.inc({ reason: 'error', had_pending_flush: 'false' }, 0) + + // Buffer flushes metric + metrics.bufferFlushes?.inc({ type: 'event', reason: 'normal' }, 0) + metrics.bufferFlushes?.inc({ type: 'event', reason: 'forced' }, 0) + + // Connection metrics + metrics.connectionAttempts?.inc({ status: 'success' }, 0) + metrics.connectionAttempts?.inc({ status: 'failure' }, 0) + metrics.connectionErrors?.inc({ error_type: 'timeout' }, 0) + metrics.connectionErrors?.inc({ error_type: 'closed' }, 0) + metrics.connectionErrors?.inc({ error_type: 'unknown' }, 0) + + // Message metrics + metrics.messagesSent?.inc({ type: 'text' }, 0) + metrics.messagesSent?.inc({ type: 'media' }, 0) + metrics.messagesSent?.inc({ type: 'other' }, 0) + metrics.messagesReceived?.inc({ type: 'text' }, 0) + metrics.messagesReceived?.inc({ type: 'media' }, 0) + metrics.messagesReceived?.inc({ type: 'notification' }, 0) + metrics.messagesReceived?.inc({ type: 'other' }, 0) + metrics.messageRetries?.inc({ type: 'text' }, 0) + metrics.messageRetries?.inc({ type: 'other' }, 0) + metrics.messageFailures?.inc({ type: 'text', reason: 'max_retries' }, 0) + metrics.messageFailures?.inc({ type: 'other', reason: 'max_retries' }, 0) + + // Circuit breaker metric + metrics.circuitBreakerTrips?.inc({ name: 'main' }, 0) + + console.log('[Prometheus] Initialized metrics with labels') + } catch (error) { + console.error('[Prometheus] Error initializing metrics with labels:', error) + } +} + /** * Initialize global metrics (call once at application startup) */ export async function initializeMetrics(config?: Partial): Promise { const manager = getMetricsManager(config) await manager.initialize() + // Initialize metrics with labels to zero values + initializeMetricsWithLabels() return manager } @@ -1881,6 +2170,30 @@ export async function shutdownMetrics(): Promise { } } +// ============================================ +// Auto-initialization +// ============================================ + +/** + * Auto-start the Prometheus metrics server when module is loaded + * and BAILEYS_PROMETHEUS_ENABLED=true + * + * This ensures the /metrics endpoint is available without requiring + * manual initialization in the application code. + */ +if (metricsConfig.enabled) { + // Use setImmediate to avoid blocking module loading + setImmediate(() => { + initializeMetrics() + .then(() => { + console.log('[Prometheus] Auto-initialized metrics server successfully') + }) + .catch((error) => { + console.error('[Prometheus] Failed to auto-initialize metrics server:', error) + }) + }) +} + // ============================================ // Default Export // ============================================ diff --git a/src/Utils/version-cache.ts b/src/Utils/version-cache.ts new file mode 100644 index 00000000..9941d890 --- /dev/null +++ b/src/Utils/version-cache.ts @@ -0,0 +1,290 @@ +import { promises as fs } from 'fs' +import { join } from 'path' +import { fetchLatestWaWebVersion } from './generics' +import type { WAVersion } from '../Types' + +/** + * Version cache entry stored in file + */ +interface VersionCacheEntry { + version: WAVersion + fetchedAt: number // timestamp in ms + source: 'online' | 'fallback' +} + +/** + * Logger interface for version cache operations + */ +export interface VersionCacheLogger { + info: (obj: unknown, msg?: string) => void + debug: (obj: unknown, msg?: string) => void + warn: (obj: unknown, msg?: string) => void +} + +/** + * In-memory cache to avoid file reads on every connection + */ +let memoryCache: VersionCacheEntry | null = null + +/** + * Promise to prevent concurrent fetches (deduplication) + */ +let fetchInProgress: Promise | null = null + +/** + * Default cache TTL: 6 hours + */ +const DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1000 + +/** + * Default cache file path. + * + * NOTE: Uses process.cwd() which may not be writable in some environments + * (containers, serverless, etc). In such cases, specify a custom `cacheFilePath` + * in the config pointing to a writable directory like `/tmp`. + */ +const DEFAULT_CACHE_FILE = join(process.cwd(), '.baileys-version-cache.json') + +/** + * Configuration for version cache + */ +export interface VersionCacheConfig { + /** Cache TTL in milliseconds (default: 6 hours) */ + cacheTtlMs?: number + /** + * Path to cache file (default: .baileys-version-cache.json in cwd) + * + * NOTE: If running in a container or serverless environment where cwd + * is not writable, specify a writable path like '/tmp/.baileys-version-cache.json' + */ + cacheFilePath?: string + /** Logger instance */ + logger?: VersionCacheLogger +} + +/** + * Result from refreshVersionCache with success status + */ +export interface RefreshVersionResult { + version: WAVersion + success: boolean + source: 'online' | 'fallback' +} + +/** + * Reads the cache from file + */ +async function readCacheFile(filePath: string): Promise { + try { + const data = await fs.readFile(filePath, 'utf-8') + return JSON.parse(data) as VersionCacheEntry + } catch { + return null + } +} + +/** + * Writes the cache to file + */ +async function writeCacheFile(filePath: string, entry: VersionCacheEntry): Promise { + try { + await fs.writeFile(filePath, JSON.stringify(entry, null, 2), 'utf-8') + } catch { + // Ignore write errors - cache is optional + } +} + +/** + * Checks if cache entry is still valid + */ +function isCacheValid(entry: VersionCacheEntry | null, ttlMs: number): boolean { + if (!entry) return false + const age = Date.now() - entry.fetchedAt + return age < ttlMs +} + +/** + * Fetches version with deduplication (prevents 150 parallel requests) + */ +async function fetchVersionOnce( + cacheFilePath: string, + logger?: VersionCacheLogger +): Promise { + logger?.info({}, 'Fetching WhatsApp Web version (shared for all connections)...') + + const result = await fetchLatestWaWebVersion() + + const entry: VersionCacheEntry = { + version: result.version, + fetchedAt: Date.now(), + source: result.isLatest ? 'online' : 'fallback' + } + + // Update memory cache + memoryCache = entry + + // Persist to file (async, don't wait) + writeCacheFile(cacheFilePath, entry).catch(() => {}) + + logger?.info( + { version: entry.version, source: entry.source }, + 'Version fetched and cached' + ) + + return entry +} + +/** + * Gets the cached WhatsApp version, fetching if necessary. + * + * Features: + * - File-based persistence (survives restarts) + * - In-memory cache (fast access) + * - Request deduplication (150 connections = 1 request) + * - Configurable TTL + * + * @example + * ```typescript + * // All 150 connections share the same cached version + * const { version } = await getCachedVersion() + * const sock = makeWASocket({ version, auth }) + * ``` + */ +export async function getCachedVersion( + config: VersionCacheConfig = {} +): Promise<{ version: WAVersion; fromCache: boolean; age: number; source: 'online' | 'fallback' | 'memory' | 'file' }> { + const { + cacheTtlMs = DEFAULT_CACHE_TTL_MS, + cacheFilePath = DEFAULT_CACHE_FILE, + logger + } = config + + // 1. Check memory cache first (fastest) + if (isCacheValid(memoryCache, cacheTtlMs)) { + const age = Date.now() - memoryCache!.fetchedAt + logger?.debug({ age: Math.round(age / 1000) + 's' }, 'Using memory cached version') + return { version: memoryCache!.version, fromCache: true, age, source: 'memory' } + } + + // 2. Check file cache (survives restarts) + const fileCache = await readCacheFile(cacheFilePath) + if (isCacheValid(fileCache, cacheTtlMs)) { + memoryCache = fileCache // Update memory cache + const age = Date.now() - fileCache!.fetchedAt + logger?.debug({ age: Math.round(age / 1000) + 's' }, 'Using file cached version') + return { version: fileCache!.version, fromCache: true, age, source: 'file' } + } + + // 3. Need to fetch - but deduplicate concurrent requests + // If 150 connections start at once, only 1 fetch happens + if (!fetchInProgress) { + fetchInProgress = fetchVersionOnce(cacheFilePath, logger) + .finally(() => { fetchInProgress = null }) + } + + const entry = await fetchInProgress + return { version: entry.version, fromCache: false, age: 0, source: entry.source } +} + +/** + * Clears the version cache (memory and file). + * Also cancels any in-progress fetch to prevent it from restoring the cache. + */ +export async function clearVersionCache( + cacheFilePath: string = DEFAULT_CACHE_FILE +): Promise { + // Wait for any in-progress fetch to complete before clearing + // This prevents the fetch from restoring the cache after we clear it + if (fetchInProgress) { + try { + await fetchInProgress + } catch { + // Ignore fetch errors during clear + } + } + + memoryCache = null + fetchInProgress = null + + try { + await fs.unlink(cacheFilePath) + } catch { + // Ignore if file doesn't exist + } +} + +/** + * Forces a refresh of the cached version. + * Returns success status to indicate if online fetch succeeded or fell back to bundled version. + * + * @returns Object with version, success status, and source + */ +export async function refreshVersionCache( + config: VersionCacheConfig = {} +): Promise { + const { cacheFilePath = DEFAULT_CACHE_FILE, logger } = config + + // Wait for any existing fetch to complete first (deduplication) + if (fetchInProgress) { + try { + const existing = await fetchInProgress + // If there's already a fresh fetch in progress, return its result + return { + version: existing.version, + success: existing.source === 'online', + source: existing.source + } + } catch { + // Ignore and proceed with new fetch + } + } + + // Clear existing cache + memoryCache = null + + // Fetch fresh + const entry = await fetchVersionOnce(cacheFilePath, logger) + + return { + version: entry.version, + success: entry.source === 'online', + source: entry.source + } +} + +/** + * Gets cache status information + */ +export function getVersionCacheStatus( + cacheTtlMs: number = DEFAULT_CACHE_TTL_MS +): { + hasCache: boolean + version: WAVersion | null + age: number | null + isExpired: boolean + expiresIn: number | null + source: 'online' | 'fallback' | null +} { + if (!memoryCache) { + return { + hasCache: false, + version: null, + age: null, + isExpired: true, + expiresIn: null, + source: null + } + } + + const age = Date.now() - memoryCache.fetchedAt + const isExpired = age >= cacheTtlMs + + return { + hasCache: true, + version: memoryCache.version, + age, + isExpired, + expiresIn: isExpired ? 0 : cacheTtlMs - age, + source: memoryCache.source + } +} diff --git a/src/__tests__/Utils/ctwa-recovery.test.ts b/src/__tests__/Utils/ctwa-recovery.test.ts index 02b4f96e..fc038494 100644 --- a/src/__tests__/Utils/ctwa-recovery.test.ts +++ b/src/__tests__/Utils/ctwa-recovery.test.ts @@ -9,6 +9,7 @@ */ import { proto } from '../../../WAProto/index.js' +import { metrics } from '../../Utils/prometheus-metrics.js' import { NO_MESSAGE_FOUND_ERROR_TEXT } from '../../Utils/decode-wa-message.js' describe('CTWA Recovery', () => { @@ -106,6 +107,51 @@ describe('CTWA Recovery', () => { }) }) + describe('Metrics Integration', () => { + it('should have all required CTWA metrics defined', () => { + expect(metrics.ctwaRecoveryRequests).toBeDefined() + expect(typeof metrics.ctwaRecoveryRequests.inc).toBe('function') + + expect(metrics.ctwaMessagesRecovered).toBeDefined() + expect(typeof metrics.ctwaMessagesRecovered.inc).toBe('function') + + expect(metrics.ctwaRecoveryLatency).toBeDefined() + expect(typeof metrics.ctwaRecoveryLatency.observe).toBe('function') + + expect(metrics.ctwaRecoveryFailures).toBeDefined() + expect(typeof metrics.ctwaRecoveryFailures.inc).toBe('function') + }) + + it('should be able to call recovery request metric with status label', () => { + // Should not throw when called with valid labels + expect(() => { + metrics.ctwaRecoveryRequests.inc({ status: 'requested' }) + }).not.toThrow() + }) + + it('should be able to call recovered messages counter', () => { + // Should not throw when called + expect(() => { + metrics.ctwaMessagesRecovered.inc() + }).not.toThrow() + }) + + it('should be able to observe recovery latency', () => { + const latencyMs = 2500 + // Should not throw when called with valid latency + expect(() => { + metrics.ctwaRecoveryLatency.observe(latencyMs) + }).not.toThrow() + }) + + it('should be able to call failure counter with reason', () => { + // Should not throw when called with valid labels + expect(() => { + metrics.ctwaRecoveryFailures.inc({ reason: 'request_failed' }) + }).not.toThrow() + }) + }) + describe('Configuration', () => { it('should have enableCTWARecovery as a valid configuration option', () => { // This tests that the type system accepts enableCTWARecovery diff --git a/src/index.ts b/src/index.ts index feb53e2a..b73f5bb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import makeWASocket from './Socket/index' +import makeWASocket, { makeWASocketAutoVersion } from './Socket/index' export * from '../WAProto/index.js' export * from './Utils/index' @@ -9,5 +9,5 @@ export * from './WAM/index' export * from './WAUSync/index' export type WASocket = ReturnType -export { makeWASocket } +export { makeWASocket, makeWASocketAutoVersion } export default makeWASocket