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:
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -264,7 +264,7 @@ export const decodeSyncdMutations = async (
|
||||
})
|
||||
}
|
||||
|
||||
return mutationKeys(keyEnc.keyData!)
|
||||
return await mutationKeys(keyEnc.keyData!)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user