fix: resolve merge conflicts with master branch

Merge origin/master into CTWA recovery feature branch

Resolved conflicts:
- src/Defaults/index.ts: kept detailed comment about enableCTWARecovery
- src/Socket/messages-recv.ts: integrated metrics with CTWA recovery logic
- src/__tests__/Utils/ctwa-recovery.test.ts: added metrics tests
This commit is contained in:
Claude
2026-01-22 16:58:59 +00:00
19 changed files with 1526 additions and 175 deletions
+91 -17
View File
@@ -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
+272 -94
View File
@@ -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<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Fetch with timeout
*/
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise<Response> {
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<VersionResult> {
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<VersionResult> {
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<VersionResult> {
// 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 => {
+9 -2
View File
@@ -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: {
+217 -1
View File
@@ -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<typeof setInterval> | 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
+52 -13
View File
@@ -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')
+13
View File
@@ -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)
+49
View File
@@ -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 () => {
+12
View File
@@ -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 }
+26
View File
@@ -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.
+1 -1
View File
@@ -264,7 +264,7 @@ export const decodeSyncdMutations = async (
})
}
return mutationKeys(keyEnc.keyData!)
return await mutationKeys(keyEnc.keyData!)
}
}
+5
View File
@@ -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')
+53 -5
View File
@@ -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')
}
+4 -1
View File
@@ -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,
+3
View File
@@ -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'
+29
View File
@@ -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
+352 -39
View File
@@ -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<T>(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<MetricsConfig>): 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<MetricsConfig>): Promise<PrometheusMetricsManager> {
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<void> {
}
}
// ============================================
// 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
// ============================================
+290
View File
@@ -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<VersionCacheEntry> | 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<VersionCacheEntry | null> {
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<void> {
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<VersionCacheEntry> {
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<void> {
// 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<RefreshVersionResult> {
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
}
}
+46
View File
@@ -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
+2 -2
View File
@@ -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<typeof makeWASocket>
export { makeWASocket }
export { makeWASocket, makeWASocketAutoVersion }
export default makeWASocket