Compare commits

..

9 Commits

Author SHA1 Message Date
Renato Alcara 9b5104b6c7 style: fix prettier formatting in viewOnceInner chain
Why: check-lint CI failed on decode-wa-message.ts:329 with prettier/prettier
error. Joining the three optional-chain operands onto one continuation line
matches printWidth=120 / useTabs config.
2026-04-26 13:35:34 -03:00
Renato Alcara 16aa02519e fix: unwrap viewOnceMessage in getMediaType so enc node gets mediatype attribute
Without this fix, view-once media (image/video/audio) was sent without
mediatype="image/video/audio" on the enc node because getMediaType only
checked the top-level message fields. The viewOnceMessage wrapper hides
the inner imageMessage, causing mediatype to be empty and WA servers to
silently drop the message on the recipient side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:21:04 -03:00
Renato Alcara 8150587de3 fix: remove unnecessary non-null assertion on fullMessage.key
fullMessage.key is always present on WAMessage. Use consistent style
with stanza-2 handling at line 297.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:21:04 -03:00
Renato Alcara 8f1fc42cc7 fix: detect view-once media (image/video/audio) on stanza 1 for linked devices
When a view-once arrives at a linked device (InfiniteAPI), the server sends:
- Stanza 1: enc payload with full media metadata (mediaKey, directPath) wrapped in
  viewOnceMessage > imageMessage/videoMessage/audioMessage with viewOnce: true
- Stanza 2: unavailable view_once fanout placeholder (already handled)

Previously only stanza 2 set key.isViewOnce = true. Stanza 1 was emitted as
plain media with no view-once indicator, making it indistinguishable from regular
media on the consumer side (messages.upsert).

This fix inspects the decrypted proto for viewOnceMessage (v1/v2/v2Ext) wrappers
containing a media message with viewOnce=true and propagates key.isViewOnce=true.

The viewOnceMessage wrapper is also used for interactive messages (carousel,
buttons, lists) but those carry interactiveMessage/listMessage inside -- never
imageMessage.viewOnce / videoMessage.viewOnce / audioMessage.viewOnce.
The distinction is unambiguous and does not affect interactive message handling.

Verified via WA Desktop CDP capture (2026-03-19) and Android Frida DB capture
of view-once types 42/43/82 in msgstore.db (2026-03-20).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:21:04 -03:00
github-actions[bot] d077902695 chore: update WhatsApp Web version (#2420)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-15 22:49:39 +02:00
João Lucas c4e5d1262a Fix ack handling (#2373)
* fix: ack handling to match wa web

* fix: prevent duplicate ack on message errors
2026-03-13 21:45:49 +02:00
Matheus Filype 6afde71691 feat: groups mention all (#2396)
* fix: improve message resend logic by adding checks for message IDs

* Revert "fix: improve message resend logic by adding checks for message IDs"

This reverts commit c03f9d8e6fc6cbfbb9d1f8f67c169700e704213d.

* feat: add mentionAll support to message context for group mentions

* fix: improve readability of condition for mentions and mentionAll in generateWAMessageContent

* Apply suggestions from code review

Co-authored-by: Rajeh Taher <rajeh@reforward.dev>

---------

Co-authored-by: Rajeh Taher <rajeh@reforward.dev>
2026-03-13 19:49:16 +02:00
ShellTear ad5ea817f7 Merge pull request #2360 from WhiskeySockets/update-version/stable 2026-02-25 01:33:33 +09:00
github-actions[bot] 3841abca35 chore: update WhatsApp Web version 2026-02-22 00:43:34 +00:00
125 changed files with 7875 additions and 53159 deletions
+1 -18
View File
@@ -1,23 +1,6 @@
---
## 🚀 InfiniteAPI é o core do InfiniteZAP (SaaS gerenciado)
Este repositório é o **motor (core)** usado pelo **InfiniteZap** — uma plataforma onde você **só se preocupa com a operação**, e a gente cuida do resto:
**Infra gerenciada** (deploy, atualizações, monitoramento e backups)
**Builds validados do nosso fork** com correções e melhorias contínuas
**Acesso antecipado** a novos recursos e hotfixes antes do rollout geral
**Suporte operacional** para manter tudo estável em produção
➡️ Teste Gratuito por 15 dias 👉: **https://www.infinitezap.com.br**
> Nota: este repositório é um fork sob licença MIT.
> Não é afiliado ao WhatsApp e não representa o projeto oficial.
---
<h1><img alt="Baileys logo" src="https://raw.githubusercontent.com/WhiskeySockets/Baileys/refs/heads/master/Media/logo.png" height="75"/></h1>
> [!CAUTION]
> NOTICE OF BREAKING CHANGE.
>
+4 -21
View File
@@ -24,33 +24,16 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Fix Git config
run: git config --global core.autocrlf input
- name: Configure Git for HTTPS
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Force git to use HTTPS with token instead of SSH for GitHub repositories
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "git@github.com:"
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
.yarn/cache
.yarn/install-state.gz
key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock', 'package.json') }}
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-${{ hashFiles('package.json') }}-
${{ runner.os }}-yarn-
- name: Install dependencies
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_TERMINAL_PROMPT: 0
run: yarn install --immutable
- name: Build project
+2 -19
View File
@@ -24,34 +24,17 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Configure Git for HTTPS
run: |
# Reescreve SSH -> HTTPS para repositórios públicos do GitHub (sem token, evita erros de autenticação)
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
node_modules
.yarn/install-state.gz
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_TERMINAL_PROMPT: 0
YARN_HTTP_TIMEOUT: 600000
YARN_HTTP_RETRY: 10
run: yarn install --immutable
- name: Auto-fix formatting
run: yarn lint:fix
continue-on-error: true
- name: Check linting
run: yarn lint
+2 -14
View File
@@ -33,23 +33,11 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Configure Git for HTTPS
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Force git to use HTTPS instead of SSH for GitHub repositories
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
# Configure git to use GITHUB_TOKEN for authentication
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
node_modules
.yarn/install-state.gz
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
- uses: mshick/add-pr-comment@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
repo-token: ${{ secrets.PERSONAL_TOKEN }}
message-id: pr-test
message: |
Thanks for opening this pull request and contributing to the project!
+2 -14
View File
@@ -32,23 +32,11 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Configure Git for HTTPS
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Force git to use HTTPS instead of SSH for GitHub repositories
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
# Configure git to use GITHUB_TOKEN for authentication
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
node_modules
.yarn/install-state.gz
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
+2 -16
View File
@@ -25,30 +25,16 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Fix Git config
run: git config --global core.autocrlf input
- name: Configure Git for HTTPS
run: |
# Reescreve SSH -> HTTPS para repositórios públicos do GitHub (sem token, evita erros de autenticação)
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
node_modules
.yarn/install-state.gz
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_TERMINAL_PROMPT: 0
run: yarn install --immutable
- name: Run tests
+5 -189
View File
@@ -2,16 +2,12 @@ name: Update WAProto
on:
schedule:
- cron: '0 3 * * *'
- cron: '10 1 * * *'
workflow_dispatch:
# Dynamic run name with run number
run-name: "Update WAProto #${{ github.run_number }}"
permissions:
contents: write
pull-requests: write
actions: write
jobs:
update-proto:
@@ -31,16 +27,6 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Configure Git for HTTPS
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Force git to use HTTPS instead of SSH for GitHub repositories
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
# Configure git to use GITHUB_TOKEN for authentication
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
- name: Install packages
run: |
yarn install --immutable
@@ -54,10 +40,6 @@ jobs:
WA_JS_URL=$(cat wa-logs.txt | perl -n -e'/Found source JS URL\: (.+)/ && print $1')
echo "wa_version=$WA_VERSION" >> $GITHUB_OUTPUT
echo "wa_js_url=$WA_JS_URL" >> $GITHUB_OUTPUT
# Display version prominently
echo "📱 WhatsApp Proto Version: v$WA_VERSION"
echo "🔗 Source JS URL: $WA_JS_URL"
- name: GenerateStatics
run: yarn gen:protobuf
@@ -66,183 +48,17 @@ jobs:
run: |
WA_VERSION="${{steps.wa_proto_info.outputs.wa_version}}"
WA_NUMBERS=$(echo $WA_VERSION | sed "s/\./, /g")
echo "{\"version\": [$WA_NUMBERS]}" > src/Defaults/baileys-version.json
# Confirm update
echo "✅ Updated baileys-version.json to v$WA_VERSION"
cat src/Defaults/baileys-version.json
echo -e "{\n\t\"version\": [$WA_NUMBERS]\n}" > src/Defaults/baileys-version.json
- name: Create Pull Request
id: create_pr
uses: peter-evans/create-pull-request@v5
with:
commit-message: 'chore: update proto/version to v${{steps.wa_proto_info.outputs.wa_version}}'
title: 'chore: update WhatsApp proto to v${{steps.wa_proto_info.outputs.wa_version}}'
commit-message: 'chore: updated proto/version to v${{steps.wa_proto_info.outputs.wa_version}}'
title: 'Whatsapp v${{steps.wa_proto_info.outputs.wa_version}} proto/version change'
branch: 'update-proto/stable'
delete-branch: true
labels: 'update-proto'
body: |
## 📦 Automated WhatsApp Proto Update
**Version:** `v${{steps.wa_proto_info.outputs.wa_version}}`
**Source JS URL:** ${{steps.wa_proto_info.outputs.wa_js_url}}
### Changes
- ✅ Updated `WAProto/*` (protobuf definitions)
- ✅ Updated `src/Defaults/baileys-version.json`
### Update Frequency
Daily at 03:00 UTC (00:00 BRT)
body: "Automated changes\nFound source JS URL: ${{steps.wa_proto_info.outputs.wa_js_url}}\nCurrent version: v${{steps.wa_proto_info.outputs.wa_version}}"
add-paths: |
WAProto/*
src/Defaults/baileys-version.json
- name: Enable Auto-merge
if: steps.create_pr.outputs.pull-request-number != ''
env:
GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ steps.create_pr.outputs.pull-request-number }}"
WA_VERSION="${{ steps.wa_proto_info.outputs.wa_version }}"
echo "🔀 Enabling auto-merge for PR #$PR_NUMBER (v$WA_VERSION)..."
# Enable auto-merge with squash method
set +e
AUTO_MERGE_OUTPUT=$(gh pr merge "$PR_NUMBER" \
--repo "${{ github.repository }}" \
--squash \
--auto \
--delete-branch 2>&1)
AUTO_MERGE_EXIT=$?
set -e
if [ $AUTO_MERGE_EXIT -eq 0 ]; then
echo "✅ Auto-merge enabled for PR #$PR_NUMBER (v$WA_VERSION)"
echo "$AUTO_MERGE_OUTPUT"
echo "⏳ PR will be merged automatically once all checks pass"
else
echo "::warning::Failed to enable auto-merge for PR #$PR_NUMBER (v$WA_VERSION)"
echo "::group::Auto-merge Error Output"
echo "$AUTO_MERGE_OUTPUT"
echo "::endgroup::"
# Fall back to waiting for checks and then merging
echo "🔄 Falling back to wait-and-merge strategy..."
# Wait for checks to start
echo "⏳ Waiting 30 seconds for checks to start..."
sleep 30
# Wait for checks to complete (max 5 minutes)
echo "⏳ Waiting for checks to complete..."
WAIT_COUNT=0
MAX_WAIT=60 # 60 * 5 seconds = 5 minutes
while [ $WAIT_COUNT -lt $MAX_WAIT ]; do
set +e
CHECK_STATUS=$(gh pr checks "$PR_NUMBER" --repo "${{ github.repository }}" 2>&1)
CHECK_EXIT=$?
set -e
if [ $CHECK_EXIT -eq 0 ]; then
echo "📊 Current check status:"
echo "$CHECK_STATUS"
# Check if all checks passed
if echo "$CHECK_STATUS" | grep -q "All checks have passed"; then
echo "✅ All checks passed!"
break
fi
# Check if any check failed
if echo "$CHECK_STATUS" | grep -qE "(fail|failure|error)"; then
echo "❌ Some checks failed, aborting merge"
exit 1
fi
fi
WAIT_COUNT=$((WAIT_COUNT + 1))
echo "⏳ Waiting... ($WAIT_COUNT/$MAX_WAIT)"
sleep 5
done
if [ $WAIT_COUNT -ge $MAX_WAIT ]; then
echo "::warning::Timeout waiting for checks to complete"
echo "::warning::PR #$PR_NUMBER requires manual merge: https://github.com/${{ github.repository }}/pull/$PR_NUMBER"
exit 0
fi
# Try to merge now that checks passed
echo "🔀 Attempting to merge PR #$PR_NUMBER..."
set +e
MERGE_OUTPUT=$(gh pr merge "$PR_NUMBER" \
--repo "${{ github.repository }}" \
--squash \
--delete-branch \
--yes 2>&1)
MERGE_EXIT=$?
set -e
if [ $MERGE_EXIT -eq 0 ]; then
echo "✅ PR #$PR_NUMBER (v$WA_VERSION) merged successfully"
echo "$MERGE_OUTPUT"
else
echo "::error::Failed to merge PR #$PR_NUMBER (v$WA_VERSION)"
echo "::group::Merge Error Output"
echo "$MERGE_OUTPUT"
echo "::endgroup::"
# Try with admin flag
echo "🔧 Attempting merge with admin override..."
set +e
ADMIN_OUTPUT=$(gh pr merge "$PR_NUMBER" \
--repo "${{ github.repository }}" \
--squash \
--delete-branch \
--admin 2>&1)
ADMIN_EXIT=$?
set -e
if [ $ADMIN_EXIT -eq 0 ]; then
echo "✅ PR #$PR_NUMBER (v$WA_VERSION) merged with admin override"
echo "$ADMIN_OUTPUT"
else
echo "::error::Failed to merge even with admin override"
echo "::group::Admin Merge Error Output"
echo "$ADMIN_OUTPUT"
echo "::endgroup::"
echo "::warning::Consider configuring a Personal Access Token (PAT) with full repo permissions as GH_PAT secret"
echo "::warning::PR #$PR_NUMBER requires manual merge: https://github.com/${{ github.repository }}/pull/$PR_NUMBER"
exit 0
fi
fi
fi
- name: Summary
if: always()
run: |
echo "### 📦 WAProto Update" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
WA_VERSION="${{ steps.wa_proto_info.outputs.wa_version }}"
WA_JS_URL="${{ steps.wa_proto_info.outputs.wa_js_url }}"
PR_NUMBER="${{ steps.create_pr.outputs.pull-request-number }}"
echo "**Version:** \`v$WA_VERSION\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -n "$PR_NUMBER" ]; then
echo "✅ **PR #$PR_NUMBER created with auto-merge enabled**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Changes:**" >> $GITHUB_STEP_SUMMARY
echo "- 📦 Updated WAProto/* (protobuf definitions)" >> $GITHUB_STEP_SUMMARY
echo "- 📝 Updated baileys-version.json" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Source:** [$WA_JS_URL]($WA_JS_URL)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "🔀 PR will merge automatically once checks pass" >> $GITHUB_STEP_SUMMARY
else
echo "️ No changes detected - already up to date" >> $GITHUB_STEP_SUMMARY
fi
+21 -248
View File
@@ -2,23 +2,13 @@ name: Update WhatsApp Version
on:
schedule:
# Run daily at 09:00 UTC (optimized timing after WhatsApp deploys)
- cron: '0 9 * * *'
# Run on the 1st of every month at 02:00 UTC
- cron: '0 0 * * 0'
workflow_dispatch:
inputs:
force:
description: 'Force create PR even if version unchanged (useful for re-triggering)'
required: false
default: 'false'
type: boolean
# Dynamic run name with version
run-name: "Update WhatsApp Version ${{ github.run_number }}"
permissions:
contents: write
pull-requests: write
actions: write
jobs:
update-version:
@@ -26,10 +16,10 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup Node and Corepack
uses: actions/setup-node@v4
uses: actions/setup-node@v3.6.0
with:
node-version: 20.x
@@ -38,30 +28,11 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Configure Git for HTTPS
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Force git to use HTTPS instead of SSH for GitHub repositories
# IMPORTANT: Order matters! More specific rules first, then general ones
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
# Configure authentication (last so it doesn't interfere with SSH -> HTTPS conversion)
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
# Verify git config applied correctly
echo "✅ Git URL rewrites configured for HTTPS"
git config --global --get-regexp url
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
node_modules
.yarn/cache
.yarn/install-state.gz
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
@@ -71,60 +42,23 @@ jobs:
- name: Update WhatsApp Web Version
id: 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
# Read and output the updated version
VERSION=$(cat src/Defaults/baileys-version.json | grep -oP '"version":\s*\[\s*\K[0-9,\s]+' | tr -d ' ')
VERSION_STRING=$(echo $VERSION | sed 's/,/./g')
echo "version=$VERSION_STRING" >> $GITHUB_OUTPUT
echo "📱 WhatsApp Version: $VERSION_STRING"
# 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
run: yarn update:version
- 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
id: create_pr
if: steps.check_changes.outputs.has_changes == 'true' || steps.check_changes.outputs.force_mode == 'true'
if: steps.check_changes.outputs.has_changes == 'true'
env:
GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH_NAME="update-version/stable"
FORCE="${{ inputs.force }}"
VERSION="${{ steps.update_version.outputs.version }}"
# Configure git
git config user.name "github-actions[bot]"
@@ -133,183 +67,22 @@ jobs:
# Check if branch exists and delete it
git push origin --delete "$BRANCH_NAME" 2>/dev/null || true
# Create new branch
# Create new branch, commit, and push
git checkout -b "$BRANCH_NAME"
# 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 to v$VERSION"
fi
git add src/Defaults/baileys-version.json src/Defaults/index.ts src/Utils/generics.ts
git commit -m "chore: update WhatsApp Web version"
git push origin "$BRANCH_NAME"
# Create PR using GitHub CLI with version in title
PR_TITLE="chore: update WhatsApp Web version to v$VERSION"
PR_BODY="Automated WhatsApp Web version update.
# Create PR using GitHub CLI
gh pr create \
--title "chore: update WhatsApp Web version" \
--body "Automated WhatsApp Web version update.
This PR updates the WhatsApp Web client revision to the latest version fetched from \`web.whatsapp.com\`.
**Version:** \`$VERSION\`
**Single source of truth:**
- \`src/Defaults/baileys-version.json\` (other files import from here)
**Update frequency:** Daily at 09:00 UTC"
if [ "$FORCE" == "true" ]; then
PR_BODY="$PR_BODY
> **Note:** This PR was created with force mode enabled."
fi
# Create PR (compatible with older gh CLI versions)
set +e
PR_URL=$(gh pr create \
--title "$PR_TITLE" \
--body "$PR_BODY" \
**Files updated:**
- \`src/Defaults/baileys-version.json\`
- \`src/Defaults/index.ts\`
- \`src/Utils/generics.ts\`" \
--base master \
--head "$BRANCH_NAME" 2>&1)
PR_CREATE_EXIT=$?
set -e
if [ $PR_CREATE_EXIT -eq 0 ]; then
echo "✅ PR created successfully: $PR_URL"
# Extract PR number from URL
PR_NUMBER=$(echo "$PR_URL" | grep -oP '\d+$')
echo "📋 PR Number: #$PR_NUMBER"
# Save PR number for next step
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
else
# PR creation failed - could be duplicate or other error
echo "⚠️ Could not create PR. Checking if PR already exists..."
# Check for existing PR (without --json for compatibility)
EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --state open 2>/dev/null | head -1)
if [ -n "$EXISTING_PR" ]; then
echo "️ PR already exists for branch $BRANCH_NAME"
echo "$EXISTING_PR"
# Extract PR number from existing PR
PR_NUMBER=$(echo "$EXISTING_PR" | grep -oP '^\d+')
if [ -n "$PR_NUMBER" ]; then
echo "📋 Existing PR Number: #$PR_NUMBER"
# Save PR number for auto-merge step
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
fi
else
echo "❌ PR creation failed with error:"
echo "$PR_URL"
fi
fi
- name: Auto-merge PR
if: steps.create_pr.outputs.pr_number != ''
env:
GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ steps.create_pr.outputs.pr_number }}"
echo "🔀 Attempting to merge PR #$PR_NUMBER via squash..."
# Check PR status
set +e
PR_STATE=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json state --jq '.state' 2>&1)
PR_VIEW_EXIT=$?
set -e
if [ $PR_VIEW_EXIT -ne 0 ] || [ -z "$PR_STATE" ]; then
echo "⚠️ Could not retrieve PR state, attempting merge anyway..."
else
echo "📊 PR State: $PR_STATE"
if [ "$PR_STATE" != "OPEN" ]; then
echo "⚠️ PR is not open (state: $PR_STATE), skipping merge"
exit 0
fi
fi
# Brief wait for GitHub to compute PR mergeability
sleep 5
# Check if PR is mergeable
set +e
MERGEABLE=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json mergeable --jq '.mergeable' 2>&1)
set -e
echo "📊 Mergeable: $MERGEABLE"
# Attempt merge with error handling
set +e
MERGE_OUTPUT=$(gh pr merge "$PR_NUMBER" \
--repo "${{ github.repository }}" \
--squash \
--delete-branch \
--yes 2>&1)
MERGE_EXIT=$?
set -e
if [ $MERGE_EXIT -eq 0 ]; then
echo "✅ PR #$PR_NUMBER merged successfully"
echo "$MERGE_OUTPUT"
else
echo "::warning::Standard merge failed for PR #$PR_NUMBER"
echo "::group::Merge Error Output"
echo "$MERGE_OUTPUT"
echo "::endgroup::"
# Try with admin flag (bypasses branch protection if user has admin rights)
echo "🔧 Attempting merge with admin override..."
set +e
ADMIN_OUTPUT=$(gh pr merge "$PR_NUMBER" \
--repo "${{ github.repository }}" \
--squash \
--delete-branch \
--admin 2>&1)
ADMIN_EXIT=$?
set -e
if [ $ADMIN_EXIT -eq 0 ]; then
echo "✅ PR #$PR_NUMBER merged successfully with admin override"
echo "$ADMIN_OUTPUT"
else
echo "::error::Failed to merge PR #$PR_NUMBER even with admin override"
echo "::group::Admin Merge Error Output"
echo "$ADMIN_OUTPUT"
echo "::endgroup::"
echo "::warning::Consider configuring a Personal Access Token (PAT) with full repo permissions as GH_PAT secret"
echo "::warning::PR #$PR_NUMBER was created but requires manual merge: https://github.com/${{ github.repository }}/pull/$PR_NUMBER"
# Don't exit with error to avoid blocking the workflow
exit 0
fi
fi
- 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 }}"
VERSION="${{ steps.update_version.outputs.version }}"
echo "**Version:** \`$VERSION\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
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
--head "$BRANCH_NAME" || echo "PR already exists or could not be created"
-1
View File
@@ -19,4 +19,3 @@ test.ts
TestData
wa-logs*
coverage
AUDIT_REPORT_*.md
-1
View File
@@ -1 +0,0 @@
legacy-peer-deps=true
-2
View File
@@ -1,3 +1 @@
nodeLinker: node-modules
httpRetry: 10
httpTimeout: 600000
+7 -10
View File
@@ -1,10 +1,7 @@
import { Boom } from '@hapi/boom'
import NodeCache from '@cacheable/node-cache'
import readline from 'readline'
import makeWASocket, { AnyMessageContent, BinaryInfo, CacheStore, DEFAULT_CONNECTION_CONFIG, delay, DisconnectReason, downloadAndProcessHistorySyncNotification, encodeWAM, fetchLatestBaileysVersion, generateMessageIDV2, getAggregateVotesInPollMessage, getHistoryMsg, isJidNewsletter, jidDecode, makeCacheableSignalKeyStore, normalizeMessageContent, PatchedMessageWithRecipientJID, proto, useMultiFileAuthState, WAMessageContent, WAMessageKey } from '../src'
//import MAIN_LOGGER from '../src/Utils/logger'
import open from 'open'
import fs from 'fs'
import makeWASocket, { CacheStore, DEFAULT_CONNECTION_CONFIG, DisconnectReason, fetchLatestBaileysVersion, generateMessageIDV2, getAggregateVotesInPollMessage, isJidNewsletter, makeCacheableSignalKeyStore, proto, useMultiFileAuthState, WAMessageContent, WAMessageKey } from '../src'
import P from 'pino'
const logger = P({
@@ -149,11 +146,11 @@ const startSock = async() => {
// go to an old chat and send this
if (text == "onDemandHistSync") {
const messageId = await sock.fetchMessageHistory(50, msg.key, msg.messageTimestamp ?? 0)
const messageId = await sock.fetchMessageHistory(50, msg.key, msg.messageTimestamp!)
logger.debug({ id: messageId }, 'requested on-demand history resync')
}
if (!msg.key.fromMe && doReplies && !isJidNewsletter(msg.key?.remoteJid ?? '')) {
if (!msg.key.fromMe && doReplies && !isJidNewsletter(msg.key?.remoteJid!)) {
const id = generateMessageIDV2(sock.user?.id)
logger.debug({id, orig_id: msg.key.id }, 'replying to message')
await sock.sendMessage(msg.key.remoteJid!, { text: 'pong '+msg.key.id }, {messageId: id })
@@ -188,7 +185,7 @@ const startSock = async() => {
}
if (events['contacts.upsert']) {
logger.debug(events['contacts.upsert'], 'contacts.upsert event fired')
logger.debug(events['message-receipt.update'])
}
if(events['messages.reaction']) {
@@ -208,18 +205,18 @@ const startSock = async() => {
if(typeof contact.imgUrl !== 'undefined') {
const newUrl = contact.imgUrl === null
? null
: await sock.profilePictureUrl(contact.id ?? '').catch(() => null)
: await sock!.profilePictureUrl(contact.id!).catch(() => null)
logger.debug({id: contact.id, newUrl}, `contact has a new profile pic` )
}
}
}
if(events['chats.delete']) {
logger.debug(events['chats.delete'], 'chats.delete event fired')
logger.debug('chats deleted ', events['chats.delete'])
}
if(events['group.member-tag.update']) {
logger.debug(events['group.member-tag.update'], 'group.member-tag.update event fired')
logger.debug('group member tag update', JSON.stringify(events['group.member-tag.update'], undefined, 2))
}
}
)
+362 -687
View File
File diff suppressed because it is too large Load Diff
+94 -811
View File
File diff suppressed because it is too large Load Diff
+617 -5749
View File
File diff suppressed because it is too large Load Diff
-506
View File
@@ -1,506 +0,0 @@
# ⚠️ EXPERIMENTAL: Interactive Messages Guide
## 🚨 **CRITICAL WARNING**
**These features MAY NOT WORK and can cause ACCOUNT BANS.**
- ❌ WhatsApp actively blocks non-business accounts from sending interactive messages
- ❌ Can result in temporary or permanent account bans
- ❌ Messages may not be delivered
-**Use ONLY for testing with DISPOSABLE accounts in DEV environment**
## 📋 Table of Contents
1. [Configuration](#configuration)
2. [Simple Text Buttons](#1-simple-text-buttons)
3. [Buttons with Image](#2-buttons-with-image)
4. [Buttons with Video](#3-buttons-with-video)
5. [List Messages](#4-list-messages)
6. [Template Buttons (Action Buttons)](#5-template-buttons-action-buttons)
7. [Carousel Messages](#6-carousel-messages)
8. [Metrics and Monitoring](#metrics-and-monitoring)
---
## Configuration
### Enable Interactive Messages
```typescript
import makeWASocket from '@whiskeysockets/baileys'
const sock = makeWASocket({
auth: state,
// ⚠️ Enable experimental interactive messages
// WARNING: Can cause account bans!
enableInteractiveMessages: true, // default: true
logger: pino({ level: 'warn' })
})
```
### Disable in Production
```typescript
const sock = makeWASocket({
auth: state,
// ✅ Disable for production safety
enableInteractiveMessages: false,
logger: pino({ level: 'info' })
})
```
---
## 1. Simple Text Buttons
Send a message with up to 3 clickable buttons.
### Example
```typescript
await sock.sendMessage(jid, {
text: 'Choose an option:',
buttons: [
{
buttonId: 'btn1',
buttonText: { displayText: 'Option 1' }
},
{
buttonId: 'btn2',
buttonText: { displayText: 'Option 2' }
},
{
buttonId: 'btn3',
buttonText: { displayText: 'Option 3' }
}
],
footerText: 'Optional footer text'
})
```
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `text` | string | ✅ | Main message text |
| `buttons` | ButtonInfo[] | ✅ | Array of buttons (max 3) |
| `footerText` | string | ❌ | Footer text below buttons |
---
## 2. Buttons with Image
Send an image with interactive buttons.
### Example
```typescript
await sock.sendMessage(jid, {
image: { url: 'https://example.com/image.jpg' },
caption: 'Image description',
buttons: [
{
buttonId: 'view_more',
buttonText: { displayText: 'View More' }
},
{
buttonId: 'buy_now',
buttonText: { displayText: 'Buy Now' }
}
],
footerText: 'Available now'
})
```
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `image` | WAMediaUpload | ✅ | Image URL or Buffer |
| `caption` | string | ❌ | Image caption |
| `buttons` | ButtonInfo[] | ✅ | Array of buttons (max 3) |
| `footerText` | string | ❌ | Footer text |
---
## 3. Buttons with Video
Send a video with interactive buttons.
### Example
```typescript
await sock.sendMessage(jid, {
video: { url: 'https://example.com/video.mp4' },
caption: 'Watch this!',
buttons: [
{
buttonId: 'watch',
buttonText: { displayText: 'Watch Now' }
}
],
footerText: 'New release'
})
```
---
## 4. List Messages
Send a message with a menu of up to 10 options organized in sections.
### Example
```typescript
await sock.sendMessage(jid, {
text: 'Choose a product:',
title: 'Product Catalog',
buttonText: 'View Options',
sections: [
{
title: 'Electronics',
rows: [
{
rowId: 'laptop_1',
title: 'Laptop Pro',
description: '$999 - High performance'
},
{
rowId: 'phone_1',
title: 'Smartphone X',
description: '$699 - Latest model'
}
]
},
{
title: 'Accessories',
rows: [
{
rowId: 'case_1',
title: 'Phone Case',
description: '$29 - Protective'
}
]
}
],
footerText: 'Free shipping'
})
```
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `text` | string | ✅ | Message description |
| `title` | string | ❌ | List title |
| `buttonText` | string | ✅ | Text on button that opens list |
| `sections` | ListSection[] | ✅ | Array of sections (max 10 items total) |
| `footerText` | string | ❌ | Footer text |
### Limits
- Maximum 10 sections
- Maximum 10 rows total across all sections
- Row title: 24 characters max
- Row description: 72 characters max
---
## 5. Template Buttons (Action Buttons)
Send buttons with actions: Quick Reply, URL, or Call.
### Example
```typescript
await sock.sendMessage(jid, {
text: 'Contact us or visit our website',
templateButtons: [
// Quick Reply Button
{
index: 1,
quickReplyButton: {
displayText: 'Quick Reply',
id: 'reply_1'
}
},
// URL Button
{
index: 2,
urlButton: {
displayText: 'Visit Website',
url: 'https://example.com'
}
},
// Call Button
{
index: 3,
callButton: {
displayText: 'Call Us',
phoneNumber: '+551199999999'
}
}
],
footer: 'We are here to help'
})
```
### Button Types
| Type | Description | Parameters |
|------|-------------|------------|
| `quickReplyButton` | Quick response button | `displayText`, `id` |
| `urlButton` | Opens URL in browser | `displayText`, `url` |
| `callButton` | Initiates phone call | `displayText`, `phoneNumber` |
---
## 6. Carousel Messages
Send up to 10 scrollable cards with images/videos and buttons.
### Example
```typescript
await sock.sendMessage(jid, {
text: 'Check out our products',
carousel: {
cards: [
// Card 1
{
header: {
title: 'Product 1',
imageMessage: {
url: 'https://example.com/product1.jpg',
mimetype: 'image/jpeg'
},
hasMediaAttachment: true
},
body: { text: 'Amazing product - $99.90' },
footer: { text: 'Limited stock' },
nativeFlowMessage: {
buttons: [
{
name: 'quick_reply',
buttonParamsJson: JSON.stringify({
display_text: 'Buy Now',
id: 'buy_1'
})
},
{
name: 'cta_url',
buttonParamsJson: JSON.stringify({
display_text: 'More Info',
url: 'https://example.com/product1'
})
}
]
}
},
// Card 2
{
header: {
title: 'Product 2',
imageMessage: {
url: 'https://example.com/product2.jpg',
mimetype: 'image/jpeg'
},
hasMediaAttachment: true
},
body: { text: 'Great deal - $149.90' },
footer: { text: 'Best seller' },
nativeFlowMessage: {
buttons: [
{
name: 'quick_reply',
buttonParamsJson: JSON.stringify({
display_text: 'Buy Now',
id: 'buy_2'
})
}
]
}
}
],
messageVersion: 1
}
})
```
### Limits
- Maximum 10 cards per carousel
- Each card requires an image or video
- Up to 3 buttons per card
- All cards must have same media type (all images or all videos)
---
## Metrics and Monitoring
All interactive messages are tracked with Prometheus metrics:
### Available Metrics
```typescript
// Messages sent by type
metrics.interactiveMessagesSent.inc({ type: 'buttons' })
// Successful sends
metrics.interactiveMessagesSuccess.inc({ type: 'list' })
// Failures with reason
metrics.interactiveMessagesFailures.inc({
type: 'template',
reason: 'feature_disabled'
})
// Send latency
metrics.interactiveMessagesLatency.observe({ type: 'carousel' }, 1234)
```
### Monitoring Dashboard
Query these metrics in Prometheus/Grafana:
```promql
# Total interactive messages sent
sum(interactive_messages_sent_total) by (type)
# Success rate
sum(interactive_messages_success_total) / sum(interactive_messages_sent_total)
# Failure breakdown
sum(interactive_messages_failures_total) by (type, reason)
# P95 latency
histogram_quantile(0.95, interactive_messages_latency_ms)
```
---
## Handling Responses
### Button Response
```typescript
sock.ev.on('messages.upsert', async ({ messages }) => {
const msg = messages[0]
// Button response
if (msg.message?.buttonsResponseMessage) {
const buttonId = msg.message.buttonsResponseMessage.selectedButtonId
const displayText = msg.message.buttonsResponseMessage.selectedDisplayText
console.log(`User clicked: ${buttonId} - "${displayText}"`)
}
// Template button response
if (msg.message?.templateButtonReplyMessage) {
const selectedId = msg.message.templateButtonReplyMessage.selectedId
const selectedText = msg.message.templateButtonReplyMessage.selectedDisplayText
console.log(`User clicked template: ${selectedId} - "${selectedText}"`)
}
// List response
if (msg.message?.listResponseMessage) {
const rowId = msg.message.listResponseMessage.singleSelectReply.selectedRowId
const title = msg.message.listResponseMessage.title
console.log(`User selected from list: ${rowId} - "${title}"`)
}
})
```
---
## Troubleshooting
### Message Not Showing
- ✅ Verify `enableInteractiveMessages` is `true`
- ✅ Check logs for `[EXPERIMENTAL]` warnings
- ✅ Ensure you're using a test/disposable account
- ❌ Interactive messages don't work on production accounts
### Account Banned/Restricted
- ⚠️ This is expected behavior
- ⚠️ WhatsApp blocks non-business accounts
- ✅ Create a new test account
- ✅ Use official WhatsApp Business API for production
### Metrics Not Showing
Check feature flag:
```typescript
if (buttonType && !enableInteractiveMessages) {
// Metrics will show reason: 'feature_disabled'
}
```
---
## Migration to WhatsApp Business API
For production use, migrate to official API:
### Evolution API (Cloud Mode)
```typescript
// config.ts
export const evolutionConfig = {
apiUrl: 'https://your-evolution-api.com',
apiKey: 'your-api-key',
instance: 'your-instance'
}
// Send button via Evolution API
await fetch(`${evolutionConfig.apiUrl}/message/sendButton`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': evolutionConfig.apiKey
},
body: JSON.stringify({
number: '5511999999999',
options: {
delay: 1200,
presence: 'composing'
},
buttonMessage: {
text: 'Choose an option',
buttons: [
{ id: '1', text: 'Option 1' },
{ id: '2', text: 'Option 2' }
],
footer: 'Footer text'
}
})
})
```
---
## References
- [WhatsApp Official Business API](https://business.whatsapp.com/products/business-platform)
- [Evolution API Documentation](https://doc.evolution-api.com/)
- [Baileys GitHub Issues #56](https://github.com/WhiskeySockets/Baileys/issues/56)
- [Baileys GitHub Issues #25](https://github.com/WhiskeySockets/Baileys/issues/25)
---
## ⚠️ Final Reminder
**THESE FEATURES ARE EXPERIMENTAL AND UNRELIABLE**
- Use only for testing
- Expect failures and bans
- Not suitable for production
- Official WhatsApp Business API is the only supported way
---
**Generated by rsalcara/InfiniteAPI**
+1 -1
View File
@@ -67,7 +67,7 @@ export default defineConfig([globalIgnores([
named: "never",
asyncArrow: "always",
}],
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": ["error", {
caughtErrors: "none",
}],
-72
View File
@@ -1,72 +0,0 @@
#!/bin/bash
#
# Script para aplicar o fix de listMessage no rsalcara/InfiniteAPI
# Corrige: biz node (product_list v2) + conversão nativeFlow → listMessage + logs
#
set -e
echo "============================================"
echo " FIX LIST MESSAGE - rsalcara/InfiniteAPI"
echo "============================================"
echo ""
# Verificar se estamos dentro do repo rsalcara/InfiniteAPI
if [ ! -f "src/Socket/messages-send.ts" ]; then
echo "ERRO: Execute este script dentro do diretório rsalcara/InfiniteAPI"
echo "Exemplo: cd /path/to/rsalcara/InfiniteAPI && bash fix-list-message.sh"
exit 1
fi
echo "[1/4] Adicionando remote infinitezap..."
git remote remove infinitezap 2>/dev/null || true
git remote add infinitezap https://github.com/infinitezap/Teste_InfiniteAPI.git
echo "[2/4] Buscando branch com o fix..."
git fetch infinitezap claude/fix-message-delivery-XlMLH
echo "[3/4] Cherry-picking os 2 commits do fix..."
echo " -> bd7c691: biz node diferenciado (product_list v2)"
echo " -> 191776a: conversão nativeFlowMessage → listMessage"
git cherry-pick bd7c691 191776a --strategy-option=theirs || {
echo ""
echo "Se houve conflito, resolvendo com a versão do fix..."
git checkout --theirs src/Socket/messages-send.ts 2>/dev/null || true
git add src/Socket/messages-send.ts
git cherry-pick --continue --no-edit 2>/dev/null || true
}
echo ""
echo "[4/4] Verificando que o fix está presente..."
echo ""
FILE="src/Socket/messages-send.ts"
CHECK1=$(grep -c "product_list" "$FILE" 2>/dev/null || echo "0")
CHECK2=$(grep -c "\[BIZ NODE\]" "$FILE" 2>/dev/null || echo "0")
CHECK3=$(grep -c "\[STANZA\]" "$FILE" 2>/dev/null || echo "0")
CHECK4=$(grep -c "\[LIST CONVERT\]" "$FILE" 2>/dev/null || echo "0")
echo " product_list (biz node lista): $CHECK1 ocorrências"
echo " [BIZ NODE] (log diagnóstico): $CHECK2 ocorrências"
echo " [STANZA] (log stanza): $CHECK3 ocorrências"
echo " [LIST CONVERT] (conversão): $CHECK4 ocorrências"
echo ""
if [ "$CHECK1" -gt "0" ] && [ "$CHECK2" -gt "0" ] && [ "$CHECK3" -gt "0" ] && [ "$CHECK4" -gt "0" ]; then
echo "============================================"
echo " FIX APLICADO COM SUCESSO!"
echo "============================================"
echo ""
echo "Agora faça o build e deploy:"
echo " npm run build"
echo " git push origin master"
echo ""
else
echo "============================================"
echo " ATENÇÃO: Alguma parte do fix está faltando!"
echo "============================================"
echo ""
echo "Verifique manualmente o arquivo:"
echo " $FILE"
echo ""
fi
+2 -13
View File
@@ -17,26 +17,15 @@ const config: Config = {
useESM: true,
tsconfig: {
module: 'esnext',
allowJs: true,
verbatimModuleSyntax: false,
allowImportingTsExtensions: false,
},
},
],
'^.+\\.js$': [
'ts-jest',
{
useESM: true,
tsconfig: {
module: 'esnext',
allowJs: true,
verbatimModuleSyntax: false,
},
},
],
'^.+\\.js$': ['ts-jest', { useESM: true }],
},
transformIgnorePatterns: [
'node_modules/(?!(protobufjs|long|@protobufjs|@types/long|whatsapp-rust-bridge)/)',
'node_modules/(?!(protobufjs|long|@protobufjs|@types/long)/)',
],
}
-11742
View File
File diff suppressed because it is too large Load Diff
+24 -26
View File
@@ -28,7 +28,7 @@
"changelog:preview": "conventional-changelog -p angular -u",
"changelog:update": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
"example": "tsx ./Example/example.ts",
"gen:protobuf": "cd WAProto && sh GenerateStatics.sh",
"gen:protobuf": "sh WAProto/GenerateStatics.sh",
"format": "prettier --write \"src/**/*.{ts,js,json,md}\"",
"lint": "tsc && eslint src --ext .js,.ts",
"lint:fix": "npm run format && npm run lint -- --fix",
@@ -41,57 +41,55 @@
"update:version": "tsx ./scripts/update-version.ts"
},
"dependencies": {
"@cacheable/node-cache": "^2.0.1",
"@hapi/boom": "^10.0.1",
"@cacheable/node-cache": "^1.4.0",
"@hapi/boom": "^9.1.3",
"async-mutex": "^0.5.0",
"fflate": "^0.8.2",
"libsignal": "github:whiskeysockets/libsignal-node",
"lru-cache": "^11.2.6",
"music-metadata": "^11.12.0",
"libsignal": "git+https://github.com/whiskeysockets/libsignal-node",
"lru-cache": "^11.1.0",
"music-metadata": "^11.7.0",
"p-queue": "^9.0.0",
"pino": "^10.3.1",
"prom-client": "^15.1.3",
"protobufjs": "^8.0.0",
"whatsapp-rust-bridge": "0.5.3",
"pino": "^9.6",
"protobufjs": "^7.2.4",
"whatsapp-rust-bridge": "0.5.2",
"ws": "^8.13.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.31.0",
"@types/jest": "^30.0.0",
"@types/node": "^20.19.33",
"@types/node": "^20.9.0",
"@types/ws": "^8.0.0",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.55.0",
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
"@whiskeysockets/eslint-config": "^1.0.0",
"conventional-changelog": "^7.1.1",
"conventional-changelog-angular": "^8.0.0",
"esbuild-register": "^3.6.0",
"eslint": "^9.39.2",
"eslint": "^9",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.4.0",
"jest": "^30.0.5",
"jimp": "^1.6.0",
"jiti": "^2.4.2",
"json": "^11.0.0",
"link-preview-js": "^4.0.0",
"lru-cache": "^11.2.6",
"open": "^11.0.0",
"link-preview-js": "^3.0.0",
"lru-cache": "^11.1.0",
"open": "^8.4.2",
"pino-pretty": "^13.1.1",
"prettier": "^3.8.1",
"protobufjs-cli": "^2.0.0",
"release-it": "^19.2.4",
"prettier": "^3.5.3",
"protobufjs-cli": "^1.1.3",
"release-it": "^15.10.3",
"ts-jest": "^29.4.0",
"tsc-esm-fix": "^3.1.2",
"tsx": "^4.20.3",
"typedoc": "^0.28.17",
"typedoc-plugin-markdown": "^4.10.0",
"typescript": "^5.9.3"
"typedoc": "^0.27.9",
"typedoc-plugin-markdown": "4.4.2",
"typescript": "^5.8.2"
},
"peerDependencies": {
"audio-decode": "^2.2.3",
"audio-decode": "^2.1.3",
"jimp": "^1.6.0",
"link-preview-js": "^4.0.0",
"link-preview-js": "^3.0.0",
"sharp": "*"
},
"peerDependenciesMeta": {
+1 -6
View File
@@ -499,12 +499,7 @@ async function findAppModules() {
const decodedProto = Object.keys(decodedProtoMap).sort();
const sortedStr = decodedProto.map((d) => decodedProtoMap[d]).join('\n');
let decodedProtoStr = `syntax = "proto3";\npackage proto;\n\n/// WhatsApp Version: ${whatsAppVersion}\n\n${sortedStr}`;
// Fix: proto3 doesn't support 'required' keyword (only proto2 does)
// Convert all 'required' fields to 'optional' for proto3 compatibility
decodedProtoStr = decodedProtoStr.replace(/(\n\s+)required /g, '$1optional ');
const decodedProtoStr = `syntax = "proto3";\npackage proto;\n\n/// WhatsApp Version: ${whatsAppVersion}\n\n${sortedStr}`;
const destinationPath = '../WAProto/WAProto.proto';
await fs.writeFile(destinationPath, decodedProtoStr);
+94 -272
View File
@@ -1,258 +1,40 @@
#!/usr/bin/env node
/**
* 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)
* 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
*
* 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, appendFileSync } from 'fs'
import { readFileSync, writeFileSync } 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')
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)
function updateBaileysVersionJson(version: [number, number, number]): boolean {
const filePath = join(ROOT_DIR, 'src/Defaults/baileys-version.json')
const content = {
version
}
try {
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 currentContent = readFileSync(filePath, '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(VERSION_FILE_PATH, JSON.stringify(content) + '\n')
writeFileSync(filePath, 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)
@@ -260,65 +42,105 @@ function updateBaileysVersionJson(version: WAVersion, source?: string): 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()
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`)
if (!result.isLatest) {
console.error('Failed to fetch latest version:', result.error)
process.exit(1)
}
const hasUpdates = updateBaileysVersionJson(result.version, result.source)
console.log(`Latest version: [${result.version.join(', ')}]\n`)
const updates = [
updateBaileysVersionJson(result.version),
updateGenerics(result.version),
updateIndex(result.version)
]
const hasUpdates = updates.some(Boolean)
console.log('')
if (hasUpdates) {
console.log('═══════════════════════════════════════════════')
console.log('Version update complete!')
console.log('═══════════════════════════════════════════════')
// Set GitHub Actions output if running in CI
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('Already up to date. No changes needed.')
console.log('All files are already up to date.')
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 => {
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1034258243]}
{"version":[2,3000,1035194821]}
+20 -140
View File
@@ -3,10 +3,8 @@ 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 = baileysVersionData.version
const version = [2, 3000, 1035194821]
export const UNAUTHORIZED_CODES = [401, 403, 419]
@@ -27,8 +25,8 @@ export const WA_DEFAULT_EPHEMERAL = 7 * 24 * 60 * 60
/** Status messages older than 24 hours are considered expired */
export const STATUS_EXPIRY_SECONDS = 24 * 60 * 60
/** CTWA placeholder messages older than 7 days won't be requested from phone */
export const PLACEHOLDER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
/** WA Web enforces a 14-day maximum age for placeholder resend requests */
export const PLACEHOLDER_MAX_AGE_SECONDS = 14 * 24 * 60 * 60
export const NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0'
export const DICT_VERSION = 3
@@ -53,46 +51,32 @@ 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,
keepAliveIntervalMs: 15_000,
keepAliveIntervalMs: 30_000,
logger: logger.child({ class: 'baileys' }),
emitOwnEvents: true,
defaultQueryTimeoutMs: 30_000,
defaultQueryTimeoutMs: 60_000,
customUploadHosts: [],
retryRequestDelayMs: 150,
retryRequestDelayMs: 250,
maxMsgRetryCount: 5,
fireInitQueries: true,
auth: undefined as unknown as AuthenticationState,
markOnlineOnConnect: true,
// Set to false if you don't need full message history (reduces bandwidth/storage)
syncFullHistory: true,
patchMessageBeforeSending: msg => msg,
shouldSyncHistoryMessage: () => true,
shouldSyncHistoryMessage: ({ syncType }: proto.Message.IHistorySyncNotification) => {
return syncType !== proto.HistorySync.HistorySyncType.FULL
},
shouldIgnoreJid: () => false,
linkPreviewImageThumbnailWidth: 192,
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 1000 },
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 },
generateHighQualityLinkPreview: false,
enableAutoSessionRecreation: true,
enableRecentMessageCache: true,
// Enable automatic recovery of Click-to-WhatsApp ads messages
// These arrive as "placeholder messages" and need to be requested from the phone
enableCTWARecovery: true,
// Enable interactive messages (buttons, lists, templates, carousel)
enableInteractiveMessages: true,
// Clear stale routingInfo on every socket creation so the WhatsApp load balancer
// assigns a fresh, healthy edge server after any restart (pm2, server reboot, deploy).
// The server always sends a new routingInfo during the connection handshake, so the
// old value is never needed. Keeping it can cause slow or unstable sessions when the
// previous edge server is overloaded or has stale state.
clearRoutingInfoOnStart: true,
options: {},
appStateMacVerification: {
patch: false,
@@ -101,22 +85,7 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
countryCode: 'US',
getMessage: async () => undefined,
cachedGroupMetadata: async () => undefined,
makeSignalRepository: makeLibSignalRepository,
// Circuit breaker configuration
enableCircuitBreaker: true,
// Listener limits (memory leak prevention)
// WebSocket: 8 core events (open, close, error, message, ping, pong, upgrade, unexpected-response)
// + 10 dynamic listeners (reconnect handlers, custom events)
// + 2 buffer slots for temporary listeners = 20 total
maxWebSocketListeners: 20,
// SocketClient: 20 core events (connection, messaging, presence, groups, calls, etc.)
// + 20 dynamic listeners (user handlers, plugins)
// + 10 buffer slots for high-load scenarios = 50 total
maxSocketClientListeners: 50,
// Unified session telemetry (reduces detection of unofficial clients)
// NOTE: undefined means "check env var first, then default to true"
// This allows BAILEYS_UNIFIED_SESSION_ENABLED env var to have precedence
enableUnifiedSession: undefined
makeSignalRepository: makeLibSignalRepository
}
export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
@@ -129,9 +98,7 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
'product-catalog-image': '/product/image',
'md-app-state': '',
'md-msg-hist': '/mms/md-app-state',
'biz-cover-photo': '/pps/biz-cover-photo',
'sticker-pack': '/mms/sticker-pack',
'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack'
'biz-cover-photo': '/pps/biz-cover-photo'
}
export const MEDIA_HKDF_KEY_MAPPING = {
@@ -153,30 +120,20 @@ export const MEDIA_HKDF_KEY_MAPPING = {
'product-catalog-image': '',
'payment-bg-image': 'Payment Background',
ptv: 'Video',
'biz-cover-photo': 'Image',
'sticker-pack': 'Sticker Pack',
'thumbnail-sticker-pack': 'Sticker Pack Thumbnail'
'biz-cover-photo': 'Image'
}
export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING
export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
/** 120s timeout for history sync stall detection, same as WA Web's handleChunkProgress / restartPausedTimer (g = 120) */
export const HISTORY_SYNC_PAUSED_TIMEOUT_MS = 120_000
export const MIN_PREKEY_COUNT = 5
// Moderate prekey count (upstream uses 812, reduced to balance rate limiting and availability)
export const INITIAL_PREKEY_COUNT = 200
export const INITIAL_PREKEY_COUNT = 812
export const UPLOAD_TIMEOUT = 30000 // 30 seconds
// Moderate upload interval to balance rate limiting and responsiveness (was 5000)
export const MIN_UPLOAD_INTERVAL = 10_000 // 10 seconds minimum between uploads
export const MIN_UPLOAD_INTERVAL = 5000 // 5 seconds minimum between uploads
/**
* Cache TTL configuration (in seconds)
*/
export const DEFAULT_CACHE_TTLS = {
SIGNAL_STORE: 5 * 60, // 5 minutes
MSG_RETRY: 60 * 60, // 1 hour
@@ -184,86 +141,9 @@ export const DEFAULT_CACHE_TTLS = {
USER_DEVICES: 5 * 60 // 5 minutes
}
/**
* Maximum cache keys per store type - prevents memory leaks
* Based on RSocket's battle-tested configuration
*
* Usage: Use these limits when initializing LRU caches to prevent unbounded growth
* Example:
* import { DEFAULT_CACHE_MAX_KEYS } from './Defaults'
* const cache = new LRUCache({ max: DEFAULT_CACHE_MAX_KEYS.SIGNAL_STORE })
*/
export const DEFAULT_CACHE_MAX_KEYS = {
SIGNAL_STORE: 10_000,
MSG_RETRY: 10_000,
CALL_OFFER: 500,
USER_DEVICES: 5_000,
PLACEHOLDER_RESEND: 5_000,
LID_PER_SOCKET: 2_000,
LID_GLOBAL: 10_000
}
/**
* Session cleanup configuration - removes inactive/orphaned Signal sessions
* Prevents unbounded database growth while maintaining security
*
* Environment variables:
* - BAILEYS_SESSION_CLEANUP_ENABLED: Enable/disable cleanup (default: true)
* - BAILEYS_SESSION_CLEANUP_INTERVAL: Cleanup interval in ms (default: 24h)
* - BAILEYS_SESSION_CLEANUP_HOUR: Hour to run cleanup (default: 3 = 3am)
* - BAILEYS_SESSION_SECONDARY_INACTIVE_DAYS: Days before cleaning secondary devices (default: 7)
* - BAILEYS_SESSION_PRIMARY_INACTIVE_DAYS: Days before cleaning primary device (default: 30)
* - BAILEYS_SESSION_LID_ORPHAN_HOURS: Hours before cleaning orphaned LID sessions (default: 24)
* - BAILEYS_SESSION_CLEANUP_ON_STARTUP: Run cleanup immediately on startup (default: true)
* - BAILEYS_SESSION_AUTO_CLEAN_CORRUPTED: Auto-delete corrupted sessions (Bad MAC) (default: true)
*/
export const DEFAULT_SESSION_CLEANUP_CONFIG = {
enabled: process.env.BAILEYS_SESSION_CLEANUP_ENABLED !== 'false',
intervalMs: parseInt(process.env.BAILEYS_SESSION_CLEANUP_INTERVAL || '86400000', 10), // 24h
cleanupHour: parseInt(process.env.BAILEYS_SESSION_CLEANUP_HOUR || '3', 10), // 3am
secondaryDeviceInactiveDays: parseInt(process.env.BAILEYS_SESSION_SECONDARY_INACTIVE_DAYS || '7', 10),
primaryDeviceInactiveDays: parseInt(process.env.BAILEYS_SESSION_PRIMARY_INACTIVE_DAYS || '30', 10),
lidOrphanHours: parseInt(process.env.BAILEYS_SESSION_LID_ORPHAN_HOURS || '24', 10),
cleanupOnStartup: process.env.BAILEYS_SESSION_CLEANUP_ON_STARTUP !== 'false',
autoCleanCorrupted: process.env.BAILEYS_SESSION_AUTO_CLEAN_CORRUPTED !== 'false'
}
// Re-export retry constants for backwards compatibility
// Actual definitions are in retry-utils.ts to avoid ESM initialization order issues
export { RETRY_BACKOFF_DELAYS, RETRY_JITTER_FACTOR } from '../Utils/retry-utils'
// ============================================
// Time Constants
// ============================================
/**
* Time constants in milliseconds for various timing calculations.
* Used by unified session, rate limiting, and other time-based features.
*
* @example
* ```typescript
* import { TimeMs } from './Defaults'
*
* // Calculate 3 days in milliseconds
* const threeDays = 3 * TimeMs.Day
*
* // Check if 1 week has passed
* if (Date.now() - lastUpdate > TimeMs.Week) {
* // do something
* }
* ```
*/
export const TimeMs = {
/** One second in milliseconds (1,000) */
Second: 1_000,
/** One minute in milliseconds (60,000) */
Minute: 60_000,
/** One hour in milliseconds (3,600,000) */
Hour: 3_600_000,
/** One day in milliseconds (86,400,000) */
Day: 86_400_000,
/** One week in milliseconds (604,800,000) */
Week: 604_800_000
} as const
export type TimeMsKey = keyof typeof TimeMs
Minute: 60 * 1000,
Hour: 60 * 60 * 1000,
Day: 24 * 60 * 60 * 1000,
Week: 7 * 24 * 60 * 60 * 1000
}
+7 -12
View File
@@ -27,7 +27,7 @@ export class SenderKeyMessage extends CiphertextMessage {
super()
if (serialized) {
const version = serialized[0] ?? 0
const version = serialized[0]!
const message = serialized.slice(1, serialized.length - this.SIGNATURE_LENGTH)
const signature = serialized.slice(-1 * this.SIGNATURE_LENGTH)
const senderKeyMessage = proto.SenderKeyMessage.decode(message).toJSON() as SenderKeyMessageStructure
@@ -42,27 +42,22 @@ export class SenderKeyMessage extends CiphertextMessage {
: senderKeyMessage.ciphertext
this.signature = signature
} else {
// eslint-disable-next-line eqeqeq
if (ciphertext == null || keyId == null || iteration == null || signatureKey == null) {
throw new Error('Missing required parameters for SenderKeyMessage construction')
}
const version = (((this.CURRENT_VERSION << 4) | this.CURRENT_VERSION) & 0xff) % 256
const ciphertextBuffer = Buffer.from(ciphertext)
const ciphertextBuffer = Buffer.from(ciphertext!)
const message = proto.SenderKeyMessage.encode(
proto.SenderKeyMessage.create({
id: keyId,
iteration: iteration,
id: keyId!,
iteration: iteration!,
ciphertext: ciphertextBuffer
})
).finish()
const signature = this.getSignature(signatureKey, Buffer.concat([Buffer.from([version]), message]))
const signature = this.getSignature(signatureKey!, Buffer.concat([Buffer.from([version]), message]))
this.serialized = Buffer.concat([Buffer.from([version]), message, Buffer.from(signature)])
this.messageVersion = this.CURRENT_VERSION
this.keyId = keyId
this.iteration = iteration
this.keyId = keyId!
this.iteration = iteration!
this.ciphertext = ciphertextBuffer
this.signature = signature
}
+194 -654
View File
@@ -1,307 +1,67 @@
/* @ts-ignore */
import { createHash } from 'crypto'
// @ts-ignore
import * as libsignal from 'libsignal'
// @ts-ignore
import { PreKeyWhisperMessage } from 'libsignal/src/protobufs'
import { LRUCache } from 'lru-cache'
import type { LIDMapping, SignalAuthState, SignalKeyStoreWithTransaction } from '../Types'
import type { BaileysEventEmitter } from '../Types/Events'
import type { SignalRepositoryWithLIDStore } from '../Types/Signal'
import { generateSignalPubKey } from '../Utils'
import { CircuitBreaker } from '../Utils/circuit-breaker.js'
import type { ILogger } from '../Utils/logger'
import { metrics } from '../Utils/prometheus-metrics.js'
import { isAnyLidUser, isAnyPnUser, jidDecode, transferDevice, WAJIDDomains } from '../WABinary'
import {
isHostedLidUser,
isHostedPnUser,
isLidUser,
isPnUser,
jidDecode,
transferDevice,
WAJIDDomains
} from '../WABinary'
import type { SenderKeyStore } from './Group/group_cipher'
import { SenderKeyName } from './Group/sender-key-name'
import { SenderKeyRecord } from './Group/sender-key-record'
import { GroupCipher, GroupSessionBuilder, SenderKeyDistributionMessage } from './Group'
import { LIDMappingStore } from './lid-mapping'
// NOTE: Console.log suppression has been moved to src/index.ts
// to ensure it runs BEFORE libsignal is loaded
// ============================================
// Identity Key Detection Constants
// ============================================
/** Cache TTL for identity keys - 30 minutes */
const IDENTITY_KEY_CACHE_TTL = 30 * 60 * 1000
/** Maximum number of identity keys to cache */
const IDENTITY_KEY_CACHE_MAX = 1000
/** Curve25519 public key type byte (0x05) */
const CURVE25519_KEY_TYPE = 0x05
/** Expected length of a Curve25519 public key with type byte */
const IDENTITY_KEY_LENGTH = 33
/** PreKeyWhisperMessage version 3 */
const PREKEY_MSG_VERSION = 3
// ============================================
// Identity Key Types
// ============================================
/**
* Result of identity key save operation
*/
export interface IdentitySaveResult {
/**
* Whether the identity key changed from a previous known value.
* - true: Key changed (contact reinstalled WhatsApp or switched devices)
* - false: Key is new (first contact) OR unchanged (same key as before)
* Use `isNew` to distinguish between new and unchanged cases.
*/
changed: boolean
/** Whether this is a new contact (first time seeing their key) */
isNew: boolean
/** Fingerprint of the previous key (only present if changed === true) */
previousFingerprint?: string
/** SHA-256 fingerprint of the current/new key (64 hex characters) */
currentFingerprint: string
}
/**
* Options for makeLibSignalRepository
*/
export interface LibSignalRepositoryOptions {
/** Event emitter for broadcasting identity changes */
ev?: BaileysEventEmitter
/** Circuit breaker for prekey operations (optional) */
preKeyCircuitBreaker?: CircuitBreaker
}
// ============================================
// Identity Key Utility Functions
// ============================================
/**
* Generate a SHA-256 fingerprint of an identity key
* This is used to display to users for verification
* Returns full 64-character hex string (256 bits) for cryptographic consistency
*
* @param key - The identity key bytes
* @returns Full SHA-256 hex string fingerprint (64 characters)
*/
function generateKeyFingerprint(key: Uint8Array): string {
return createHash('sha256').update(key).digest('hex')
}
/**
* Extract identity key from a PreKeyWhisperMessage
*
* The PreKeyWhisperMessage format (version 3):
* - Byte 0: Version byte (high nibble = current version, low nibble = 3)
* - Bytes 1+: Protobuf-encoded PreKeySignalMessage
*
* The protobuf contains:
* - registrationId (uint32)
* - preKeyId (uint32, optional)
* - signedPreKeyId (uint32)
* - baseKey (bytes, 33 bytes - Curve25519 public key)
* - identityKey (bytes, 33 bytes - Curve25519 public key)
* - message (bytes - the actual encrypted message)
*
* We manually parse the protobuf to extract identityKey without depending on
* the full protobuf library, making this compatible with any Signal implementation.
*
* @param ciphertext - The raw PreKeyWhisperMessage bytes
* @param logger - Logger for debug output
* @returns The identity key bytes or undefined if extraction fails
*/
function extractIdentityFromPkmsg(ciphertext: Uint8Array, logger?: ILogger): Uint8Array | undefined {
const timer = metrics.signalIdentityKeyOperations?.startTimer({ operation: 'extract' })
/** Extract identity key from PreKeyWhisperMessage for identity change detection */
function extractIdentityFromPkmsg(ciphertext: Uint8Array): Uint8Array | undefined {
try {
// Minimum size: 1 byte version + at least 34 bytes for minimal protobuf with identity key
if (!ciphertext || ciphertext.length < 35) {
logger?.debug({ length: ciphertext?.length }, 'Ciphertext too short for identity extraction')
if (!ciphertext || ciphertext.length < 2) {
return undefined
}
// Version byte check (version 3)
const version = ciphertext[0]!
// Version byte format: high nibble = current version, low nibble = message type (3 = PreKey)
const messageType = version & 0x0f
if (messageType !== PREKEY_MSG_VERSION) {
logger?.debug({ version, messageType }, 'Not a PreKeyWhisperMessage (version 3)')
if ((version & 0xf) !== 3) {
return undefined
}
// Parse protobuf manually - we're looking for field 5 (identityKey)
// Protobuf wire format: varint tag (field_number << 3 | wire_type), then value
// Wire type 2 = length-delimited (used for bytes)
let offset = 1 // Skip version byte
while (offset < ciphertext.length) {
// Read tag varint
const tagResult = readVarint(ciphertext, offset)
if (!tagResult) break
const tag = tagResult.value
offset = tagResult.nextOffset
const fieldNumber = tag >> 3
const wireType = tag & 0x07
if (wireType === 2) {
// Length-delimited field
const lengthResult = readVarint(ciphertext, offset)
if (!lengthResult) break
const length = lengthResult.value
offset = lengthResult.nextOffset
// Field 5 is identityKey
if (fieldNumber === 5) {
// Validate key length
// eslint-disable-next-line max-depth
if (length !== IDENTITY_KEY_LENGTH) {
logger?.debug({ length, expected: IDENTITY_KEY_LENGTH }, 'Invalid identity key length')
return undefined
}
// eslint-disable-next-line max-depth
if (offset + length > ciphertext.length) {
logger?.debug('Identity key extends beyond ciphertext bounds')
return undefined
}
const identityKey = ciphertext.slice(offset, offset + length)
// Validate key type byte (must be 0x05 for Curve25519)
// eslint-disable-next-line max-depth
if (identityKey[0] !== CURVE25519_KEY_TYPE) {
logger?.debug({ type: identityKey[0], expected: CURVE25519_KEY_TYPE }, 'Invalid identity key type')
return undefined
}
return new Uint8Array(identityKey)
}
offset += length
// Bounds check after skipping field
if (offset > ciphertext.length) {
logger?.debug(
{ offset, length: ciphertext.length },
'Offset exceeds ciphertext bounds after length-delimited field'
)
break
}
} else if (wireType === 0) {
// Varint
const varintResult = readVarint(ciphertext, offset)
if (!varintResult) break
offset = varintResult.nextOffset
} else if (wireType === 1) {
// 64-bit fixed
offset += 8
if (offset > ciphertext.length) break
} else if (wireType === 5) {
// 32-bit fixed
offset += 4
if (offset > ciphertext.length) break
} else {
// Unknown wire type, cannot continue
logger?.debug({ wireType }, 'Unknown wire type in protobuf')
break
}
// Parse protobuf (skip version byte)
const preKeyProto = PreKeyWhisperMessage.decode(ciphertext.slice(1))
if (preKeyProto.identityKey?.length === 33) {
return new Uint8Array(preKeyProto.identityKey)
}
logger?.debug('Identity key field not found in PreKeyWhisperMessage')
return undefined
} catch (error) {
logger?.debug({ error: (error as Error).message }, 'Failed to extract identity from pkmsg')
} catch {
return undefined
} finally {
timer?.()
}
}
/**
* Read a varint from a buffer
*
* @param buffer - The buffer to read from
* @param offset - Starting offset
* @returns Object with value and nextOffset, or undefined if invalid
*/
function readVarint(buffer: Uint8Array, offset: number): { value: number; nextOffset: number } | undefined {
let result = 0
let shift = 0
while (offset < buffer.length) {
const byte = buffer[offset]!
result |= (byte & 0x7f) << shift
offset++
if ((byte & 0x80) === 0) {
return { value: result >>> 0, nextOffset: offset } // >>> 0 ensures unsigned
}
shift += 7
if (shift > 35) {
// Varint too long (max 5 bytes for 32-bit)
// This could indicate a malformed or malicious message
// Caller should log this condition at debug level
return undefined
}
}
// Incomplete varint - buffer ended before termination byte
return undefined
}
export function makeLibSignalRepository(
auth: SignalAuthState,
logger: ILogger,
pnToLIDFunc?: (jids: string[]) => Promise<LIDMapping[] | undefined>,
options?: LibSignalRepositoryOptions
pnToLIDFunc?: (jids: string[]) => Promise<LIDMapping[] | undefined>
): SignalRepositoryWithLIDStore {
const { ev, preKeyCircuitBreaker } = options || {}
const lidMapping = new LIDMappingStore(auth.keys as SignalKeyStoreWithTransaction, logger, pnToLIDFunc)
// Identity key cache to avoid repeated storage reads
const identityKeyCache = new LRUCache<string, Uint8Array>({
max: IDENTITY_KEY_CACHE_MAX,
ttl: IDENTITY_KEY_CACHE_TTL,
ttlAutopurge: true,
updateAgeOnGet: true
})
// Update cache size metric periodically
const cacheMetricsInterval = setInterval(() => {
metrics.signalIdentityKeyCacheSize?.set(identityKeyCache.size)
}, 60000) // Every minute
// Allow process to exit even with interval active (prevents blocking in short-lived scripts/tests)
if (typeof cacheMetricsInterval.unref === 'function') {
cacheMetricsInterval.unref()
}
const storage = signalStorage(auth, lidMapping, identityKeyCache, ev, preKeyCircuitBreaker, logger)
const storage = signalStorage(auth, lidMapping)
const parsedKeys = auth.keys as SignalKeyStoreWithTransaction
const migratedSessionCache = new LRUCache<string, true>({
ttl: 3 * 24 * 60 * 60 * 1000, // 3 days
ttl: 3 * 24 * 60 * 60 * 1000, // 7 days
ttlAutopurge: true,
updateAgeOnGet: true
})
// Cache for device-list DB reads in migrateSession.
// The device list changes rarely (new linked device), so a 5-minute TTL avoids
// a DB round-trip on every incoming LID message without risking stale state:
// new devices are picked up at most 5 minutes late (still migrated via their own
// decrypt transaction when the session is first used).
const deviceListCache = new LRUCache<string, string[]>({
max: 500,
ttl: 5 * 60 * 1000, // 5 minutes
ttlAutopurge: true
})
// In-flight deduplication map for migrateSession: if N concurrent callers arrive
// for the same PN user before the first migration completes, they all share one
// Promise instead of each spawning their own DB transactions.
const migrationInFlight = new Map<string, Promise<{ migrated: number; skipped: number; total: number }>>()
const repository: SignalRepositoryWithLIDStore = {
decryptGroupMessage({ group, authorJid, msg }) {
const senderName = jidToSignalSenderKeyName(group, authorJid)
@@ -346,51 +106,27 @@ export function makeLibSignalRepository(
const addr = jidToSignalProtocolAddress(jid)
const session = new libsignal.SessionCipher(storage, addr)
async function doDecrypt() {
// For PreKeyWhisperMessage, extract and verify identity key BEFORE decryption
// This handles the case where a contact reinstalled WhatsApp (new identity key)
if (type === 'pkmsg') {
const identityKey = extractIdentityFromPkmsg(ciphertext, logger)
if (identityKey) {
const addrStr = addr.toString()
try {
const saveResult = await storage.saveIdentity(addrStr, identityKey)
if (saveResult.changed) {
logger.info(
{
jid,
addr: addrStr,
previousFingerprint: saveResult.previousFingerprint,
newFingerprint: saveResult.currentFingerprint
},
'Identity key changed - contact may have reinstalled WhatsApp, session will be re-established'
)
// Reset prekey circuit breaker since we identified the cause
// Reset regardless of state (could be open, half-open, or closed with accumulated failures)
// eslint-disable-next-line max-depth
if (preKeyCircuitBreaker) {
preKeyCircuitBreaker.reset()
logger.debug({ jid }, 'Reset prekey circuit breaker after identity key change detection')
}
} else if (saveResult.isNew) {
logger.debug(
{ jid, addr: addrStr, fingerprint: saveResult.currentFingerprint },
'New contact identity key saved (Trust On First Use)'
)
}
} catch (error) {
// Log but don't fail decryption - identity tracking is best-effort
logger.warn({ jid, error: (error as Error).message }, 'Failed to save identity key during decryption')
}
// Extract and save sender's identity key before decryption for identity change detection
if (type === 'pkmsg') {
const identityKey = extractIdentityFromPkmsg(ciphertext)
if (identityKey) {
const addrStr = addr.toString()
const identityChanged = await storage.saveIdentity(addrStr, identityKey)
if (identityChanged) {
logger.info({ jid, addr: addrStr }, 'identity key changed or new contact, session will be re-established')
}
}
}
async function doDecrypt() {
let result: Buffer
if (type === 'pkmsg') {
result = await session.decryptPreKeyWhisperMessage(ciphertext)
} else {
result = await session.decryptWhisperMessage(ciphertext)
switch (type) {
case 'pkmsg':
result = await session.decryptPreKeyWhisperMessage(ciphertext)
break
case 'msg':
result = await session.decryptWhisperMessage(ciphertext)
break
}
return result
@@ -492,199 +228,143 @@ export function makeLibSignalRepository(
toJid: string
): Promise<{ migrated: number; skipped: number; total: number }> {
// TODO: use usync to handle this entire mess
if (!fromJid || !isAnyLidUser(toJid)) return { migrated: 0, skipped: 0, total: 0 }
if (!fromJid || (!isLidUser(toJid) && !isHostedLidUser(toJid))) return { migrated: 0, skipped: 0, total: 0 }
// Only support PN to LID migration
if (!isAnyPnUser(fromJid)) {
if (!isPnUser(fromJid) && !isHostedPnUser(fromJid)) {
return { migrated: 0, skipped: 0, total: 1 }
}
const decoded1 = jidDecode(fromJid)
if (!decoded1) {
logger.warn({ fromJid }, 'bulkDeviceMigration: failed to decode fromJid, aborting migration')
const { user } = jidDecode(fromJid)!
logger.debug({ fromJid }, 'bulk device migration - loading all user devices')
// Get user's device list from storage
const { [user]: userDevices } = await parsedKeys.get('device-list', [user])
if (!userDevices) {
return { migrated: 0, skipped: 0, total: 0 }
}
const { user } = decoded1
// In-flight deduplication: if a migration is already running for this PN user,
// return the existing Promise. The check+set is synchronous (before any await)
// so it is safe in Node.js's single-threaded model.
const inFlight = migrationInFlight.get(user)
if (inFlight) {
logger.trace({ fromJid }, 'migrateSession: reusing in-flight migration for same user')
return inFlight
const { device: fromDevice } = jidDecode(fromJid)!
const fromDeviceStr = fromDevice?.toString() || '0'
if (!userDevices.includes(fromDeviceStr)) {
userDevices.push(fromDeviceStr)
}
const migrationPromise = (async (): Promise<{ migrated: number; skipped: number; total: number }> => {
// Get user's device list — use in-memory cache to avoid a DB round-trip on
// every incoming LID message. Cache is invalidated after 5 minutes.
// We use undefined to mean "not yet checked" and [] to mean "checked, no devices
// found" so that DB misses are also cached and don't cause a per-message lookup.
let userDevices: string[] | undefined = deviceListCache.get(user)
if (userDevices === undefined) {
logger.debug({ fromJid }, 'bulk device migration - loading all user devices from DB')
const result = await parsedKeys.get('device-list', [user])
userDevices = result[user] ?? []
deviceListCache.set(user, userDevices)
} else {
logger.trace({ fromJid, deviceCount: userDevices.length }, 'bulk device migration - device list from cache')
}
// Filter out cached devices before database fetch
const uncachedDevices = userDevices.filter(device => {
const deviceKey = `${user}.${device}`
return !migratedSessionCache.has(deviceKey)
})
if (userDevices.length === 0) {
return { migrated: 0, skipped: 0, total: 0 }
}
// Bulk check session existence only for uncached devices
const deviceSessionKeys = uncachedDevices.map(device => `${user}.${device}`)
const existingSessions = await parsedKeys.get('session', deviceSessionKeys)
// Work on a copy so we don't mutate the cached array
userDevices = [...userDevices]
const { device: fromDevice } = decoded1
const fromDeviceStr = fromDevice?.toString() || '0'
if (!userDevices.includes(fromDeviceStr)) {
userDevices.push(fromDeviceStr)
}
// Filter out cached devices before database fetch
const uncachedDevices = userDevices.filter(device => {
const deviceKey = `${user}.${device}`
return !migratedSessionCache.has(deviceKey)
})
// All devices already confirmed as migrated — skip DB lookups entirely
if (uncachedDevices.length === 0) {
logger.debug(
{ fromJid, totalDevices: userDevices.length },
'bulk device migration - all devices already cached, skipping'
)
return { migrated: 0, skipped: 0, total: userDevices.length }
}
// Bulk check session existence only for uncached devices
const deviceSessionKeys = uncachedDevices.map(device => `${user}.${device}`)
const existingSessions = await parsedKeys.get('session', deviceSessionKeys)
// Step 3: Convert existing sessions to JIDs (only migrate sessions that exist)
const deviceJids: string[] = []
for (const [sessionKey, sessionData] of Object.entries(existingSessions)) {
if (sessionData) {
// Session exists in storage
const deviceStr = sessionKey.split('.')[1]
if (!deviceStr) continue
const deviceNum = parseInt(deviceStr)
let jid = deviceNum === 0 ? `${user}@s.whatsapp.net` : `${user}:${deviceNum}@s.whatsapp.net`
if (deviceNum === 99) {
jid = `${user}:99@hosted`
}
deviceJids.push(jid)
}
}
logger.debug(
{
fromJid,
totalDevices: userDevices.length,
devicesWithSessions: deviceJids.length,
devices: deviceJids
},
'bulk device migration complete - all user devices processed'
)
// No PN-format sessions found: all devices are already migrated to LID addressing.
// Cache them now so subsequent messages skip redundant DB lookups entirely.
if (deviceJids.length === 0) {
for (const device of uncachedDevices) {
migratedSessionCache.set(`${user}.${device}`, true)
// Step 3: Convert existing sessions to JIDs (only migrate sessions that exist)
const deviceJids: string[] = []
for (const [sessionKey, sessionData] of Object.entries(existingSessions)) {
if (sessionData) {
// Session exists in storage
const deviceStr = sessionKey.split('.')[1]
if (!deviceStr) continue
const deviceNum = parseInt(deviceStr)
let jid = deviceNum === 0 ? `${user}@s.whatsapp.net` : `${user}:${deviceNum}@s.whatsapp.net`
if (deviceNum === 99) {
jid = `${user}:99@hosted`
}
return { migrated: 0, skipped: 0, total: userDevices.length }
deviceJids.push(jid)
}
}
// Single transaction for all migrations
return parsedKeys.transaction(
async (): Promise<{ migrated: number; skipped: number; total: number }> => {
// Prepare migration operations with addressing metadata
type MigrationOp = {
fromJid: string
toJid: string
pnUser: string
lidUser: string
deviceId: number
fromAddr: libsignal.ProtocolAddress
toAddr: libsignal.ProtocolAddress
logger.debug(
{
fromJid,
totalDevices: userDevices.length,
devicesWithSessions: deviceJids.length,
devices: deviceJids
},
'bulk device migration complete - all user devices processed'
)
// Single transaction for all migrations
return parsedKeys.transaction(
async (): Promise<{ migrated: number; skipped: number; total: number }> => {
// Prepare migration operations with addressing metadata
type MigrationOp = {
fromJid: string
toJid: string
pnUser: string
lidUser: string
deviceId: number
fromAddr: libsignal.ProtocolAddress
toAddr: libsignal.ProtocolAddress
}
const migrationOps: MigrationOp[] = deviceJids.map(jid => {
const lidWithDevice = transferDevice(jid, toJid)
const fromDecoded = jidDecode(jid)!
const toDecoded = jidDecode(lidWithDevice)!
return {
fromJid: jid,
toJid: lidWithDevice,
pnUser: fromDecoded.user,
lidUser: toDecoded.user,
deviceId: fromDecoded.device || 0,
fromAddr: jidToSignalProtocolAddress(jid),
toAddr: jidToSignalProtocolAddress(lidWithDevice)
}
})
const migrationOps: MigrationOp[] = deviceJids.map(jid => {
const lidWithDevice = transferDevice(jid, toJid)
const fromDecoded = jidDecode(jid)
const toDecoded = jidDecode(lidWithDevice)
if (!fromDecoded || !toDecoded) {
throw new Error(`Failed to decode JID during migration: ${jid} -> ${lidWithDevice}`)
const totalOps = migrationOps.length
let migratedCount = 0
// Bulk fetch PN sessions - already exist (verified during device discovery)
const pnAddrStrings = Array.from(new Set(migrationOps.map(op => op.fromAddr.toString())))
const pnSessions = await parsedKeys.get('session', pnAddrStrings)
// Prepare bulk session updates (PN → LID migration + deletion)
const sessionUpdates: { [key: string]: Uint8Array | null } = {}
for (const op of migrationOps) {
const pnAddrStr = op.fromAddr.toString()
const lidAddrStr = op.toAddr.toString()
const pnSession = pnSessions[pnAddrStr]
if (pnSession) {
// Session exists (guaranteed from device discovery)
const fromSession = libsignal.SessionRecord.deserialize(pnSession)
if (fromSession.haveOpenSession()) {
// Queue for bulk update: copy to LID, delete from PN
sessionUpdates[lidAddrStr] = fromSession.serialize()
sessionUpdates[pnAddrStr] = null
migratedCount++
}
}
}
return {
fromJid: jid,
toJid: lidWithDevice,
pnUser: fromDecoded.user,
lidUser: toDecoded.user,
deviceId: fromDecoded.device || 0,
fromAddr: jidToSignalProtocolAddress(jid),
toAddr: jidToSignalProtocolAddress(lidWithDevice)
}
})
const totalOps = migrationOps.length
let migratedCount = 0
// Reuse existingSessions fetched above — for PN users on s.whatsapp.net the
// signal-address format (user.device) is identical to deviceSessionKeys, so
// existingSessions already contains every session needed here.
// Avoids a redundant storage round-trip inside the transaction.
const pnSessions = existingSessions
// Prepare bulk session updates (PN → LID migration + deletion)
const sessionUpdates: { [key: string]: Uint8Array | null } = {}
// Single bulk session update for all migrations
if (Object.keys(sessionUpdates).length > 0) {
await parsedKeys.set({ session: sessionUpdates })
logger.debug({ migratedSessions: migratedCount }, 'bulk session migration complete')
// Cache device-level migrations
for (const op of migrationOps) {
const pnAddrStr = op.fromAddr.toString()
const lidAddrStr = op.toAddr.toString()
const pnSession = pnSessions[pnAddrStr]
if (pnSession) {
// Session exists (guaranteed from device discovery)
const fromSession = libsignal.SessionRecord.deserialize(pnSession)
if (fromSession.haveOpenSession()) {
// Queue for bulk update: copy to LID, delete from PN
sessionUpdates[lidAddrStr] = fromSession.serialize()
sessionUpdates[pnAddrStr] = null
migratedCount++
}
if (sessionUpdates[op.toAddr.toString()]) {
const deviceKey = `${op.pnUser}.${op.deviceId}`
migratedSessionCache.set(deviceKey, true)
}
}
}
// Single bulk session update for all migrations
if (Object.keys(sessionUpdates).length > 0) {
await parsedKeys.set({ session: sessionUpdates })
logger.debug({ migratedSessions: migratedCount }, 'bulk session migration complete')
}
// Cache ALL processed devices (migrated, skipped, or closed-session) to prevent
// redundant DB lookups on subsequent messages from the same contact.
for (const op of migrationOps) {
migratedSessionCache.set(`${op.pnUser}.${op.deviceId}`, true)
}
const skippedCount = totalOps - migratedCount
return { migrated: migratedCount, skipped: skippedCount, total: totalOps }
},
`migrate-${deviceJids.length}-sessions-${jidDecode(toJid)?.user}`
)
})()
migrationInFlight.set(user, migrationPromise)
migrationPromise.finally(() => migrationInFlight.delete(user))
return migrationPromise
const skippedCount = totalOps - migratedCount
return { migrated: migratedCount, skipped: skippedCount, total: totalOps }
},
`migrate-${deviceJids.length}-sessions-${jidDecode(toJid)?.user}`
)
}
}
@@ -692,11 +372,7 @@ export function makeLibSignalRepository(
}
const jidToSignalProtocolAddress = (jid: string): libsignal.ProtocolAddress => {
const decoded = jidDecode(jid)
if (!decoded) {
throw new Error(`Failed to decode JID: "${jid}"`)
}
const decoded = jidDecode(jid)!
const { user, device, server, domainType } = decoded
if (!user) {
@@ -719,47 +395,19 @@ const jidToSignalSenderKeyName = (group: string, user: string): SenderKeyName =>
return new SenderKeyName(group, jidToSignalProtocolAddress(user))
}
/**
* Extended SignalStorage with identity key management
* This type adds identity key operations to the standard Signal storage
*/
type ExtendedSignalStorage = SenderKeyStore &
libsignal.SignalStorage & {
/**
* Load identity key for a contact
* @param id - Signal protocol address string
* @returns Identity key bytes or undefined if not found
*/
loadIdentityKey(id: string): Promise<Uint8Array | undefined>
/**
* Save/update identity key for a contact
* Handles Trust On First Use (TOFU) and change detection
*
* @param id - Signal protocol address string
* @param identityKey - The identity key bytes (33 bytes with type prefix)
* @returns Result indicating if key changed, is new, and fingerprints
*/
saveIdentity(id: string, identityKey: Uint8Array): Promise<IdentitySaveResult>
}
function signalStorage(
{ creds, keys }: SignalAuthState,
lidMapping: LIDMappingStore,
identityKeyCache: LRUCache<string, Uint8Array>,
ev?: BaileysEventEmitter,
preKeyCircuitBreaker?: CircuitBreaker,
logger?: ILogger
): ExtendedSignalStorage {
lidMapping: LIDMappingStore
): SenderKeyStore &
libsignal.SignalStorage & {
loadIdentityKey(id: string): Promise<Uint8Array | undefined>
saveIdentity(id: string, identityKey: Uint8Array): Promise<boolean>
} {
// Shared function to resolve PN signal address to LID if mapping exists
const resolveLIDSignalAddress = async (id: string): Promise<string> => {
if (id.includes('.')) {
const [deviceId, device] = id.split('.')
if (!deviceId) {
throw new Error('Missing device ID')
}
const [user, domainType_] = deviceId.split('_')
const [user, domainType_] = deviceId!.split('_')
const domainType = parseInt(domainType_ || '0')
if (domainType === WAJIDDomains.LID || domainType === WAJIDDomains.HOSTED_LID) return id
@@ -796,7 +444,38 @@ function signalStorage(
await keys.set({ session: { [wireJid]: session.serialize() } })
},
isTrustedIdentity: () => {
return true // todo: implement proper trust management
return true // TOFU - Trust on First Use (same as WhatsApp Web)
},
loadIdentityKey: async (id: string) => {
const wireJid = await resolveLIDSignalAddress(id)
const { [wireJid]: key } = await keys.get('identity-key', [wireJid])
return key || undefined
},
saveIdentity: async (id: string, identityKey: Uint8Array): Promise<boolean> => {
const wireJid = await resolveLIDSignalAddress(id)
const { [wireJid]: existingKey } = await keys.get('identity-key', [wireJid])
const keysMatch =
existingKey &&
existingKey.length === identityKey.length &&
existingKey.every((byte, i) => byte === identityKey[i])
if (existingKey && !keysMatch) {
// Identity changed - clear session and update key
await keys.set({
session: { [wireJid]: null },
'identity-key': { [wireJid]: identityKey }
})
return true
}
if (!existingKey) {
// New contact - Trust on First Use (TOFU)
await keys.set({ 'identity-key': { [wireJid]: identityKey } })
return true
}
return false
},
loadPreKey: async (id: number | string) => {
const keyId = id.toString()
@@ -837,145 +516,6 @@ function signalStorage(
privKey: Buffer.from(signedIdentityKey.private),
pubKey: Buffer.from(generateSignalPubKey(signedIdentityKey.public))
}
},
// ============================================
// Identity Key Management (NEW)
// ============================================
/**
* Load identity key for a contact from cache or storage
*/
loadIdentityKey: async (id: string): Promise<Uint8Array | undefined> => {
const timer = metrics.signalIdentityKeyOperations?.startTimer({ operation: 'load' })
try {
const wireJid = await resolveLIDSignalAddress(id)
// Check cache first
const cached = identityKeyCache.get(wireJid)
if (cached) {
metrics.signalIdentityKeyCacheHits?.inc()
return cached
}
metrics.signalIdentityKeyCacheMisses?.inc()
// Load from storage
const { [wireJid]: key } = await keys.get('identity-key', [wireJid])
if (key) {
// Populate cache
identityKeyCache.set(wireJid, key)
return key
}
return undefined
} finally {
timer?.()
}
},
/**
* Save identity key for a contact with change detection
* Implements Trust On First Use (TOFU) and emits events on changes
*/
saveIdentity: async (id: string, identityKey: Uint8Array): Promise<IdentitySaveResult> => {
const timer = metrics.signalIdentityKeyOperations?.startTimer({ operation: 'save' })
try {
const wireJid = await resolveLIDSignalAddress(id)
const currentFingerprint = generateKeyFingerprint(identityKey)
// Load existing key (from cache or storage)
const { [wireJid]: existingKey } = await keys.get('identity-key', [wireJid])
// Check if keys match
const keysMatch =
existingKey?.length === identityKey.length && existingKey.every((byte, i) => byte === identityKey[i])
if (existingKey && !keysMatch) {
// IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices
const previousFingerprint = generateKeyFingerprint(existingKey)
// Delete old session and save new identity key atomically
await keys.set({
session: { [wireJid]: null },
'identity-key': { [wireJid]: identityKey }
})
// Update cache
identityKeyCache.set(wireJid, identityKey)
// Record metrics
metrics.signalIdentityChanges?.inc({ type: 'changed' })
// Emit event for application to notify user
if (ev) {
ev.emit('identity.changed', {
jid: wireJid,
previousKeyFingerprint: previousFingerprint,
newKeyFingerprint: currentFingerprint,
timestamp: Date.now(),
isNewContact: false
})
}
logger?.warn(
{
event: 'identity_key_changed',
jid: wireJid,
previousFingerprint,
newFingerprint: currentFingerprint
},
'Contact identity key changed - security code changed'
)
return {
changed: true,
isNew: false,
previousFingerprint,
currentFingerprint
}
}
if (!existingKey) {
// NEW CONTACT - Trust On First Use (TOFU)
await keys.set({ 'identity-key': { [wireJid]: identityKey } })
// Update cache
identityKeyCache.set(wireJid, identityKey)
// Record metrics
metrics.signalIdentityChanges?.inc({ type: 'new' })
// Emit event for new contact
if (ev) {
ev.emit('identity.changed', {
jid: wireJid,
previousKeyFingerprint: null,
newKeyFingerprint: currentFingerprint,
timestamp: Date.now(),
isNewContact: true
})
}
return {
changed: false,
isNew: true,
currentFingerprint
}
}
// Key unchanged
return {
changed: false,
isNew: false,
currentFingerprint
}
} finally {
timer?.()
}
}
}
}
+232 -1044
View File
File diff suppressed because it is too large Load Diff
-274
View File
@@ -1,274 +0,0 @@
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { ILogger } from '../Utils/logger'
/**
* Session activity tracker configuration
*/
export interface SessionActivityConfig {
/** Flush interval in milliseconds (default: 60s) */
flushIntervalMs: number
/** Enable activity tracking (default: true) */
enabled: boolean
}
/**
* Default configuration for session activity tracking
*/
export const DEFAULT_SESSION_ACTIVITY_CONFIG: SessionActivityConfig = {
flushIntervalMs: parseInt(process.env.BAILEYS_SESSION_ACTIVITY_FLUSH_MS || '60000', 10), // 1 minute
enabled: process.env.BAILEYS_SESSION_ACTIVITY_ENABLED !== 'false'
}
/**
* Session activity metadata stored in key store
*/
export interface SessionActivityMetadata {
/** Last activity timestamp (Unix milliseconds) */
lastActivityAt: number
/** Session created timestamp (Unix milliseconds) */
createdAt?: number
}
/**
* Session Activity Tracker
*
* Tracks when sessions were last used (message sent/received) to enable
* cleanup of inactive sessions.
*
* PERFORMANCE OPTIMIZATION:
* - In-memory cache: Updates stored in Map (no disk I/O per message)
* - Periodic flush: Writes to disk every 60s (configurable)
* - Batch writes: All updates written in single transaction
*
* OVERHEAD: <0.1ms per message (just Map.set() in memory)
*
* @example
* const tracker = makeSessionActivityTracker(keys, logger)
* tracker.start()
*
* // On message send/receive:
* tracker.recordActivity('5511999999999@s.whatsapp.net')
*
* // On cleanup:
* const lastActivity = await tracker.getLastActivity('5511999999999@s.whatsapp.net')
* if (Date.now() - lastActivity > 30_DAYS) {
* // Delete session
* }
*/
export const makeSessionActivityTracker = (
keys: SignalKeyStoreWithTransaction,
logger: ILogger,
config: SessionActivityConfig = DEFAULT_SESSION_ACTIVITY_CONFIG
) => {
// In-memory cache: JID -> timestamp
const activityCache = new Map<string, number>()
// Dirty flag: tracks if cache has unflushed changes
let isDirty = false
// Flush interval timer
let flushInterval: ReturnType<typeof setInterval> | null = null
// Statistics
const stats = {
totalUpdates: 0,
totalFlushes: 0,
lastFlushAt: 0,
lastFlushDuration: 0,
cacheSize: 0
}
/**
* Record activity for a JID
* Updates in-memory cache only (fast, <0.1ms)
*/
const recordActivity = (jid: string): void => {
if (!config.enabled) return
const now = Date.now()
activityCache.set(jid, now)
isDirty = true
stats.totalUpdates++
stats.cacheSize = activityCache.size
}
/**
* Get last activity timestamp for a JID
* Checks in-memory cache first, then disk
*/
const getLastActivity = async (jid: string): Promise<number | undefined> => {
if (!config.enabled) return undefined
// Check cache first
const cached = activityCache.get(jid)
if (cached) return cached
// Fallback to disk
try {
const key = `session-activity:${jid}`
const data = await keys.get('session-activity' as any, [key])
const metadata = data[key] as SessionActivityMetadata | undefined
return metadata?.lastActivityAt
} catch (error) {
logger.warn({ error, jid }, 'Failed to get session activity from disk')
return undefined
}
}
/**
* Get all session activities (for cleanup)
* Returns Map of JID -> lastActivityAt
*/
const getAllActivities = async (): Promise<Map<string, number>> => {
if (!config.enabled) return new Map()
const result = new Map<string, number>()
try {
// Get all session activity keys from disk
const allData = await keys.get('session-activity' as any, [])
for (const [key, value] of Object.entries(allData)) {
if (key.startsWith('session-activity:')) {
const jid = key.replace('session-activity:', '')
const metadata = value as SessionActivityMetadata
if (metadata?.lastActivityAt) {
result.set(jid, metadata.lastActivityAt)
}
}
}
// Merge with in-memory cache (cache is more recent)
for (const [jid, timestamp] of activityCache.entries()) {
result.set(jid, timestamp)
}
} catch (error) {
logger.warn({ error }, 'Failed to get all session activities')
}
return result
}
/**
* Flush in-memory cache to disk
* Writes all pending updates in single transaction
*/
const flush = async (): Promise<void> => {
if (!config.enabled || !isDirty || activityCache.size === 0) {
return
}
const startTime = Date.now()
try {
// Prepare batch update
const updates: { [key: string]: SessionActivityMetadata } = {}
for (const [jid, timestamp] of activityCache.entries()) {
const key = `session-activity:${jid}`
updates[key] = {
lastActivityAt: timestamp,
createdAt: timestamp // First activity = creation time
}
}
// Single transaction for all updates
await keys.transaction(async () => {
await keys.set({ 'session-activity': updates } as any)
}, 'session-activity-flush')
isDirty = false
stats.totalFlushes++
stats.lastFlushAt = Date.now()
stats.lastFlushDuration = Date.now() - startTime
logger.debug(
{
count: activityCache.size,
duration: stats.lastFlushDuration
},
'💾 Session activity flushed to disk'
)
// Clear cache after successful flush
activityCache.clear()
stats.cacheSize = 0
} catch (error) {
logger.error({ error, count: activityCache.size }, 'Failed to flush session activity')
// Keep cache for retry on next flush
}
}
/**
* Start periodic flush
*/
const start = (): void => {
if (!config.enabled) {
logger.info('Session activity tracking is disabled')
return
}
if (flushInterval) {
logger.warn('Session activity tracker already started')
return
}
logger.info(
{
flushIntervalMs: config.flushIntervalMs,
flushIntervalSeconds: config.flushIntervalMs / 1000
},
'⏱️ Session activity tracker started'
)
// Flush immediately on start (recover any pending data)
flush().catch(err => {
logger.warn({ err }, 'Initial flush failed')
})
// Schedule periodic flush
flushInterval = setInterval(() => {
flush().catch(err => {
logger.warn({ err }, 'Periodic flush failed')
})
}, config.flushIntervalMs)
}
/**
* Stop periodic flush and flush pending data
*/
const stop = async (): Promise<void> => {
if (flushInterval) {
clearInterval(flushInterval)
flushInterval = null
}
// Final flush before stopping
await flush()
logger.info('Session activity tracker stopped')
}
/**
* Get tracker statistics
*/
const getStats = () => ({
enabled: config.enabled,
...stats
})
return {
recordActivity,
getLastActivity,
getAllActivities,
flush,
start,
stop,
getStats
}
}
/**
* Session activity tracker type
*/
export type SessionActivityTracker = ReturnType<typeof makeSessionActivityTracker>
-449
View File
@@ -1,449 +0,0 @@
/* eslint-disable max-depth, @typescript-eslint/no-unused-vars */
import { DEFAULT_SESSION_CLEANUP_CONFIG } from '../Defaults'
import type { SessionCleanupConfig, SessionCleanupStats, SignalKeyStoreWithTransaction } from '../Types'
import type { ILogger } from '../Utils/logger'
import { jidDecode } from '../WABinary'
import type { LIDMappingStore } from './lid-mapping'
import type { SessionActivityTracker } from './session-activity-tracker'
// Re-export for backward compatibility
export type { SessionCleanupConfig, SessionCleanupStats }
/**
* Session metadata for cleanup decisions
*/
interface SessionMetadata {
jid: string
isLID: boolean
isPrimary: boolean
lastActivityMs?: number
createdAtMs?: number
hasLIDMapping?: boolean
}
/**
* Creates a session cleanup manager
*
* SAFETY GUARANTEES:
* - Does NOT affect WebSocket connections (only local database)
* - Does NOT cause message loss (Signal Protocol auto-recreates sessions)
* - Runs in low-traffic hours (configurable, default 3am)
* - Atomic transactions (all-or-nothing)
* - Comprehensive logging and statistics
*
* CLEANUP RULES:
* 1. Secondary devices (Web, Desktop) - Inactive > X days (default: 15)
* 2. Primary devices - Inactive > Y days (default: 30)
* 3. LID orphans (no PN mapping) - Inactive > Z hours (default: 24)
*
* @param keys - Signal key store with transaction support
* @param lidMapping - LID mapping store for orphan detection
* @param sessionActivityTracker - Session activity tracker for timestamp-based cleanup
* @param logger - Structured logger instance
* @param config - Cleanup configuration (uses defaults from env)
*/
export const makeSessionCleanup = (
keys: SignalKeyStoreWithTransaction,
lidMapping: LIDMappingStore,
sessionActivityTracker: SessionActivityTracker | null,
logger: ILogger,
config: SessionCleanupConfig = DEFAULT_SESSION_CLEANUP_CONFIG
) => {
let cleanupInterval: ReturnType<typeof setInterval> | null = null
let initialTimeout: ReturnType<typeof setTimeout> | null = null
let lastCleanupAt = 0
let cleanupRunning = false
let startupCleanupPromise: Promise<void> | null = null
/**
* Get all sessions from database
* Returns array of session keys (signal addresses)
*/
const getAllSessionKeys = async (): Promise<string[]> => {
try {
// Get all sessions from key store
// Signal addresses format: "user_domain.device"
const sessions = await keys.get('session', [])
return Object.keys(sessions)
} catch (error) {
logger.error({ error }, 'Failed to get all session keys')
return []
}
}
/**
* Parse session metadata from signal address
* Signal address format: "user_domain.device"
* Examples:
* "5511999999999_0.0" → PN primary device
* "5511999999999_0.1" → PN secondary device
* "123456789_2.0" → LID primary device
*/
const parseSessionMetadata = async (signalAddr: string): Promise<SessionMetadata | null> => {
try {
// Parse signal address: "user_domain.device"
const [userWithDomain, deviceStr] = signalAddr.split('.')
if (!userWithDomain) return null
const [user, domainStr] = userWithDomain.split('_')
if (!user) return null
const device = parseInt(deviceStr || '0', 10)
const domain = parseInt(domainStr || '0', 10)
// Determine if LID (domain 2 or 6) or PN (domain 0 or 5)
const isLID = domain === 2 || domain === 6
const isPrimary = device === 0
// Construct JID for mapping lookup
let jid: string
if (isLID) {
jid = `${user}@lid`
} else {
jid = `${user}${device > 0 ? `:${device}` : ''}@s.whatsapp.net`
}
// Check if LID has PN mapping
let hasLIDMapping: boolean | undefined
if (isLID) {
const pn = await lidMapping.getPNForLID(jid)
hasLIDMapping = !!pn
}
return {
jid,
isLID,
isPrimary,
hasLIDMapping
}
} catch (error) {
logger.warn({ error, signalAddr }, 'Failed to parse session metadata')
return null
}
}
/**
* Determine if session should be cleaned up
*/
const shouldCleanupSession = (metadata: SessionMetadata, now: number): { cleanup: boolean; reason: string } => {
// Rule 1: LID orphans (no PN mapping) after X hours
if (metadata.isLID && metadata.hasLIDMapping === false) {
const thresholdMs = config.lidOrphanHours * 60 * 60 * 1000
// Check if lastActivity is old enough
if (metadata.lastActivityMs) {
const inactiveDuration = now - metadata.lastActivityMs
if (inactiveDuration > thresholdMs) {
return {
cleanup: true,
reason: `LID orphan without PN mapping, inactive for ${Math.floor(inactiveDuration / 3600000)}h (threshold: ${config.lidOrphanHours}h)`
}
}
} else {
// No activity tracked - consider it old
return {
cleanup: true,
reason: `LID orphan without PN mapping, no activity tracked (threshold: ${config.lidOrphanHours}h)`
}
}
return { cleanup: false, reason: 'LID orphan but not inactive long enough' }
}
// Rule 2: Secondary devices inactive > X days
if (!metadata.isPrimary && metadata.lastActivityMs) {
const thresholdMs = config.secondaryDeviceInactiveDays * 24 * 60 * 60 * 1000
const inactiveDuration = now - metadata.lastActivityMs
if (inactiveDuration > thresholdMs) {
return {
cleanup: true,
reason: `Secondary device inactive for ${Math.floor(inactiveDuration / 86400000)} days (threshold: ${config.secondaryDeviceInactiveDays} days)`
}
}
return { cleanup: false, reason: `Secondary device active within ${config.secondaryDeviceInactiveDays} days` }
}
// Rule 3: Primary devices inactive > Y days
if (metadata.isPrimary && metadata.lastActivityMs) {
const thresholdMs = config.primaryDeviceInactiveDays * 24 * 60 * 60 * 1000
const inactiveDuration = now - metadata.lastActivityMs
if (inactiveDuration > thresholdMs) {
return {
cleanup: true,
reason: `Primary device inactive for ${Math.floor(inactiveDuration / 86400000)} days (threshold: ${config.primaryDeviceInactiveDays} days)`
}
}
return { cleanup: false, reason: `Primary device active within ${config.primaryDeviceInactiveDays} days` }
}
// No lastActivity tracked - don't cleanup yet (grace period)
return { cleanup: false, reason: 'No activity tracked yet' }
}
/**
* Run session cleanup
* Returns statistics about cleanup operation
*/
const runCleanup = async (): Promise<SessionCleanupStats> => {
const startTime = Date.now()
const stats: SessionCleanupStats = {
totalScanned: 0,
secondaryDevicesDeleted: 0,
primaryDevicesDeleted: 0,
lidOrphansDeleted: 0,
totalDeleted: 0,
durationMs: 0,
errors: 0
}
if (!config.enabled) {
logger.info('Session cleanup is disabled')
return stats
}
if (cleanupRunning) {
logger.warn('Session cleanup already running, skipping')
return stats
}
cleanupRunning = true
logger.info('🧹 Starting session cleanup')
try {
// Get all session keys
const sessionKeys = await getAllSessionKeys()
stats.totalScanned = sessionKeys.length
logger.info({ totalSessions: sessionKeys.length }, 'Scanning sessions for cleanup')
// Get all activity timestamps (if tracker available)
const activityMap = sessionActivityTracker
? await sessionActivityTracker.getAllActivities()
: new Map<string, number>()
logger.debug({ trackedActivities: activityMap.size }, 'Loaded session activity timestamps')
// Prepare bulk deletion
const sessionsToDelete: string[] = []
const deletionReasons: { [key: string]: string } = {}
// Process each session
for (const signalAddr of sessionKeys) {
try {
const metadata = await parseSessionMetadata(signalAddr)
if (!metadata) {
stats.errors++
continue
}
// Add lastActivity from tracker
metadata.lastActivityMs = activityMap.get(metadata.jid)
const { cleanup, reason } = shouldCleanupSession(metadata, Date.now())
if (cleanup) {
sessionsToDelete.push(signalAddr)
deletionReasons[signalAddr] = reason
// Update statistics
if (metadata.isLID && !metadata.hasLIDMapping) {
stats.lidOrphansDeleted++
} else if (!metadata.isPrimary) {
stats.secondaryDevicesDeleted++
} else {
stats.primaryDevicesDeleted++
}
logger.debug(
{
jid: metadata.jid,
signalAddr,
reason,
isLID: metadata.isLID,
isPrimary: metadata.isPrimary
},
'Session marked for deletion'
)
}
} catch (error) {
logger.warn({ error, signalAddr }, 'Error processing session for cleanup')
stats.errors++
}
}
// Bulk delete sessions
if (sessionsToDelete.length > 0) {
logger.info({ count: sessionsToDelete.length }, '🗑️ Deleting orphaned/inactive sessions')
try {
// Prepare session updates (set to null = delete)
const sessionUpdates: { [key: string]: null } = {}
sessionsToDelete.forEach(addr => {
sessionUpdates[addr] = null
})
// Single atomic transaction for all deletions
await keys.transaction(async () => {
await keys.set({ session: sessionUpdates })
}, 'session-cleanup')
stats.totalDeleted = sessionsToDelete.length
logger.info(
{
deleted: stats.totalDeleted,
lidOrphans: stats.lidOrphansDeleted,
secondaryDevices: stats.secondaryDevicesDeleted,
primaryDevices: stats.primaryDevicesDeleted,
errors: stats.errors
},
'✅ Session cleanup completed successfully'
)
} catch (error) {
logger.error({ error, count: sessionsToDelete.length }, '❌ Failed to delete sessions')
stats.errors++
}
} else {
logger.info('No sessions to cleanup')
}
lastCleanupAt = Date.now()
stats.durationMs = Date.now() - startTime
return stats
} catch (error) {
logger.error({ error }, '❌ Session cleanup failed')
stats.errors++
stats.durationMs = Date.now() - startTime
return stats
} finally {
cleanupRunning = false
}
}
/**
* Calculate milliseconds until next cleanup time
* Ensures cleanup runs at configured hour (default: 3am)
*/
const msUntilNextCleanup = (): number => {
const now = new Date()
const next = new Date()
next.setHours(config.cleanupHour, 0, 0, 0)
// If we're past cleanup hour today, schedule for tomorrow
if (now.getHours() >= config.cleanupHour) {
next.setDate(next.getDate() + 1)
}
return next.getTime() - now.getTime()
}
/**
* Start periodic session cleanup
* Runs at configured hour (default: 3am daily)
*/
const start = () => {
if (!config.enabled) {
logger.info('Session cleanup is disabled')
return
}
if (cleanupInterval) {
logger.warn('Session cleanup already started')
return
}
logger.info(
{
enabled: config.enabled,
intervalHours: config.intervalMs / (60 * 60 * 1000),
cleanupHour: config.cleanupHour,
secondaryDeviceInactiveDays: config.secondaryDeviceInactiveDays,
primaryDeviceInactiveDays: config.primaryDeviceInactiveDays,
lidOrphanHours: config.lidOrphanHours,
cleanupOnStartup: config.cleanupOnStartup,
autoCleanCorrupted: config.autoCleanCorrupted
},
'🧹 Session cleanup scheduler started'
)
// Run immediate cleanup on startup if enabled
if (config.cleanupOnStartup) {
logger.info('🚀 Running cleanup on startup...')
startupCleanupPromise = runCleanup()
.catch(err => {
logger.error({ err }, 'Cleanup on startup failed')
})
.then(() => {
startupCleanupPromise = null // Clear after completion
})
}
// Schedule first cleanup at configured hour
const msUntilFirst = msUntilNextCleanup()
logger.info(
{ msUntilFirst, nextCleanup: new Date(Date.now() + msUntilFirst).toISOString() },
'⏰ First cleanup scheduled'
)
initialTimeout = setTimeout(async () => {
initialTimeout = null // Clear reference after execution
// Wait for startup cleanup to complete (if still running)
if (startupCleanupPromise) {
logger.debug('Waiting for startup cleanup to complete before scheduled cleanup...')
await startupCleanupPromise
}
// Run first cleanup
await runCleanup()
// Schedule recurring cleanup
cleanupInterval = setInterval(async () => {
await runCleanup()
}, config.intervalMs)
}, msUntilFirst)
}
/**
* Stop periodic session cleanup
*/
const stop = () => {
if (initialTimeout) {
clearTimeout(initialTimeout)
initialTimeout = null
}
if (cleanupInterval) {
clearInterval(cleanupInterval)
cleanupInterval = null
logger.info('Session cleanup scheduler stopped')
}
}
/**
* Get cleanup statistics
*/
const getStats = () => ({
enabled: config.enabled,
lastCleanupAt,
cleanupRunning,
config
})
return {
start,
stop,
runCleanup,
getStats
}
}
/**
* Session cleanup manager type
*/
export type SessionCleanupManager = ReturnType<typeof makeSessionCleanup>
+1 -8
View File
@@ -13,14 +13,7 @@ export abstract class AbstractSocketClient extends EventEmitter {
public config: SocketConfig
) {
super()
// Set max listeners from config (default: 50)
// WARNING: 0 disables limit and allows potential memory leaks
const maxListeners = this.config.maxSocketClientListeners ?? 50
if (maxListeners === 0) {
this.config.logger?.warn('SocketClient setMaxListeners(0) allows UNLIMITED listeners - potential memory leak!')
}
this.setMaxListeners(maxListeners)
this.setMaxListeners(0)
}
abstract connect(): void
+1 -8
View File
@@ -31,14 +31,7 @@ export class WebSocketClient extends AbstractSocketClient {
agent: this.config.agent
})
// Set max listeners from config (default: 20)
// WARNING: 0 disables limit and allows potential memory leaks
const maxListeners = this.config.maxWebSocketListeners ?? 20
if (maxListeners === 0) {
this.config.logger?.warn('WebSocket setMaxListeners(0) allows UNLIMITED listeners - potential memory leak!')
}
this.socket.setMaxListeners(maxListeners)
this.socket.setMaxListeners(0)
const events = ['close', 'error', 'upgrade', 'message', 'open', 'ping', 'pong', 'unexpected-response']
+3 -15
View File
@@ -125,11 +125,7 @@ export const makeBusinessSocket = (config: SocketConfig) => {
]
})
if (!fbid) {
throw new Error('Cover photo upload failed: missing fbid')
}
return fbid
return fbid!
}
const removeCoverPhoto = async (id: string) => {
@@ -336,11 +332,7 @@ export const makeBusinessSocket = (config: SocketConfig) => {
const productCatalogEditNode = getBinaryNodeChild(result, 'product_catalog_edit')
const productNode = getBinaryNodeChild(productCatalogEditNode, 'product')
if (!productNode) {
throw new Error('Product node not found in catalog edit response')
}
return parseProductNode(productNode)
return parseProductNode(productNode!)
}
const productCreate = async (create: ProductCreate) => {
@@ -380,11 +372,7 @@ export const makeBusinessSocket = (config: SocketConfig) => {
const productCatalogAddNode = getBinaryNodeChild(result, 'product_catalog_add')
const productNode = getBinaryNodeChild(productCatalogAddNode, 'product')
if (!productNode) {
throw new Error('Product node not found in catalog add response')
}
return parseProductNode(productNode)
return parseProductNode(productNode!)
}
const productDelete = async (productIds: string[]) => {
+78 -439
View File
@@ -1,19 +1,12 @@
import NodeCache from '@cacheable/node-cache'
import { Boom } from '@hapi/boom'
import { LRUCache } from 'lru-cache'
import { proto } from '../../WAProto/index.js'
import {
DEFAULT_CACHE_MAX_KEYS,
DEFAULT_CACHE_TTLS,
HISTORY_SYNC_PAUSED_TIMEOUT_MS,
PROCESSABLE_HISTORY_TYPES
} from '../Defaults'
import { DEFAULT_CACHE_TTLS, PROCESSABLE_HISTORY_TYPES } from '../Defaults'
import type {
BotListInfo,
CacheStore,
ChatModification,
ChatMutation,
ChatUpdate,
LTHashState,
MessageUpsertType,
PresenceData,
@@ -42,25 +35,19 @@ import {
decodePatches,
decodeSyncdSnapshot,
encodeSyncdPatch,
ensureLTHashStateVersion,
extractSyncdPatches,
generateProfilePicture,
getHistoryMsg,
isAppStateSyncIrrecoverable,
isMissingKeyError,
MAX_SYNC_ATTEMPTS,
newLTHashState,
processSyncAction
} from '../Utils'
import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex'
import { makeMutex } from '../Utils/make-mutex'
import processMessage from '../Utils/process-message'
import { buildTcTokenFromJid } from '../Utils/tc-token-utils'
import {
type BinaryNode,
getBinaryNodeChild,
getBinaryNodeChildren,
isAnyLidUser,
isAnyPnUser,
jidDecode,
jidNormalizedUser,
reduceBinaryNodeToDictionary,
@@ -68,6 +55,7 @@ import {
} from '../WABinary'
import { USyncQuery, USyncUser } from '../WAUSync'
import { makeSocket } from './socket.js'
const MAX_SYNC_ATTEMPTS = 2
export const makeChatsSocket = (config: SocketConfig) => {
const {
@@ -89,42 +77,28 @@ export const makeChatsSocket = (config: SocketConfig) => {
query,
signalRepository,
onUnexpectedError,
sendUnifiedSession,
skipOfflineBuffer: socketSkippedOfflineBuffer
sendUnifiedSession
} = sock
const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
let privacySettings: { [_: string]: string } | undefined
let syncState: SyncState = SyncState.Connecting
/** this mutex ensures that messages from the same chat are processed in order, while allowing parallel processing of messages from different chats */
const messageMutex = makeKeyedMutex()
/** this mutex ensures that messages are processed in order */
const messageMutex = makeMutex()
/** this mutex ensures that receipts from the same chat are processed in order, while allowing parallel processing across chats */
const receiptMutex = makeKeyedMutex()
/** this mutex ensures that receipts are processed in order */
const receiptMutex = makeMutex()
/** this mutex ensures that app state patches are processed in order */
const appStatePatchMutex = makeMutex()
/** this mutex ensures that notifications from the same chat are processed in order, while allowing parallel processing across chats */
const notificationMutex = makeKeyedMutex()
/** this mutex ensures that notifications are processed in order */
const notificationMutex = makeMutex()
// Timeout for AwaitingInitialSync state
let awaitingSyncTimeout: NodeJS.Timeout | undefined
// In-memory history sync completion tracking (resets on reconnection)
const historySyncStatus = {
initialBootstrapComplete: false,
recentSyncComplete: false
}
let historySyncPausedTimeout: NodeJS.Timeout | undefined
// Collections blocked on missing app state sync keys (mirrors WA Web's "Blocked" state).
// When a key arrives via APP_STATE_SYNC_KEY_SHARE, these are re-synced.
const blockedCollections = new Set<WAPatchName>()
const placeholderResendCache =
config.placeholderResendCache ||
(new NodeCache<number>({
@@ -142,55 +116,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
return key
}
/**
* App State Sync Key Cache with LRU eviction policy
* Prevents repeated database lookups for same keys during sync operations.
*
* MEMORY SAFETY: Limited by DEFAULT_CACHE_MAX_KEYS.SIGNAL_STORE with 1-hour TTL.
* Auto-purges expired entries to maintain memory bounds.
*
* TYPE SAFETY: Only successful lookups (non-null values) are cached.
* Null/undefined values are NOT cached to prevent blocking newly arrived keys.
* LRUCache.get() returns undefined for missing keys.
*/
const appStateSyncKeyCache = new LRUCache<string, proto.Message.IAppStateSyncKeyData>({
max: DEFAULT_CACHE_MAX_KEYS.SIGNAL_STORE, // Use constant from Defaults (10,000)
ttl: DEFAULT_CACHE_TTLS.MSG_RETRY * 1000, // 1 hour TTL (convert seconds to ms)
ttlAutopurge: true, // Automatically remove expired entries
updateAgeOnGet: true // LRU refresh on access
})
/**
* Cached version of getAppStateSyncKey
* Uses LRU cache to reduce database calls during snapshot/patch decoding.
*
* Performance: 5x faster sync operations by eliminating redundant key fetches.
* Memory: Bounded by LRU policy (max 1000 keys, 1h TTL)
*
* CRITICAL FIX: Only cache successful lookups (non-null values) to prevent
* stale null values from blocking newly arrived keys via APP_STATE_SYNC_KEY_SHARE.
*/
const getCachedAppStateSyncKey = async (keyId: string) => {
// Use get() directly to avoid race between has() and get() (Fix: Copilot C)
const cached = appStateSyncKeyCache.get(keyId)
if (cached !== undefined) {
// Cache hit - return the cached key
return cached
}
// Cache miss - fetch from database
const key = await getAppStateSyncKey(keyId)
// CRITICAL: Only cache non-null values
// Null/undefined means key doesn't exist YET, but may arrive via APP_STATE_SYNC_KEY_SHARE
// If we cache null, the cache (TTL 1h) will block newly arrived keys
if (key) {
appStateSyncKeyCache.set(keyId, key)
}
return key
}
const fetchPrivacySettings = async (force = false) => {
if (!privacySettings || force) {
const { content } = await query({
@@ -307,12 +232,9 @@ export const makeChatsSocket = (config: SocketConfig) => {
for (const section of getBinaryNodeChildren(botNode, 'section')) {
if (section.attrs.type === 'all') {
for (const bot of getBinaryNodeChildren(section, 'bot')) {
const jid = bot.attrs.jid
const personaId = bot.attrs['persona_id']
if (!jid || !personaId) continue
botList.push({
jid,
personaId
jid: bot.attrs.jid!,
personaId: bot.attrs['persona_id']!
})
}
}
@@ -360,9 +282,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
)
}
const me = authState.creds.me
if (!me) throw new Boom('Not authenticated', { statusCode: 401 })
if (jidNormalizedUser(jid) !== jidNormalizedUser(me.id)) {
if (jidNormalizedUser(jid) !== jidNormalizedUser(authState.creds.me!.id)) {
targetJid = jidNormalizedUser(jid) // in case it is someone other than us
} else {
targetJid = undefined
@@ -396,9 +316,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
)
}
const me = authState.creds.me
if (!me) throw new Boom('Not authenticated', { statusCode: 401 })
if (jidNormalizedUser(jid) !== jidNormalizedUser(me.id)) {
if (jidNormalizedUser(jid) !== jidNormalizedUser(authState.creds.me!.id)) {
targetJid = jidNormalizedUser(jid) // in case it is someone other than us
} else {
targetJid = undefined
@@ -453,43 +371,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
const updateBlockStatus = async (jid: string, action: 'block' | 'unblock') => {
const normalizedJid = jidNormalizedUser(jid)
let lid: string
let pn_jid: string | undefined
if (isAnyLidUser(normalizedJid)) {
lid = normalizedJid
if (action === 'block') {
const pn = await signalRepository.lidMapping.getPNForLID(normalizedJid)
if (!pn) {
throw new Boom(`Unable to resolve PN JID for LID: ${jid}`, { statusCode: 400 })
}
pn_jid = jidNormalizedUser(pn)
}
} else if (isAnyPnUser(normalizedJid)) {
const mapped = await signalRepository.lidMapping.getLIDForPN(normalizedJid)
if (!mapped) {
throw new Boom(`Unable to resolve LID for PN JID: ${jid}`, { statusCode: 400 })
}
lid = mapped
if (action === 'block') {
pn_jid = normalizedJid
}
} else {
throw new Boom(`Invalid jid for block/unblock: ${jid}`, { statusCode: 400 })
}
const itemAttrs: { action: 'block' | 'unblock'; jid: string; pn_jid?: string } = {
action,
jid: lid
}
if (action === 'block' && pn_jid) {
itemAttrs.pn_jid = pn_jid
}
await query({
tag: 'iq',
attrs: {
@@ -500,7 +381,10 @@ export const makeChatsSocket = (config: SocketConfig) => {
content: [
{
tag: 'item',
attrs: itemAttrs
attrs: {
action,
jid
}
}
]
})
@@ -581,12 +465,10 @@ export const makeChatsSocket = (config: SocketConfig) => {
const newAppStateChunkHandler = (isInitialSync: boolean) => {
return {
onMutation(mutation: ChatMutation) {
const me = authState.creds.me
if (!me) throw new Boom('Not authenticated', { statusCode: 401 })
processSyncAction(
mutation,
ev,
me,
authState.creds.me!,
isInitialSync ? { accountSettings: authState.creds.accountSettings } : undefined,
logger
)
@@ -596,11 +478,24 @@ export const makeChatsSocket = (config: SocketConfig) => {
const resyncAppState = ev.createBufferedFunction(
async (collections: readonly WAPatchName[], isInitialSync: boolean) => {
const appStateSyncKeyCache = new Map<string, proto.Message.IAppStateSyncKeyData | null>()
const getCachedAppStateSyncKey = async (
keyId: string
): Promise<proto.Message.IAppStateSyncKeyData | null | undefined> => {
if (appStateSyncKeyCache.has(keyId)) {
return appStateSyncKeyCache.get(keyId) ?? undefined
}
const key = await getAppStateSyncKey(keyId)
appStateSyncKeyCache.set(keyId, key ?? null)
return key
}
// we use this to determine which events to fire
// otherwise when we resync from scratch -- all notifications will fire
const initialVersionMap: { [T in WAPatchName]?: number } = {}
const globalMutationMap: ChatMutationMap = {}
const forceSnapshotCollections = new Set<WAPatchName>()
await authState.keys.transaction(async () => {
const collectionsToHandle = new Set<string>(collections)
@@ -618,7 +513,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
let state = result[name]
if (state) {
state = ensureLTHashStateVersion(state)
if (typeof initialVersionMap[name] === 'undefined') {
initialVersionMap[name] = state.version
}
@@ -628,20 +522,15 @@ export const makeChatsSocket = (config: SocketConfig) => {
states[name] = state
const shouldForceSnapshot = forceSnapshotCollections.has(name)
if (shouldForceSnapshot) {
forceSnapshotCollections.delete(name)
}
logger.info(`resyncing ${name} from v${state.version}${shouldForceSnapshot ? ' (forcing snapshot)' : ''}`)
logger.info(`resyncing ${name} from v${state.version}`)
nodes.push({
tag: 'collection',
attrs: {
name,
version: state.version.toString(),
// return snapshot if syncing from scratch or forcing after a failed attempt
return_snapshot: (shouldForceSnapshot || !state.version).toString()
// return snapshot if being synced from scratch
return_snapshot: (!state.version).toString()
}
})
}
@@ -712,44 +601,23 @@ export const makeChatsSocket = (config: SocketConfig) => {
collectionsToHandle.delete(name)
}
} catch (error: any) {
// if retry attempts overshoot
// or key not found
const isIrrecoverableError =
attemptsMap[name]! >= MAX_SYNC_ATTEMPTS ||
error.output?.statusCode === 404 ||
error.name === 'TypeError'
logger.info(
{ name, error: error.stack },
`failed to sync state from version${isIrrecoverableError ? '' : ', removing and trying from scratch'}`
)
await authState.keys.set({ 'app-state-sync-version': { [name]: null } })
// increment number of retries
attemptsMap[name] = (attemptsMap[name] || 0) + 1
const logData = {
name,
attempt: attemptsMap[name],
version: states[name].version,
statusCode: error.output?.statusCode,
errorType: error.name,
error: error.stack
}
if (isMissingKeyError(error) && attemptsMap[name] >= MAX_SYNC_ATTEMPTS) {
// WA Web treats missing keys as "Blocked" — park the collection
// until the key arrives via APP_STATE_SYNC_KEY_SHARE.
logger.warn(
logData,
`${name} blocked on missing key from v${states[name].version}, parking after ${attemptsMap[name]} attempts`
)
blockedCollections.add(name)
if (isIrrecoverableError) {
// stop retrying
collectionsToHandle.delete(name)
} else if (isMissingKeyError(error)) {
// Retry with a snapshot which may use a different key.
logger.info(
logData,
`${name} blocked on missing key from v${states[name].version}, retrying with snapshot`
)
forceSnapshotCollections.add(name)
} else if (isAppStateSyncIrrecoverable(error, attemptsMap[name])) {
logger.warn(logData, `failed to sync ${name} from v${states[name].version}, giving up`)
// reset persisted version to null so the next resyncAppState call
// requests a full snapshot instead of reusing the stale version that caused the error
await authState.keys.set({ 'app-state-sync-version': { [name]: null } })
collectionsToHandle.delete(name)
} else {
logger.info(logData, `failed to sync ${name} from v${states[name].version}, forcing snapshot retry`)
// force a full snapshot on retry to recover from
// corrupted local state (e.g. LTHash MAC mismatch)
forceSnapshotCollections.add(name)
}
}
}
@@ -758,9 +626,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
const { onMutation } = newAppStateChunkHandler(isInitialSync)
for (const key in globalMutationMap) {
const mutation = globalMutationMap[key]
if (!mutation) continue
onMutation(mutation)
onMutation(globalMutationMap[key]!)
}
}
)
@@ -773,26 +639,9 @@ export const makeChatsSocket = (config: SocketConfig) => {
const profilePictureUrl = async (jid: string, type: 'preview' | 'image' = 'preview', timeoutMs?: number) => {
const baseContent: BinaryNode[] = [{ tag: 'picture', attrs: { type, query: 'url' } }]
// WA Web only includes tctoken for user JIDs (not groups/newsletters)
// and never for own profile pic (Chat model for self has no tcToken).
// Including tctoken for own JID causes the server to never respond.
const normalizedJid = jidNormalizedUser(jid)
const isUserJid = isAnyPnUser(normalizedJid) || isAnyLidUser(normalizedJid)
const me = authState.creds.me
const isSelf =
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
let content: BinaryNode[] | undefined = baseContent
const tcTokenContent = await buildTcTokenFromJid({ authState, jid, baseContent })
if (isUserJid && !isSelf) {
content = await buildTcTokenFromJid({
authState,
jid: normalizedJid,
baseContent,
getLIDForPN
})
}
jid = normalizedJid
jid = jidNormalizedUser(jid)
const result = await query(
{
tag: 'iq',
@@ -802,7 +651,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
type: 'get',
xmlns: 'w:profile:picture'
},
content
content: tcTokenContent
},
timeoutMs
)
@@ -833,15 +682,19 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
const sendPresenceUpdate = async (type: WAPresence, toJid?: string) => {
const me = authState.creds.me
if (!me) throw new Boom('Not authenticated', { statusCode: 401 })
if (type === 'available' || type === 'unavailable') {
const me = authState.creds.me!
const isAvailableType = type === 'available'
if (isAvailableType || type === 'unavailable') {
if (!me.name) {
logger.warn('no name present, ignoring presence update request...')
return
}
ev.emit('connection.update', { isOnline: type === 'available' })
ev.emit('connection.update', { isOnline: isAvailableType })
if (isAvailableType) {
void sendUnifiedSession()
}
await sendNode({
tag: 'presence',
@@ -850,34 +703,15 @@ export const makeChatsSocket = (config: SocketConfig) => {
type
}
})
// Send unified_session telemetry when going online
// This mimics official WhatsApp Web client behavior
if (type === 'available') {
sendUnifiedSession('presence').catch(err => {
logger.debug({ err }, 'Failed to send unified_session on presence available')
})
}
} else {
if (!toJid) {
logger.warn('sendPresenceUpdate: toJid is missing, skipping')
return
}
const decoded = jidDecode(toJid)
if (!decoded) {
logger.warn({ toJid }, 'sendPresenceUpdate: failed to decode toJid, skipping')
return
}
const { server } = decoded
const { server } = jidDecode(toJid)!
const isLid = server === 'lid'
await sendNode({
tag: 'chatstate',
attrs: {
from: isLid ? me.lid || me.id : me.id,
to: toJid
from: isLid ? me.lid! : me.id,
to: toJid!
},
content: [
{
@@ -894,12 +728,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
* @param tcToken token for subscription, use if present
*/
const presenceSubscribe = async (toJid: string) => {
// Only include tctoken for user JIDs — groups/newsletters don't use tctokens
const normalizedToJid = jidNormalizedUser(toJid)
const isUserJid = isAnyPnUser(normalizedToJid) || isAnyLidUser(normalizedToJid)
const tcTokenContent = isUserJid
? await buildTcTokenFromJid({ authState, jid: normalizedToJid, getLIDForPN })
: undefined
const tcTokenContent = await buildTcTokenFromJid({ authState, jid: toJid })
return sendNode({
tag: 'presence',
@@ -916,12 +745,8 @@ export const makeChatsSocket = (config: SocketConfig) => {
let presence: PresenceData | undefined
const jid = attrs.from
const participant = attrs.participant || attrs.from
if (!jid) {
logger.warn({ attrs }, 'handlePresenceUpdate: jid (attrs.from) is missing, skipping')
return
}
if (shouldIgnoreJid(jid) && jid !== S_WHATSAPP_NET) {
if (shouldIgnoreJid(jid!) && jid !== S_WHATSAPP_NET) {
return
}
@@ -932,17 +757,12 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
} else if (Array.isArray(content)) {
const [firstChild] = content
if (!firstChild) {
logger.warn({ jid }, 'handlePresenceUpdate: firstChild content is empty, skipping')
return
}
let type = firstChild.tag as WAPresence
let type = firstChild!.tag as WAPresence
if (type === 'paused') {
type = 'available'
}
if (firstChild.attrs?.media === 'audio') {
if (firstChild!.attrs?.media === 'audio') {
type = 'recording'
}
@@ -952,12 +772,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
if (presence) {
if (!participant) {
logger.warn({ jid }, 'handlePresenceUpdate: participant is missing, skipping')
return
}
ev.emit('presence.update', { id: jid, presences: { [participant]: presence } })
ev.emit('presence.update', { id: jid!, presences: { [participant!]: presence } })
}
}
@@ -978,7 +793,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
await resyncAppState([name], false)
const { [name]: currentSyncVersion } = await authState.keys.get('app-state-sync-version', [name])
initial = currentSyncVersion ? ensureLTHashStateVersion(currentSyncVersion) : newLTHashState()
initial = currentSyncVersion || newLTHashState()
encodeResult = await encodeSyncdPatch(patchCreate, myAppStateKeyId, initial, getAppStateSyncKey)
const { patch, state } = encodeResult
@@ -1264,56 +1079,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
PROCESSABLE_HISTORY_TYPES.includes(historyMsg.syncType! as proto.HistorySync.HistorySyncType)
: false
if (historyMsg && shouldProcessHistoryMsg) {
const syncType = historyMsg.syncType as proto.HistorySync.HistorySyncType
// INITIAL_BOOTSTRAP — fire immediately, no progress check (same as WA Web K function)
if (
syncType === proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP &&
!historySyncStatus.initialBootstrapComplete
) {
historySyncStatus.initialBootstrapComplete = true
ev.emit('messaging-history.status', {
syncType,
status: 'complete',
explicit: true
})
}
// RECENT with progress === 100 — explicit completion
if (
syncType === proto.HistorySync.HistorySyncType.RECENT &&
historyMsg.progress === 100 &&
!historySyncStatus.recentSyncComplete
) {
historySyncStatus.recentSyncComplete = true
clearTimeout(historySyncPausedTimeout)
historySyncPausedTimeout = undefined
ev.emit('messaging-history.status', {
syncType,
status: 'complete',
explicit: true
})
}
// Reset 120s paused timeout on any RECENT chunk (like WA Web's handleChunkProgress)
if (syncType === proto.HistorySync.HistorySyncType.RECENT && !historySyncStatus.recentSyncComplete) {
clearTimeout(historySyncPausedTimeout)
historySyncPausedTimeout = setTimeout(() => {
if (!historySyncStatus.recentSyncComplete) {
historySyncStatus.recentSyncComplete = true
ev.emit('messaging-history.status', {
syncType: proto.HistorySync.HistorySyncType.RECENT,
status: 'paused',
explicit: false
})
}
historySyncPausedTimeout = undefined
}, HISTORY_SYNC_PAUSED_TIMEOUT_MS)
}
}
// State machine: decide on sync and flush
if (historyMsg && syncState === SyncState.AwaitingInitialSync) {
if (awaitingSyncTimeout) {
@@ -1334,9 +1099,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
const doAppStateSync = async () => {
if (syncState === SyncState.Syncing) {
// All collections will be synced, so clear any blocked ones
blockedCollections.clear()
logger.info('Doing app state sync')
await resyncAppState(ALL_WA_PATCH_NAMES, true)
@@ -1415,57 +1177,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
)
}
// Clean up app state sync key cache on connection close
if (connection === 'close') {
blockedCollections.clear()
clearTimeout(historySyncPausedTimeout)
historySyncPausedTimeout = undefined
appStateSyncKeyCache.clear()
logger.debug('App state sync key cache cleared on connection close')
}
if (!receivedPendingNotifications || syncState !== SyncState.Connecting) {
return
}
historySyncStatus.initialBootstrapComplete = false
historySyncStatus.recentSyncComplete = false
clearTimeout(historySyncPausedTimeout)
historySyncPausedTimeout = undefined
syncState = SyncState.AwaitingInitialSync
logger.info('Connection is now AwaitingInitialSync, buffering events')
ev.buffer()
// On reconnections, app state was already synced in a previous session.
// Skip the AwaitingInitialSync wait and go directly to Online so that
// live incoming messages are not held in the buffer for up to 4 seconds.
//
// Two signals indicate a reconnect (either is sufficient):
// 1. accountSyncCounter > 0 — at least one full sync completed before
// 2. socketSkippedOfflineBuffer — socket.ts already determined this is a
// reconnect (e.g. stale routingInfo was cleared) and skipped the offline
// phase buffer. Keeping the second buffer active while the first was already
// skipped would cause a mismatch: events flow immediately then stall for 4s.
const isReconnection = (authState.creds.accountSyncCounter ?? 0) > 0 || socketSkippedOfflineBuffer
if (isReconnection) {
logger.info(
{ accountSyncCounter: authState.creds.accountSyncCounter, socketSkippedOfflineBuffer },
'Reconnection detected, skipping AwaitingInitialSync wait. Transitioning to Online immediately.'
)
blockedCollections.clear()
syncState = SyncState.Online
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
ev.emit('creds.update', { accountSyncCounter })
// Fire-and-forget: pick up patches missed during downtime (mute/archive/pin/read state).
// Runs in background so live incoming messages are not blocked.
resyncAppState(ALL_WA_PATCH_NAMES, true).catch(err =>
logger.warn({ err }, 'Background app state resync failed (non-critical on reconnection)')
)
setTimeout(() => ev.flush(), 0)
return
}
const willSyncHistory = shouldSyncHistoryMessage(
proto.Message.HistorySyncNotification.create({
syncType: proto.HistorySync.HistorySyncType.RECENT
@@ -1479,15 +1198,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
return
}
// perf(inbound-latency): reduced from 20s → 8s → 4s. On first connection we wait for
// the history-sync notification so that doAppStateSync runs before live messages are
// emitted. If the notification does not arrive within 4s we stop waiting, go Online,
// and flush so that any live message arriving after connection is never held more than 4s.
// History that arrives late is still processed via processMessage regardless of state.
// This 4s timeout fires before the event-buffer's own adaptive safety timer
// (BAILEYS_BUFFER_TIMEOUT_MS defaults to 5s), ensuring the buffer cannot stall
// beyond 4s on a first connect regardless of event rate.
logger.info('First connection, awaiting history sync notification with a 4s timeout.')
logger.info('History sync is enabled, awaiting notification with a 20s timeout.')
if (awaitingSyncTimeout) {
clearTimeout(awaitingSyncTimeout)
@@ -1495,91 +1206,19 @@ export const makeChatsSocket = (config: SocketConfig) => {
awaitingSyncTimeout = setTimeout(() => {
if (syncState === SyncState.AwaitingInitialSync) {
logger.warn('Timeout in AwaitingInitialSync (2s), forcing state to Online and flushing buffer')
// TODO: investigate
logger.warn('Timeout in AwaitingInitialSync, forcing state to Online and flushing buffer')
syncState = SyncState.Online
ev.flush()
// Increment so subsequent reconnections skip the wait entirely.
// Late-arriving history is still processed via processMessage
// regardless of the state machine phase.
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
ev.emit('creds.update', { accountSyncCounter })
}
}, 2_000)
}, 20_000)
})
// When an app state sync key arrives (myAppStateKeyId is set) and there are
// collections blocked on a missing key, trigger a re-sync for just those collections.
// This mirrors WA Web's Blocked → retry-on-key-arrival behavior.
ev.on('creds.update', ({ myAppStateKeyId }) => {
if (!myAppStateKeyId || blockedCollections.size === 0) {
return
}
// If we're in the middle of a full sync, doAppStateSync handles all collections
if (syncState === SyncState.Syncing) {
blockedCollections.clear()
return
}
const collections = [...blockedCollections] as WAPatchName[]
blockedCollections.clear()
logger.info({ collections }, 'app state sync key arrived, re-syncing blocked collections')
resyncAppState(collections, false).catch(error => onUnexpectedError(error, 'blocked collections resync'))
})
ev.on('lid-mapping.update', async mappings => {
ev.on('lid-mapping.update', async ({ lid, pn }) => {
try {
const result = await signalRepository.lidMapping.storeLIDPNMappings(mappings)
logger.debug(
{ count: mappings.length, stored: result.stored, skipped: result.skipped, errors: result.errors },
'stored LID-PN mappings from update event'
)
if (result.stored > 0) {
logger.info(
{ count: mappings.length, stored: result.stored },
'fallback LID mappings are now available from update event'
)
}
// Automatic chat merge: notify consumers about LID→PN mapping
// This allows ZPRO and other consumers to merge/rename chats accordingly
// Collect all merge notifications to emit in a single batch
const mergeNotifications: ChatUpdate[] = []
const mergedAt = Date.now()
for (const mapping of mappings) {
const lidUser = jidNormalizedUser(mapping.lid)
const pnUser = jidNormalizedUser(mapping.pn)
if (lidUser && pnUser && lidUser !== pnUser) {
logger.debug({ lid: lidUser, pn: pnUser }, 'collected chat update for LID→PN merge notification')
mergeNotifications.push({
id: pnUser,
merged: true,
previousId: lidUser,
mergedAt
})
}
}
// Emit single batch of merge notifications for better performance
if (mergeNotifications.length > 0) {
logger.debug({ count: mergeNotifications.length }, 'emitting batch of chat merge notifications')
ev.emit('chats.update', mergeNotifications)
}
// Log warning if some mappings failed to store
if (result.errors > 0) {
logger.warn(
{ errors: result.errors, total: mappings.length, notified: mergeNotifications.length },
'some LID-PN mappings failed to store, but merge notifications were sent'
)
}
await signalRepository.lidMapping.storeLIDPNMappings([{ lid, pn }])
} catch (error) {
logger.warn({ count: mappings.length, error }, 'Failed to store LID-PN mappings')
logger.warn({ lid, pn, error }, 'Failed to store LID-PN mapping')
}
})
+6 -16
View File
@@ -78,11 +78,11 @@ export const makeCommunitiesSocket = (config: SocketConfig) => {
}
async function parseGroupResult(node: BinaryNode) {
logger.debug({ nodeTag: node.tag }, 'parseGroupResult')
logger.info({ node }, 'parseGroupResult')
const groupNode = getBinaryNodeChild(node, 'group')
if (groupNode) {
try {
logger.debug({ groupId: groupNode.attrs?.id }, 'parsing group metadata')
logger.info({ groupNode }, 'groupNode')
const metadata = await sock.groupMetadata(`${groupNode.attrs.id}@g.us`)
return metadata ? metadata : Optional.empty()
} catch (error) {
@@ -100,13 +100,7 @@ export const makeCommunitiesSocket = (config: SocketConfig) => {
}
sock.ws.on('CB:ib,,dirty', async (node: BinaryNode) => {
const dirtyNode = getBinaryNodeChild(node, 'dirty')
if (!dirtyNode) {
logger.debug({ node: node.tag }, 'community dirty handler: no dirty node found, skipping')
return
}
const { attrs } = dirtyNode
const { attrs } = getBinaryNodeChild(node, 'dirty')!
if (attrs.type !== 'communities') {
return
}
@@ -364,7 +358,7 @@ export const makeCommunitiesSocket = (config: SocketConfig) => {
tag: 'accept',
attrs: {
code: inviteMessage.inviteCode!,
expiration: (inviteMessage.inviteExpiration ?? 0).toString(),
expiration: inviteMessage.inviteExpiration!.toString(),
admin: key.remoteJid!
}
}
@@ -438,11 +432,7 @@ export const makeCommunitiesSocket = (config: SocketConfig) => {
}
export const extractCommunityMetadata = (result: BinaryNode) => {
const community = getBinaryNodeChild(result, 'community')
if (!community) {
throw new Error('Missing community node in result')
}
const community = getBinaryNodeChild(result, 'community')!
const descChild = getBinaryNodeChild(community, 'description')
let desc: string | undefined
let descId: string | undefined
@@ -481,7 +471,7 @@ export const extractCommunityMetadata = (result: BinaryNode) => {
}
}),
ephemeralDuration: eph ? +eph : undefined,
addressingMode: getBinaryNodeChildString(community, 'addressing_mode') as GroupMetadata['addressingMode']
addressingMode: getBinaryNodeChildString(community, 'addressing_mode')! as GroupMetadata['addressingMode']
}
return metadata
}
+4 -4
View File
@@ -316,19 +316,19 @@ export const extractGroupMetadata = (result: BinaryNode) => {
descId = descChild.attrs.id
}
const groupId = group.attrs.id!.includes('@') ? group.attrs.id! : jidEncode(group.attrs.id!, 'g.us')
const groupId = group.attrs.id!.includes('@') ? group.attrs.id : jidEncode(group.attrs.id!, 'g.us')
const eph = getBinaryNodeChild(group, 'ephemeral')?.attrs.expiration
const memberAddMode = getBinaryNodeChildString(group, 'member_add_mode') === 'all_member_add'
const metadata: GroupMetadata = {
id: groupId,
id: groupId!,
notify: group.attrs.notify,
addressingMode: group.attrs.addressing_mode === 'lid' ? WAMessageAddressingMode.LID : WAMessageAddressingMode.PN,
subject: group.attrs.subject!,
subjectOwner: group.attrs.s_o,
subjectOwnerPn: group.attrs.s_o_pn,
subjectTime: +(group.attrs.s_t ?? '0'),
subjectTime: +group.attrs.s_t!,
size: group.attrs.size ? +group.attrs.size : getBinaryNodeChildren(group, 'participant').length,
creation: +(group.attrs.creation ?? '0'),
creation: +group.attrs.creation!,
owner: group.attrs.creator ? jidNormalizedUser(group.attrs.creator) : undefined,
ownerPn: group.attrs.creator_pn ? jidNormalizedUser(group.attrs.creator_pn) : undefined,
owner_country_code: group.attrs.creator_country_code,
+1 -206
View File
@@ -1,37 +1,7 @@
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
import type { UserFacingSocketConfig, WAVersion } from '../Types'
import type { VersionCacheLogger } from '../Utils/version-cache'
import { clearVersionCache, getCachedVersion, getVersionCacheStatus, refreshVersionCache } from '../Utils/version-cache'
import type { UserFacingSocketConfig } from '../Types'
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 = {
@@ -42,179 +12,4 @@ 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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -22
View File
@@ -7,34 +7,21 @@ import { makeGroupsSocket } from './groups'
import { executeWMexQuery as genericExecuteWMexQuery } from './mex'
const parseNewsletterCreateResponse = (response: NewsletterCreateResponse): NewsletterMetadata => {
// Validate response structure before destructuring
if (!response?.id || !response?.thread_metadata) {
throw new Error('Invalid newsletter response: missing id or thread_metadata')
}
const { id, thread_metadata: thread, viewer_metadata: viewer } = response
// Validate required thread metadata fields
if (!thread.name?.text) {
throw new Error('Invalid newsletter response: missing thread name')
}
return {
id: id,
owner: undefined,
name: thread.name.text,
creation_time: parseInt(thread.creation_time, 10) || 0,
description: thread.description?.text || '',
invite: thread.invite || '',
subscribers: parseInt(thread.subscribers_count, 10) || 0,
creation_time: parseInt(thread.creation_time, 10),
description: thread.description.text,
invite: thread.invite,
subscribers: parseInt(thread.subscribers_count, 10),
verification: thread.verification,
picture: thread.picture
? {
id: thread.picture.id || '',
directPath: thread.picture.direct_path || ''
}
: { id: '', directPath: '' },
mute_state: viewer?.mute
picture: {
id: thread.picture.id,
directPath: thread.picture.direct_path
},
mute_state: viewer.mute
}
}
+109 -786
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -80,8 +80,7 @@ export type SignalDataTypeMap = {
'app-state-sync-version': LTHashState
'lid-mapping': string
'device-list': string[]
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number }
/** Identity key for Signal Protocol - used for detecting contact reinstalls */
tctoken: { token: Buffer; timestamp?: string }
'identity-key': Uint8Array
}
@@ -99,7 +98,6 @@ export type SignalKeyStore = {
export type SignalKeyStoreWithTransaction = SignalKeyStore & {
isInTransaction: () => boolean
transaction<T>(exec: () => Promise<T>, key: string): Promise<T>
destroy?: () => void
}
export type TransactionCapabilityOptions = {
+1 -2
View File
@@ -3,6 +3,7 @@ export type WACallUpdateType = 'offer' | 'ringing' | 'timeout' | 'reject' | 'acc
export type WACallEvent = {
chatId: string
from: string
callerPn?: string
isGroup?: boolean
groupJid?: string
id: string
@@ -11,6 +12,4 @@ export type WACallEvent = {
status: WACallUpdateType
offline: boolean
latencyMs?: number
/** Phone number of the caller (sanitized to fix Brazilian landline bug) */
callerPn?: string
}
-6
View File
@@ -75,12 +75,6 @@ export type ChatUpdate = Partial<
conditional: (bufferedData: BufferedEventData) => boolean | undefined
/** last update time */
timestamp?: number
/** indicates if this chat was merged from LID to PN */
merged?: boolean
/** previous chat ID before merge (LID format) */
previousId?: string
/** timestamp when the merge occurred */
mergedAt?: number
}
>
+2 -59
View File
@@ -27,29 +27,17 @@ export type BaileysEventMap = {
chats: Chat[]
contacts: Contact[]
messages: WAMessage[]
lidPnMappings?: LIDMapping[]
isLatest?: boolean
progress?: number | null
syncType?: proto.HistorySync.HistorySyncType | null
chunkOrder?: number | null
peerDataRequestSessionId?: string | null
}
/** signals history sync milestones (completion or stall) per sync type */
'messaging-history.status': {
/** which sync phase this status refers to */
syncType: proto.HistorySync.HistorySyncType
/** the status of this sync phase */
status: 'complete' | 'paused'
/**
* progress === 100 was received from the server.
* when false, completion was inferred via timeout (no more chunks arriving).
*/
explicit: boolean
}
/** upsert chats */
'chats.upsert': Chat[]
/** update the given chats */
'chats.update': ChatUpdate[]
'lid-mapping.update': LIDMapping[]
'lid-mapping.update': LIDMapping
/** delete chats with given ID */
'chats.delete': string[]
/** presence of contact in a chat updated */
@@ -120,18 +108,6 @@ 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 }
@@ -147,37 +123,6 @@ export type BaileysEventMap = {
setting: 'channelsPersonalisedRecommendation'
value: proto.SyncActionValue.IPrivacySettingChannelsPersonalisedRecommendationAction
}
/**
* Emitted when a contact's Signal identity key changes.
* This typically indicates the contact reinstalled WhatsApp or switched devices.
* Applications can use this to notify users about the security code change,
* similar to the "Security code changed" notification in the official WhatsApp client.
*/
'identity.changed': {
/** JID of the contact whose identity key changed */
jid: string
/** SHA-256 fingerprint of the previous identity key (hex string) */
previousKeyFingerprint: string | null
/** SHA-256 fingerprint of the new identity key (hex string) */
newKeyFingerprint: string
/** Timestamp when the change was detected */
timestamp: number
/** Whether this is a new contact (true) or an existing contact with changed key (false) */
isNewContact: boolean
}
/**
* Emitted when the session TTL (Time-To-Live) expires after 7 days.
* Applications can listen to this event to perform graceful cleanup,
* flush pending operations, or rotate credentials before the socket closes.
*/
'session.ttl-expired': {
/** Timestamp when the session started (Date.now()) */
startTime: number | undefined
/** Duration of the session in milliseconds */
duration: number
}
}
export type BufferedEventData = {
@@ -189,7 +134,6 @@ export type BufferedEventData = {
isLatest: boolean
progress?: number | null
syncType?: proto.HistorySync.HistorySyncType
chunkOrder?: number | null
peerDataRequestSessionId?: string
}
chatUpserts: { [jid: string]: Chat }
@@ -203,7 +147,6 @@ export type BufferedEventData = {
messageReactions: { [key: string]: { key: WAMessageKey; reactions: proto.IReaction[] } }
messageReceipts: { [key: string]: { key: WAMessageKey; userReceipt: proto.IUserReceipt[] } }
groupUpdates: { [jid: string]: Partial<GroupMetadata> }
lidMappings: { [key: string]: LIDMapping }
}
export type BaileysEvent = keyof BaileysEventMap
+4 -660
View File
@@ -24,16 +24,6 @@ export type WAMessageKey = proto.IMessageKey & {
addressingMode?: string
isViewOnce?: boolean // TODO: remove out of the message key, place in WebMessageInfo
}
/** Metadata cached for CTWA placeholder resend to preserve original message details */
export type PlaceholderMessageData = {
key: WAMessageKey
pushName?: string | null
messageTimestamp?: WAMessage['messageTimestamp']
participant?: string | null
participantAlt?: string | null
}
export type WATextMessage = proto.Message.IExtendedTextMessage
export type WAContextInfo = proto.IContextInfo
export type WALocationMessage = proto.Message.ILocationMessage
@@ -49,59 +39,6 @@ import type { ILogger } from '../Utils/logger'
export type WAMediaPayloadURL = { url: URL | string }
export type WAMediaPayloadStream = { stream: Readable }
export type WAMediaUpload = Buffer | WAMediaPayloadStream | WAMediaPayloadURL
/**
* Individual sticker in a sticker pack
*/
export type Sticker = {
/** Buffer, Stream or URL of the sticker image (will be converted to WebP if needed) */
data: WAMediaUpload
/** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */
emojis?: string[]
/** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */
accessibilityLabel?: string
}
/**
* Sticker Pack message - send a complete pack of stickers
*
* Follows WhatsApp official specifications:
* - 3-30 stickers per pack (enforced)
* - WebP format (auto-converted)
* - Max 100KB per static sticker, 500KB per animated (recommended, not enforced)
* - Either all static OR all animated (recommended)
*
* @example
* ```typescript
* await sock.sendMessage(jid, {
* stickerPack: {
* name: 'My Awesome Pack',
* publisher: 'Your Name',
* description: 'Cool stickers collection',
* cover: Buffer.from(...), // or URL or Stream
* stickers: [
* { data: Buffer.from(...), emojis: ['😀', '😃'] },
* { data: 'https://example.com/sticker.webp', emojis: ['😎'] }
* ]
* }
* })
* ```
*/
export type StickerPack = {
/** Array of stickers (minimum 3, maximum 30 per WhatsApp official spec) */
stickers: Sticker[]
/** Cover/tray icon for the pack (will be auto-resized to 252x252 JPEG) */
cover: WAMediaUpload
/** Pack name (max 128 characters) */
name: string
/** Publisher/author name (max 128 characters) */
publisher: string
/** Optional pack description */
description?: string
/** Optional custom pack ID (auto-generated if omitted) */
packId?: string
}
/** Set of message types that are supported by the library */
export type MessageType = keyof proto.Message
@@ -176,6 +113,8 @@ export interface WAUrlInfo {
type Mentionable = {
/** list of jids that are mentioned in the accompanying text */
mentions?: string[]
/** mention all */
mentionAll?: boolean
}
type Contextable = {
/** add contextInfo to the message */
@@ -258,9 +197,7 @@ export type AnyMediaMessageContent = (
fileName?: string
caption?: string
} & Contextable)
) & { mimetype?: string } & Editable &
Partial<Buttonable> &
Partial<Templatable>
) & { mimetype?: string } & Editable
export type ButtonReplyInfo = {
displayText: string
@@ -280,454 +217,13 @@ export type WASendableProduct = Omit<proto.Message.ProductMessage.IProductSnapsh
productImage: WAMediaUpload
}
// Interactive message types
export type ButtonInfo = {
buttonId: string
buttonText: { displayText: string }
type?: proto.Message.ButtonsMessage.Button.Type
}
export type Buttonable = {
buttons: ButtonInfo[]
headerType?: proto.Message.ButtonsMessage.HeaderType
footerText?: string
}
export type TemplateButton =
| { index: number; quickReplyButton: { displayText: string; id: string } }
| { index: number; urlButton: { displayText: string; url: string } }
| { index: number; callButton: { displayText: string; phoneNumber: string } }
export type Templatable = {
templateButtons: TemplateButton[]
footer?: string
}
export type ListSection = {
title: string
rows: Array<{
rowId: string
title: string
description?: string
}>
}
export type Listable = {
sections: ListSection[]
title?: string
buttonText?: string
}
// ========== Native Flow Button Types ==========
/**
* Button types supported by WhatsApp Native Flow
* - cta_url: Opens a URL
* - cta_copy: Copies text to clipboard
* - cta_call: Initiates a phone call
* - quick_reply: Sends a quick reply with ID
* - single_select: Opens a list selection
*/
export type NativeFlowButtonType = 'cta_url' | 'cta_copy' | 'cta_call' | 'quick_reply' | 'single_select'
/**
* URL button - opens a link when clicked
*/
export type UrlButton = {
type: 'url'
text: string
url: string
/** Optional merchant URL for tracking */
merchantUrl?: string
}
/**
* Copy button - copies text to clipboard when clicked
*/
export type CopyButton = {
type: 'copy'
text: string
copyText: string
}
/**
* Quick reply button - sends a reply with an ID
*/
export type QuickReplyButton = {
type: 'reply'
text: string
id: string
}
/**
* Call button - initiates a phone call when clicked
*/
export type CallButton = {
type: 'call'
text: string
phoneNumber: string
}
/**
* Union type for all button types
*/
export type NativeButton = UrlButton | CopyButton | QuickReplyButton | CallButton
/**
* Formatted button for Native Flow (internal use)
*/
export type NativeFlowButton = {
name: string
buttonParamsJson: string
}
/**
* Row item in a list section
*/
export type ListRow = {
/** Unique ID returned when selected */
id: string
/** Display title */
title: string
/** Optional description */
description?: string
}
/**
* Section in a native list message (uses ListRow with id)
*/
export type NativeListSection = {
/** Section title */
title: string
/** Rows in this section */
rows: ListRow[]
}
/**
* Options for generating a list message
*/
export type ListMessageOptions = {
/** Button text to open the list */
buttonText: string
/** Sections with selectable items */
sections: NativeListSection[]
/** Main text/body of the message */
text: string
/** Title shown in header */
title?: string
/** Footer text */
footer?: string
}
/**
* Options for generating a button message
*/
export type ButtonMessageOptions = {
/** Array of buttons (2-3 recommended) */
buttons: NativeButton[]
/** Main text/body of the message */
text: string
/** Footer text (optional) */
footer?: string
/** Header title (optional, used if no media) */
headerTitle?: string
/** Header image (optional) */
headerImage?: WAMediaUpload
/** Header video (optional) */
headerVideo?: WAMediaUpload
/** Message version (default: 2) */
messageVersion?: number
}
/**
* Single card in a carousel message
*/
export type CarouselCardInput = {
/** Card title in header */
title: string
/** Card body text */
body: string
/** Card footer text (optional) */
footer?: string
/** Card image (optional) */
image?: WAMediaUpload
/** Card video (optional) */
video?: WAMediaUpload
/** Buttons for this card */
buttons: NativeButton[]
}
/**
* Options for generating a carousel message
*/
export type CarouselMessageOptions = {
/** Cards in the carousel (2-10 recommended) */
cards: CarouselCardInput[]
/** Header title (displayed once above the carousel) */
title?: string
/** Main body text */
text?: string
/** Footer text */
footer?: string
}
export type CarouselCard = {
header: {
title: string
imageMessage?: {
url: string
mimetype: string
}
videoMessage?: {
url: string
mimetype: string
}
hasMediaAttachment: boolean
}
body: { text: string }
footer?: { text: string }
nativeFlowMessage?: {
buttons: Array<{
name: string
buttonParamsJson: string
}>
}
}
export type Carouselable = {
carousel: {
cards: CarouselCard[]
messageVersion?: number
}
}
// ========== Product List Message Types ==========
/**
* Product reference in a product list
* Uses the product ID from the WhatsApp Business catalog
*/
export type ProductItem = {
/** Product ID from the catalog */
productId: string
}
/**
* Section containing products in a product list message
*/
export type ProductSection = {
/** Section title */
title: string
/** Products in this section */
products: ProductItem[]
}
/**
* Header image configuration for product list
* Can reference a product's image from the catalog
*/
export type ProductListHeaderImage = {
/** Product ID whose image to use as header */
productId: string
/** Optional JPEG thumbnail */
jpegThumbnail?: Buffer
}
/**
* Options for generating a product list message (multi-product)
* Allows sending multiple products from the catalog in a single message
*
* @example
* ```typescript
* const msg = generateProductListMessage({
* title: 'Our Best Sellers',
* description: 'Check out our most popular products!',
* buttonText: 'View Products',
* footerText: 'Tap to browse',
* businessOwnerJid: '5511999999999@s.whatsapp.net',
* productSections: [
* {
* title: 'Electronics',
* products: [
* { productId: 'prod_001' },
* { productId: 'prod_002' }
* ]
* },
* {
* title: 'Accessories',
* products: [
* { productId: 'prod_003' }
* ]
* }
* ],
* headerImage: { productId: 'prod_001' }
* })
* await sock.sendMessage(jid, msg)
* ```
*/
export type ProductListMessageOptions = {
/** Message title */
title: string
/** Message description/body text */
description: string
/** Button text to open the product list */
buttonText: string
/** Footer text (optional) */
footerText?: string
/** Business owner JID (the catalog owner) */
businessOwnerJid: string
/** Sections with products */
productSections: ProductSection[]
/** Header image configuration (optional) */
headerImage?: ProductListHeaderImage
}
// ========== Album Message Types ==========
/**
* Single media item in an album (image or video)
* Each item can have its own caption, thumbnail, and metadata
*/
export type AlbumMediaItem =
| ({
image: WAMediaUpload
caption?: string
jpegThumbnail?: string
} & Mentionable &
Contextable &
WithDimensions)
| ({
video: WAMediaUpload
caption?: string
gifPlayback?: boolean
jpegThumbnail?: string
/** Duration in seconds */
seconds?: number
} & Mentionable &
Contextable &
WithDimensions)
/**
* Configuration for album message sending
*/
export type AlbumMessageOptions = {
/** Array of media items (images/videos) - min 2, max 10 */
medias: AlbumMediaItem[]
/**
* Delay strategy between media sends
* - 'adaptive': Calculates delay based on media type (videos get 2x delay),
* position in album, and random jitter (recommended)
* - number: Fixed delay in milliseconds
* @default 'adaptive'
*/
delay?: 'adaptive' | number
/**
* Number of retry attempts for failed media items
* @default 3
*/
retryCount?: number
/**
* Whether to continue sending remaining items if one fails
* @default true
*/
continueOnFailure?: boolean
}
/**
* Result of a single media item send attempt
*/
export type AlbumMediaResult = {
/** Index in the original medias array */
index: number
/** Whether this item was sent successfully */
success: boolean
/** The sent message (if successful) */
message?: WAMessage
/** Error details (if failed) */
error?: Error
/** Total number of attempts made (1 = success on first try, >1 = retries occurred) */
retryAttempts: number
/** Time taken to send this item in ms */
latencyMs: number
}
/**
* Complete result of album message sending
*/
export type AlbumSendResult = {
/** Key of the album root message */
albumKey: WAMessageKey
/** Results for each media item */
results: AlbumMediaResult[]
/** Total number of items in the album */
totalItems: number
/** Number of items that were actually attempted (may be < totalItems if stoppedEarly) */
attemptedItems: number
/** Number of successfully sent items */
successCount: number
/** Number of failed items */
failedCount: number
/** Indices of failed items (for potential retry) */
failedIndices: number[]
/** Overall success (all items sent) */
success: boolean
/** Whether the send was interrupted early due to continueOnFailure=false */
stoppedEarly: boolean
/** Total time taken in ms */
totalLatencyMs: number
}
// ========== Product Carousel Message Types ==========
/**
* Single product card in a product carousel
* References a product from WhatsApp Business catalog
*/
export type ProductCarouselCard = {
/** Product retailer ID from the catalog */
productId: string
}
/**
* Options for generating a product carousel message
* Uses products from WhatsApp Business catalog
*
* @example
* ```typescript
* await sock.sendMessage(jid, {
* productCarousel: {
* businessOwnerJid: '5511999999999@s.whatsapp.net',
* products: [
* { productId: 'iphone_15' },
* { productId: 'macbook_air' },
* { productId: 'apple_watch' }
* ]
* },
* body: 'Check out our featured products!'
* })
* ```
*/
export type ProductCarouselMessageOptions = {
/** JID of the business owner (who owns the catalog) */
businessOwnerJid: string
/** Products to display (2-10 cards required) */
products: ProductCarouselCard[]
/** Body text for the message */
body?: string
}
export type AnyRegularMessageContent = (
| ({
text: string
linkPreview?: WAUrlInfo | null
} & Mentionable &
Contextable &
Editable &
Partial<Buttonable> &
Partial<Templatable> &
Partial<Listable> &
Partial<Carouselable>)
Editable)
| AnyMediaMessageContent
| { event: EventMessageOptions }
| ({
@@ -769,158 +265,6 @@ export type AnyRegularMessageContent = (
body?: string
footer?: string
}
| {
/**
* Native Flow Buttons - Modern button message format
* Works reliably on iOS and Android with viewOnceMessage wrapper
*
* @example
* ```typescript
* await sock.sendMessage(jid, {
* text: 'Choose an option:',
* nativeButtons: [
* { type: 'url', text: 'Visit Site', url: 'https://example.com' },
* { type: 'copy', text: 'Copy Code', copyText: 'ABC123' },
* { type: 'reply', text: 'Contact Us', id: 'btn_contact' }
* ],
* footer: 'Powered by InfiniteAPI'
* })
* ```
*/
nativeButtons: NativeButton[]
text?: string
footer?: string
headerTitle?: string
headerImage?: WAMediaUpload
headerVideo?: WAMediaUpload
}
| {
/**
* Native Carousel Message - Multiple swipeable cards with buttons
*
* @example
* ```typescript
* await sock.sendMessage(jid, {
* text: 'Our Products',
* nativeCarousel: {
* cards: [
* { title: 'Item 1', body: 'Description', buttons: [...] },
* { title: 'Item 2', body: 'Description', buttons: [...] }
* ]
* },
* footer: 'Swipe for more'
* })
* ```
*/
nativeCarousel: {
cards: CarouselCardInput[]
}
text?: string
footer?: string
}
| {
/**
* Product Carousel Message - Swipeable product cards from WhatsApp Business catalog
* Requires: WhatsApp Business account with configured catalog
*
* @example
* ```typescript
* await sock.sendMessage(jid, {
* productCarousel: {
* businessOwnerJid: '5511999999999@s.whatsapp.net',
* products: [
* { productId: 'produto_001' },
* { productId: 'produto_002' },
* { productId: 'produto_003' }
* ]
* },
* body: 'Confira nossos produtos em destaque!'
* })
* ```
*/
productCarousel: ProductCarouselMessageOptions
body?: string
}
| {
/**
* Native List Message - Interactive list with sections
*
* @example
* ```typescript
* await sock.sendMessage(jid, {
* text: 'Choose an option:',
* title: 'Menu',
* nativeList: {
* buttonText: 'View Options',
* sections: [
* {
* title: 'Category 1',
* rows: [
* { id: 'opt1', title: 'Option 1', description: 'Desc' },
* { id: 'opt2', title: 'Option 2' }
* ]
* }
* ]
* },
* footer: 'Select one'
* })
* ```
*/
nativeList: {
buttonText: string
sections: NativeListSection[]
}
text?: string
title?: string
footer?: string
}
| {
/**
* Album message - send multiple images/videos grouped together
* WARNING: Do NOT use with sendMessage() - use sendAlbumMessage() instead!
* sendMessage only relays the root message and won't send individual media items
* @internal Used internally by generateWAMessage
*/
album: AlbumMessageOptions
}
| {
/**
* Sticker Pack - Send a complete pack of stickers (3-30 stickers)
*
* The pack will appear in the recipient's sticker tray, similar to official sticker packs.
* All stickers are automatically converted to WebP format if needed.
*
* @example
* ```typescript
* import { readFileSync } from 'fs'
*
* await sock.sendMessage(jid, {
* stickerPack: {
* name: 'Emoji Pack',
* publisher: 'InfiniteAPI',
* description: 'Fun emoji stickers',
* cover: readFileSync('./pack-cover.png'),
* stickers: [
* { data: readFileSync('./sticker1.webp'), emojis: ['😀'] },
* { data: readFileSync('./sticker2.png'), emojis: ['😎', '🔥'] },
* { data: { url: 'https://example.com/sticker3.webp' }, emojis: ['🎉'] }
* ]
* }
* })
* ```
*
* **Requirements:**
* - `fflate` package (installed automatically)
* - `sharp` package for image processing: `yarn add sharp`
*
* **Specifications (WhatsApp Official):**
* - Minimum 3 stickers, maximum 30 per pack
* - Recommended: 100KB per static sticker, 500KB per animated
* - WebP format (auto-converted from PNG/JPG/etc)
* - Best practice: All stickers either static OR animated, not mixed
*/
stickerPack: StickerPack
}
| SharePhoneNumber
| RequestPhoneNumber
) &
-26
View File
@@ -1,26 +0,0 @@
/**
* Session cleanup configuration
*/
export interface SessionCleanupConfig {
enabled: boolean
intervalMs: number
cleanupHour: number
secondaryDeviceInactiveDays: number
primaryDeviceInactiveDays: number
lidOrphanHours: number
cleanupOnStartup: boolean
autoCleanCorrupted: boolean
}
/**
* Session cleanup statistics
*/
export interface SessionCleanupStats {
totalScanned: number
secondaryDevicesDeleted: number
primaryDevicesDeleted: number
lidOrphansDeleted: number
totalDeleted: number
durationMs: number
errors: number
}
-114
View File
@@ -1,12 +1,10 @@
import type { Agent } from 'https'
import type { URL } from 'url'
import { proto } from '../../WAProto/index.js'
import type { CircuitBreakerOptions } from '../Utils/circuit-breaker'
import type { ILogger } from '../Utils/logger'
import type { AuthenticationState, LIDMapping, SignalAuthState, TransactionCapabilityOptions } from './Auth'
import type { GroupMetadata } from './GroupMetadata'
import { type MediaConnInfo, type WAMessageKey } from './Message'
import type { SessionCleanupConfig } from './SessionCleanup'
import type { SignalRepositoryWithLIDStore } from './Signal'
export type WAVersion = [number, number, number]
@@ -50,17 +48,6 @@ 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 */
@@ -119,48 +106,6 @@ 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
/**
* Enable interactive messages (buttons, lists, templates, carousel).
* When true, injects biz/bot binary nodes required by WhatsApp.
* @default true
*/
enableInteractiveMessages: boolean
/**
* When true, clears the `routingInfo` stored in credentials before connecting.
*
* `routingInfo` is a hint that directs the socket to reconnect to the same
* WhatsApp edge server used in the previous session. After a code update or
* server-side configuration change, the old edge server may retain stale state
* (throttling, queued messages, etc.) that causes persistent slowness even with
* fresh code only solvable by re-scanning the QR code.
*
* Setting this to `true` forces WhatsApp to assign a fresh edge server on the
* next connection, equivalent to the clean state you get after a QR re-scan,
* but without invalidating the session or Signal keys.
*
* Recommended usage: enable this option in your `startSock()` call right after
* deploying a new version, then disable it on subsequent reconnections.
*
* @default true
*/
clearRoutingInfoOnStart: boolean
/**
* Returns if a jid should be ignored,
* no event for that jid will be triggered.
@@ -202,63 +147,4 @@ export type SocketConfig = {
logger: ILogger,
pnToLIDFunc?: (jids: string[]) => Promise<LIDMapping[] | undefined>
) => SignalRepositoryWithLIDStore
// === Circuit Breaker Configuration ===
/** Enable circuit breaker protection for socket operations (default: true) */
enableCircuitBreaker?: boolean
/** Circuit breaker configuration for query operations */
queryCircuitBreaker?: Partial<CircuitBreakerOptions>
/** Circuit breaker configuration for connection operations */
connectionCircuitBreaker?: Partial<CircuitBreakerOptions>
/** Circuit breaker configuration for pre-key operations */
preKeyCircuitBreaker?: Partial<CircuitBreakerOptions>
/** Circuit breaker configuration for message operations */
messageCircuitBreaker?: Partial<CircuitBreakerOptions>
// === Listener Limits (Memory Leak Prevention) ===
/**
* Max WebSocket event listeners (default: 20)
* Calculation: 8 core WS events + 10 dynamic listeners + 2 buffer slots
* WARNING: Setting to 0 disables limit and allows potential memory leaks!
*/
maxWebSocketListeners?: number
/**
* Max SocketClient event listeners (default: 50)
* Calculation: 20 core events + 20 dynamic listeners + 10 buffer slots
* WARNING: Setting to 0 disables limit and allows potential memory leaks!
*/
maxSocketClientListeners?: number
// === Unified Session Telemetry ===
/**
* Enable unified_session telemetry to reduce detection of unofficial clients.
*
* When enabled, sends time-based session identifiers that mimic official
* WhatsApp Web client behavior. This may help reduce "Your account may be
* at risk" warnings, though effectiveness is not guaranteed.
*
* Telemetry is sent at specific trigger points:
* - After successful login
* - After successful pairing
* - When sending 'available' presence
*
* Can also be controlled via environment variable:
* BAILEYS_UNIFIED_SESSION_ENABLED=true|false
*
* @default true
* @see https://github.com/tulir/whatsmeow/pull/1057
* @see https://github.com/WhiskeySockets/Baileys/pull/2294
*/
enableUnifiedSession?: boolean
/** Session cleanup configuration (optional, partial overrides merged with defaults) */
sessionCleanupConfig?: Partial<SessionCleanupConfig>
}
-6
View File
@@ -40,10 +40,4 @@ export type ConnectionState = {
* If this is false, the primary phone and other devices will receive notifs
* */
isOnline?: boolean
/**
* indicates the disconnect was caused by a session error (keys desynchronized).
* When true, the consumer should recreate the socket with makeWASocket()
* to establish a fresh session.
*/
isSessionError?: boolean
}
+1 -3
View File
@@ -10,7 +10,6 @@ export * from './Product'
export * from './Call'
export * from './Signal'
export * from './Newsletter'
export * from './SessionCleanup'
import type { AuthenticationState } from './Auth'
import type { SocketConfig } from './Socket'
@@ -35,8 +34,7 @@ export enum DisconnectReason {
restartRequired = 515,
multideviceMismatch = 411,
forbidden = 403,
unavailableService = 503,
sessionInvalidated = 516
unavailableService = 503
}
export type WAInitResponse = {
-67
View File
@@ -129,9 +129,6 @@ export const addTransactionCapability = (
// Pre-key manager for specialized operations
const preKeyManager = new PreKeyManager(state, logger)
// Destroyed flag to prevent operations after cleanup
let destroyed = false
/**
* Get or create a queue for a specific key type
*/
@@ -317,12 +314,6 @@ export const addTransactionCapability = (
try {
return await mutex.runExclusive(async () => {
// CRITICAL: Check destroyed flag INSIDE mutex to prevent race condition
// This ensures atomic check-and-execute: if we acquire mutex, resources exist
if (destroyed) {
throw new Error('Transaction capability destroyed - cannot initiate new transactions')
}
const ctx: TransactionContext = {
cache: {},
mutations: {},
@@ -348,64 +339,6 @@ export const addTransactionCapability = (
} finally {
releaseTxMutexRef(key)
}
},
/**
* Cleanup all resources (queues, managers, mutexes)
* Should be called during connection cleanup
*
* IMPORTANT BEHAVIOR:
* - Always sets destroyed=true to prevent NEW transactions
* - If mutexes are locked (active transactions), returns early WITHOUT destroying resources
* - This creates intentional temporary inconsistent state:
* * destroyed=true (new transactions rejected)
* * resources exist (active transactions complete safely)
* * resources cleaned up by GC after active transactions finish
* - If no locked mutexes, destroys resources immediately
*/
destroy: () => {
// CRITICAL: Set destroyed flag FIRST to prevent new transactions
// Note: Flag is set even if early return occurs (see doc above)
destroyed = true
logger.debug('🗑️ Cleaning up transaction capability resources')
// Count locked mutexes BEFORE destroying resources
let clearedCount = 0
let lockedCount = 0
const lockedKeys: string[] = []
txMutexes.forEach((mutex, key) => {
if (!mutex.isLocked()) {
txMutexes.delete(key)
txMutexRefCounts.delete(key)
clearedCount++
} else {
lockedCount++
lockedKeys.push(key)
}
})
// If there are locked mutexes, log error and skip resource destruction
if (lockedCount > 0) {
logger.error(
{ lockedCount, lockedKeys },
'❌ Cannot destroy resources - transactions still active! Resources will be cleaned up by GC.'
)
return
}
// Safe to destroy resources (no active transactions)
preKeyManager.destroy()
// Clear all key queues
keyQueues.forEach((queue, keyType) => {
queue.clear()
queue.pause()
logger.debug(`Queue for ${keyType} cleared and paused`)
})
keyQueues.clear()
logger.debug({ clearedCount }, 'Transaction capability cleanup completed')
}
}
}
-781
View File
@@ -1,781 +0,0 @@
/**
* Baileys Event Stream Management
*
* Provides:
* - Event buffering with backpressure
* - Event transformation and filtering
* - Priority queues for events
* - Batch processing
* - Dead letter queue for failed events
* - Event replay
* - Logging and metrics integration
*
* @module Utils/baileys-event-stream
*/
import { EventEmitter } from 'events'
import type { BaileysLogCategory } from './baileys-logger.js'
import { metrics } from './prometheus-metrics.js'
/**
* Baileys event types
*/
export type BaileysEventType =
| 'connection.update'
| 'creds.update'
| 'messaging-history.set'
| 'chats.set'
| 'contacts.set'
| 'messages.upsert'
| 'messages.update'
| 'messages.delete'
| 'messages.reaction'
| 'message-receipt.update'
| 'groups.upsert'
| 'groups.update'
| 'group-participants.update'
| 'presence.update'
| 'chats.update'
| 'chats.delete'
| 'labels.edit'
| 'labels.association'
| 'call'
| 'blocklist.set'
| 'blocklist.update'
| string // For custom events
/**
* Event priority
*/
export type EventPriority = 'critical' | 'high' | 'normal' | 'low'
/**
* Numeric priority values
*/
const PRIORITY_VALUES: Record<EventPriority, number> = {
critical: 0,
high: 1,
normal: 2,
low: 3
}
/**
* Stream event
*/
export interface StreamEvent<T = unknown> {
id: string
type: BaileysEventType
data: T
timestamp: number
priority: EventPriority
category: BaileysLogCategory
metadata?: Record<string, unknown>
retryCount?: number
originalTimestamp?: number
}
/**
* Event Stream options
*/
export interface EventStreamOptions {
/** Maximum buffer size (default: 10000) */
maxBufferSize?: number
/** Whether to apply backpressure when buffer is full */
enableBackpressure?: boolean
/** High water mark limit for backpressure */
highWaterMark?: number
/** Low water mark limit to resume */
lowWaterMark?: number
/** Batch size for processing */
batchSize?: number
/** Flush interval in ms (0 = disabled) */
flushInterval?: number
/** Maximum retries for failed events */
maxRetries?: number
/** Dead letter queue size */
deadLetterQueueSize?: number
/** Whether to collect metrics */
collectMetrics?: boolean
/** Stream name for metrics */
streamName?: string
}
/**
* Event handler
*/
export type EventHandler<T = unknown> = (event: StreamEvent<T>) => void | Promise<void>
/**
* Event filter
*/
export type EventFilter<T = unknown> = (event: StreamEvent<T>) => boolean
/**
* Event transformer
*/
export type EventTransformer<T = unknown, R = unknown> = (event: StreamEvent<T>) => StreamEvent<R>
/**
* Batch processing result
*/
export interface BatchResult {
processed: number
failed: number
duration: number
}
/**
* Stream statistics
*/
export interface EventStreamStats {
bufferSize: number
totalReceived: number
totalProcessed: number
totalFailed: number
totalDropped: number
deadLetterQueueSize: number
isBackpressured: boolean
lastEventTimestamp?: number
eventsByType: Record<string, number>
eventsByPriority: Record<EventPriority, number>
}
/**
* Event type to category mapping
*/
const EVENT_CATEGORY_MAP: Record<string, BaileysLogCategory> = {
'connection.update': 'connection',
'creds.update': 'auth',
'messaging-history.set': 'sync',
'chats.set': 'sync',
'contacts.set': 'sync',
'messages.upsert': 'message',
'messages.update': 'message',
'messages.delete': 'message',
'messages.reaction': 'message',
'message-receipt.update': 'message',
'groups.upsert': 'group',
'groups.update': 'group',
'group-participants.update': 'group',
'presence.update': 'presence',
'chats.update': 'message',
'chats.delete': 'message',
call: 'call',
'blocklist.set': 'sync',
'blocklist.update': 'sync'
}
/**
* Default priority by event type
*/
const EVENT_PRIORITY_MAP: Partial<Record<BaileysEventType, EventPriority>> = {
'connection.update': 'critical',
'creds.update': 'critical',
'messages.upsert': 'high',
'messages.update': 'high',
call: 'high',
'presence.update': 'low',
'messaging-history.set': 'normal'
}
/**
* Generate unique event ID
*/
function generateEventId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
}
/**
* Main Event Stream class
*/
export class BaileysEventStream extends EventEmitter {
private buffer: StreamEvent[] = []
private handlers: Map<BaileysEventType | '*', Set<EventHandler>> = new Map()
private filters: EventFilter[] = []
private transformers: EventTransformer[] = []
private deadLetterQueue: StreamEvent[] = []
private options: Required<EventStreamOptions>
private stats: EventStreamStats
private isProcessing = false
private flushTimer?: ReturnType<typeof setInterval>
private paused = false
constructor(options: EventStreamOptions = {}) {
super()
this.options = {
maxBufferSize: options.maxBufferSize ?? 10000,
enableBackpressure: options.enableBackpressure ?? true,
highWaterMark: options.highWaterMark ?? 8000,
lowWaterMark: options.lowWaterMark ?? 2000,
batchSize: options.batchSize ?? 100,
flushInterval: options.flushInterval ?? 0,
maxRetries: options.maxRetries ?? 3,
deadLetterQueueSize: options.deadLetterQueueSize ?? 1000,
collectMetrics: options.collectMetrics ?? true,
streamName: options.streamName ?? 'baileys'
}
this.stats = this.createInitialStats()
// Start periodic flush if configured
if (this.options.flushInterval > 0) {
this.flushTimer = setInterval(() => this.flush(), this.options.flushInterval)
}
}
private createInitialStats(): EventStreamStats {
return {
bufferSize: 0,
totalReceived: 0,
totalProcessed: 0,
totalFailed: 0,
totalDropped: 0,
deadLetterQueueSize: 0,
isBackpressured: false,
eventsByType: {},
eventsByPriority: {
critical: 0,
high: 0,
normal: 0,
low: 0
}
}
}
/**
* Add event to stream
*/
push<T>(
type: BaileysEventType,
data: T,
options?: { priority?: EventPriority; metadata?: Record<string, unknown> }
): boolean {
// Check backpressure
if (this.options.enableBackpressure && this.buffer.length >= this.options.highWaterMark) {
this.stats.isBackpressured = true
this.emit('backpressure', { bufferSize: this.buffer.length })
if (this.buffer.length >= this.options.maxBufferSize) {
this.stats.totalDropped++
this.emit('dropped', { type, reason: 'buffer_full' })
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'event_stream', code: 'dropped' })
}
return false
}
}
const event: StreamEvent<T> = {
id: generateEventId(),
type,
data,
timestamp: Date.now(),
priority: options?.priority || EVENT_PRIORITY_MAP[type] || 'normal',
category: EVENT_CATEGORY_MAP[type] || 'unknown',
metadata: options?.metadata,
retryCount: 0
}
// Apply transformers
let transformedEvent: StreamEvent = event
for (const transformer of this.transformers) {
transformedEvent = transformer(transformedEvent)
}
// Apply filters
for (const filter of this.filters) {
if (!filter(transformedEvent)) {
return false
}
}
// Add to buffer at correct position (by priority)
this.insertByPriority(transformedEvent)
// Update statistics
this.stats.totalReceived++
this.stats.bufferSize = this.buffer.length
this.stats.lastEventTimestamp = Date.now()
this.stats.eventsByType[type] = (this.stats.eventsByType[type] || 0) + 1
this.stats.eventsByPriority[event.priority]++
if (this.options.collectMetrics) {
metrics.socketEvents.inc({ event: type })
}
this.emit('event', transformedEvent)
// Process if not paused
if (!this.paused && !this.isProcessing) {
void this.processNext()
}
return true
}
/**
* Insert event in buffer by priority
*/
private insertByPriority(event: StreamEvent): void {
const eventPriorityValue = PRIORITY_VALUES[event.priority]
// Find correct position
let insertIndex = this.buffer.length
for (let i = 0; i < this.buffer.length; i++) {
const bufferEvent = this.buffer[i]
if (bufferEvent && PRIORITY_VALUES[bufferEvent.priority] > eventPriorityValue) {
insertIndex = i
break
}
}
this.buffer.splice(insertIndex, 0, event)
}
/**
* Register handler for event type
*/
on<T = unknown>(
event: BaileysEventType | '*' | 'backpressure' | 'drain' | 'dropped' | 'batch-processed' | 'retry',
handler: EventHandler<T>
): this {
// For control events (backpressure, drain, etc), use native EventEmitter
if (
event === 'backpressure' ||
event === 'drain' ||
event === 'dropped' ||
event === 'batch-processed' ||
event === 'retry'
) {
super.on(event, handler as any)
return this
}
// For Baileys events, use custom handler system
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set())
}
this.handlers.get(event)!.add(handler as EventHandler)
return this
}
/**
* Remove handler
*/
off(
event: BaileysEventType | '*' | 'backpressure' | 'drain' | 'dropped' | 'batch-processed' | 'retry',
handler: EventHandler
): this {
// For control events, use native EventEmitter
if (
event === 'backpressure' ||
event === 'drain' ||
event === 'dropped' ||
event === 'batch-processed' ||
event === 'retry'
) {
super.off(event, handler as any)
return this
}
// For Baileys events, use custom handler system
const handlers = this.handlers.get(event)
if (handlers) {
handlers.delete(handler)
}
return this
}
/**
* Register single-use handler
*/
once<T = unknown>(event: BaileysEventType, handler: EventHandler<T>): this {
const wrappedHandler: EventHandler<T> = e => {
this.off(event, wrappedHandler as EventHandler)
return handler(e)
}
return this.on(event, wrappedHandler)
}
/**
* Add filter
*/
addFilter(filter: EventFilter): this {
this.filters.push(filter)
return this
}
/**
* Remove filter
*/
removeFilter(filter: EventFilter): this {
const index = this.filters.indexOf(filter)
if (index !== -1) {
this.filters.splice(index, 1)
}
return this
}
/**
* Add transformer
*/
addTransformer(transformer: EventTransformer): this {
this.transformers.push(transformer)
return this
}
/**
* Process next events
*/
private async processNext(): Promise<void> {
if (this.isProcessing || this.paused || this.buffer.length === 0) {
return
}
this.isProcessing = true
try {
// Get batch of events
const batch = this.buffer.splice(0, this.options.batchSize)
this.stats.bufferSize = this.buffer.length
// Check if exited backpressure
if (this.stats.isBackpressured && this.buffer.length <= this.options.lowWaterMark) {
this.stats.isBackpressured = false
this.emit('drain')
}
// Process batch
const startTime = Date.now()
let processed = 0
let failed = 0
for (const event of batch) {
try {
await this.processEvent(event)
processed++
this.stats.totalProcessed++
} catch (error) {
failed++
this.stats.totalFailed++
await this.handleFailedEvent(event, error as Error)
}
}
const duration = Date.now() - startTime
this.emit('batch-processed', { processed, failed, duration } as BatchResult)
// Continue processing if there are more
if (this.buffer.length > 0) {
setImmediate(() => this.processNext())
}
} finally {
this.isProcessing = false
}
}
/**
* Process a single event
*/
private async processEvent(event: StreamEvent): Promise<void> {
// Type-specific handlers
const typeHandlers = this.handlers.get(event.type)
if (typeHandlers) {
for (const handler of typeHandlers) {
await handler(event)
}
}
// Global handlers
const globalHandlers = this.handlers.get('*')
if (globalHandlers) {
for (const handler of globalHandlers) {
await handler(event)
}
}
}
/**
* Handle failed event
*/
private async handleFailedEvent(event: StreamEvent, error: Error): Promise<void> {
event.retryCount = (event.retryCount || 0) + 1
if (event.retryCount <= this.options.maxRetries) {
// Re-add to buffer for retry
event.originalTimestamp = event.originalTimestamp || event.timestamp
event.timestamp = Date.now()
this.buffer.push(event)
this.stats.bufferSize = this.buffer.length
this.emit('retry', { event, error, attempt: event.retryCount })
} else {
// Send to dead letter queue
this.addToDeadLetterQueue(event, error)
}
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'event_stream', code: 'processing_failed' })
}
}
/**
* Add event to dead letter queue
*/
private addToDeadLetterQueue(event: StreamEvent, error: Error): void {
const dlqEvent = {
...event,
metadata: {
...event.metadata,
error: error.message,
errorStack: error.stack,
movedToDlqAt: Date.now()
}
}
this.deadLetterQueue.push(dlqEvent)
// Limit DLQ size
while (this.deadLetterQueue.length > this.options.deadLetterQueueSize) {
this.deadLetterQueue.shift()
}
this.stats.deadLetterQueueSize = this.deadLetterQueue.length
this.emit('dead-letter', dlqEvent)
}
/**
* Force flush the buffer
*/
async flush(): Promise<BatchResult> {
const startTime = Date.now()
let processed = 0
let failed = 0
while (this.buffer.length > 0 && !this.paused) {
const batch = this.buffer.splice(0, this.options.batchSize)
for (const event of batch) {
try {
await this.processEvent(event)
processed++
this.stats.totalProcessed++
} catch (error) {
failed++
this.stats.totalFailed++
await this.handleFailedEvent(event, error as Error)
}
}
}
this.stats.bufferSize = this.buffer.length
return {
processed,
failed,
duration: Date.now() - startTime
}
}
/**
* Pause processing
*/
pause(): void {
this.paused = true
this.emit('pause')
}
/**
* Resume processing
*/
resume(): void {
this.paused = false
this.emit('resume')
void this.processNext()
}
/**
* Check if paused
*/
isPaused(): boolean {
return this.paused
}
/**
* Clear the buffer
*/
clear(): void {
this.buffer = []
this.stats.bufferSize = 0
this.emit('clear')
}
/**
* Return dead letter queue events
*/
getDeadLetterQueue(): StreamEvent[] {
return [...this.deadLetterQueue]
}
/**
* Clear dead letter queue
*/
clearDeadLetterQueue(): void {
this.deadLetterQueue = []
this.stats.deadLetterQueueSize = 0
}
/**
* Replay dead letter queue events
*/
async replayDeadLetterQueue(): Promise<BatchResult> {
const events = this.deadLetterQueue.splice(0)
this.stats.deadLetterQueueSize = 0
let processed = 0
let failed = 0
const startTime = Date.now()
for (const event of events) {
// Reset retry count
event.retryCount = 0
delete event.metadata?.error
delete event.metadata?.errorStack
delete event.metadata?.movedToDlqAt
try {
await this.processEvent(event)
processed++
} catch (error) {
failed++
this.addToDeadLetterQueue(event, error as Error)
}
}
return {
processed,
failed,
duration: Date.now() - startTime
}
}
/**
* Return statistics
*/
getStats(): EventStreamStats {
return { ...this.stats }
}
/**
* Reset statistics
*/
resetStats(): void {
this.stats = this.createInitialStats()
this.stats.bufferSize = this.buffer.length
this.stats.deadLetterQueueSize = this.deadLetterQueue.length
}
/**
* Destroy and clean up resources
*/
destroy(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer)
}
this.buffer = []
this.deadLetterQueue = []
this.handlers.clear()
this.filters = []
this.transformers = []
this.removeAllListeners()
}
}
/**
* Factory to create event stream
*/
export function createEventStream(options?: EventStreamOptions): BaileysEventStream {
return new BaileysEventStream(options)
}
/**
* Pre-defined filters
*/
export const eventFilters = {
/** Filter by event type */
byType:
(...types: BaileysEventType[]): EventFilter =>
event =>
types.includes(event.type),
/** Filter by category */
byCategory:
(...categories: BaileysLogCategory[]): EventFilter =>
event =>
categories.includes(event.category),
/** Filter by minimum priority */
byMinPriority:
(minPriority: EventPriority): EventFilter =>
event =>
PRIORITY_VALUES[event.priority] <= PRIORITY_VALUES[minPriority],
/** Filter recent events (within ms) */
recentOnly:
(maxAgeMs: number): EventFilter =>
event =>
Date.now() - event.timestamp <= maxAgeMs,
/** Combine filters with AND */
and:
(...filters: EventFilter[]): EventFilter =>
event =>
filters.every(f => f(event)),
/** Combine filters with OR */
or:
(...filters: EventFilter[]): EventFilter =>
event =>
filters.some(f => f(event))
}
/**
* Pre-defined transformers
*/
export const eventTransformers = {
/** Add processing timestamp */
addProcessingTimestamp: (): EventTransformer => event => ({
...event,
metadata: {
...event.metadata,
processingTimestamp: Date.now()
}
}),
/** Add trace ID */
addTraceId:
(traceIdGenerator: () => string): EventTransformer =>
event => ({
...event,
metadata: {
...event.metadata,
traceId: traceIdGenerator()
}
}),
/** Elevate priority based on condition */
elevatepriorityIf:
(condition: (event: StreamEvent) => boolean, newPriority: EventPriority): EventTransformer =>
event =>
condition(event) ? { ...event, priority: newPriority } : event
}
export default BaileysEventStream
File diff suppressed because it is too large Load Diff
+16 -381
View File
@@ -1,396 +1,31 @@
import { existsSync, readFileSync } from 'fs'
import { platform, release } from 'os'
import { proto } from '../../WAProto/index.js'
import type { BrowsersMap } from '../Types'
// ============================================================
// Constants
// ============================================================
/**
* Default platform name when OS detection fails or returns unsupported value
*/
const DEFAULT_PLATFORM_NAME = 'Ubuntu' as const
/**
* Default platform ID (Chrome = 1) for unknown browser mappings
* @see proto.DeviceProps.PlatformType
*/
const DEFAULT_PLATFORM_ID = '1' as const
/**
* Default OS version fallback when detection fails
*/
const DEFAULT_OS_VERSION = '1.0.0' as const
/**
* Fallback versions used when automatic detection fails.
* These should be updated periodically to reflect current OS versions.
* Last updated: 2025-01
*/
const FALLBACK_VERSIONS = {
ubuntu: '24.04.1',
macOS: '15.2',
windows: '10.0.26100',
baileys: '6.5.0'
} as const
/**
* Maps Darwin kernel versions to macOS marketing versions.
* Darwin 24.x = macOS 15.x (Sequoia)
* Darwin 23.x = macOS 14.x (Sonoma)
* Darwin 22.x = macOS 13.x (Ventura)
* Darwin 21.x = macOS 12.x (Monterey)
* Darwin 20.x = macOS 11.x (Big Sur)
*/
const DARWIN_TO_MACOS: Readonly<Record<number, number>> = {
24: 15, // Sequoia
23: 14, // Sonoma
22: 13, // Ventura
21: 12, // Monterey
20: 11, // Big Sur
19: 10 // Catalina (10.15)
} as const
/**
* Maps Node.js platform identifiers to WhatsApp-recognized platform names.
* Values of `undefined` will fall back to DEFAULT_PLATFORM_NAME.
*/
const PLATFORM_MAP: Readonly<Record<NodeJS.Platform, string | undefined>> = {
const PLATFORM_MAP = {
aix: 'AIX',
android: 'Android',
cygwin: undefined,
darwin: 'Mac OS',
win32: 'Windows',
android: 'Android',
freebsd: 'FreeBSD',
haiku: undefined,
linux: undefined,
netbsd: undefined,
openbsd: 'OpenBSD',
sunos: 'Solaris',
win32: 'Windows'
} as const
// ============================================================
// Platform Type Resolution
// ============================================================
/**
* Pre-computed map of browser names to platform IDs.
* Built once at module load to avoid repeated proto access.
*
* Note: Protobuf enums have bidirectional mappings (namevalue and valuename).
* We filter to only include string keys with numeric values.
*/
const BROWSER_TO_PLATFORM_ID: ReadonlyMap<string, string> = (() => {
const platformType = proto.DeviceProps?.PlatformType
if (!platformType || typeof platformType !== 'object') {
return new Map<string, string>()
}
const entries: Array<[string, string]> = []
for (const [key, value] of Object.entries(platformType)) {
if (typeof value === 'number' && typeof key === 'string' && !/^\d+$/.test(key)) {
entries.push([key.toUpperCase(), value.toString()])
}
}
return new Map(entries)
})()
// ============================================================
// Version Detection Functions
// ============================================================
/**
* Detects the Linux distribution version by reading /etc/os-release.
* This file is standard on most modern Linux distributions.
*
* @returns The VERSION_ID from os-release, or fallback version
*
* @example
* // On Ubuntu 24.04:
* getLinuxVersion() // Returns '24.04' or '24.04.1'
*/
const getLinuxVersion = (): string => {
try {
const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release']
for (const filePath of osReleasePaths) {
if (existsSync(filePath)) {
const content = readFileSync(filePath, 'utf-8')
// Try VERSION_ID first (e.g., "24.04")
const versionIdMatch = content.match(/^VERSION_ID\s*=\s*"?([^"\n]+)"?/m)
if (versionIdMatch?.[1]) {
return versionIdMatch[1]
}
// Fallback to VERSION (e.g., "24.04.1 LTS (Noble Numbat)")
const versionMatch = content.match(/^VERSION\s*=\s*"?([0-9][0-9.]*)/m)
if (versionMatch?.[1]) {
return versionMatch[1]
}
}
}
} catch {
// Silently fail and use fallback
}
return FALLBACK_VERSIONS.ubuntu
linux: undefined,
haiku: undefined,
cygwin: undefined,
netbsd: undefined
}
/**
* Converts Darwin kernel version to macOS marketing version.
* Darwin versions map to macOS versions with an offset.
*
* @returns macOS version string (e.g., '15.2')
*
* @example
* // On macOS Sequoia with Darwin 24.2.0:
* getMacOSVersion() // Returns '15.2'
*/
const getMacOSVersion = (): string => {
try {
const darwinVersion = release() // e.g., '24.2.0'
const parts = darwinVersion.split('.')
const majorVersionStr = parts[0]
const minorVersionStr = parts[1]
if (!majorVersionStr) {
return FALLBACK_VERSIONS.macOS
}
const majorVersion = parseInt(majorVersionStr, 10)
if (isNaN(majorVersion)) {
return FALLBACK_VERSIONS.macOS
}
const minorVersion = minorVersionStr || '0'
const macOSMajor = DARWIN_TO_MACOS[majorVersion]
if (macOSMajor === undefined) {
// For newer Darwin versions, estimate macOS version
// Darwin 24 = macOS 15, so offset is 9
const estimatedMajor = majorVersion >= 20 ? majorVersion - 9 : 10
return `${estimatedMajor}.${minorVersion}`
}
// Special case: macOS 10.x (Catalina and earlier)
if (macOSMajor === 10) {
return `10.15.${minorVersion}`
}
return `${macOSMajor}.${minorVersion}`
} catch {
return FALLBACK_VERSIONS.macOS
}
}
/**
* Gets Windows version directly from os.release().
* Windows correctly reports version (e.g., '10.0.22631').
*
* @returns Windows version string
*/
const getWindowsVersion = (): string => {
try {
const version = release()
// Validate it looks like a Windows version (X.X.XXXXX)
if (/^\d+\.\d+\.\d+$/.test(version)) {
return version
}
} catch {
// Silently fail
}
return FALLBACK_VERSIONS.windows
}
/**
* Detects the current OS version automatically based on the platform.
* Uses platform-specific methods for accurate version detection.
*
* @returns Object containing detected versions for all platforms
*/
const detectOSVersions = (): Readonly<{
ubuntu: string
macOS: string
windows: string
baileys: string
}> => {
const currentPlatform = (() => {
try {
return platform()
} catch {
return 'linux' as NodeJS.Platform
}
})()
// Detect versions based on current platform
// For non-matching platforms, use fallback values
return {
ubuntu: currentPlatform === 'linux' ? getLinuxVersion() : FALLBACK_VERSIONS.ubuntu,
macOS: currentPlatform === 'darwin' ? getMacOSVersion() : FALLBACK_VERSIONS.macOS,
windows: currentPlatform === 'win32' ? getWindowsVersion() : FALLBACK_VERSIONS.windows,
baileys: FALLBACK_VERSIONS.baileys
}
}
/**
* Cached OS versions, detected once at module load.
* This ensures consistent versions throughout the application lifecycle.
*/
const OS_VERSIONS = detectOSVersions()
// ============================================================
// Helper Functions
// ============================================================
/**
* Resolves the current platform name for WhatsApp device identification.
* Uses the OS platform detection with a fallback to Ubuntu.
*
* @returns Platform name string (never undefined)
*/
const getPlatformName = (): string => {
try {
const currentPlatform = platform()
return PLATFORM_MAP[currentPlatform] ?? DEFAULT_PLATFORM_NAME
} catch {
return DEFAULT_PLATFORM_NAME
}
}
/**
* Normalizes a browser identifier for platform type lookup.
*
* @param browser - The browser identifier to normalize (accepts any type for runtime safety)
* @returns Uppercase trimmed string, or null if input is invalid
*/
const normalizeBrowserKey = (browser: unknown): string | null => {
if (typeof browser !== 'string') {
return null
}
const normalized = browser.trim()
return normalized.length > 0 ? normalized.toUpperCase() : null
}
/**
* Gets the appropriate OS version for the current platform.
* Uses automatic detection with fallback to sensible defaults.
*
* @returns OS version string appropriate for the current platform
*/
const getAppropriateVersion = (): string => {
try {
const currentPlatform = platform()
switch (currentPlatform) {
case 'darwin':
return OS_VERSIONS.macOS
case 'win32':
return OS_VERSIONS.windows
case 'linux':
return OS_VERSIONS.ubuntu
default:
// For other platforms, try os.release() directly
const version = release()
return version || DEFAULT_OS_VERSION
}
} catch {
return DEFAULT_OS_VERSION
}
}
// ============================================================
// Exported Constants
// ============================================================
/**
* Browser configuration presets for WhatsApp device registration.
* Each factory returns a tuple of [platform, browser, version].
*
* Versions are automatically detected at module load:
* - Linux: Reads /etc/os-release for distribution version
* - macOS: Converts Darwin kernel version to macOS version
* - Windows: Uses os.release() directly
*
* @example
* // Use Ubuntu preset with auto-detected version
* const config = Browsers.ubuntu('Chrome')
* // Returns: ['Ubuntu', 'Chrome', '24.04.1'] (version detected automatically)
*
* @example
* // Use automatic platform and version detection
* const config = Browsers.appropriate('MyApp')
* // Returns: ['Mac OS', 'MyApp', '15.2'] (on macOS Sequoia)
*/
export const Browsers: BrowsersMap = {
ubuntu: (browser: string): [string, string, string] => ['Ubuntu', browser, OS_VERSIONS.ubuntu],
macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS],
windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows],
baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys],
appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()]
} as const
/**
* Exposed OS versions for debugging and logging purposes.
* These are the versions that will be used by the Browsers presets.
*/
export const detectedOSVersions = OS_VERSIONS
// ============================================================
// Exported Functions
// ============================================================
/**
* Resolves the platform type ID for a given browser name.
* Uses the WhatsApp protocol buffer definitions for mapping.
*
* This function safely handles invalid inputs (null, undefined, non-strings)
* by returning the default Chrome platform ID.
*
* @param browser - Browser identifier (e.g., 'chrome', 'firefox', 'safari').
* Accepts unknown types for runtime safety.
* @returns Platform type ID as string (defaults to '1' for Chrome)
*
* @example
* getPlatformId('chrome') // Returns '1'
* getPlatformId('CHROME') // Returns '1' (case-insensitive)
* getPlatformId('firefox') // Returns platform-specific ID
* getPlatformId('') // Returns '1' (default)
*/
export const getPlatformId = (browser: unknown): string => {
const key = normalizeBrowserKey(browser)
if (key === null) {
return DEFAULT_PLATFORM_ID
}
return BROWSER_TO_PLATFORM_ID.get(key) ?? DEFAULT_PLATFORM_ID
ubuntu: browser => ['Ubuntu', browser, '22.04.4'],
macOS: browser => ['Mac OS', browser, '14.4.1'],
baileys: browser => ['Baileys', browser, '6.5.0'],
windows: browser => ['Windows', browser, '10.0.22631'],
/** The appropriate browser based on your OS & release */
appropriate: browser => [PLATFORM_MAP[platform()] || 'Ubuntu', browser, release()]
}
/**
* Type guard to check if a value is a valid browser preset key.
* Useful for external validation before using Browsers presets.
*
* Uses Object.prototype.hasOwnProperty to avoid matching inherited
* properties like 'toString' or 'constructor'.
*
* @param value - Value to check
* @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate')
*
* @example
* isValidBrowserPreset('ubuntu') // true
* isValidBrowserPreset('macOS') // true
* isValidBrowserPreset('invalid') // false
* isValidBrowserPreset('toString') // false (inherited property)
*
* @example
* if (isValidBrowserPreset(userInput)) {
* const config = Browsers[userInput]('MyApp')
* }
*/
export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value)
export const getPlatformId = (browser: string) => {
const platformType = proto.DeviceProps.PlatformType[browser.toUpperCase() as any]
return platformType ? platformType.toString() : '1' //chrome
}
-524
View File
@@ -1,524 +0,0 @@
/**
* Smart Cache System
*
* Provides:
* - In-memory cache with configurable TTL
* - Automatic and manual invalidation
* - Hit/miss metrics
* - LRU (Least Recently Used) strategy
* - Distributed cache (prepared for Redis)
* - Namespace for isolation
* - Customizable serialization
*
* @module Utils/cache-utils
*/
import { LRUCache } from 'lru-cache'
import { metrics } from './prometheus-metrics.js'
/**
* Cache configuration options
*/
export interface CacheOptions<V> {
/** Time to live in ms (default: 5 minutes) */
ttl?: number
/** Maximum cache size (default: 1000) */
maxSize?: number
/** Function to calculate item size */
sizeCalculation?: (value: V) => number
/** Whether to update TTL on access */
updateAgeOnGet?: boolean
/** Namespace for isolation */
namespace?: string
/** Callback when item expires */
onExpire?: (key: string, value: V) => void
/** Whether to collect metrics */
collectMetrics?: boolean
/** Cache name for metrics */
metricName?: string
}
/**
* Cache statistics
*/
export interface CacheStats {
hits: number
misses: number
size: number
maxSize: number
hitRate: number
}
/**
* Cache item with metadata
*/
export interface CacheItem<V> {
value: V
createdAt: number
expiresAt: number
accessCount: number
lastAccess: number
}
/**
* Cache operation result
*/
export interface CacheResult<V> {
value: V | undefined
hit: boolean
expired?: boolean
key: string
}
/**
* Main Cache class
*/
export class Cache<V = unknown> {
private cache: LRUCache<string, CacheItem<V>>
private options: Required<CacheOptions<V>>
private stats: { hits: number; misses: number }
private namespace: string
constructor(options: CacheOptions<V> = {}) {
this.options = {
ttl: options.ttl ?? 5 * 60 * 1000, // 5 minutos
maxSize: options.maxSize ?? 1000,
sizeCalculation: options.sizeCalculation ?? (() => 1),
updateAgeOnGet: options.updateAgeOnGet ?? false,
namespace: options.namespace ?? 'default',
onExpire: options.onExpire ?? (() => {}),
collectMetrics: options.collectMetrics ?? true,
metricName: options.metricName ?? 'cache'
}
this.namespace = this.options.namespace
this.stats = { hits: 0, misses: 0 }
this.cache = new LRUCache<string, CacheItem<V>>({
maxSize: this.options.maxSize,
ttl: this.options.ttl,
updateAgeOnGet: this.options.updateAgeOnGet,
sizeCalculation: item => this.options.sizeCalculation(item.value),
dispose: (value, key) => {
this.options.onExpire(key, value.value)
}
})
}
/**
* Get value from cache
*/
get(key: string): V | undefined {
const fullKey = this.getFullKey(key)
const item = this.cache.get(fullKey)
if (item) {
this.stats.hits++
item.accessCount++
item.lastAccess = Date.now()
if (this.options.collectMetrics) {
metrics.cacheHits.inc({ cache: this.options.metricName })
}
return item.value
}
this.stats.misses++
if (this.options.collectMetrics) {
metrics.cacheMisses.inc({ cache: this.options.metricName })
}
return undefined
}
/**
* Get value with detailed result
*/
getWithResult(key: string): CacheResult<V> {
const fullKey = this.getFullKey(key)
const item = this.cache.get(fullKey)
if (item) {
this.stats.hits++
item.accessCount++
item.lastAccess = Date.now()
if (this.options.collectMetrics) {
metrics.cacheHits.inc({ cache: this.options.metricName })
}
return {
value: item.value,
hit: true,
key
}
}
this.stats.misses++
if (this.options.collectMetrics) {
metrics.cacheMisses.inc({ cache: this.options.metricName })
}
return {
value: undefined,
hit: false,
key
}
}
/**
* Set value in cache
*/
set(key: string, value: V, ttl?: number): void {
const fullKey = this.getFullKey(key)
const now = Date.now()
const itemTtl = ttl ?? this.options.ttl
const item: CacheItem<V> = {
value,
createdAt: now,
expiresAt: now + itemTtl,
accessCount: 0,
lastAccess: now
}
this.cache.set(fullKey, item, { ttl: itemTtl })
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
}
}
/**
* Check if key exists
*/
has(key: string): boolean {
const fullKey = this.getFullKey(key)
return this.cache.has(fullKey)
}
/**
* Remove item from cache
*/
delete(key: string): boolean {
const fullKey = this.getFullKey(key)
const result = this.cache.delete(fullKey)
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
}
return result
}
/**
* Clear the entire cache
*/
clear(): void {
this.cache.clear()
this.stats = { hits: 0, misses: 0 }
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, 0)
}
}
/**
* Get or set value (cache-aside pattern)
*/
async getOrSet(key: string, factory: () => V | Promise<V>, ttl?: number): Promise<V> {
const existing = this.get(key)
if (existing !== undefined) {
return existing
}
const value = await factory()
this.set(key, value, ttl)
return value
}
/**
* Get or set value synchronously
*/
getOrSetSync(key: string, factory: () => V, ttl?: number): V {
const existing = this.get(key)
if (existing !== undefined) {
return existing
}
const value = factory()
this.set(key, value, ttl)
return value
}
/**
* Invalidate items by pattern
*/
invalidateByPattern(pattern: RegExp): number {
let count = 0
const prefix = `${this.namespace}:`
for (const key of this.cache.keys()) {
const shortKey = key.startsWith(prefix) ? key.slice(prefix.length) : key
if (pattern.test(shortKey)) {
this.cache.delete(key)
count++
}
}
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
}
return count
}
/**
* Invalidate items by prefix
*/
invalidateByPrefix(prefix: string): number {
return this.invalidateByPattern(new RegExp(`^${prefix}`))
}
/**
* Return cache statistics
*/
getStats(): CacheStats {
const total = this.stats.hits + this.stats.misses
return {
hits: this.stats.hits,
misses: this.stats.misses,
size: this.cache.size,
maxSize: this.options.maxSize,
hitRate: total > 0 ? this.stats.hits / total : 0
}
}
/**
* Return current size
*/
get size(): number {
return this.cache.size
}
/**
* Return all keys
*/
keys(): string[] {
const prefix = `${this.namespace}:`
return Array.from(this.cache.keys()).map(k => (k.startsWith(prefix) ? k.slice(prefix.length) : k))
}
/**
* Return all values
*/
values(): V[] {
return Array.from(this.cache.values()).map(item => item.value)
}
/**
* Return all items with metadata
*/
entries(): Array<{ key: string; item: CacheItem<V> }> {
const prefix = `${this.namespace}:`
return Array.from(this.cache.entries()).map(([key, item]) => ({
key: key.startsWith(prefix) ? key.slice(prefix.length) : key,
item
}))
}
/**
* Update TTL of an item
*/
touch(key: string, ttl?: number): boolean {
const fullKey = this.getFullKey(key)
const item = this.cache.get(fullKey)
if (!item) {
return false
}
const newTtl = ttl ?? this.options.ttl
item.expiresAt = Date.now() + newTtl
this.cache.set(fullKey, item, { ttl: newTtl })
return true
}
/**
* Get expired item (if still in memory)
*/
peek(key: string): V | undefined {
const fullKey = this.getFullKey(key)
const item = this.cache.peek(fullKey)
return item?.value
}
private getFullKey(key: string): string {
return `${this.namespace}:${key}`
}
}
/**
* Factory to create typed cache
*/
export function createCache<V>(options?: CacheOptions<V>): Cache<V> {
return new Cache<V>(options)
}
/**
* Multi-level cache (L1: memory, L2: external)
*/
export class MultiLevelCache<V> {
private l1: Cache<V>
private l2?: {
get: (key: string) => Promise<V | undefined>
set: (key: string, value: V, ttl?: number) => Promise<void>
delete: (key: string) => Promise<boolean>
}
constructor(
l1Options: CacheOptions<V>,
l2?: {
get: (key: string) => Promise<V | undefined>
set: (key: string, value: V, ttl?: number) => Promise<void>
delete: (key: string) => Promise<boolean>
}
) {
this.l1 = new Cache<V>(l1Options)
this.l2 = l2
}
async get(key: string): Promise<V | undefined> {
// Try L1 first
const l1Value = this.l1.get(key)
if (l1Value !== undefined) {
return l1Value
}
// Try L2 if available
if (this.l2) {
const l2Value = await this.l2.get(key)
if (l2Value !== undefined) {
// Promote to L1
this.l1.set(key, l2Value)
return l2Value
}
}
return undefined
}
async set(key: string, value: V, ttl?: number): Promise<void> {
this.l1.set(key, value, ttl)
if (this.l2) {
await this.l2.set(key, value, ttl)
}
}
async delete(key: string): Promise<boolean> {
const l1Result = this.l1.delete(key)
let l2Result = false
if (this.l2) {
l2Result = await this.l2.delete(key)
}
return l1Result || l2Result
}
getL1(): Cache<V> {
return this.l1
}
}
/**
* Decorator to cache method result
*/
export function cached<V>(options: CacheOptions<V> & { keyGenerator?: (...args: unknown[]) => string } = {}) {
const cache = new Cache<V>(options)
const keyGenerator = options.keyGenerator ?? ((...args) => JSON.stringify(args))
return function (
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => V | Promise<V>>
) {
const originalMethod = descriptor.value
if (!originalMethod) return descriptor
descriptor.value = async function (...args: unknown[]): Promise<V> {
const key = `${propertyKey}:${keyGenerator(...args)}`
const cachedValue = cache.get(key)
if (cachedValue !== undefined) {
return cachedValue
}
const result = await originalMethod.apply(this, args)
cache.set(key, result)
return result
}
return descriptor
}
}
/**
* Wrapper for function with cache
*/
// eslint-disable-next-line space-before-function-paren
export function withCache<T extends (...args: unknown[]) => unknown>(
fn: T,
options: CacheOptions<ReturnType<T>> & { keyGenerator?: (...args: Parameters<T>) => string } = {}
): T {
const cache = new Cache<ReturnType<T>>(options)
const keyGenerator = options.keyGenerator ?? ((...args) => JSON.stringify(args))
return ((...args: Parameters<T>): ReturnType<T> => {
const key = keyGenerator(...args)
const cachedValue = cache.get(key)
if (cachedValue !== undefined) {
return cachedValue as ReturnType<T>
}
const result = fn(...args) as ReturnType<T>
if (result instanceof Promise) {
return result.then(value => {
cache.set(key, value as ReturnType<T>)
return value
}) as ReturnType<T>
}
cache.set(key, result)
return result
}) as T
}
/**
* Global singleton cache by namespace
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const globalCaches: Map<string, Cache<any>> = new Map()
export function getGlobalCache<V>(namespace: string, options?: CacheOptions<V>): Cache<V> {
if (!globalCaches.has(namespace)) {
globalCaches.set(namespace, new Cache<V>({ ...options, namespace }))
}
return globalCaches.get(namespace) as Cache<V>
}
export function clearGlobalCaches(): void {
for (const cache of globalCaches.values()) {
cache.clear()
}
globalCaches.clear()
}
export default Cache
+78 -225
View File
@@ -1,5 +1,5 @@
/* eslint-disable eqeqeq */
import { Boom } from '@hapi/boom'
import { expandAppStateKeys } from 'whatsapp-rust-bridge'
import { proto } from '../../WAProto/index.js'
import type {
BaileysEventEmitter,
@@ -26,14 +26,20 @@ import type { ILogger } from './logger'
import { LT_HASH_ANTI_TAMPERING } from './lt-hash'
import { downloadContentFromMessage } from './messages-media'
import { emitSyncActionResults, processContactAction } from './sync-action-utils'
import { expandAppStateKeys } from './wasm-bridge'
type FetchAppStateSyncKey = (keyId: string) => Promise<proto.Message.IAppStateSyncKeyData | null | undefined>
export type ChatMutationMap = { [index: string]: ChatMutation }
const mutationKeys = (keydata: Uint8Array) => {
return expandAppStateKeys(keydata)
const keys = expandAppStateKeys(keydata)
return {
indexKey: keys.indexKey,
valueEncryptionKey: keys.valueEncryptionKey,
valueMacKey: keys.valueMacKey,
snapshotMacKey: keys.snapshotMacKey,
patchMacKey: keys.patchMacKey
}
}
const generateMac = (
@@ -42,30 +48,22 @@ const generateMac = (
keyId: Uint8Array | string,
key: Uint8Array
) => {
const getKeyData = () => {
let r: number
switch (operation) {
case proto.SyncdMutation.SyncdOperation.SET:
r = 0x01
break
case proto.SyncdMutation.SyncdOperation.REMOVE:
r = 0x02
break
}
const opByte = operation === proto.SyncdMutation.SyncdOperation.SET ? 0x01 : 0x02
const keyIdBuffer = typeof keyId === 'string' ? Buffer.from(keyId, 'base64') : keyId
const keyData = new Uint8Array(1 + keyIdBuffer.length)
keyData[0] = opByte
keyData.set(keyIdBuffer, 1)
const buff = Buffer.from([r])
return Buffer.concat([buff, Buffer.from(keyId as string, 'base64')])
}
const last = new Uint8Array(8)
last[7] = keyData.length
const keyData = getKeyData()
const total = new Uint8Array(keyData.length + data.length + last.length)
total.set(keyData, 0)
total.set(data, keyData.length)
total.set(last, keyData.length + data.length)
const last = Buffer.alloc(8) // 8 bytes
last.set([keyData.length], last.length - 1)
const total = Buffer.concat([keyData, data, last])
const hmac = hmacSign(total, key, 'sha512')
return hmac.slice(0, 32)
return hmac.subarray(0, 32)
}
const to64BitNetworkOrder = (e: number) => {
@@ -76,7 +74,7 @@ const to64BitNetworkOrder = (e: number) => {
type Mac = { indexMac: Uint8Array; valueMac: Uint8Array; operation: proto.SyncdMutation.SyncdOperation }
export const makeLtHashGenerator = ({ indexValueMap, hash }: Pick<LTHashState, 'hash' | 'indexValueMap'>) => {
const makeLtHashGenerator = ({ indexValueMap, hash }: Pick<LTHashState, 'hash' | 'indexValueMap'>) => {
indexValueMap = { ...indexValueMap }
const addBuffs: Uint8Array[] = []
const subBuffs: Uint8Array[] = []
@@ -87,26 +85,23 @@ export const makeLtHashGenerator = ({ indexValueMap, hash }: Pick<LTHashState, '
const prevOp = indexValueMap[indexMacBase64]
if (operation === proto.SyncdMutation.SyncdOperation.REMOVE) {
if (!prevOp) {
// WA Web does not throw here — it logs a warning and skips the subtract.
// The missing REMOVE will cause an LTHash mismatch, which is handled
// by the MAC validation layer (snapshot recovery or retry).
return
throw new Boom('tried remove, but no previous op', { data: { indexMac, valueMac } })
}
// remove from index value mac, since this mutation is erased
delete indexValueMap[indexMacBase64]
} else {
addBuffs.push(new Uint8Array(valueMac))
addBuffs.push(valueMac)
// add this index into the history map
indexValueMap[indexMacBase64] = { valueMac }
}
if (prevOp) {
subBuffs.push(new Uint8Array(prevOp.valueMac))
subBuffs.push(prevOp.valueMac as Uint8Array)
}
},
finish: () => {
const result = LT_HASH_ANTI_TAMPERING().subtractThenAdd(new Uint8Array(hash), addBuffs, subBuffs)
const result = LT_HASH_ANTI_TAMPERING.subtractThenAdd(hash, subBuffs, addBuffs)
return {
hash: Buffer.from(result),
@@ -134,34 +129,6 @@ const generatePatchMac = (
export const newLTHashState = (): LTHashState => ({ version: 0, hash: Buffer.alloc(128), indexValueMap: {} })
export const ensureLTHashStateVersion = (state: LTHashState): LTHashState => {
if (typeof state.version !== 'number' || isNaN(state.version)) {
state.version = 0
}
return state
}
export const MAX_SYNC_ATTEMPTS = 2
/**
* Check if an error is a missing app state sync key.
* WA Web treats these as "Blocked" (waits for key arrival), not fatal.
* In Baileys we retry with a snapshot which may use a different key.
*/
export const isMissingKeyError = (error: any): boolean => {
return error?.data?.isMissingKey === true
}
/**
* Determines if an app state sync error is unrecoverable.
* TypeError indicates a WASM crash; otherwise we give up after MAX_SYNC_ATTEMPTS.
* Missing keys are NOT checked here they are handled separately as "Blocked".
*/
export const isAppStateSyncIrrecoverable = (error: any, attempts: number): boolean => {
return attempts >= MAX_SYNC_ATTEMPTS || error?.name === 'TypeError'
}
export const encodeSyncdPatch = async (
{ type, index, syncAction, apiVersion, operation }: WAPatchCreate,
myAppStateKeyId: string,
@@ -170,7 +137,7 @@ export const encodeSyncdPatch = async (
) => {
const key = !!myAppStateKeyId ? await getAppStateSyncKey(myAppStateKeyId) : undefined
if (!key) {
throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { data: { isMissingKey: true } })
throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { statusCode: 404 })
}
const encKeyId = Buffer.from(myAppStateKeyId, 'base64')
@@ -186,12 +153,7 @@ export const encodeSyncdPatch = async (
})
const encoded = proto.SyncActionData.encode(dataProto).finish()
const keyData = key.keyData
if (!keyData) {
throw new Boom('Missing keyData for encoding', { statusCode: 500 })
}
const keyValue = mutationKeys(keyData)
const keyValue = mutationKeys(key.keyData!)
const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey)
const valueMac = generateMac(operation, encValue, encKeyId, keyValue.valueMacKey)
@@ -240,6 +202,8 @@ export const decodeSyncdMutations = async (
validateMacs: boolean
) => {
const ltGenerator = makeLtHashGenerator(initialState)
const derivedKeyCache = new Map<string, ReturnType<typeof mutationKeys>>()
// indexKey used to HMAC sign record.index.blob
// valueEncryptionKey used to AES-256-CBC encrypt record.value.blob[0:-32]
// the remaining record.value.blob[0:-32] is the mac, it the HMAC sign of key.keyId + decoded proto data + length of bytes in keyId
@@ -247,35 +211,15 @@ export const decodeSyncdMutations = async (
// if it's a syncdmutation, get the operation property
// otherwise, if it's only a record -- it'll be a SET mutation
const operation = 'operation' in msgMutation ? msgMutation.operation : proto.SyncdMutation.SyncdOperation.SET
if (operation == null) {
throw new Boom('Missing operation in mutation', { statusCode: 500 })
}
const record =
'record' in msgMutation && !!msgMutation.record ? msgMutation.record : (msgMutation as proto.ISyncdRecord)
const keyIdBuf = record.keyId?.id
if (!keyIdBuf) {
throw new Boom('Missing keyId in record', { statusCode: 500 })
}
const recordBlob = record.value?.blob
if (!recordBlob) {
throw new Boom('Missing record blob', { statusCode: 500 })
}
const indexBlob = record.index?.blob
if (!indexBlob) {
throw new Boom('Missing index blob in record', { statusCode: 500 })
}
const key = await getKey(keyIdBuf)
const content = Buffer.from(recordBlob)
const encContent = content.slice(0, -32)
const ogValueMac = content.slice(-32)
const key = await getKey(record.keyId!.id!)
const content = record.value!.blob!
const encContent = content.subarray(0, -32)
const ogValueMac = content.subarray(-32)
if (validateMacs) {
const contentHmac = generateMac(operation, encContent, keyIdBuf, key.valueMacKey)
const contentHmac = generateMac(operation!, encContent, record.keyId!.id!, key.valueMacKey)
if (Buffer.compare(contentHmac, ogValueMac) !== 0) {
throw new Boom('HMAC content verification failed')
}
@@ -284,25 +228,20 @@ export const decodeSyncdMutations = async (
const result = aesDecrypt(encContent, key.valueEncryptionKey)
const syncAction = proto.SyncActionData.decode(result)
const syncActionIndex = syncAction.index
if (!syncActionIndex) {
throw new Boom('Missing index in sync action data', { statusCode: 500 })
}
if (validateMacs) {
const hmac = hmacSign(syncActionIndex, key.indexKey)
if (Buffer.compare(hmac, indexBlob) !== 0) {
const hmac = hmacSign(syncAction.index!, key.indexKey)
if (Buffer.compare(hmac, record.index!.blob!) !== 0) {
throw new Boom('HMAC index verification failed')
}
}
const indexStr = Buffer.from(syncActionIndex).toString()
const indexStr = Buffer.from(syncAction.index!).toString()
onMutation({ syncAction, index: JSON.parse(indexStr) })
ltGenerator.mix({
indexMac: indexBlob,
indexMac: record.index!.blob!,
valueMac: ogValueMac,
operation: operation
operation: operation!
})
}
@@ -310,19 +249,23 @@ export const decodeSyncdMutations = async (
async function getKey(keyId: Uint8Array) {
const base64Key = Buffer.from(keyId).toString('base64')
const cached = derivedKeyCache.get(base64Key)
if (cached) {
return cached
}
const keyEnc = await getAppStateSyncKey(base64Key)
if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, {
data: { isMissingKey: true, msgMutations }
statusCode: 404,
data: { msgMutations }
})
}
const keyData = keyEnc.keyData
if (!keyData) {
throw new Boom('Missing keyData in app state sync key', { statusCode: 500 })
}
return mutationKeys(keyData)
const keys = mutationKeys(keyEnc.keyData!)
derivedKeyCache.set(base64Key, keys)
return keys
}
}
@@ -335,66 +278,28 @@ export const decodeSyncdPatch = async (
validateMacs: boolean
) => {
if (validateMacs) {
const msgKeyId = msg.keyId?.id
if (!msgKeyId) {
throw new Boom('Missing keyId in patch message', { statusCode: 500 })
}
const base64Key = Buffer.from(msgKeyId).toString('base64')
const base64Key = Buffer.from(msg.keyId!.id!).toString('base64')
const mainKeyObj = await getAppStateSyncKey(base64Key)
if (!mainKeyObj) {
throw new Boom(`failed to find key "${base64Key}" to decode patch`, { data: { isMissingKey: true, msg } })
throw new Boom(`failed to find key "${base64Key}" to decode patch`, { statusCode: 404, data: { msg } })
}
const mainKeyData = mainKeyObj.keyData
if (!mainKeyData) {
throw new Boom('Missing keyData in main key object', { statusCode: 500 })
}
const mainKey = mutationKeys(mainKeyObj.keyData!)
const mutationmacs = msg.mutations!.map(mutation => mutation.record!.value!.blob!.slice(-32))
const mainKey = mutationKeys(mainKeyData)
const msgMutations = msg.mutations
if (!msgMutations) {
throw new Boom('Missing mutations in patch message', { statusCode: 500 })
}
const mutationmacs = msgMutations.map(mutation => {
const mutBlob = mutation.record?.value?.blob
if (!mutBlob) {
throw new Boom('Missing blob in mutation record', { statusCode: 500 })
}
return mutBlob.slice(-32)
})
const msgSnapshotMac = msg.snapshotMac
if (!msgSnapshotMac) {
throw new Boom('Missing snapshotMac in patch message', { statusCode: 500 })
}
const msgVersion = msg.version?.version
if (msgVersion == null) {
throw new Boom('Missing version in patch message', { statusCode: 500 })
}
const msgPatchMac = msg.patchMac
if (!msgPatchMac) {
throw new Boom('Missing patchMac in patch message', { statusCode: 500 })
}
const patchMac = generatePatchMac(msgSnapshotMac, mutationmacs, toNumber(msgVersion), name, mainKey.patchMacKey)
if (Buffer.compare(patchMac, msgPatchMac) !== 0) {
const patchMac = generatePatchMac(
msg.snapshotMac!,
mutationmacs,
toNumber(msg.version!.version),
name,
mainKey.patchMacKey
)
if (Buffer.compare(patchMac, msg.patchMac!) !== 0) {
throw new Boom('Invalid patch mac')
}
}
const patchMutations = msg.mutations
if (!patchMutations) {
throw new Boom('Missing mutations in patch message', { statusCode: 500 })
}
const result = await decodeSyncdMutations(patchMutations, initialState, getAppStateSyncKey, onMutation, validateMacs)
const result = await decodeSyncdMutations(msg.mutations!, initialState, getAppStateSyncKey, onMutation, validateMacs)
return result
}
@@ -436,7 +341,7 @@ export const extractSyncdPatches = async (result: BinaryNode, options: RequestIn
const syncd = proto.SyncdPatch.decode(content as Uint8Array)
if (!syncd.version) {
syncd.version = { version: +(collectionNode.attrs.version ?? 0) + 1 }
syncd.version = { version: +collectionNode.attrs.version! + 1 }
}
syncds.push(syncd)
@@ -474,25 +379,13 @@ export const decodeSyncdSnapshot = async (
validateMacs = true
) => {
const newState = newLTHashState()
const snapshotVersion = snapshot.version?.version
if (snapshotVersion == null) {
throw new Boom('Missing version in snapshot', { statusCode: 500 })
}
newState.version = toNumber(snapshotVersion)
newState.version = toNumber(snapshot.version!.version)
const mutationMap: ChatMutationMap = {}
const areMutationsRequired = typeof minimumVersionNumber === 'undefined' || newState.version > minimumVersionNumber
const snapshotRecords = snapshot.records
if (!snapshotRecords) {
throw new Boom('Missing records in snapshot', { statusCode: 500 })
}
const { hash, indexValueMap } = await decodeSyncdMutations(
snapshotRecords,
snapshot.records!,
newState,
getAppStateSyncKey,
areMutationsRequired
@@ -507,31 +400,15 @@ export const decodeSyncdSnapshot = async (
newState.indexValueMap = indexValueMap
if (validateMacs) {
const snapKeyId = snapshot.keyId?.id
if (!snapKeyId) {
throw new Boom('Missing keyId in snapshot', { statusCode: 500 })
}
const base64Key = Buffer.from(snapKeyId).toString('base64')
const base64Key = Buffer.from(snapshot.keyId!.id!).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key)
if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { data: { isMissingKey: true } })
throw new Boom(`failed to find key "${base64Key}" to decode mutation`)
}
const snapKeyData = keyEnc.keyData
if (!snapKeyData) {
throw new Boom('Missing keyData in snapshot key', { statusCode: 500 })
}
const result = mutationKeys(snapKeyData)
const result = mutationKeys(keyEnc.keyData!)
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
const snapshotMac = snapshot.mac
if (!snapshotMac) {
throw new Boom('Missing mac in snapshot', { statusCode: 500 })
}
if (Buffer.compare(snapshotMac, computedSnapshotMac) !== 0) {
if (Buffer.compare(snapshot.mac!, computedSnapshotMac) !== 0) {
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name} from snapshot`)
}
}
@@ -568,12 +445,7 @@ export const decodePatches = async (
syncd.mutations?.push(...ref.mutations)
}
const ver = version?.version
if (ver == null) {
throw new Boom('Missing version in patch', { statusCode: 500 })
}
const patchVersion = toNumber(ver)
const patchVersion = toNumber(version!.version)
newState.version = patchVersion
const shouldMutate = typeof minimumVersionNumber === 'undefined' || patchVersion > minimumVersionNumber
@@ -596,30 +468,15 @@ export const decodePatches = async (
newState.indexValueMap = decodeResult.indexValueMap
if (validateMacs) {
const patchKeyId = keyId?.id
if (!patchKeyId) {
throw new Boom('Missing keyId in patch', { statusCode: 500 })
}
const base64Key = Buffer.from(patchKeyId).toString('base64')
const base64Key = Buffer.from(keyId!.id!).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key)
if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { data: { isMissingKey: true } })
throw new Boom(`failed to find key "${base64Key}" to decode mutation`)
}
const patchKeyData = keyEnc.keyData
if (!patchKeyData) {
throw new Boom('Missing keyData in patch key', { statusCode: 500 })
}
const result = mutationKeys(patchKeyData)
const result = mutationKeys(keyEnc.keyData!)
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
if (!snapshotMac) {
throw new Boom('Missing snapshotMac in patch', { statusCode: 500 })
}
if (Buffer.compare(snapshotMac, computedSnapshotMac) !== 0) {
if (Buffer.compare(snapshotMac!, computedSnapshotMac) !== 0) {
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name}`)
}
}
@@ -767,11 +624,7 @@ export const chatModificationToAppPatch = (mod: ChatModification, jid: string) =
operation: OP.SET
}
} else if ('star' in mod) {
const key = mod.star.messages[0]
if (!key) {
throw new Boom('Missing star message key', { statusCode: 400 })
}
const key = mod.star.messages[0]!
patch = {
syncAction: {
starAction: {
@@ -1053,7 +906,7 @@ export const processSyncAction = (
ev.emit('settings.update', { setting: 'timeFormat', value: action.timeFormatAction })
} else if (action?.pnForLidChatAction) {
if (action.pnForLidChatAction.pnJid) {
ev.emit('lid-mapping.update', [{ lid: id!, pn: action.pnForLidChatAction.pnJid }])
ev.emit('lid-mapping.update', { lid: id!, pn: action.pnForLidChatAction.pnJid })
}
} else if (action?.privacySettingRelayAllCalls) {
ev.emit('settings.update', {
-766
View File
@@ -1,766 +0,0 @@
/**
* Circuit Breaker Pattern Implementation
*
* Provides protection against cascading failures by monitoring operation outcomes
* and temporarily blocking requests when failure thresholds are exceeded.
*
* States:
* - CLOSED: Normal operation, requests pass through
* - OPEN: Failures exceeded threshold, requests are blocked
* - HALF_OPEN: Testing recovery, limited requests allowed
*
* @module Utils/circuit-breaker
*/
import { EventEmitter } from 'events'
import { metrics } from './prometheus-metrics.js'
/**
* Circuit breaker operational states
*/
export type CircuitState = 'closed' | 'open' | 'half-open'
/**
* Failure record with timestamp for sliding window tracking
*/
export interface FailureRecord {
timestamp: number
error: Error
}
/**
* Circuit breaker configuration options
*/
export interface CircuitBreakerOptions {
/** Unique identifier for this circuit breaker (used in metrics/logging) */
name: string
/** Number of failures within the window to trigger OPEN state (default: 5) */
failureThreshold?: number
/** Time window in ms for counting failures (default: 60000) */
failureWindow?: number
/** Number of successes required in HALF_OPEN to return to CLOSED (default: 2) */
successThreshold?: number
/** Time in ms to wait before transitioning from OPEN to HALF_OPEN (default: 30000) */
resetTimeout?: number
/** Timeout for individual operations in ms (default: 10000) */
timeout?: number
/** Minimum number of requests before circuit can trip (default: 5) */
volumeThreshold?: number
/** Predicate to determine if an error should count as a failure */
shouldCountError?: (error: Error) => boolean
/** Whether to collect Prometheus metrics (default: true) */
collectMetrics?: boolean
/** Fallback function when circuit is OPEN */
fallback?: <T>() => T | Promise<T>
/** Callback when state changes */
onStateChange?: (from: CircuitState, to: CircuitState) => void
/** Callback on failure */
onFailure?: (error: Error) => void
/** Callback on success */
onSuccess?: () => void
/** Callback when circuit opens */
onOpen?: () => void
/** Callback when circuit closes */
onClose?: () => void
/** Callback when circuit enters half-open */
onHalfOpen?: () => void
}
/**
* Circuit breaker statistics
*/
export interface CircuitBreakerStats {
state: CircuitState
failures: number
successes: number
consecutiveFailures: number
consecutiveSuccesses: number
totalCalls: number
totalFailures: number
totalSuccesses: number
totalRejected: number
failureRate: number
lastFailureTime?: number
lastSuccessTime?: number
lastStateChange?: number
isOpen: boolean
isClosed: boolean
isHalfOpen: boolean
}
/**
* Error thrown when circuit is OPEN and request is rejected
*/
export class CircuitOpenError extends Error {
constructor(
public readonly circuitName: string,
public readonly state: CircuitState
) {
super(`Circuit breaker "${circuitName}" is ${state}`)
this.name = 'CircuitOpenError'
}
}
/**
* Error thrown when operation exceeds timeout
*/
export class CircuitTimeoutError extends Error {
constructor(
public readonly circuitName: string,
public readonly timeoutMs: number
) {
super(`Circuit breaker "${circuitName}" operation timed out after ${timeoutMs}ms`)
this.name = 'CircuitTimeoutError'
}
}
/**
* Circuit Breaker implementation with sliding window failure tracking
*
* @example
* ```typescript
* const breaker = new CircuitBreaker({
* name: 'whatsapp-api',
* failureThreshold: 5,
* resetTimeout: 30000
* })
*
* try {
* const result = await breaker.execute(() => sendMessage(msg))
* } catch (error) {
* if (error instanceof CircuitOpenError) {
* // Circuit is open, use fallback
* }
* }
* ```
*/
export class CircuitBreaker extends EventEmitter {
private state: CircuitState = 'closed'
private failureRecords: FailureRecord[] = []
private consecutiveFailures = 0
private consecutiveSuccesses = 0
private totalCalls = 0
private totalFailures = 0
private totalSuccesses = 0
private totalRejected = 0
private lastFailureTime?: number
private lastSuccessTime?: number
private lastStateChange: number
private resetTimer?: ReturnType<typeof setTimeout>
private readonly options: Required<CircuitBreakerOptions>
constructor(options: CircuitBreakerOptions) {
super()
this.options = {
name: options.name,
failureThreshold: options.failureThreshold ?? 5,
failureWindow: options.failureWindow ?? 60000,
successThreshold: options.successThreshold ?? 2,
resetTimeout: options.resetTimeout ?? 30000,
timeout: options.timeout ?? 10000,
volumeThreshold: options.volumeThreshold ?? 5,
shouldCountError: options.shouldCountError ?? (() => true),
collectMetrics: options.collectMetrics ?? true,
fallback:
options.fallback ??
(() => {
throw new CircuitOpenError(this.options.name, this.state)
}),
onStateChange: options.onStateChange ?? (() => {}),
onFailure: options.onFailure ?? (() => {}),
onSuccess: options.onSuccess ?? (() => {}),
onOpen: options.onOpen ?? (() => {}),
onClose: options.onClose ?? (() => {}),
onHalfOpen: options.onHalfOpen ?? (() => {})
}
this.lastStateChange = Date.now()
}
/**
* Check if the circuit allows execution
*/
canExecute(): boolean {
if (this.state === 'closed') {
return true
}
if (this.state === 'open') {
return false
}
// HALF_OPEN: allow limited requests for testing
return true
}
/**
* Execute an async operation with circuit breaker protection
*/
async execute<T>(operation: () => T | Promise<T>): Promise<T> {
this.totalCalls++
// Check if circuit allows execution
if (this.state === 'open') {
this.totalRejected++
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'circuit_breaker', code: 'rejected' })
}
return this.options.fallback() as T
}
// Execute with timeout protection
try {
const result = await this.executeWithTimeout(operation)
this.recordSuccess()
return result
} catch (error) {
this.recordFailure(error as Error)
throw error
}
}
/**
* Execute a synchronous operation with circuit breaker protection
*/
executeSync<T>(operation: () => T): T {
this.totalCalls++
if (this.state === 'open') {
this.totalRejected++
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'circuit_breaker', code: 'rejected' })
}
return this.options.fallback() as T
}
try {
const result = operation()
this.recordSuccess()
return result
} catch (error) {
this.recordFailure(error as Error)
throw error
}
}
/**
* Execute operation with timeout wrapper
*/
private async executeWithTimeout<T>(operation: () => T | Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new CircuitTimeoutError(this.options.name, this.options.timeout))
}, this.options.timeout)
Promise.resolve(operation())
.then(result => {
clearTimeout(timer)
resolve(result)
})
.catch(error => {
clearTimeout(timer)
reject(error)
})
})
}
/**
* Record a successful operation
*/
private recordSuccess(): void {
this.totalSuccesses++
this.lastSuccessTime = Date.now()
this.consecutiveSuccesses++
this.consecutiveFailures = 0
if (this.options.collectMetrics) {
metrics.socketEvents.inc({ event: 'circuit_success' })
}
this.options.onSuccess()
this.emit('success')
// In HALF_OPEN state, check if we can close the circuit
if (this.state === 'half-open') {
if (this.consecutiveSuccesses >= this.options.successThreshold) {
this.transitionTo('closed')
}
}
}
/**
* Record a failed operation
*/
private recordFailure(error: Error): void {
// Check if this error should count as a failure
if (!this.options.shouldCountError(error)) {
return
}
const now = Date.now()
this.totalFailures++
this.lastFailureTime = now
this.consecutiveFailures++
this.consecutiveSuccesses = 0
// Add to failure records for sliding window
this.failureRecords.push({ timestamp: now, error })
// Clean old failures outside the window
this.cleanOldFailures()
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'circuit_breaker', code: 'failure' })
}
this.options.onFailure(error)
this.emit('failure', error)
// State transition logic
if (this.state === 'half-open') {
// Any failure in HALF_OPEN immediately reopens the circuit
this.transitionTo('open')
} else if (this.state === 'closed') {
// Check if we should trip the circuit
const recentFailures = this.failureRecords.length
if (this.totalCalls >= this.options.volumeThreshold && recentFailures >= this.options.failureThreshold) {
this.transitionTo('open')
}
}
}
/**
* Remove failure records outside the sliding window
*/
private cleanOldFailures(): void {
const cutoff = Date.now() - this.options.failureWindow
this.failureRecords = this.failureRecords.filter(record => record.timestamp > cutoff)
}
/**
* Transition to a new state
*/
private transitionTo(newState: CircuitState): void {
const oldState = this.state
if (oldState === newState) {
return
}
this.state = newState
this.lastStateChange = Date.now()
// Clear existing reset timer
if (this.resetTimer) {
clearTimeout(this.resetTimer)
this.resetTimer = undefined
}
// State-specific actions
switch (newState) {
case 'closed':
this.consecutiveFailures = 0
this.consecutiveSuccesses = 0
this.failureRecords = []
this.options.onClose()
this.emit('close')
break
case 'open':
this.consecutiveSuccesses = 0
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')
}, this.options.resetTimeout)
break
case 'half-open':
this.consecutiveSuccesses = 0
this.consecutiveFailures = 0
this.options.onHalfOpen()
this.emit('half-open')
break
}
this.options.onStateChange(oldState, newState)
this.emit('state-change', { from: oldState, to: newState })
}
/**
* Manually trip the circuit to OPEN state
*/
trip(): void {
this.transitionTo('open')
}
/**
* Manually reset the circuit to CLOSED state
*/
reset(): void {
this.transitionTo('closed')
}
/**
* Get current circuit state
*/
getState(): CircuitState {
return this.state
}
/**
* Check if circuit is OPEN
*/
isOpen(): boolean {
return this.state === 'open'
}
/**
* Check if circuit is CLOSED
*/
isClosed(): boolean {
return this.state === 'closed'
}
/**
* Check if circuit is HALF_OPEN
*/
isHalfOpen(): boolean {
return this.state === 'half-open'
}
/**
* Get circuit breaker statistics
*/
getStats(): CircuitBreakerStats {
this.cleanOldFailures()
const failureRate = this.totalCalls > 0 ? (this.totalFailures / this.totalCalls) * 100 : 0
return {
state: this.state,
failures: this.failureRecords.length,
successes: this.consecutiveSuccesses,
consecutiveFailures: this.consecutiveFailures,
consecutiveSuccesses: this.consecutiveSuccesses,
totalCalls: this.totalCalls,
totalFailures: this.totalFailures,
totalSuccesses: this.totalSuccesses,
totalRejected: this.totalRejected,
failureRate,
lastFailureTime: this.lastFailureTime,
lastSuccessTime: this.lastSuccessTime,
lastStateChange: this.lastStateChange,
isOpen: this.isOpen(),
isClosed: this.isClosed(),
isHalfOpen: this.isHalfOpen()
}
}
/**
* Get circuit breaker name
*/
getName(): string {
return this.options.name
}
/**
* Destroy circuit breaker and clean up resources
*/
destroy(): void {
if (this.resetTimer) {
clearTimeout(this.resetTimer)
}
this.failureRecords = []
this.removeAllListeners()
}
}
/**
* Factory function to create a circuit breaker
*/
export function createCircuitBreaker(options: CircuitBreakerOptions): CircuitBreaker {
return new CircuitBreaker(options)
}
/**
* Registry for managing multiple circuit breakers
*/
export class CircuitBreakerRegistry {
private breakers: Map<string, CircuitBreaker> = new Map()
/**
* Get or create a circuit breaker by name
*/
get(name: string, options?: Omit<CircuitBreakerOptions, 'name'>): CircuitBreaker {
if (!this.breakers.has(name)) {
const breaker = new CircuitBreaker({ ...options, name })
this.breakers.set(name, breaker)
}
return this.breakers.get(name)!
}
/**
* Check if a circuit breaker exists
*/
has(name: string): boolean {
return this.breakers.has(name)
}
/**
* Remove a circuit breaker
*/
remove(name: string): boolean {
const breaker = this.breakers.get(name)
if (breaker) {
breaker.destroy()
return this.breakers.delete(name)
}
return false
}
/**
* Get all circuit breakers
*/
getAll(): Map<string, CircuitBreaker> {
return new Map(this.breakers)
}
/**
* Get statistics for all circuit breakers
*/
getAllStats(): Record<string, CircuitBreakerStats> {
const stats: Record<string, CircuitBreakerStats> = {}
for (const [name, breaker] of this.breakers) {
stats[name] = breaker.getStats()
}
return stats
}
/**
* Reset all circuit breakers to CLOSED state
*/
resetAll(): void {
for (const breaker of this.breakers.values()) {
breaker.reset()
}
}
/**
* Destroy all circuit breakers
*/
destroyAll(): void {
for (const breaker of this.breakers.values()) {
breaker.destroy()
}
this.breakers.clear()
}
}
/**
* Global circuit breaker registry instance
*/
export const globalCircuitRegistry = new CircuitBreakerRegistry()
/**
* Decorator to protect a method with circuit breaker
*
* @example
* ```typescript
* class MyService {
* @circuitBreaker({ failureThreshold: 3 })
* async fetchData() {
* return await api.getData()
* }
* }
* ```
*/
export function circuitBreaker(options: Omit<CircuitBreakerOptions, 'name'> & { name?: string } = {}) {
return function (
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => unknown>
) {
const originalMethod = descriptor.value
if (!originalMethod) return descriptor
const name = options.name || propertyKey
const breaker = globalCircuitRegistry.get(name, options)
descriptor.value = async function (...args: unknown[]): Promise<unknown> {
return breaker.execute(() => originalMethod.apply(this, args))
}
return descriptor
}
}
/**
* Wrap a function with circuit breaker protection
*
* @example
* ```typescript
* const protectedFetch = withCircuitBreaker(
* fetchData,
* { name: 'api-fetch', failureThreshold: 5 }
* )
* ```
*/
// eslint-disable-next-line space-before-function-paren
export function withCircuitBreaker<T extends (...args: unknown[]) => unknown>(
fn: T,
options: CircuitBreakerOptions
): T {
const breaker = new CircuitBreaker(options)
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
return breaker.execute(() => fn(...args)) as Promise<ReturnType<T>>
}) as unknown as T
}
/**
* Get health status of all circuit breakers
*/
export function getCircuitHealth(): {
healthy: boolean
openCircuits: string[]
stats: Record<string, CircuitBreakerStats>
} {
const stats = globalCircuitRegistry.getAllStats()
const openCircuits: string[] = []
for (const [name, stat] of Object.entries(stats)) {
if (stat.isOpen) {
openCircuits.push(name)
}
}
return {
healthy: openCircuits.length === 0,
openCircuits,
stats
}
}
/**
* Create a pre-configured circuit breaker for WhatsApp PreKey operations
*
* This circuit breaker is optimized for handling encryption/session errors
* that commonly occur with WhatsApp's Signal protocol implementation.
*
* @example
* ```typescript
* const preKeyBreaker = createPreKeyCircuitBreaker()
*
* async function sendEncryptedMessage(msg) {
* return preKeyBreaker.execute(async () => {
* return await encryptAndSend(msg)
* })
* }
* ```
*/
export function createPreKeyCircuitBreaker(customOptions?: Partial<CircuitBreakerOptions>): CircuitBreaker {
const preKeyErrorPatterns = ['prekey', 'pre-key', 'session', 'signal', 'encrypt', 'decrypt', 'cipher', 'key']
return new CircuitBreaker({
name: 'prekey-operations',
failureThreshold: 5,
failureWindow: 60000,
resetTimeout: 30000,
successThreshold: 2,
shouldCountError: (error: Error) => {
const message = error.message.toLowerCase()
return preKeyErrorPatterns.some(pattern => message.includes(pattern))
},
...customOptions
})
}
/**
* Create a circuit breaker for WebSocket connection operations
*/
export function createConnectionCircuitBreaker(customOptions?: Partial<CircuitBreakerOptions>): CircuitBreaker {
const connectionErrorPatterns = [
'econnrefused',
'econnreset',
'etimedout',
'enotfound',
'socket',
'websocket',
'connection',
'network'
]
// Normal WS lifecycle status codes that must NEVER trip the query circuit breaker.
// These are transient events that happen on every reconnect and carry no information
// about persistent server-side failures. The reconnection logic in makeSocket handles
// them independently.
const WS_LIFECYCLE_STATUS_CODES = new Set([
428, // connectionClosed
408, // connectionLost / timedOut
440, // connectionReplaced
])
return new CircuitBreaker({
name: 'connection-operations',
failureThreshold: 3,
failureWindow: 30000,
resetTimeout: 60000,
successThreshold: 1,
shouldCountError: (error: Error) => {
// CircuitTimeoutError messages embed the circuit name (e.g. "socket-query"), which
// accidentally matches the "socket" pattern below and causes a self-reinforcing loop
// where the breaker's own timeouts keep tripping the breaker. Exclude them explicitly.
if (error instanceof CircuitTimeoutError) return false
// Exclude normal WS reconnect events (Connection Closed, Connection Lost, Timed Out, etc.).
// These are not server-side failures — they happen on every restart/redeploy and should
// never cause the circuit to open. If we counted them, the 3 concurrent parallel
// post-login queries (sendPassiveIq, uploadPreKeysToServerIfRequired, digestKeyBundle) could all
// fail simultaneously when the WS drops, instantly opening the circuit and blocking
// profile-picture fetches and message delivery for the next 30 s.
const statusCode = (error as { output?: { statusCode?: number } })?.output?.statusCode
if (statusCode !== undefined && WS_LIFECYCLE_STATUS_CODES.has(statusCode)) return false
const message = error.message.toLowerCase()
const code = (error as NodeJS.ErrnoException).code?.toLowerCase() || ''
return connectionErrorPatterns.some(pattern => message.includes(pattern) || code.includes(pattern))
},
...customOptions
})
}
/**
* Create a circuit breaker for message sending operations
*/
export function createMessageCircuitBreaker(customOptions?: Partial<CircuitBreakerOptions>): CircuitBreaker {
return new CircuitBreaker({
name: 'message-operations',
failureThreshold: 5,
failureWindow: 60000,
resetTimeout: 15000,
successThreshold: 2,
timeout: 30000,
...customOptions
})
}
export default CircuitBreaker
+4 -5
View File
@@ -2,6 +2,7 @@ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes }
import * as curve from 'libsignal/src/curve'
import { KEY_BUNDLE_TYPE } from '../Defaults'
import type { KeyPair } from '../Types'
export { md5, hkdf } from 'whatsapp-rust-bridge'
// insure browser & node compatibility
const { subtle } = globalThis.crypto
@@ -83,7 +84,7 @@ export function aesDecryptCTR(ciphertext: Uint8Array, key: Uint8Array, iv: Uint8
/** decrypt AES 256 CBC; where the IV is prefixed to the buffer */
export function aesDecrypt(buffer: Uint8Array, key: Uint8Array) {
return aesDecryptWithIV(buffer.subarray(16, buffer.length), key, buffer.subarray(0, 16))
return aesDecryptWithIV(buffer.subarray(16), key, buffer.subarray(0, 16))
}
/** decrypt AES 256 CBC */
@@ -93,14 +94,14 @@ export function aesDecryptWithIV(buffer: Uint8Array, key: Uint8Array, IV: Uint8A
}
// encrypt AES 256 CBC; where a random IV is prefixed to the buffer
export function aesEncrypt(buffer: Buffer | Uint8Array, key: Uint8Array) {
export function aesEncrypt(buffer: Uint8Array, key: Uint8Array) {
const IV = randomBytes(16)
const aes = createCipheriv('aes-256-cbc', key, IV)
return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer
}
// encrypt AES 256 CBC with a given IV
export function aesEncrypWithIV(buffer: Buffer | Uint8Array, key: Uint8Array, IV: Uint8Array) {
export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
const aes = createCipheriv('aes-256-cbc', key, IV)
return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer
}
@@ -118,8 +119,6 @@ export function sha256(buffer: Buffer) {
return createHash('sha256').update(buffer).digest()
}
export { hkdf, md5 } from './wasm-bridge'
export async function derivePairingCodeKey(pairingCode: string, salt: Buffer): Promise<Buffer> {
// Convert inputs to formats Web Crypto API can work with
const encoder = new TextEncoder()
+46 -232
View File
@@ -13,13 +13,11 @@ import {
isJidNewsletter,
isJidStatusBroadcast,
isLidUser,
isPnUser,
jidDecode
isPnUser
// transferDevice
} from '../WABinary'
import { unpadRandomMax16 } from './generics'
import type { ILogger } from './logger'
import { retry, RetryExhaustedError, type RetryOptions } from './retry-utils'
export const getDecryptionJid = async (sender: string, repository: SignalRepositoryWithLIDStore): Promise<string> => {
if (isLidUser(sender) || isHostedLidUser(sender)) {
@@ -53,45 +51,12 @@ const storeMappingFromEnvelope = async (
export const NO_MESSAGE_FOUND_ERROR_TEXT = 'Message absent from node'
export const MISSING_KEYS_ERROR_TEXT = 'Key used already or never filled'
export const BAD_MAC_ERROR_TEXT = 'Bad MAC'
// Retry configuration for failed decryption
export const DECRYPTION_RETRY_CONFIG = {
maxRetries: 3,
baseDelayMs: 100,
sessionRecordErrors: ['No session record', 'SessionError: No session record'],
corruptedSessionErrors: ['Bad MAC', 'MessageCounterError', MISSING_KEYS_ERROR_TEXT]
}
/**
* Retry options for decryption operations
* Uses exponential backoff with jitter to handle transient failures
*/
export const DECRYPTION_RETRY_OPTIONS: RetryOptions = {
maxAttempts: 3,
baseDelay: 200, // 200ms base delay
maxDelay: 2000, // 2s max delay
backoffStrategy: 'exponential',
backoffMultiplier: 2,
jitter: 0.2, // 20% jitter
collectMetrics: false, // No Prometheus metrics
operationName: 'message_decryption',
shouldRetry: (error: Error, attempt: number) => {
const errorMsg = error?.message || ''
// Always retry on session record errors (session might be syncing)
if (DECRYPTION_RETRY_CONFIG.sessionRecordErrors.some(err => errorMsg.includes(err))) {
return attempt < 3 // Retry up to 3 times
}
// Don't retry on corrupted session errors (need cleanup first)
if (DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(err => errorMsg.includes(err))) {
return false
}
// Retry other transient errors
return attempt < 2 // Retry up to 2 times for unknown errors
}
sessionRecordErrors: ['No session record', 'SessionError: No session record']
}
export const NACK_REASONS = {
@@ -107,15 +72,7 @@ export const NACK_REASONS = {
UnhandledError: 500,
UnsupportedAdminRevoke: 550,
UnsupportedLIDGroup: 551,
DBOperationFailed: 552,
CorruptedSession: 553
}
export const SERVER_ERROR_CODES = {
MissingTcToken: '463',
SmaxInvalid: '479',
StaleGroupAddressingMode: '421',
NewChatMessagesCapped: '475'
DBOperationFailed: 552
}
type MessageType =
@@ -170,10 +127,6 @@ export function decodeMessageNode(stanza: BinaryNode, meId: string, meLid: strin
const msgId = stanza.attrs.id
const from = stanza.attrs.from
if (!from) {
throw new Boom('Missing from attribute in message', { data: stanza })
}
const participant: string | undefined = stanza.attrs.participant
const recipient: string | undefined = stanza.attrs.recipient
@@ -184,21 +137,21 @@ export function decodeMessageNode(stanza: BinaryNode, meId: string, meLid: strin
if (isPnUser(from) || isLidUser(from) || isHostedLidUser(from) || isHostedPnUser(from)) {
if (recipient && !isJidMetaAI(recipient)) {
if (!isMe(from) && !isMeLid(from)) {
if (!isMe(from!) && !isMeLid(from!)) {
throw new Boom('receipient present, but msg not from me', { data: stanza })
}
if (isMe(from) || isMeLid(from)) {
if (isMe(from!) || isMeLid(from!)) {
fromMe = true
}
chatId = recipient
} else {
chatId = from
chatId = from!
}
msgType = 'chat'
author = from
author = from!
} else if (isJidGroup(from)) {
if (!participant) {
throw new Boom('No participant in group message')
@@ -210,28 +163,28 @@ export function decodeMessageNode(stanza: BinaryNode, meId: string, meLid: strin
msgType = 'group'
author = participant
chatId = from
chatId = from!
} else if (isJidBroadcast(from)) {
if (!participant) {
throw new Boom('No participant in group message')
}
const isParticipantMe = isMe(participant)
if (isJidStatusBroadcast(from)) {
if (isJidStatusBroadcast(from!)) {
msgType = isParticipantMe ? 'direct_peer_status' : 'other_status'
} else {
msgType = isParticipantMe ? 'peer_broadcast' : 'other_broadcast'
}
fromMe = isParticipantMe
chatId = from
chatId = from!
author = participant
} else if (isJidNewsletter(from)) {
msgType = 'newsletter'
chatId = from
author = from
chatId = from!
author = from!
if (isMe(from) || isMeLid(from)) {
if (isMe(from!) || isMeLid(from!)) {
fromMe = true
}
} else {
@@ -254,7 +207,7 @@ export function decodeMessageNode(stanza: BinaryNode, meId: string, meLid: strin
const fullMessage: WAMessage = {
key,
category: stanza.attrs.category,
messageTimestamp: +(stanza.attrs.t ?? 0),
messageTimestamp: +stanza.attrs.t!,
pushName: pushname,
broadcast: isJidBroadcast(from)
}
@@ -275,8 +228,7 @@ export const decryptMessageNode = (
meId: string,
meLid: string,
repository: SignalRepositoryWithLIDStore,
logger: ILogger,
autoCleanCorrupted = true
logger: ILogger
) => {
const { fullMessage, author, sender } = decodeMessageNode(stanza, meId, meLid)
return {
@@ -289,10 +241,8 @@ export const decryptMessageNode = (
for (const { tag, attrs, content } of stanza.content) {
if (tag === 'verified_name' && content instanceof Uint8Array) {
const cert = proto.VerifiedNameCertificate.decode(content)
if (cert.details) {
const details = proto.VerifiedNameCertificate.Details.decode(cert.details)
fullMessage.verifiedBizName = details.verifiedName
}
const details = proto.VerifiedNameCertificate.Details.decode(cert.details!)
fullMessage.verifiedBizName = details.verifiedName
}
if (tag === 'unavailable' && attrs.type === 'view_once') {
@@ -325,46 +275,21 @@ export const decryptMessageNode = (
try {
const e2eType = tag === 'plaintext' ? 'plaintext' : attrs.type
// Wrap decryption in retry logic for transient failures
switch (e2eType) {
case 'skmsg':
msgBuffer = await retry(
() =>
repository.decryptGroupMessage({
group: sender,
authorJid: author,
msg: content
}),
{
...DECRYPTION_RETRY_OPTIONS,
onRetry: (error, attempt, delay) => {
logger.debug(
{ error: error.message, attempt, delay, group: sender, author },
'Retrying group message decryption'
)
}
}
)
msgBuffer = await repository.decryptGroupMessage({
group: sender,
authorJid: author,
msg: content
})
break
case 'pkmsg':
case 'msg':
msgBuffer = await retry(
() =>
repository.decryptMessage({
jid: decryptionJid,
type: e2eType,
ciphertext: content
}),
{
...DECRYPTION_RETRY_OPTIONS,
onRetry: (error, attempt, delay) => {
logger.debug(
{ error: error.message, attempt, delay, jid: decryptionJid, type: e2eType },
'Retrying message decryption'
)
}
}
)
msgBuffer = await repository.decryptMessage({
jid: decryptionJid,
type: e2eType,
ciphertext: content
})
break
case 'plaintext':
msgBuffer = content
@@ -394,85 +319,35 @@ export const decryptMessageNode = (
} else {
fullMessage.message = msg
}
// Detect view-once media on stanza 1 received by linked device.
// viewOnceMessage wrapper is also used for interactive messages
// (interactiveMessage, listMessage, nativeFlowMessage) -- those do NOT have
// imageMessage.viewOnce / videoMessage.viewOnce / audioMessage.viewOnce = true.
// Only real view-once media carries viewOnce: true on the inner media message.
const viewOnceInner =
msg.viewOnceMessage?.message || msg.viewOnceMessageV2?.message || msg.viewOnceMessageV2Extension?.message
if (
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
) {
fullMessage.key.isViewOnce = true
}
} catch (err: any) {
// Check if this is a final failure after all retries exhausted
const isRetryExhausted = err instanceof RetryExhaustedError
const originalError = isRetryExhausted ? err.originalError : err
const isCorrupted = isCorruptedSessionError(originalError)
const isSessionRecord = isSessionRecordError(originalError)
const errorContext = {
key: fullMessage.key,
err: originalError,
err,
messageType: tag === 'plaintext' ? 'plaintext' : attrs.type,
sender,
author,
decryptionJid,
isSessionRecordError: isSessionRecord,
isCorruptedSession: isCorrupted,
...(isRetryExhausted && { retriesExhausted: true, attempts: err.attempts })
isSessionRecordError: isSessionRecordError(err)
}
// Smart logging based on error type and retry status
if (isCorrupted) {
// Corrupted session errors are expected and auto-recovered
// Only log as ERROR if retries exhausted, otherwise WARN on first attempt
// eslint-disable-next-line max-depth
if (isRetryExhausted) {
logger.error(
errorContext,
`⚠️ Session corrupted and recovery failed after ${err.attempts} attempts. Auto-cleanup will attempt to recover.`
)
} else {
// First occurrence - log as warning since auto-recovery will attempt
logger.warn(errorContext, '⚠️ Corrupted session detected - attempting auto-recovery')
}
// Automatic cleanup of corrupted session (if enabled)
// eslint-disable-next-line max-depth
if (autoCleanCorrupted) {
// eslint-disable-next-line max-depth
try {
const deletedCount = await cleanupCorruptedSession(decryptionJid, repository, logger)
// Mask only user portion of JID for privacy (preserve domain info)
const { user, server } = jidDecode(decryptionJid) || {}
const maskedUser =
user && user.length > 8 ? `${user.substring(0, 4)}****${user.substring(user.length - 4)}` : user
const maskedJid = maskedUser && server ? `${maskedUser}@${server}` : decryptionJid
logger.info(
{ jid: decryptionJid, maskedJid, targetedDevices: deletedCount },
`🔄 Session Reset | JID: ${maskedJid} | Targeted: ${deletedCount} devices | Will recreate on next message`
)
} catch (cleanupErr) {
logger.error({ decryptionJid, err: cleanupErr }, '❌ Failed to cleanup corrupted session')
}
}
} else if (isSessionRecord) {
// Session record errors are transient - retry should handle them
// eslint-disable-next-line max-depth
if (isRetryExhausted) {
logger.error(errorContext, `Failed to decrypt: No session record found after ${err.attempts} attempts`)
} else {
logger.debug(errorContext, 'No session record - will retry')
}
} else {
// Unknown/unexpected errors (protobuf, parsing, etc.)
// These don't go through retry, so always log as ERROR
// Type-safe explicit logging instead of dynamic property access
logger.error(
errorContext,
isRetryExhausted
? `Failed to decrypt message after ${err.attempts} attempts`
: 'Failed to decrypt message'
)
}
logger.error(errorContext, 'failed to decrypt message')
fullMessage.messageStubType = proto.WebMessageInfo.StubType.CIPHERTEXT
// Safe coercion handling edge cases where message might be undefined
fullMessage.messageStubParameters = [String(originalError?.message ?? originalError)]
fullMessage.messageStubParameters = [err.message.toString()]
}
}
}
@@ -493,64 +368,3 @@ function isSessionRecordError(error: any): boolean {
const errorMessage = error?.message || error?.toString() || ''
return DECRYPTION_RETRY_CONFIG.sessionRecordErrors.some(errorPattern => errorMessage.includes(errorPattern))
}
/**
* Utility function to check if an error indicates a corrupted session
* (Bad MAC, MessageCounterError, Key already used)
*/
export function isCorruptedSessionError(error: any): boolean {
const errorMessage = error?.message || error?.toString() || ''
return DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(errorPattern => errorMessage.includes(errorPattern))
}
/**
* Clean up corrupted session by deleting all device sessions for a JID
* Signal Protocol will automatically recreate the session on next message
*/
async function cleanupCorruptedSession(
jid: string,
repository: SignalRepositoryWithLIDStore,
logger: ILogger
): Promise<number> {
const { user, device } = jidDecode(jid) || {}
if (!user) {
logger.warn({ jid }, 'Cannot cleanup session - invalid JID')
return 0
}
// Build list of JIDs to delete (primary + secondary devices)
const jidsToDelete: string[] = []
// Determine domain type correctly (handle hosted JIDs)
// JID formats:
// - PN: user@s.whatsapp.net
// - LID: user@lid
// - Hosted PN: user@hosted
// - Hosted LID: user@hosted.lid
const isLID = jid.endsWith('@lid') || jid.endsWith('@hosted.lid')
const isHosted = jid.includes('@hosted')
let domain: string
if (isLID) {
domain = isHosted ? 'hosted.lid' : 'lid'
} else {
domain = isHosted ? 'hosted' : 's.whatsapp.net'
}
// Primary device (0)
jidsToDelete.push(`${user}@${domain}`)
// Secondary devices (1-5 common range for Web/Desktop/etc)
for (let i = 1; i <= 5; i++) {
jidsToDelete.push(`${user}:${i}@${domain}`)
}
// If specific device was identified and > 5, ensure it's included
if (device !== undefined && device > 5) {
jidsToDelete.push(`${user}:${device}@${domain}`)
}
await repository.deleteSession(jidsToDelete)
return jidsToDelete.length
}
+70 -775
View File
File diff suppressed because it is too large Load Diff
+28 -52
View File
@@ -1,10 +1,7 @@
import { Boom } from '@hapi/boom'
import { createHash, randomBytes } from 'crypto'
import { proto } from '../../WAProto/index.js'
// 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
const baileysVersion = [2, 3000, 1035194821]
import type {
BaileysEventEmitter,
BaileysEventMap,
@@ -56,7 +53,7 @@ export const isStringNullOrEmpty = (value: string | null | undefined): value is
export const writeRandomPadMax16 = (msg: Uint8Array) => {
const pad = randomBytes(1)
const padLength = ((pad[0] ?? 0) & 0x0f) + 1
const padLength = (pad[0]! & 0x0f) + 1
return Buffer.concat([msg, Buffer.alloc(padLength, padLength)])
}
@@ -67,7 +64,7 @@ export const unpadRandomMax16 = (e: Uint8Array | Buffer) => {
throw new Error('unpadPkcs7 given empty bytes')
}
var r = t[t.length - 1] ?? 0
var r = t[t.length - 1]!
if (r > t.length) {
throw new Error(`unpad given ${t.length} bytes, but pad is ${r}`)
}
@@ -85,7 +82,7 @@ export const generateParticipantHashV2 = (participants: string[]): string => {
export const encodeWAMessage = (message: proto.IMessage) => writeRandomPadMax16(proto.Message.encode(message).finish())
export const generateRegistrationId = (): number => {
return (Uint16Array.from(randomBytes(2))[0] ?? 0) & 16383
return Uint16Array.from(randomBytes(2))[0]! & 16383
}
export const encodeBigEndian = (e: number, t = 4) => {
@@ -128,6 +125,7 @@ export const debouncedTimeout = (intervalMs = 1000, task?: () => void) => {
export const delay = (ms: number) => delayCancellable(ms).delay
export const delayCancellable = (ms: number) => {
const stack = new Error().stack
let timeout: NodeJS.Timeout
let reject: (error: any) => void
const delay: Promise<void> = new Promise((resolve, _reject) => {
@@ -136,9 +134,14 @@ export const delayCancellable = (ms: number) => {
})
const cancel = () => {
clearTimeout(timeout)
// Boom creates native Error instances and calls Error.captureStackTrace()
// The .stack property is preserved automatically
reject(new Boom('Cancelled', { statusCode: 500 }))
reject(
new Boom('Cancelled', {
statusCode: 500,
data: {
stack
}
})
)
}
return { delay, cancel }
@@ -152,16 +155,18 @@ export async function promiseTimeout<T>(
return new Promise(promise)
}
const stack = new Error().stack
// Create a promise that rejects in <ms> milliseconds
// Boom creates native Error instances and calls Error.captureStackTrace()
// The .stack property is preserved automatically
const { delay, cancel } = delayCancellable(ms)
const p = new Promise((resolve, reject) => {
delay
.then(() =>
reject(
new Boom('Timed Out', {
statusCode: DisconnectReason.timedOut
statusCode: DisconnectReason.timedOut,
data: {
stack
}
})
)
)
@@ -246,18 +251,10 @@ export const fetchLatestBaileysVersion = async (options: RequestInit = {}) => {
// Extract version from line 7 (const version = [...])
const lines = text.split('\n')
const versionLine = lines[6] // Line 7 (0-indexed)
if (!versionLine) {
throw new Error('Version line not found')
}
const versionMatch = versionLine.match(/const version = \[(\d+),\s*(\d+),\s*(\d+)\]/)
const versionMatch = versionLine!.match(/const version = \[(\d+),\s*(\d+),\s*(\d+)\]/)
if (versionMatch) {
const version = [
parseInt(versionMatch[1] ?? '0'),
parseInt(versionMatch[2] ?? '0'),
parseInt(versionMatch[3] ?? '0')
] as WAVersion
const version = [parseInt(versionMatch[1]!), parseInt(versionMatch[2]!), parseInt(versionMatch[3]!)] as WAVersion
return {
version,
@@ -355,48 +352,27 @@ export const getStatusFromReceiptType = (type: string | undefined) => {
return status
}
/** Maps child node tag to a DisconnectReason (child-level code parsing). */
const CODE_MAP: { [_: string]: DisconnectReason } = {
// conflict is handled explicitly below to distinguish type=replaced vs others
conflict: DisconnectReason.connectionReplaced
}
/**
* Parse stream:error node and map to a DisconnectReason.
* Matches WA Web's WAWebHandleStreamError parser.
*
* Resolution order:
* 1. conflict child type attribute determines connectionReplaced vs loggedOut
* 2. parent code attribute (515 restartRequired, 516 sessionInvalidated, other numeric)
* 3. CODE_MAP lookup from child tag (child-level code parsing)
* 4. DisconnectReason.badSession fallback
* Stream errors generally provide a reason, map that to a baileys DisconnectReason
* @param reason the string reason given, eg. "conflict"
*/
export const getErrorCodeFromStreamError = (node: BinaryNode) => {
const [reasonNode] = getAllBinaryNodeChildren(node)
let reason = reasonNode?.tag || 'unknown'
// Conflict child: type attribute determines connectionReplaced vs loggedOut.
// WA Web default: any type other than 'replaced' is treated as device_removed (loggedOut).
if (reason === 'conflict') {
const conflictType = reasonNode!.attrs?.type
if (conflictType === 'replaced') {
return { reason: 'replaced', statusCode: DisconnectReason.connectionReplaced }
}
return { reason: 'device_removed', statusCode: DisconnectReason.loggedOut }
}
// Child-level code parsing: parent code attr > child code attr > CODE_MAP from child tag > badSession
const statusCode = +(node.attrs.code || reasonNode?.attrs?.code || CODE_MAP[reason] || DisconnectReason.badSession)
const statusCode = +(node.attrs.code || CODE_MAP[reason] || DisconnectReason.badSession)
if (statusCode === DisconnectReason.restartRequired) {
reason = 'restart required'
} else if (statusCode === DisconnectReason.sessionInvalidated) {
reason = 'session invalidated'
} else if (node.attrs.code) {
reason = `code ${statusCode}`
}
return { reason, statusCode }
return {
reason,
statusCode
}
}
export const getCallStatusFromNode = ({ tag, attrs }: BinaryNode) => {
-209
View File
@@ -1,209 +0,0 @@
/**
* Health Status Utilities
*
* Provides health check and status information for monitoring and k8s probes.
*
* @module Utils/health-status
*/
import { globalCircuitRegistry } from './circuit-breaker.js'
import { getVersionCacheStatus } from './version-cache.js'
/**
* Circuit breaker health information
*/
export interface CircuitBreakerHealth {
name: string
state: 'closed' | 'open' | 'half-open'
failures: number
successes: number
totalCalls: number
}
/**
* Cache health information
*/
export interface CacheHealth {
versionCache: {
hasCache: boolean
isExpired: boolean
ageMs: number | null
source: string | null
}
}
/**
* Overall health status
*/
export interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy'
timestamp: number
uptime: number
version: string
circuitBreakers: CircuitBreakerHealth[]
cache: CacheHealth
checks: {
name: string
status: 'pass' | 'warn' | 'fail'
message?: string
}[]
}
/**
* Get the current health status of the Baileys instance.
*
* Useful for:
* - Kubernetes liveness/readiness probes
* - Load balancer health checks
* - Monitoring dashboards
*
* @returns HealthStatus object with detailed status information
*
* @example
* ```typescript
* import { getHealthStatus } from '@whiskeysockets/baileys'
*
* // Simple health check endpoint
* app.get('/health', (req, res) => {
* const health = getHealthStatus()
* const statusCode = health.status === 'healthy' ? 200 :
* health.status === 'degraded' ? 200 : 503
* res.status(statusCode).json(health)
* })
*
* // Kubernetes probe
* app.get('/healthz', (req, res) => {
* const health = getHealthStatus()
* res.status(health.status !== 'unhealthy' ? 200 : 503).send(health.status)
* })
* ```
*/
export function getHealthStatus(): HealthStatus {
const checks: HealthStatus['checks'] = []
let overallStatus: HealthStatus['status'] = 'healthy'
// 1. Check version cache
const versionCacheStatus = getVersionCacheStatus()
if (!versionCacheStatus.hasCache) {
checks.push({
name: 'version_cache',
status: 'warn',
message: 'No version cache available'
})
if (overallStatus === 'healthy') overallStatus = 'degraded'
} else if (versionCacheStatus.isExpired) {
checks.push({
name: 'version_cache',
status: 'warn',
message: 'Version cache is expired'
})
if (overallStatus === 'healthy') overallStatus = 'degraded'
} else {
checks.push({
name: 'version_cache',
status: 'pass'
})
}
// 2. Check circuit breakers
const circuitBreakers: CircuitBreakerHealth[] = []
let openCircuits = 0
for (const [name, breaker] of globalCircuitRegistry.getAll()) {
const stats = breaker.getStats()
const state = breaker.getState()
circuitBreakers.push({
name,
state,
failures: stats.totalFailures,
successes: stats.totalSuccesses,
totalCalls: stats.totalCalls
})
if (state === 'open') {
openCircuits++
}
}
if (openCircuits > 0) {
checks.push({
name: 'circuit_breakers',
status: openCircuits > 2 ? 'fail' : 'warn',
message: `${openCircuits} circuit breaker(s) are open`
})
if (openCircuits > 2) {
overallStatus = 'unhealthy'
} else if (overallStatus === 'healthy') {
overallStatus = 'degraded'
}
} else {
checks.push({
name: 'circuit_breakers',
status: 'pass'
})
}
// 3. Check memory usage (warn if > 90%)
const memUsage = process.memoryUsage()
const heapUsedPercent = (memUsage.heapUsed / memUsage.heapTotal) * 100
if (heapUsedPercent > 90) {
checks.push({
name: 'memory',
status: 'warn',
message: `Heap usage is ${heapUsedPercent.toFixed(1)}%`
})
if (overallStatus === 'healthy') overallStatus = 'degraded'
} else {
checks.push({
name: 'memory',
status: 'pass'
})
}
return {
status: overallStatus,
timestamp: Date.now(),
uptime: process.uptime(),
version: process.env.npm_package_version || 'unknown',
circuitBreakers,
cache: {
versionCache: {
hasCache: versionCacheStatus.hasCache,
isExpired: versionCacheStatus.isExpired,
ageMs: versionCacheStatus.age,
source: versionCacheStatus.source
}
},
checks
}
}
/**
* Simple health check - returns true if system is healthy or degraded.
* Use this for basic liveness probes.
*
* @returns true if healthy or degraded, false if unhealthy
*/
export function isHealthy(): boolean {
const status = getHealthStatus()
return status.status !== 'unhealthy'
}
/**
* Get a simple status string for minimal health endpoints.
*
* @returns 'ok', 'degraded', or 'error'
*/
export function getSimpleHealthStatus(): 'ok' | 'degraded' | 'error' {
const status = getHealthStatus()
switch (status.status) {
case 'healthy':
return 'ok'
case 'degraded':
return 'degraded'
case 'unhealthy':
return 'error'
}
}
+44 -308
View File
@@ -3,15 +3,7 @@ import { inflate } from 'zlib'
import { proto } from '../../WAProto/index.js'
import type { Chat, Contact, LIDMapping, WAMessage } from '../Types'
import { WAMessageStubType } from '../Types'
import {
isAnyLidUser,
isAnyPnUser,
isJidBroadcast,
isJidGroup,
isJidNewsletter,
jidDecode,
jidNormalizedUser
} from '../WABinary/index.js'
import { isHostedLidUser, isHostedPnUser, isLidUser, isPnUser } from '../WABinary'
import { toNumber } from './generics'
import type { ILogger } from './logger.js'
import { normalizeMessageContent } from './messages'
@@ -19,13 +11,24 @@ import { downloadContentFromMessage } from './messages-media'
const inflatePromise = promisify(inflate)
/**
* Downloads and decompresses history sync data from WhatsApp servers.
*
* @param msg - The history sync notification message containing download info
* @param options - Request options for the download
* @returns Decoded HistorySync protocol buffer
*/
const extractPnFromMessages = (messages: proto.IHistorySyncMsg[]): string | undefined => {
for (const msgItem of messages) {
const message = msgItem.message
// Only extract from outgoing messages (fromMe: true) in 1:1 chats
// because userReceipt.userJid is the recipient's JID
if (!message?.key?.fromMe || !message.userReceipt?.length) {
continue
}
const userJid = message.userReceipt[0]?.userJid
if (userJid && (isPnUser(userJid) || isHostedPnUser(userJid))) {
return userJid
}
}
return undefined
}
export const downloadHistory = async (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => {
const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options })
const bufferArray: Buffer[] = []
@@ -42,247 +45,18 @@ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotificatio
return syncData
}
/**
* Checks if a JID represents a person (can have LID-PN mapping).
* Excludes groups, broadcasts, newsletters, and bots.
*
* @param jid - The JID to check
* @returns true if the JID can have LID-PN mapping
*/
export function isPersonJid(jid: string | undefined): boolean {
if (!jid) {
return false
}
// Groups, broadcasts, and newsletters don't have LID-PN mappings
if (isJidGroup(jid) || isJidBroadcast(jid) || isJidNewsletter(jid)) {
return false
}
// Only person JIDs (LID or PN formats) can have mappings
return isAnyLidUser(jid) || isAnyPnUser(jid)
}
/**
* Extracts LID-PN mapping from a conversation object.
*
* WhatsApp uses two identifier systems:
* - LID (Logical ID): Format `{number}@lid` or `{number}@hosted.lid`
* - PN (Phone Number): Format `{number}@s.whatsapp.net` or `{number}@hosted`
*
* Conversations may have their ID in either format, with the alternate
* format stored in `lidJid` or `pnJid` properties respectively.
*
* Skips non-person JIDs:
* - `@g.us` (groups)
* - `@broadcast` (broadcast lists)
* - `@newsletter` (channels)
*
* @param chatId - The conversation ID (may be LID or PN format)
* @param lidJid - The LID JID if chat ID is PN format
* @param pnJid - The PN JID if chat ID is LID format
* @returns LID-PN mapping if extractable, undefined otherwise
*
* @example
* // Chat ID is LID, pnJid contains phone number
* extractLidPnFromConversation('123456789@lid', undefined, '5511999999999@s.whatsapp.net')
* // Returns: { lid: '123456789@lid', pn: '5511999999999@s.whatsapp.net' }
*
* @example
* // Chat ID is PN, lidJid contains LID
* extractLidPnFromConversation('5511999999999@s.whatsapp.net', '123456789@lid', undefined)
* // Returns: { lid: '123456789@lid', pn: '5511999999999@s.whatsapp.net' }
*
* @example
* // Newsletter - returns undefined (no mapping)
* extractLidPnFromConversation('123456789@newsletter', undefined, undefined)
* // Returns: undefined
*/
export function extractLidPnFromConversation(
chatId: string,
lidJid: string | undefined | null,
pnJid: string | undefined | null
): LIDMapping | undefined {
// Skip non-person JIDs (groups, broadcasts, newsletters)
if (!isPersonJid(chatId)) {
return undefined
}
// Check if chat ID is in LID format
const chatIsLid = isAnyLidUser(chatId)
// Check if chat ID is in PN format
const chatIsPn = isAnyPnUser(chatId)
if (chatIsLid && pnJid) {
// Chat ID is LID, pnJid contains the phone number
return {
lid: jidNormalizedUser(chatId),
pn: jidNormalizedUser(pnJid)
}
}
if (chatIsPn && lidJid) {
// Chat ID is PN, lidJid contains the LID
return {
lid: jidNormalizedUser(lidJid),
pn: jidNormalizedUser(chatId)
}
}
return undefined
}
/**
* Extracts LID-PN mapping from a message's alternative JID fields.
*
* Messages may contain alternate JID formats in:
* - `key.remoteJidAlt` - Alternative remote JID format
* - `key.participantAlt` - Alternative participant JID format (for groups)
*
* IMPORTANT: Uses || (OR) to ensure BOTH JIDs are person JIDs before extracting.
* This prevents "poisoned" mappings where one side is a group/newsletter/broadcast.
*
* @param remoteJid - The primary remote JID
* @param remoteJidAlt - The alternative remote JID (may be LID or PN)
* @param participant - The primary participant JID (for group messages)
* @param participantAlt - The alternative participant JID
* @returns LID-PN mapping if extractable, undefined otherwise
*/
export function extractLidPnFromMessage(
remoteJid: string | undefined | null,
remoteJidAlt: string | undefined | null,
participant: string | undefined | null,
participantAlt: string | undefined | null
): LIDMapping | undefined {
// For group messages, use participant fields
const primaryJid = participant || remoteJid
const altJid = participantAlt || remoteJidAlt
if (!primaryJid || !altJid) {
return undefined
}
// FIXED: Use || (OR) instead of && (AND)
// Both JIDs MUST be person JIDs to create a valid mapping.
// Using && would allow mixed scenarios (e.g., person + group) to proceed,
// resulting in invalid "poisoned" mappings.
if (!isPersonJid(primaryJid) || !isPersonJid(altJid)) {
return undefined
}
const primaryDecoded = jidDecode(primaryJid)
const altDecoded = jidDecode(altJid)
if (!primaryDecoded || !altDecoded) {
return undefined
}
// Determine which is LID and which is PN
const primaryIsLid = primaryDecoded.server === 'lid' || primaryDecoded.server === 'hosted.lid'
const altIsLid = altDecoded.server === 'lid' || altDecoded.server === 'hosted.lid'
if (primaryIsLid && !altIsLid) {
// Primary is LID, alt is PN
return {
lid: jidNormalizedUser(primaryJid),
pn: jidNormalizedUser(altJid)
}
}
if (!primaryIsLid && altIsLid) {
// Primary is PN, alt is LID
return {
lid: jidNormalizedUser(altJid),
pn: jidNormalizedUser(primaryJid)
}
}
return undefined
}
/**
* Extracts phone number (PN) from message userReceipt fields.
*
* This is a fallback mechanism for LID chats that don't have a pnJid property.
* It looks at outgoing messages (fromMe: true) and extracts the recipient's
* phone number from the userReceipt.userJid field.
*
* @param messages - Array of history sync messages from a conversation
* @returns The extracted phone number JID, or undefined if not found
*
* @see https://github.com/WhiskeySockets/Baileys/pull/2282
*/
const extractPnFromMessages = (messages: proto.IHistorySyncMsg[]): string | undefined => {
for (const msgItem of messages) {
const message = msgItem.message
// Only extract from outgoing messages (fromMe: true) in 1:1 chats
// because userReceipt.userJid is the recipient's JID
if (!message?.key?.fromMe || !message.userReceipt?.length) {
continue
}
const userJid = message.userReceipt[0]?.userJid
if (userJid && isAnyPnUser(userJid)) {
return userJid
}
}
return undefined
}
/**
* Processes a history sync message and extracts chats, contacts, messages,
* and LID-PN mappings.
*
* LID-PN mappings are extracted from three sources:
* 1. Top-level `phoneNumberToLidMappings` array in the history sync payload
* 2. Individual conversation objects that contain both LID and PN identifiers
* (via `lidJid` and `pnJid` properties)
* 3. Message objects with alternate JID fields (`remoteJidAlt`, `participantAlt`)
*
* This multi-source extraction ensures maximum mapping coverage, as WhatsApp may
* provide mappings in different locations depending on the sync type and context.
*
* Skipped JID types (no LID-PN mapping):
* - `@g.us` (groups)
* - `@broadcast` (broadcast lists)
* - `@newsletter` (channels)
*
* @param item - The history sync protocol buffer to process
* @param logger - Optional logger instance for trace-level debugging
* @returns Processed data including chats, contacts, messages, and LID-PN mappings
*
* @see https://github.com/WhiskeySockets/Baileys/issues/2263
*/
export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger) => {
logger?.trace({ syncType: item.syncType, progress: item.progress }, 'processing history sync')
const messages: WAMessage[] = []
const contacts: Contact[] = []
const chats: Chat[] = []
const lidPnMappings: LIDMapping[] = []
// Use Map for O(1) deduplication of LID-PN mappings
const lidPnMap = new Map<string, LIDMapping>()
logger?.trace({ progress: item.progress }, 'processing history of type ' + item.syncType?.toString())
/**
* Adds a LID-PN mapping to the map with deduplication.
* Uses LID as key since each LID should map to exactly one PN.
*/
const addLidPnMapping = (mapping: LIDMapping): void => {
// Normalize and validate
if (!mapping.lid || !mapping.pn) {
return
}
lidPnMap.set(mapping.lid, mapping)
}
// Source 1: Extract from top-level phoneNumberToLidMappings array
// Extract LID-PN mappings for all sync types
for (const m of item.phoneNumberToLidMappings || []) {
if (m.lidJid && m.pnJid) {
addLidPnMapping({
lid: jidNormalizedUser(m.lidJid),
pn: jidNormalizedUser(m.pnJid)
})
lidPnMappings.push({ lid: m.lidJid, pn: m.pnJid })
}
}
@@ -291,53 +65,36 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
case proto.HistorySync.HistorySyncType.RECENT:
case proto.HistorySync.HistorySyncType.FULL:
case proto.HistorySync.HistorySyncType.ON_DEMAND:
for (const chat of (item.conversations ?? []) as Chat[]) {
const chatId = chat.id!
// Source 2: Extract LID-PN mapping from conversation object
// This handles cases where the mapping isn't in phoneNumberToLidMappings
const conversationMapping = extractLidPnFromConversation(chatId, chat.lidJid, chat.pnJid)
if (conversationMapping) {
addLidPnMapping(conversationMapping)
} else if (isAnyLidUser(chatId) && !chat.pnJid) {
// Source 2b: Fallback - extract PN from userReceipt in messages when pnJid is missing
// This handles edge cases where the conversation is LID but pnJid wasn't provided
const pnFromReceipt = extractPnFromMessages(chat.messages || [])
if (pnFromReceipt) {
addLidPnMapping({
lid: jidNormalizedUser(chatId),
pn: jidNormalizedUser(pnFromReceipt)
})
}
}
for (const chat of item.conversations! as Chat[]) {
contacts.push({
id: chatId,
id: chat.id!,
name: chat.displayName || chat.name || chat.username || undefined,
lid: chat.lidJid || chat.accountLid || undefined,
phoneNumber: chat.pnJid || undefined
})
const chatId = chat.id!
const isLid = isLidUser(chatId) || isHostedLidUser(chatId)
const isPn = isPnUser(chatId) || isHostedPnUser(chatId)
if (isLid && chat.pnJid) {
lidPnMappings.push({ lid: chatId, pn: chat.pnJid })
} else if (isPn && chat.lidJid) {
lidPnMappings.push({ lid: chat.lidJid, pn: chatId })
} else if (isLid && !chat.pnJid) {
// Fallback: extract PN from userReceipt in messages when pnJid is missing
const pnFromReceipt = extractPnFromMessages(chat.messages || [])
if (pnFromReceipt) {
lidPnMappings.push({ lid: chatId, pn: pnFromReceipt })
}
}
const msgs = chat.messages || []
delete chat.messages
for (const item of msgs) {
if (!item.message) continue
const message = item.message as WAMessage
const message = item.message! as WAMessage
messages.push(message)
// Source 3: Extract LID-PN mapping from message's alternative JID fields
// Messages may have remoteJidAlt or participantAlt with alternate format
const messageMapping = extractLidPnFromMessage(
message.key.remoteJid,
message.key.remoteJidAlt,
message.key.participant,
message.key.participantAlt
)
if (messageMapping) {
addLidPnMapping(messageMapping)
}
if (!chat.messages?.length) {
// keep only the most recent message in the chat array
chat.messages = [{ message }]
@@ -353,7 +110,7 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
message.messageStubParameters?.[0]
) {
contacts.push({
id: message.key.participant || message.key.remoteJid || '',
id: message.key.participant || message.key.remoteJid!,
verifiedName: message.messageStubParameters?.[0]
})
}
@@ -364,16 +121,13 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
break
case proto.HistorySync.HistorySyncType.PUSH_NAME:
for (const c of item.pushnames ?? []) {
for (const c of item.pushnames!) {
contacts.push({ id: c.id!, notify: c.pushname! })
}
break
}
// Convert Map back to array for return
const lidPnMappings = Array.from(lidPnMap.values())
return {
chats,
contacts,
@@ -384,18 +138,6 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
}
}
/**
* Downloads and processes a history sync notification in one step.
*
* Handles two cases:
* - Inline payload: Decodes directly from `initialHistBootstrapInlinePayload`
* - Remote download: Fetches from WhatsApp servers via `downloadHistory`
*
* @param msg - The history sync notification message
* @param options - Request options for the download
* @param logger - Optional logger instance for trace-level debugging
* @returns Processed history data including chats, contacts, messages, and LID-PN mappings
*/
export const downloadAndProcessHistorySyncNotification = async (
msg: proto.Message.IHistorySyncNotification,
options: RequestInit,
@@ -411,15 +153,9 @@ export const downloadAndProcessHistorySyncNotification = async (
return processHistoryMessage(historyMsg, logger)
}
/**
* Extracts the history sync notification from a protocol message.
*
* @param message - The protocol message to check
* @returns The history sync notification if present, undefined otherwise
*/
export const getHistoryMsg = (message: proto.IMessage) => {
const normalizedContent = !!message ? normalizeMessageContent(message) : undefined
const anyHistoryMsg = normalizedContent?.protocolMessage?.historySyncNotification
const anyHistoryMsg = normalizedContent?.protocolMessage?.historySyncNotification!
return anyHistoryMsg
}
+16 -114
View File
@@ -1,35 +1,8 @@
/**
* Identity Change Handler
*
* Handles WhatsApp identity change notifications which occur when a contact's
* encryption keys change. This typically happens when:
* - User reinstalls WhatsApp
* - User changes devices
* - User's phone number verification expires and is re-verified
*
* This module centralizes the identity change logic to:
* - Prevent duplicate session refreshes via debouncing
* - Skip unnecessary refreshes for companion devices
* - Handle offline notification scenarios correctly
* - Provide clear result types for monitoring and debugging
*
* @module identity-change-handler
* @see https://github.com/WhiskeySockets/Baileys/issues/2132
*/
import NodeCache from '@cacheable/node-cache'
import { areJidsSameUser, type BinaryNode, getBinaryNodeChild, jidDecode } from '../WABinary'
import { isStringNullOrEmpty } from './generics'
import type { ILogger } from './logger'
// ============================================================================
// TYPES
// ============================================================================
/**
* Result type for identity change handling operations.
* Each action variant indicates a specific outcome with relevant context.
*/
export type IdentityChangeResult =
| { action: 'no_identity_node' }
| { action: 'invalid_notification' }
@@ -37,75 +10,19 @@ export type IdentityChangeResult =
| { action: 'skipped_self_primary' }
| { action: 'debounced' }
| { action: 'skipped_offline' }
| { action: 'session_refreshed'; hadExistingSession: boolean }
| { action: 'skipped_no_session' }
| { action: 'session_refreshed' }
| { action: 'session_refresh_failed'; error: unknown }
/**
* Context required for identity change handling.
* Provides access to session management and logging capabilities.
*/
export type IdentityChangeContext = {
/** Current user's phone number JID (e.g., "5511999999999@s.whatsapp.net") */
meId: string | undefined
/** Current user's LID (Logical ID) if available */
meLid: string | undefined
/** Function to validate if a session exists for a JID */
validateSession: (jid: string) => Promise<{ exists: boolean; reason?: string }>
/** Function to assert/refresh sessions for given JIDs */
assertSessions: (jids: string[], force?: boolean) => Promise<boolean>
/** Cache for debouncing identity change processing */
debounceCache: NodeCache<boolean>
/** Logger instance for debugging and monitoring */
logger: ILogger
}
// ============================================================================
// MAIN HANDLER
// ============================================================================
/**
* Handles an identity change notification from WhatsApp.
*
* This function processes identity change events with the following logic:
*
* 1. **Validation**: Ensures the notification has required fields (from, identity node)
* 2. **Companion Device Filter**: Skips notifications from companion devices (device > 0)
* as they don't require session refresh
* 3. **Self-Primary Skip**: Skips notifications for the current user's own identity
* 4. **Debouncing**: Prevents duplicate processing for the same JID within TTL window
* 5. **Offline Handling**: Skips refresh during offline notification processing
* 6. **Session Refresh**: Attempts to refresh/create the session with force flag
*
* **Important**: Identity change notifications signal that we need to rebuild the session,
* regardless of whether an existing session exists. This is critical for cases where
* the local session was cleared (e.g., after key reset or device restore).
*
* @param node - The binary node containing the identity change notification
* @param ctx - Context object with required dependencies
* @returns Promise resolving to the result of the identity change handling
*
* @example
* ```typescript
* const result = await handleIdentityChange(notificationNode, {
* meId: '5511999999999@s.whatsapp.net',
* meLid: undefined,
* validateSession: async (jid) => authState.keys.get('session', [jid]),
* assertSessions: async (jids, force) => assertSession(jids, force),
* debounceCache: new NodeCache({ stdTTL: 5 }),
* logger: pino()
* })
*
* switch (result.action) {
* case 'session_refreshed':
* console.log('Session refreshed, had existing:', result.hadExistingSession)
* break
* case 'session_refresh_failed':
* console.error('Failed to refresh session:', result.error)
* break
* // ... handle other cases
* }
* ```
*/
export async function handleIdentityChange(
node: BinaryNode,
ctx: IdentityChangeContext
@@ -115,7 +32,6 @@ export async function handleIdentityChange(
return { action: 'invalid_notification' }
}
// Check for identity node presence
const identityNode = getBinaryNodeChild(node, 'identity')
if (!identityNode) {
return { action: 'no_identity_node' }
@@ -123,57 +39,43 @@ export async function handleIdentityChange(
ctx.logger.info({ jid: from }, 'identity changed')
// Skip companion devices - they don't need session refresh
const decoded = jidDecode(from)
if (decoded?.device && decoded.device !== 0) {
ctx.logger.debug({ jid: from, device: decoded.device }, 'ignoring identity change from companion device')
return { action: 'skipped_companion_device', device: decoded.device }
}
// Skip self-primary identity changes
// FIX: Check meId and meLid independently to handle cases where only meLid exists
const matchesMeId = ctx.meId && areJidsSameUser(from, ctx.meId)
const matchesMeLid = ctx.meLid && areJidsSameUser(from, ctx.meLid)
if (matchesMeId || matchesMeLid) {
const isSelfPrimary = ctx.meId && (areJidsSameUser(from, ctx.meId) || (ctx.meLid && areJidsSameUser(from, ctx.meLid)))
if (isSelfPrimary) {
ctx.logger.info({ jid: from }, 'self primary identity changed')
return { action: 'skipped_self_primary' }
}
// Debounce to prevent duplicate processing
// Check early but don't set yet - we only want to debounce actual refresh attempts
if (ctx.debounceCache.get(from)) {
ctx.logger.debug({ jid: from }, 'skipping identity assert (debounced)')
return { action: 'debounced' }
}
// Skip refresh during offline notification processing
// Offline notifications are processed in batch and shouldn't trigger immediate refreshes
// FIX: Check this BEFORE setting debounce cache to avoid incorrect debouncing
ctx.debounceCache.set(from, true)
const isOfflineNotification = !isStringNullOrEmpty(node.attrs.offline)
const hasExistingSession = await ctx.validateSession(from)
if (!hasExistingSession.exists) {
ctx.logger.debug({ jid: from }, 'no old session, skipping session refresh')
return { action: 'skipped_no_session' }
}
ctx.logger.debug({ jid: from }, 'old session exists, will refresh session')
if (isOfflineNotification) {
ctx.logger.debug({ jid: from }, 'skipping session refresh during offline processing')
return { action: 'skipped_offline' }
}
// Check if we have an existing session (for logging purposes only)
// FIX: We no longer skip refresh when no session exists - identity change is the
// signal to rebuild the session, which is critical after key reset or device restore
const hasExistingSession = await ctx.validateSession(from)
if (hasExistingSession.exists) {
ctx.logger.debug({ jid: from }, 'existing session found, will refresh')
} else {
ctx.logger.debug({ jid: from }, 'no existing session, will create new session')
}
// FIX: Set debounce cache only immediately before the actual refresh attempt
// This ensures we don't incorrectly debounce when we exit early (offline, etc.)
ctx.debounceCache.set(from, true)
// Attempt session refresh/creation
try {
await ctx.assertSessions([from], true)
return { action: 'session_refreshed', hadExistingSession: hasExistingSession.exists }
return { action: 'session_refreshed' }
} catch (error) {
ctx.logger.warn({ error, jid: from }, 'failed to assert sessions after identity change')
return { action: 'session_refresh_failed', error }
+1 -31
View File
@@ -9,7 +9,6 @@ export * from './noise-handler'
export * from './history'
export * from './chat-utils'
export * from './lt-hash'
export { wasmBridgeReady } from './wasm-bridge'
export * from './auth-utils'
export * from './use-multi-file-auth-state'
export * from './link-preview'
@@ -17,34 +16,5 @@ export * from './event-buffer'
export * from './process-message'
export * from './message-retry-manager'
export * from './browser-utils'
// === Identity and Session Management ===
export * from './identity-change-handler'
// === Observability and Resilience Utilities ===
// Structured logging
export * from './structured-logger'
export * from './logger-adapter'
export * from './baileys-logger'
// Observability and tracing
export * from './trace-context'
export * from './prometheus-metrics'
// Resilience and performance
export * from './cache-utils'
export * from './circuit-breaker'
export * from './retry-utils'
// Telemetry and detection mitigation
export * from './unified-session'
// Version management
export * from './version-cache'
// Health monitoring
export * from './health-status'
// Event streaming
export * from './baileys-event-stream'
export * from './stanza-ack'
-374
View File
@@ -1,374 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* @fileoverview Adaptador entre diferentes sistemas de logging
* @module Utils/logger-adapter
*
* Fornece:
* - Adapter pattern para integrar diferentes loggers
* - Mapeamento de níveis de log entre sistemas
* - Transformação de formatos de log
* - Compatibilidade com Pino, Console e StructuredLogger
*/
import type P from 'pino'
import type { ILogger } from './logger.js'
import { createStructuredLogger, LOG_LEVEL_VALUES, type LogLevel, StructuredLogger } from './structured-logger.js'
/**
* Tipo de logger suportado
*/
export type LoggerType = 'pino' | 'console' | 'structured' | 'custom'
/**
* Configuração do adapter
*/
export interface LoggerAdapterConfig {
/** Tipo de logger de origem */
sourceType: LoggerType
/** Tipo de logger de destino */
targetType: LoggerType
/** Mapeamento customizado de níveis */
levelMapping?: Record<string, LogLevel>
/** Transformador de contexto */
contextTransformer?: (context: Record<string, unknown>) => Record<string, unknown>
/** Filtro de logs */
logFilter?: (level: LogLevel, message: string, data?: unknown) => boolean
}
/**
* Mapeamento padrão de níveis Pino para StructuredLogger
*/
const PINO_LEVEL_MAPPING: Record<number, LogLevel> = {
10: 'trace',
20: 'debug',
30: 'info',
40: 'warn',
50: 'error',
60: 'fatal'
}
/**
* Mapeamento reverso para Pino
*/
const STRUCTURED_TO_PINO_LEVEL: Record<LogLevel, number> = {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60,
silent: 100
}
/**
* Classe adaptadora principal
*/
export class LoggerAdapter implements ILogger {
private sourceLogger: ILogger
private targetLogger: ILogger | null = null
private config: LoggerAdapterConfig
constructor(sourceLogger: ILogger, config: Partial<LoggerAdapterConfig> = {}) {
this.sourceLogger = sourceLogger
this.config = {
sourceType: config.sourceType || 'pino',
targetType: config.targetType || 'structured',
levelMapping: config.levelMapping,
contextTransformer: config.contextTransformer,
logFilter: config.logFilter
}
}
get level(): string {
return this.sourceLogger.level
}
set level(newLevel: string) {
this.sourceLogger.level = newLevel
if (this.targetLogger) {
this.targetLogger.level = newLevel
}
}
/**
* Define o logger de destino
*/
setTargetLogger(logger: ILogger): void {
this.targetLogger = logger
}
/**
* Cria um logger filho
*/
child(obj: Record<string, unknown>): LoggerAdapter {
const transformedContext = this.config.contextTransformer ? this.config.contextTransformer(obj) : obj
const childAdapter = new LoggerAdapter(this.sourceLogger.child(transformedContext), this.config)
if (this.targetLogger) {
childAdapter.setTargetLogger(this.targetLogger.child(transformedContext))
}
return childAdapter
}
/**
* Mapeia nível de log
*/
private mapLevel(level: string | number): LogLevel {
if (typeof level === 'number') {
return PINO_LEVEL_MAPPING[level] || 'info'
}
if (this.config.levelMapping && level in this.config.levelMapping) {
return this.config.levelMapping[level] ?? 'info'
}
return (level as LogLevel) || 'info'
}
/**
* Verifica se o log deve ser processado
*/
private shouldLog(level: LogLevel, msg: string, obj?: unknown): boolean {
if (this.config.logFilter) {
return this.config.logFilter(level, msg, obj)
}
return true
}
/**
* Processa log em ambos loggers
*/
private processLog(level: LogLevel, obj: unknown, msg?: string): void {
if (!this.shouldLog(level, msg || '', obj)) {
return
}
// Log no source logger
const sourceMethod = this.sourceLogger[level as keyof ILogger]
if (typeof sourceMethod === 'function') {
;(sourceMethod as (obj: unknown, msg?: string) => void).call(this.sourceLogger, obj, msg)
}
// Log no target logger se configurado
if (this.targetLogger) {
const targetMethod = this.targetLogger[level as keyof ILogger]
if (typeof targetMethod === 'function') {
;(targetMethod as (obj: unknown, msg?: string) => void).call(this.targetLogger, obj, msg)
}
}
}
trace(obj: unknown, msg?: string): void {
this.processLog('trace', obj, msg)
}
debug(obj: unknown, msg?: string): void {
this.processLog('debug', obj, msg)
}
info(obj: unknown, msg?: string): void {
this.processLog('info', obj, msg)
}
warn(obj: unknown, msg?: string): void {
this.processLog('warn', obj, msg)
}
error(obj: unknown, msg?: string): void {
this.processLog('error', obj, msg)
}
}
/**
* Wrapper para converter Pino logger em StructuredLogger
*/
export class PinoToStructuredAdapter implements ILogger {
private pinoLogger: P.Logger
private structuredLogger: StructuredLogger
constructor(pinoLogger: P.Logger, structuredLoggerConfig?: Parameters<typeof createStructuredLogger>[0]) {
this.pinoLogger = pinoLogger
this.structuredLogger = createStructuredLogger({
level: this.mapPinoLevel(pinoLogger.level),
...structuredLoggerConfig
})
}
get level(): string {
return this.pinoLogger.level
}
set level(newLevel: string) {
this.pinoLogger.level = newLevel
this.structuredLogger.level = newLevel
}
private mapPinoLevel(pinoLevel: string): LogLevel {
const levelMap: Record<string, LogLevel> = {
trace: 'trace',
debug: 'debug',
info: 'info',
warn: 'warn',
error: 'error',
fatal: 'fatal',
silent: 'silent'
}
return levelMap[pinoLevel] || 'info'
}
child(obj: Record<string, unknown>): PinoToStructuredAdapter {
const adapter = new PinoToStructuredAdapter(this.pinoLogger.child(obj))
return adapter
}
trace(obj: unknown, msg?: string): void {
this.pinoLogger.trace(obj as object, msg)
this.structuredLogger.trace(obj, msg)
}
debug(obj: unknown, msg?: string): void {
this.pinoLogger.debug(obj as object, msg)
this.structuredLogger.debug(obj, msg)
}
info(obj: unknown, msg?: string): void {
this.pinoLogger.info(obj as object, msg)
this.structuredLogger.info(obj, msg)
}
warn(obj: unknown, msg?: string): void {
this.pinoLogger.warn(obj as object, msg)
this.structuredLogger.warn(obj, msg)
}
error(obj: unknown, msg?: string): void {
this.pinoLogger.error(obj as object, msg)
this.structuredLogger.error(obj, msg)
}
/**
* Obtém métricas do structured logger
*/
getMetrics() {
return this.structuredLogger.getMetrics()
}
}
/**
* Factory para criar adapter baseado no tipo de logger
*/
export function createLoggerAdapter(logger: ILogger, config?: Partial<LoggerAdapterConfig>): LoggerAdapter {
return new LoggerAdapter(logger, config)
}
/**
* Converte qualquer logger para a interface ILogger
*/
export function normalizeLogger(logger: unknown): ILogger {
if (isILogger(logger)) {
return logger
}
// Se for um objeto com métodos de log
if (typeof logger === 'object' && logger !== null) {
const logObj = logger as Record<string, unknown>
return {
level: (logObj.level as string) || 'info',
child: (obj: Record<string, unknown>) => {
if (typeof logObj.child === 'function') {
return normalizeLogger((logObj.child as (obj: Record<string, unknown>) => unknown)(obj))
}
return normalizeLogger(logger)
},
trace: createLogMethod(logObj, 'trace'),
debug: createLogMethod(logObj, 'debug'),
info: createLogMethod(logObj, 'info'),
warn: createLogMethod(logObj, 'warn'),
error: createLogMethod(logObj, 'error')
}
}
// Fallback: console logger
return createConsoleLogger()
}
/**
* Verifica se objeto implementa ILogger
*/
export function isILogger(obj: unknown): obj is ILogger {
if (typeof obj !== 'object' || obj === null) {
return false
}
const logger = obj as Record<string, unknown>
return (
typeof logger.child === 'function' &&
typeof logger.trace === 'function' &&
typeof logger.debug === 'function' &&
typeof logger.info === 'function' &&
typeof logger.warn === 'function' &&
typeof logger.error === 'function'
)
}
/**
* Cria método de log genérico
*/
function createLogMethod(logger: Record<string, unknown>, level: string): (obj: unknown, msg?: string) => void {
return (obj: unknown, msg?: string) => {
if (typeof logger[level] === 'function') {
;(logger[level] as (obj: unknown, msg?: string) => void)(obj, msg)
} else {
// Fallback to console methods
const consoleMethod = (console as unknown as Record<string, ((...args: unknown[]) => void) | undefined>)[level]
if (typeof consoleMethod === 'function') {
consoleMethod(obj, msg)
}
}
}
}
/**
* Cria um logger baseado em console
*/
export function createConsoleLogger(prefix?: string): ILogger {
const formatMessage = (level: string, obj: unknown, msg?: string): string => {
const timestamp = new Date().toISOString()
const prefixStr = prefix ? `[${prefix}]` : ''
const message = msg || (typeof obj === 'string' ? obj : '')
const data = typeof obj === 'object' ? JSON.stringify(obj) : ''
return `${timestamp} ${prefixStr}[${level.toUpperCase()}] ${message} ${data}`.trim()
}
return {
level: 'info',
child(obj: Record<string, unknown>): ILogger {
const childPrefix = prefix ? `${prefix}:${Object.values(obj)[0]}` : String(Object.values(obj)[0])
return createConsoleLogger(childPrefix)
},
trace(obj: unknown, msg?: string): void {
console.debug(formatMessage('trace', obj, msg))
},
debug(obj: unknown, msg?: string): void {
console.debug(formatMessage('debug', obj, msg))
},
info(obj: unknown, msg?: string): void {
console.info(formatMessage('info', obj, msg))
},
warn(obj: unknown, msg?: string): void {
console.warn(formatMessage('warn', obj, msg))
},
error(obj: unknown, msg?: string): void {
console.error(formatMessage('error', obj, msg))
}
}
}
export default LoggerAdapter
+2 -143
View File
@@ -1,22 +1,4 @@
/**
* Configurable Logger for Baileys
*
* Supports environment variables:
* - BAILEYS_LOG: Enable/disable logging (default: true)
* - BAILEYS_LOG_LEVEL: Log level - trace/debug/info/warn/error/fatal/silent (default: info)
* - USE_STRUCTURED_LOGS: Use structured logger with advanced features (default: false)
* - LOG_FORMAT: Output format - 'json' or 'pretty' (default: json)
* - LOGGER_INFO: Enable info level (default: true)
* - LOGGER_WARN: Enable warn level (default: true)
* - LOGGER_ERROR: Enable error level (default: true)
*
* @module Utils/logger
*/
import { createRequire } from 'module'
import P, { type Logger as PinoLogger } from 'pino'
const require = createRequire(import.meta.url)
import P from 'pino'
export interface ILogger {
level: string
@@ -28,127 +10,4 @@ export interface ILogger {
error(obj: unknown, msg?: string): void
}
/**
* Logger configuration from environment variables
*/
interface LoggerConfig {
enabled: boolean
level: string
format: 'json' | 'pretty'
useStructuredLogs: boolean
levelFilters: {
info: boolean
warn: boolean
error: boolean
}
}
/**
* Load logger configuration from environment
*/
function loadLoggerConfig(): LoggerConfig {
return {
enabled: process.env.BAILEYS_LOG !== 'false',
level: process.env.BAILEYS_LOG_LEVEL || 'info',
format: (process.env.LOG_FORMAT as 'json' | 'pretty') || 'json',
useStructuredLogs: process.env.USE_STRUCTURED_LOGS === 'true',
levelFilters: {
info: process.env.LOGGER_INFO !== 'false',
warn: process.env.LOGGER_WARN !== 'false',
error: process.env.LOGGER_ERROR !== 'false'
}
}
}
function canUsePrettyTransport(): boolean {
try {
require.resolve('pino-pretty')
return true
} catch {
return false
}
}
/**
* Create a filtered logger that respects LOGGER_INFO/WARN/ERROR settings
*/
function createFilteredLogger(baseLogger: PinoLogger, config: LoggerConfig): ILogger {
const noop = () => {}
return {
get level() {
return baseLogger.level
},
set level(newLevel: string) {
baseLogger.level = newLevel
},
child(obj: Record<string, unknown>): ILogger {
return createFilteredLogger(baseLogger.child(obj), config)
},
trace: baseLogger.trace.bind(baseLogger),
debug: baseLogger.debug.bind(baseLogger),
info: config.levelFilters.info ? baseLogger.info.bind(baseLogger) : noop,
warn: config.levelFilters.warn ? baseLogger.warn.bind(baseLogger) : noop,
error: config.levelFilters.error ? baseLogger.error.bind(baseLogger) : noop
}
}
/**
* Create a silent logger when logging is disabled
*/
function createSilentLogger(): ILogger {
const noop = () => {}
const silentLogger: ILogger = {
level: 'silent',
child: () => silentLogger,
trace: noop,
debug: noop,
info: noop,
warn: noop,
error: noop
}
return silentLogger
}
/**
* Create the configured logger instance
*/
function createLogger(): ILogger {
const config = loadLoggerConfig()
// If logging is disabled, return silent logger
if (!config.enabled) {
return createSilentLogger()
}
// Create pino options
const pinoOptions: P.LoggerOptions = {
level: config.level,
timestamp: () => `,"time":"${new Date().toJSON()}"`
}
// Add pretty printing when explicitly set and available
if (config.format === 'pretty' && canUsePrettyTransport()) {
pinoOptions.transport = {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname'
}
}
}
const baseLogger = P(pinoOptions)
return createFilteredLogger(baseLogger, config)
}
// Export the configured logger instance
const logger = createLogger()
export default logger
// Export config loader for testing/inspection
export { loadLoggerConfig, type LoggerConfig }
export default P({ timestamp: () => `,"time":"${new Date().toJSON()}"` })
+7 -2
View File
@@ -1,3 +1,8 @@
import { getLTHashAntiTampering } from './wasm-bridge'
import { LTHashAntiTampering } from 'whatsapp-rust-bridge'
export { getLTHashAntiTampering as LT_HASH_ANTI_TAMPERING }
/**
* LT Hash is a summation based hash algorithm that maintains the integrity of a piece of data
* over a series of mutations. You can add/remove mutations and it'll return a hash equal to
* if the same series of mutations was made sequentially.
*/
export const LT_HASH_ANTI_TAMPERING = new LTHashAntiTampering()
+37 -136
View File
@@ -1,7 +1,6 @@
import { LRUCache } from 'lru-cache'
import type { proto } from '../../WAProto/index.js'
import type { ILogger } from './logger'
import { metrics } from './prometheus-metrics.js'
/** Number of sent messages to cache in memory for handling retry receipts */
const RECENT_MESSAGES_SIZE = 512
@@ -11,63 +10,6 @@ const MESSAGE_KEY_SEPARATOR = '\u0000'
/** Timeout for session recreation - 1 hour */
const RECREATE_SESSION_TIMEOUT = 60 * 60 * 1000 // 1 hour in milliseconds
const PHONE_REQUEST_DELAY = 3000
/**
* Retry reason codes from WhatsApp protocol
* These map to the error codes sent in retry receipts
*
* @see https://github.com/WhiskeySockets/Baileys/pull/2307
*/
export enum RetryReason {
/** Unknown or unspecified error */
UnknownError = 0,
/** No Signal session exists for recipient */
SignalErrorNoSession = 1,
/** Invalid key format or corrupted key */
SignalErrorInvalidKey = 2,
/** Invalid pre-key ID (key not found) */
SignalErrorInvalidKeyId = 3,
/** Invalid message - MAC verification failed */
SignalErrorInvalidMessage = 4,
/** Invalid signature on message or key */
SignalErrorInvalidSignature = 5,
/** Message from the future (timestamp issue) */
SignalErrorFutureMessage = 6,
/** Explicit MAC verification failure */
SignalErrorBadMac = 7,
/** Session is corrupted or invalid state */
SignalErrorInvalidSession = 8,
/** Invalid message key (decryption key issue) */
SignalErrorInvalidMsgKey = 9,
/** Bad broadcast ephemeral setting */
BadBroadcastEphemeralSetting = 10,
/** Unknown companion device without pre-key */
UnknownCompanionNoPrekey = 11,
/** ADV (Announcement Delivery Verification) failure */
AdvFailure = 12,
/** Status revoke was delayed */
StatusRevokeDelay = 13
}
/**
* MAC error codes that indicate identity key mismatch
* These errors occur when the sender's identity key has changed (e.g., reinstalled WhatsApp)
* and require immediate session recreation without waiting for the normal timeout
*/
export const MAC_ERROR_CODES = new Set<RetryReason>([
RetryReason.SignalErrorInvalidMessage,
RetryReason.SignalErrorBadMac
])
/**
* Session-related error codes that may require session recreation
*/
export const SESSION_ERROR_CODES = new Set<RetryReason>([
RetryReason.SignalErrorNoSession,
RetryReason.SignalErrorInvalidSession,
RetryReason.SignalErrorInvalidKey,
RetryReason.SignalErrorInvalidKeyId
])
export interface RecentMessageKey {
to: string
id: string
@@ -97,6 +39,29 @@ export interface RetryStatistics {
phoneRequests: number
}
// Retry reason codes matching WhatsApp Web's Signal error codes.
export enum RetryReason {
UnknownError = 0,
SignalErrorNoSession = 1,
SignalErrorInvalidKey = 2,
SignalErrorInvalidKeyId = 3,
/** MAC verification failed - most common cause of decryption failures */
SignalErrorInvalidMessage = 4,
SignalErrorInvalidSignature = 5,
SignalErrorFutureMessage = 6,
/** Explicit MAC failure - session is definitely out of sync */
SignalErrorBadMac = 7,
SignalErrorInvalidSession = 8,
SignalErrorInvalidMsgKey = 9,
BadBroadcastEphemeralSetting = 10,
UnknownCompanionNoPrekey = 11,
AdvFailure = 12,
StatusRevokeDelay = 13
}
/** Error codes that indicate a MAC failure and require immediate session recreation */
const MAC_ERROR_CODES = new Set([RetryReason.SignalErrorInvalidMessage, RetryReason.SignalErrorBadMac])
export class MessageRetryManager {
private recentMessagesMap = new LRUCache<string, RecentMessage>({
max: RECENT_MESSAGES_SIZE,
@@ -156,44 +121,17 @@ export class MessageRetryManager {
}
/**
* Get a recent message from the cache.
*
* First attempts an exact `to+id` key lookup. If that misses which happens when
* the retry receipt arrives from a device-specific JID (e.g. `55123:82@s.whatsapp.net`)
* while the message was stored under the normalised base JID (`55123@s.whatsapp.net`),
* or when the JID domain flipped between LID and PN falls back to the `messageKeyIndex`
* which maps bare message IDs to stored keys regardless of the `to` format.
* Get a recent message from the cache
*/
getRecentMessage(to: string, id: string): RecentMessage | undefined {
const key: RecentMessageKey = { to, id }
const keyStr = this.keyToString(key)
const exact = this.recentMessagesMap.get(keyStr)
if (exact) return exact
// Fallback: look up by message ID only to handle JID format mismatches
// (device suffix present/absent, LID vs PN, etc.)
const indexedKeyStr = this.messageKeyIndex.get(id)
if (indexedKeyStr) {
const message = this.recentMessagesMap.get(indexedKeyStr)
if (!message) {
// The LRU cache evicted this entry; clean up the stale index reference
// to prevent repeated futile lookups on subsequent retry receipts.
this.messageKeyIndex.delete(id)
}
return message
}
return undefined
return this.recentMessagesMap.get(keyStr)
}
/**
* Check if a session should be recreated based on retry count, history, and error code
*
* @param jid - The JID of the recipient
* @param hasSession - Whether a Signal session exists for this JID
* @param errorCode - Optional error code from the retry receipt (indicates type of failure)
* @returns Object with reason string and boolean indicating if session should be recreated
* Check if a session should be recreated based on retry count, history, and error code.
* MAC errors (codes 4 and 7) trigger immediate session recreation regardless of timeout.
*/
shouldRecreateSession(
jid: string,
@@ -204,46 +142,26 @@ export class MessageRetryManager {
if (!hasSession) {
this.sessionRecreateHistory.set(jid, Date.now())
this.statistics.sessionRecreations++
metrics.signalSessionRecreations?.inc({ reason: 'no_session' })
return {
reason: "we don't have a Signal session with them",
recreate: true
}
}
// MAC errors require IMMEDIATE session recreation regardless of history
// This handles the case where contact reinstalled WhatsApp (identity key changed)
// IMMEDIATE recreation for MAC errors - session is definitely out of sync
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
this.sessionRecreateHistory.set(jid, Date.now())
this.statistics.sessionRecreations++
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
metrics.signalMacErrors?.inc({ action: 'session_recreation' })
metrics.signalSessionRecreations?.inc({ reason: 'mac_error' })
this.logger.warn({ jid, errorCode: reasonName }, 'MAC error detected, forcing immediate session recreation')
this.logger.warn(
{ jid, errorCode: RetryReason[errorCode] },
'MAC error detected, forcing immediate session recreation'
)
return {
reason: `MAC error (${reasonName}) - contact may have reinstalled WhatsApp`,
reason: `MAC error (code ${errorCode}: ${RetryReason[errorCode]}), immediate session recreation`,
recreate: true
}
}
// Session-related errors also warrant recreation
if (errorCode !== undefined && SESSION_ERROR_CODES.has(errorCode)) {
const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid)
// For session errors, use a shorter timeout (5 minutes) since these are more severe
const sessionErrorTimeout = 5 * 60 * 1000
if (!prevTime || now - prevTime > sessionErrorTimeout) {
this.sessionRecreateHistory.set(jid, now)
this.statistics.sessionRecreations++
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
metrics.signalSessionRecreations?.inc({ reason: 'session_error' })
return {
reason: `Session error (${reasonName})`,
recreate: true
}
}
}
const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid)
@@ -251,7 +169,6 @@ export class MessageRetryManager {
if (!prevTime || now - prevTime > RECREATE_SESSION_TIMEOUT) {
this.sessionRecreateHistory.set(jid, now)
this.statistics.sessionRecreations++
metrics.signalSessionRecreations?.inc({ reason: 'timeout_exceeded' })
return {
reason: 'retry count > 1 and over an hour since last recreation',
recreate: true
@@ -262,10 +179,8 @@ export class MessageRetryManager {
}
/**
* Parse error code from retry receipt attribute
*
* @param errorAttr - The error attribute string from the retry receipt
* @returns Parsed RetryReason or undefined if invalid
* Parse error code from retry receipt's retry node.
* Returns undefined if no error code is present.
*/
parseRetryErrorCode(errorAttr: string | undefined): RetryReason | undefined {
if (errorAttr === undefined || errorAttr === '') {
@@ -277,35 +192,21 @@ export class MessageRetryManager {
return undefined
}
// Validate code is within known range
// Validate it's a known RetryReason
if (code >= RetryReason.UnknownError && code <= RetryReason.StatusRevokeDelay) {
return code as RetryReason
}
// Unknown code, treat as UnknownError
return RetryReason.UnknownError
}
/**
* Check if an error code indicates a MAC verification failure
*
* @param errorCode - The retry error code to check
* @returns True if this is a MAC error requiring immediate session recreation
* Check if an error code indicates a MAC failure
*/
isMacError(errorCode: RetryReason | undefined): boolean {
return errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)
}
/**
* Check if an error code indicates a session-related failure
*
* @param errorCode - The retry error code to check
* @returns True if this is a session error
*/
isSessionError(errorCode: RetryReason | undefined): boolean {
return errorCode !== undefined && SESSION_ERROR_CODES.has(errorCode)
}
/**
* Increment retry counter for a message
*/
+23 -49
View File
@@ -1,5 +1,5 @@
import { Boom } from '@hapi/boom'
import { execFile } from 'child_process'
import { exec } from 'child_process'
import * as Crypto from 'crypto'
import { once } from 'events'
import { createReadStream, createWriteStream, promises as fs, WriteStream } from 'fs'
@@ -31,12 +31,7 @@ import type { ILogger } from './logger'
const getTmpFilesDirectory = () => tmpdir()
/**
* Get available image processing library (Sharp or Jimp)
* Exported for use in sticker pack processing
* @returns Object with sharp or jimp property, or throws if neither available
*/
export const getImageProcessingLibrary = async () => {
const getImageProcessingLibrary = async () => {
//@ts-ignore
const [jimp, sharp] = await Promise.all([import('jimp').catch(() => {}), import('sharp').catch(() => {})])
@@ -98,10 +93,10 @@ export const getRawMediaUploadData = async (media: WAMediaUpload, mediaType: Med
}
/** generates all the keys required to encrypt/decrypt & sign a media message */
export function getMediaKeys(
export async function getMediaKeys(
buffer: Uint8Array | string | null | undefined,
mediaType: MediaType
): MediaDecryptionKeyInfo {
): Promise<MediaDecryptionKeyInfo> {
if (!buffer) {
throw new Boom('Cannot derive from empty media key')
}
@@ -127,17 +122,14 @@ const extractVideoThumb = async (
size: { width: number; height: number }
) =>
new Promise<void>((resolve, reject) => {
execFile(
'ffmpeg',
['-ss', time, '-i', path, '-y', '-vf', `scale=${size.width}:-1`, '-vframes', '1', '-f', 'image2', destPath],
err => {
if (err) {
reject(err)
} else {
resolve()
}
const cmd = `ffmpeg -ss ${time} -i ${path} -y -vf scale=${size.width}:-1 -vframes 1 -f image2 ${destPath}`
exec(cmd, err => {
if (err) {
reject(err)
} else {
resolve()
}
)
})
})
export const extractImageThumb = async (bufferOrFilePath: Readable | Buffer | string, width = 32) => {
@@ -388,22 +380,19 @@ type EncryptedStreamOptions = {
saveOriginalFileIfRequired?: boolean
logger?: ILogger
opts?: RequestInit
/** Optional mediaKey to reuse (required for sticker pack thumbnail to match ZIP encryption) */
mediaKey?: Uint8Array
}
export const encryptedStream = async (
media: WAMediaUpload,
mediaType: MediaType,
{ logger, saveOriginalFileIfRequired, opts, mediaKey: providedMediaKey }: EncryptedStreamOptions = {}
{ logger, saveOriginalFileIfRequired, opts }: EncryptedStreamOptions = {}
) => {
const { stream, type } = await getStream(media, opts)
logger?.debug('fetched media stream')
// Use provided mediaKey or generate new one
const mediaKey = providedMediaKey || Crypto.randomBytes(32)
const { cipherKey, iv, macKey } = getMediaKeys(mediaKey, mediaType)
const mediaKey = Crypto.randomBytes(32)
const { cipherKey, iv, macKey } = await getMediaKeys(mediaKey, mediaType)
const encFilePath = join(getTmpFilesDirectory(), mediaType + generateMessageIDV2() + '-enc')
const encFileWriteStream = createWriteStream(encFilePath)
@@ -418,11 +407,7 @@ export const encryptedStream = async (
let fileLength = 0
const aes = Crypto.createCipheriv('aes-256-cbc', cipherKey, iv)
if (!macKey) {
throw new Boom('Failed to derive media mac key')
}
const hmac = Crypto.createHmac('sha256', macKey).update(iv)
const hmac = Crypto.createHmac('sha256', macKey!).update(iv)
const sha256Plain = Crypto.createHash('sha256')
const sha256Enc = Crypto.createHash('sha256')
@@ -541,7 +526,7 @@ export const downloadContentFromMessage = async (
throw new Boom('No valid media URL or directPath present in message', { statusCode: 400 })
}
const keys = getMediaKeys(mediaKey, type)
const keys = await getMediaKeys(mediaKey, type)
return downloadEncryptedContent(downloadUrl, keys, opts)
}
@@ -599,8 +584,8 @@ export const downloadEncryptedContent = async (
const pushBytes = (bytes: Buffer, push: (bytes: Buffer) => void) => {
if (startByte || endByte) {
const start = bytesFetched >= (startByte ?? 0) ? undefined : Math.max((startByte ?? 0) - bytesFetched, 0)
const end = bytesFetched + bytes.length < (endByte ?? 0) ? undefined : Math.max((endByte ?? 0) - bytesFetched, 0)
const start = bytesFetched >= startByte! ? undefined : Math.max(startByte! - bytesFetched, 0)
const end = bytesFetched + bytes.length < endByte! ? undefined : Math.max(endByte! - bytesFetched, 0)
push(bytes.slice(start, end))
@@ -904,25 +889,17 @@ const getMediaRetryKey = (mediaKey: Buffer | Uint8Array) => {
* Generate a binary node that will request the phone to re-upload the media & return the newly uploaded URL
*/
export const encryptMediaRetryRequest = (key: WAMessageKey, mediaKey: Buffer | Uint8Array, meId: string) => {
if (!key.id) {
throw new Boom('Missing message ID for media retry request')
}
if (!key.remoteJid) {
throw new Boom('Missing remote JID for media retry request')
}
const recp: proto.IServerErrorReceipt = { stanzaId: key.id }
const recpBuffer = proto.ServerErrorReceipt.encode(recp).finish()
const iv = Crypto.randomBytes(12)
const retryKey = getMediaRetryKey(mediaKey)
const ciphertext = aesEncryptGCM(recpBuffer, retryKey, iv, Buffer.from(key.id))
const ciphertext = aesEncryptGCM(recpBuffer, retryKey, iv, Buffer.from(key.id!))
const req: BinaryNode = {
tag: 'receipt',
attrs: {
id: key.id,
id: key.id!,
to: jidNormalizedUser(meId),
type: 'server-error'
},
@@ -941,7 +918,7 @@ export const encryptMediaRetryRequest = (key: WAMessageKey, mediaKey: Buffer | U
{
tag: 'rmr',
attrs: {
jid: key.remoteJid,
jid: key.remoteJid!,
from_me: (!!key.fromMe).toString(),
// @ts-ignore
participant: key.participant || undefined
@@ -954,10 +931,7 @@ export const encryptMediaRetryRequest = (key: WAMessageKey, mediaKey: Buffer | U
}
export const decodeMediaRetryNode = (node: BinaryNode) => {
const rmrNode = getBinaryNodeChild(node, 'rmr')
if (!rmrNode) {
throw new Boom('Missing rmr node in media retry response')
}
const rmrNode = getBinaryNodeChild(node, 'rmr')!
const event: BaileysEventMap['messages.media-update'][number] = {
key: {
@@ -970,7 +944,7 @@ export const decodeMediaRetryNode = (node: BinaryNode) => {
const errorNode = getBinaryNodeChild(node, 'error')
if (errorNode) {
const errorCode = +(errorNode.attrs.code ?? '0')
const errorCode = +errorNode.attrs.code!
event.error = new Boom(`Failed to re-upload media (${errorCode})`, {
data: errorNode.attrs,
statusCode: getStatusCodeForMediaRetry(errorCode)
+40 -1158
View File
File diff suppressed because it is too large Load Diff
+12 -28
View File
@@ -64,9 +64,9 @@ export const makeNoiseHandler = ({
const data = Buffer.from(NOISE_MODE)
let hash = data.byteLength === 32 ? data : sha256(data)
let salt: Buffer = hash
let encKey: Buffer = hash
let decKey: Buffer = hash
let salt: Uint8Array = hash
let encKey: Uint8Array = hash
let decKey: Uint8Array = hash
let counter = 0
let sentIntro = false
@@ -123,9 +123,9 @@ export const makeNoiseHandler = ({
const mixIntoKey = (data: Uint8Array) => {
const [write, read] = localHKDF(data)
salt = Buffer.from(write)
encKey = Buffer.from(read)
decKey = Buffer.from(read)
salt = write
encKey = read
decKey = read
counter = 0
}
@@ -180,25 +180,13 @@ export const makeNoiseHandler = ({
mixIntoKey,
finishInit,
processHandshake: ({ serverHello }: proto.HandshakeMessage, noiseKey: KeyPair) => {
if (!serverHello?.ephemeral) {
throw new Boom('Missing server hello ephemeral', { statusCode: 500 })
}
authenticate(serverHello!.ephemeral!)
mixIntoKey(Curve.sharedKey(privateKey, serverHello!.ephemeral!))
if (!serverHello?.static) {
throw new Boom('Missing server hello static', { statusCode: 500 })
}
if (!serverHello?.payload) {
throw new Boom('Missing server hello payload', { statusCode: 500 })
}
authenticate(serverHello.ephemeral)
mixIntoKey(Curve.sharedKey(privateKey, serverHello.ephemeral))
const decStaticContent = decrypt(serverHello.static)
const decStaticContent = decrypt(serverHello!.static!)
mixIntoKey(Curve.sharedKey(privateKey, decStaticContent))
const certDecoded = decrypt(serverHello.payload)
const certDecoded = decrypt(serverHello!.payload!)
const { intermediate: certIntermediate, leaf } = proto.CertChain.decode(certDecoded)
// leaf
@@ -214,11 +202,7 @@ export const makeNoiseHandler = ({
const { issuerSerial } = details
if (!details.key) {
throw new Boom('Missing certificate key', { statusCode: 500 })
}
const verify = Curve.verify(details.key, leaf.details, leaf.signature)
const verify = Curve.verify(details.key!, leaf.details, leaf.signature)
const verifyIntermediate = Curve.verify(
WA_CERT_DETAILS.PUBLIC_KEY,
@@ -239,7 +223,7 @@ export const makeNoiseHandler = ({
}
const keyEnc = encrypt(noiseKey.public)
mixIntoKey(Curve.sharedKey(noiseKey.private, serverHello.ephemeral))
mixIntoKey(Curve.sharedKey(noiseKey.private, serverHello!.ephemeral!))
return keyEnc
},
+70
View File
@@ -0,0 +1,70 @@
import type { BinaryNode } from '../WABinary'
export type MessageType = 'message' | 'call' | 'receipt' | 'notification'
type OfflineNode = {
type: MessageType
node: BinaryNode
}
export type OfflineNodeProcessorDeps = {
isWsOpen: () => boolean
onUnexpectedError: (error: Error, msg: string) => void
yieldToEventLoop: () => Promise<void>
}
/**
* Creates a processor for offline stanza nodes that:
* - Queues nodes for sequential processing
* - Yields to the event loop periodically to avoid blocking
* - Catches handler errors to prevent the processing loop from crashing
*/
export function makeOfflineNodeProcessor(
nodeProcessorMap: Map<MessageType, (node: BinaryNode) => Promise<void>>,
deps: OfflineNodeProcessorDeps,
batchSize = 10
) {
const nodes: OfflineNode[] = []
let isProcessing = false
const enqueue = (type: MessageType, node: BinaryNode) => {
nodes.push({ type, node })
if (isProcessing) {
return
}
isProcessing = true
const promise = async () => {
let processedInBatch = 0
while (nodes.length && deps.isWsOpen()) {
const { type, node } = nodes.shift()!
const nodeProcessor = nodeProcessorMap.get(type)
if (!nodeProcessor) {
deps.onUnexpectedError(new Error(`unknown offline node type: ${type}`), 'processing offline node')
continue
}
await nodeProcessor(node).catch(err => deps.onUnexpectedError(err, `processing offline ${type}`))
processedInBatch++
// Yield to event loop after processing a batch
// This prevents blocking the event loop for too long when there are many offline nodes
if (processedInBatch >= batchSize) {
processedInBatch = 0
await deps.yieldToEventLoop()
}
}
isProcessing = false
}
promise().catch(error => deps.onUnexpectedError(error, 'processing offline nodes'))
}
return { enqueue }
}
-59
View File
@@ -8,34 +8,11 @@ import type { ILogger } from './logger'
export class PreKeyManager {
private readonly queues = new Map<string, PQueue>()
/**
* Destroyed flag - protected by atomic check-and-set in destroy()
*
* THREAD SAFETY: Prevents operations from executing after destroy() is called.
* All public methods check this flag before proceeding.
*
* CRITICAL: Prevents race conditions where:
* - Operations add tasks to queues after they've been cleared/paused
* - New queues are created after destroy() has cleaned them up
* - Tasks execute on destroyed resources
*/
private destroyed = false
constructor(
private readonly store: SignalKeyStore,
private readonly logger: ILogger
) {}
/**
* Check if manager has been destroyed
* @throws Error if manager has been destroyed
*/
private checkDestroyed(): void {
if (this.destroyed) {
throw new Error('PreKeyManager has been destroyed - cannot perform operations')
}
}
/**
* Get or create a queue for a specific key type
*/
@@ -57,9 +34,6 @@ export class PreKeyManager {
mutations: SignalDataSet,
isInTransaction: boolean
): Promise<void> {
// PROTECTION: Check destroyed flag before processing
this.checkDestroyed()
const keyData = data[keyType]
if (!keyData) return
@@ -131,9 +105,6 @@ export class PreKeyManager {
* Validate and process pre-key deletions outside transactions
*/
async validateDeletions(data: SignalDataSet, keyType: keyof SignalDataTypeMap): Promise<void> {
// PROTECTION: Check destroyed flag before processing
this.checkDestroyed()
const keyData = data[keyType]
if (!keyData) return
@@ -152,34 +123,4 @@ export class PreKeyManager {
}
})
}
/**
* Cleanup all queues and resources
* Should be called during connection cleanup to prevent memory leaks
*/
destroy(): void {
// PROTECTION: Atomic check-and-set to prevent race conditions
// Flag is set IMMEDIATELY after check, BEFORE any operations
// This prevents:
// 1. Multiple calls to destroy() (reentrancy guard)
// 2. Operations from executing after destroy() starts
// 3. New queues from being created after cleanup
if (this.destroyed) {
this.logger.debug('PreKeyManager already destroyed')
return
}
this.destroyed = true // ← Set IMMEDIATELY to close race window
this.logger.debug('🗑️ Destroying PreKeyManager')
this.queues.forEach((queue, keyType) => {
queue.clear()
queue.pause()
this.logger.debug(`Queue for ${keyType} cleared and paused`)
})
this.queues.clear()
this.logger.debug('PreKeyManager destroyed - all queues cleaned up')
}
}
+54 -225
View File
@@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import Long from 'long'
import { proto } from '../../WAProto/index.js'
import type {
AuthenticationCreds,
@@ -10,7 +8,6 @@ import type {
GroupParticipant,
LIDMapping,
ParticipantAction,
PlaceholderMessageData,
RequestJoinAction,
RequestJoinMethod,
SignalKeyStoreWithTransaction,
@@ -36,7 +33,6 @@ import { aesDecryptGCM, hmacSign } from './crypto'
import { getKeyAuthor, toNumber } from './generics'
import { downloadAndProcessHistorySyncNotification } from './history'
import type { ILogger } from './logger'
import { metrics, recordHistorySyncMessages } from './prometheus-metrics.js'
type ProcessMessageContext = {
shouldProcessHistoryMsg: boolean
@@ -83,17 +79,11 @@ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) =>
const content = normalizeMessageContent(message.message)
// if the message has a reaction, ensure fromMe & remoteJid are from our perspective
if (content?.reactionMessage) {
const reactionKey = content.reactionMessage.key
if (reactionKey) {
normaliseKey(reactionKey)
}
normaliseKey(content.reactionMessage.key!)
}
if (content?.pollUpdateMessage) {
const pollCreationKey = content.pollUpdateMessage.pollCreationMessageKey
if (pollCreationKey) {
normaliseKey(pollCreationKey)
}
normaliseKey(content.pollUpdateMessage.pollCreationMessageKey!)
}
function normaliseKey(msgKey: WAMessageKey) {
@@ -117,52 +107,14 @@ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) =>
}
}
export const normalizeMessageJids = async (
message: WAMessage,
signalRepository: SignalRepositoryWithLIDStore,
logger?: ILogger
): Promise<void> => {
const resolveLidToPn = async (jid: string | undefined | null): Promise<string | undefined> => {
if (!jid) {
return undefined
}
if (isLidUser(jid) || isHostedLidUser(jid)) {
const pn = await signalRepository.lidMapping.getPNForLID(jid)
if (pn) {
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN for inbound message')
} else {
logger?.debug({ lid: jid }, 'PN not found for inbound LID, keeping LID')
}
return pn || jid
}
return jid
}
// Execute both lookups in parallel instead of sequentially to reduce latency
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([
resolveLidToPn(message.key.remoteJid),
resolveLidToPn(message.key.participant)
])
if (resolvedRemoteJid) {
message.key.remoteJid = resolvedRemoteJid
}
if (resolvedParticipant) {
message.key.participant = resolvedParticipant
}
}
// TODO: target:audit AUDIT THIS FUNCTION AGAIN
export const isRealMessage = (message: WAMessage) => {
const normalizedContent = normalizeMessageContent(message.message)
const hasSomeContent = !!getContentType(normalizedContent)
const stubType = message.messageStubType ?? 0
return (
(!!normalizedContent || REAL_MSG_STUB_TYPES.has(stubType) || REAL_MSG_REQ_ME_STUB_TYPES.has(stubType)) &&
(!!normalizedContent ||
REAL_MSG_STUB_TYPES.has(message.messageStubType!) ||
REAL_MSG_REQ_ME_STUB_TYPES.has(message.messageStubType!)) &&
hasSomeContent &&
!normalizedContent?.protocolMessage &&
!normalizedContent?.reactionMessage &&
@@ -228,11 +180,7 @@ export function decryptPollVote(
const decKey = hmacSign(sign, key0, 'sha256')
const aad = toBinary(`${pollMsgId}\u0000${voterJid}`)
if (!encPayload || !encIv) {
throw new Error('Missing encPayload or encIv for poll vote decryption')
}
const decrypted = aesDecryptGCM(encPayload, decKey, encIv, aad)
const decrypted = aesDecryptGCM(encPayload!, decKey, encIv!, aad)
return proto.Message.PollVoteMessage.decode(decrypted)
function toBinary(txt: string) {
@@ -262,11 +210,7 @@ export function decryptEventResponse(
const decKey = hmacSign(sign, key0, 'sha256')
const aad = toBinary(`${eventMsgId}\u0000${responderJid}`)
if (!encPayload || !encIv) {
throw new Error('Missing encPayload or encIv for event response decryption')
}
const decrypted = aesDecryptGCM(encPayload, decKey, encIv, aad)
const decrypted = aesDecryptGCM(encPayload!, decKey, encIv!, aad)
return proto.Message.EventResponseMessage.decode(decrypted)
function toBinary(txt: string) {
@@ -288,13 +232,7 @@ const processMessage = async (
getMessage
}: ProcessMessageContext
) => {
const meUser = creds.me
if (!meUser) {
logger?.warn({ messageKey: message.key }, 'processMessage: creds.me not set, skipping message')
return
}
const meId = meUser.id
const meId = creds.me!.id
const { accountSettings } = creds
const chat: Partial<Chat> = { id: jidNormalizedUser(getChatId(message.key)) }
@@ -322,11 +260,7 @@ const processMessage = async (
if (protocolMsg) {
switch (protocolMsg.type) {
case proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION:
const histNotification = protocolMsg.historySyncNotification
if (!histNotification) {
break
}
const histNotification = protocolMsg.historySyncNotification!
const process = shouldProcessHistoryMsg
const isLatest = !creds.processedHistoryMessages?.length
@@ -353,64 +287,32 @@ const processMessage = async (
const data = await downloadAndProcessHistorySyncNotification(histNotification, options, logger)
// Emit LID-PN mappings from history sync
// This is how WhatsApp Web learns mappings for chats with non-contacts
if (data.lidPnMappings?.length) {
logger?.debug({ count: data.lidPnMappings.length }, 'processing LID-PN mappings from history sync')
// eslint-disable-next-line max-depth
try {
const result = await signalRepository.lidMapping.storeLIDPNMappings(data.lidPnMappings)
logger?.debug(
{ stored: result.stored, skipped: result.skipped, errors: result.errors },
'stored LID-PN mappings from history sync'
)
// eslint-disable-next-line max-depth
if (result.stored > 0) {
logger?.info({ stored: result.stored }, 'fallback LID mappings are now available from history sync')
}
} catch (error) {
logger?.warn({ error }, 'Failed to store LID-PN mappings from history sync')
}
// Emit all mappings at once for better performance
// eslint-disable-next-line max-depth
if (data.lidPnMappings.length > 0) {
ev.emit('lid-mapping.update', data.lidPnMappings)
}
await signalRepository.lidMapping
.storeLIDPNMappings(data.lidPnMappings)
.catch(err => logger?.warn({ err }, 'failed to store LID-PN mappings from history sync'))
}
ev.emit('messaging-history.set', {
...data,
isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined,
chunkOrder: histNotification.chunkOrder,
peerDataRequestSessionId: histNotification.peerDataRequestSessionId
})
// Record history sync metrics
if (data.messages?.length) {
recordHistorySyncMessages(data.messages.length)
}
}
break
case proto.Message.ProtocolMessage.Type.APP_STATE_SYNC_KEY_SHARE:
const keys = protocolMsg.appStateSyncKeyShare?.keys
const keys = protocolMsg.appStateSyncKeyShare!.keys
if (keys?.length) {
let newAppStateSyncKeyId = ''
await keyStore.transaction(async () => {
const newKeys: string[] = []
for (const { keyData, keyId } of keys) {
const keyIdValue = keyId?.keyId
if (!keyIdValue) {
continue
}
const strKeyId = Buffer.from(keyIdValue).toString('base64')
const strKeyId = Buffer.from(keyId!.keyId!).toString('base64')
newKeys.push(strKeyId)
if (keyData) {
await keyStore.set({ 'app-state-sync-key': { [strKeyId]: keyData } })
}
await keyStore.set({ 'app-state-sync-key': { [strKeyId]: keyData! } })
newAppStateSyncKeyId = strKeyId
}
@@ -429,7 +331,7 @@ const processMessage = async (
{
key: {
...message.key,
id: protocolMsg.key?.id
id: protocolMsg.key!.id
},
update: { message: null, messageStubType: WAMessageStubType.REVOKE, key: message.key }
}
@@ -442,109 +344,55 @@ const processMessage = async (
})
break
case proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE:
const response = protocolMsg.peerDataOperationRequestResponseMessage
const response = protocolMsg.peerDataOperationRequestResponseMessage!
if (response) {
// Retrieve cached metadata BEFORE deletion
// This preserves original message details that the phone might not send
const cachedData = response.stanzaId
? await placeholderResendCache?.get<PlaceholderMessageData | boolean>(response.stanzaId)
: undefined
// Clean up cache after retrieving data
if (response.stanzaId) {
await placeholderResendCache?.del(response.stanzaId)
}
// TODO: IMPLEMENT HISTORY SYNC ETC (sticker uploads etc.).
const { peerDataOperationResult } = response
if (!peerDataOperationResult) {
break
}
let recoveredCount = 0
const peerDataOperationResult = response.peerDataOperationResult || []
for (const result of peerDataOperationResult) {
const { placeholderMessageResendResponse: retryResponse } = result
const retryResponse = result?.placeholderMessageResendResponse
//eslint-disable-next-line max-depth
if (retryResponse) {
// eslint-disable-next-line max-depth
if (!retryResponse.webMessageInfoBytes) {
continue
}
if (!retryResponse?.webMessageInfoBytes) {
continue
}
//eslint-disable-next-line max-depth
try {
const webMessageInfo = proto.WebMessageInfo.decode(retryResponse.webMessageInfoBytes)
// Merge cached metadata with decoded message
// This ensures we don't lose critical information like pushName and LID mappings
// eslint-disable-next-line max-depth
if (cachedData && typeof cachedData === 'object') {
// Preserve pushName if not present in PDO response
// eslint-disable-next-line max-depth
if (cachedData.pushName && !webMessageInfo.pushName) {
webMessageInfo.pushName = cachedData.pushName
logger?.debug({ msgId: webMessageInfo.key?.id }, 'CTWA: Restored pushName from cached metadata')
}
// Preserve participantAlt (LID) if not present in PDO response
// This is critical for maintaining LID/PN mapping in groups
// eslint-disable-next-line max-depth
if (cachedData.participantAlt && webMessageInfo.key) {
const msgKey = webMessageInfo.key as WAMessageKey
// eslint-disable-next-line max-depth
if (!msgKey.participantAlt) {
msgKey.participantAlt = cachedData.participantAlt
logger?.debug(
{ msgId: webMessageInfo.key?.id, participantAlt: cachedData.participantAlt },
'CTWA: Restored participantAlt (LID) from cached metadata'
)
}
}
// Preserve original participant if not in PDO response
// eslint-disable-next-line max-depth
if (cachedData.participant && webMessageInfo.key && !webMessageInfo.key.participant) {
webMessageInfo.key.participant = cachedData.participant
logger?.debug({ msgId: webMessageInfo.key?.id }, 'CTWA: Restored participant from cached metadata')
}
// Only use cached timestamp if PDO response doesn't have one
// PDO response timestamp is more authoritative if present
// eslint-disable-next-line max-depth
if (!webMessageInfo.messageTimestamp && cachedData.messageTimestamp) {
webMessageInfo.messageTimestamp = cachedData.messageTimestamp
}
const msgId = webMessageInfo.key?.id
// Retrieve cached original message data (preserves LID details,
// timestamps, etc. that the phone may omit in its PDO response)
const cachedData = msgId ? await placeholderResendCache?.get<Partial<WAMessage> | true>(msgId) : undefined
//eslint-disable-next-line max-depth
if (msgId) {
await placeholderResendCache?.del(msgId)
}
// Track CTWA message recovery success
recoveredCount++
logger?.info(
{
msgId: webMessageInfo.key?.id,
remoteJid: webMessageInfo.key?.remoteJid,
requestId: response.stanzaId,
hasMetadata: !!cachedData && typeof cachedData === 'object'
},
'CTWA: Successfully recovered message via placeholder resend'
)
let finalMsg: WAMessage
//eslint-disable-next-line max-depth
if (cachedData && typeof cachedData === 'object') {
// Apply decoded message content onto cached metadata (preserves LID etc.)
cachedData.message = webMessageInfo.message
//eslint-disable-next-line max-depth
if (webMessageInfo.messageTimestamp) {
cachedData.messageTimestamp = webMessageInfo.messageTimestamp
}
finalMsg = cachedData as WAMessage
} else {
finalMsg = webMessageInfo as WAMessage
}
logger?.debug({ msgId, requestId: response.stanzaId }, 'received 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', {
messages: [webMessageInfo as WAMessage],
messages: [finalMsg],
type: 'notify',
requestId: response.stanzaId!
})
} catch (err) {
logger?.warn({ err, stanzaId: response.stanzaId }, 'failed to decode placeholder resend response')
}
}
// 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
@@ -580,11 +428,7 @@ const processMessage = async (
break
case proto.Message.ProtocolMessage.Type.LID_MIGRATION_MAPPING_SYNC:
const encodedPayload = protocolMsg.lidMigrationMappingSyncMessage?.encodedMappingPayload
if (!encodedPayload) {
break
}
const encodedPayload = protocolMsg.lidMigrationMappingSyncMessage?.encodedMappingPayload!
const { pnToLidMappings, chatDbMigrationTimestamp } =
proto.LIDMigrationMappingSyncPayload.decode(encodedPayload)
logger?.debug({ pnToLidMappings, chatDbMigrationTimestamp }, 'got lid mappings and chat db migration timestamp')
@@ -602,12 +446,6 @@ const processMessage = async (
}
}
} else if (content?.reactionMessage) {
const reactionKey = content.reactionMessage.key
if (!reactionKey) {
logger?.warn({ messageKey: message.key }, 'processMessage: reactionMessage.key missing, skipping')
return
}
const reaction: proto.IReaction = {
...content.reactionMessage,
key: message.key
@@ -615,16 +453,12 @@ const processMessage = async (
ev.emit('messages.reaction', [
{
reaction,
key: reactionKey
key: content.reactionMessage?.key!
}
])
} else if (content?.encEventResponseMessage) {
const encEventResponse = content.encEventResponseMessage
const creationMsgKey = encEventResponse.eventCreationMessageKey
if (!creationMsgKey) {
logger?.warn({ messageKey: message.key }, 'processMessage: eventCreationMessageKey missing, skipping')
return
}
const creationMsgKey = encEventResponse.eventCreationMessageKey!
// we need to fetch the event creation message to get the event enc key
const eventMsg = await getMessage(creationMsgKey)
@@ -637,13 +471,8 @@ const processMessage = async (
const eventCreatorPn = isLidUser(eventCreatorKey)
? await signalRepository.lidMapping.getPNForLID(eventCreatorKey)
: eventCreatorKey
if (!eventCreatorPn) {
logger?.warn({ messageKey: message.key, eventCreatorKey }, 'processMessage: eventCreatorPn missing, skipping')
return
}
const eventCreatorJid = getKeyAuthor(
{ remoteJid: jidNormalizedUser(eventCreatorPn), fromMe: meIdNormalised === eventCreatorPn },
{ remoteJid: jidNormalizedUser(eventCreatorPn!), fromMe: meIdNormalised === eventCreatorPn },
meIdNormalised
)
File diff suppressed because it is too large Load Diff
-700
View File
@@ -1,700 +0,0 @@
/**
* Smart Retry Logic
*
* Provides:
* - Exponential backoff
* - Jitter to avoid thundering herd
* - Configurable max attempts
* - Customizable retry predicates
* - Circuit breaker integration
* - Event hooks
* - Cancellation support
*
* @module Utils/retry-utils
*/
import { EventEmitter } from 'events'
import type { CircuitBreaker } from './circuit-breaker.js'
import { metrics } from './prometheus-metrics.js'
/**
* Retry configuration with custom progressive backoff
* Fixed delay steps in milliseconds: 1s 2s 5s 10s 20s
* Exported for external use (e.g., custom retry logic)
*/
export const RETRY_BACKOFF_DELAYS = [1000, 2000, 5000, 10000, 20000] as const
/**
* Jitter factor for retry delays (0.15 = ±15% randomization)
* Helps prevent thundering herd problem
*/
export const RETRY_JITTER_FACTOR = 0.15 as const
/**
* Backoff strategies
*/
export type BackoffStrategy = 'exponential' | 'linear' | 'constant' | 'fibonacci' | 'stepped'
/**
* Retry configuration options
*/
export interface RetryOptions {
/** Maximum number of attempts (default: 3) */
maxAttempts?: number
/** Base delay in ms (default: 1000) */
baseDelay?: number
/** Maximum delay in ms (default: 30000) */
maxDelay?: number
/** Backoff strategy (default: exponential) */
backoffStrategy?: BackoffStrategy
/** Multiplier for exponential backoff (default: 2) */
backoffMultiplier?: number
/** Jitter percentage (0-1, default: 0.1) */
jitter?: number
/** Function to determine if should retry */
shouldRetry?: (error: Error, attempt: number) => boolean | Promise<boolean>
/** Timeout per attempt in ms */
timeout?: number
/** Operation name for metrics */
operationName?: string
/** Collect metrics */
collectMetrics?: boolean
/** Circuit breaker for integration */
circuitBreaker?: CircuitBreaker
/** Callback before each retry */
onRetry?: (error: Error, attempt: number, delay: number) => void | Promise<void>
/** Callback on success */
onSuccess?: (result: unknown, attempt: number) => void
/** Callback on final failure */
onFailure?: (error: Error, attempts: number) => void
/** Signal for cancellation */
abortSignal?: AbortSignal
}
/**
* Result of operation with retry
*/
export interface RetryResult<T> {
success: boolean
result?: T
error?: Error
attempts: number
totalDuration: number
lastAttemptDuration: number
}
/**
* Retry context
*/
export interface RetryContext {
attempt: number
maxAttempts: number
lastError?: Error
startTime: number
aborted: boolean
}
/**
* Retry exhausted error
*/
export class RetryExhaustedError extends Error {
constructor(
public readonly originalError: Error,
public readonly attempts: number,
public readonly operationName?: string
) {
super(
`Retry exhausted after ${attempts} attempts${operationName ? ` for "${operationName}"` : ''}: ${originalError.message}`
)
this.name = 'RetryExhaustedError'
}
}
/**
* Abort error
*/
export class RetryAbortedError extends Error {
constructor(public readonly attempt: number) {
super(`Retry aborted at attempt ${attempt}`)
this.name = 'RetryAbortedError'
}
}
/**
* Calculate delay based on strategy
*/
export function calculateDelay(
attempt: number,
baseDelay: number,
maxDelay: number,
strategy: BackoffStrategy,
multiplier: number,
jitter: number
): number {
// Normalize attempt to ensure valid calculation (must be >= 1)
const normalizedAttempt = Math.max(attempt, 1)
let delay: number
switch (strategy) {
case 'exponential':
delay = baseDelay * Math.pow(multiplier, normalizedAttempt - 1)
break
case 'linear':
delay = baseDelay * normalizedAttempt
break
case 'constant':
delay = baseDelay
break
case 'fibonacci': {
const fib = fibonacciNumber(normalizedAttempt)
delay = baseDelay * fib
break
}
case 'stepped': {
// Uses pre-defined delay array directly (ignores baseDelay/multiplier)
// Falls back to last delay if attempt exceeds array length
const index = Math.min(normalizedAttempt - 1, RETRY_BACKOFF_DELAYS.length - 1)
delay = RETRY_BACKOFF_DELAYS[index] ?? RETRY_BACKOFF_DELAYS[0] ?? baseDelay
break
}
default:
delay = baseDelay
}
// Apply jitter BEFORE capping to maxDelay
if (jitter > 0) {
const jitterAmount = delay * jitter
delay = delay + (Math.random() * 2 - 1) * jitterAmount
}
// Apply max delay cap AFTER jitter to ensure we never exceed maxDelay
delay = Math.min(delay, maxDelay)
return Math.max(0, Math.round(delay))
}
/**
* Calculate Fibonacci number
*/
function fibonacciNumber(n: number): number {
if (n <= 1) return 1
let a = 1,
b = 1
for (let i = 2; i < n; i++) {
const c = a + b
a = b
b = c
}
return b
}
/**
* Sleep with abort support
*/
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
let abortHandler: (() => void) | undefined
const timer = setTimeout(() => {
// Cleanup abort listener on normal completion to prevent memory leak
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler)
}
resolve()
}, ms)
if (signal) {
if (signal.aborted) {
clearTimeout(timer)
reject(new RetryAbortedError(0))
return
}
abortHandler = () => {
clearTimeout(timer)
reject(new RetryAbortedError(0))
}
signal.addEventListener('abort', abortHandler, { once: true })
}
})
}
/**
* Execute operation with timeout
*/
async function executeWithTimeout<T>(operation: () => Promise<T>, timeout: number, signal?: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
let completed = false
const timer = setTimeout(() => {
if (!completed) {
completed = true
reject(new Error(`Operation timed out after ${timeout}ms`))
}
}, timeout)
if (signal?.aborted) {
clearTimeout(timer)
reject(new RetryAbortedError(0))
return
}
operation()
.then(result => {
if (!completed) {
completed = true
clearTimeout(timer)
resolve(result)
}
})
.catch(error => {
if (!completed) {
completed = true
clearTimeout(timer)
reject(error)
}
})
})
}
/**
* Main retry function
*/
export async function retry<T>(
operation: (context: RetryContext) => T | Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const config = {
maxAttempts: options.maxAttempts ?? 3,
baseDelay: options.baseDelay ?? 1000,
maxDelay: options.maxDelay ?? 30000,
backoffStrategy: options.backoffStrategy ?? 'exponential',
backoffMultiplier: options.backoffMultiplier ?? 2,
jitter: options.jitter ?? 0.1,
shouldRetry: options.shouldRetry ?? (() => true),
timeout: options.timeout,
operationName: options.operationName ?? 'operation',
collectMetrics: options.collectMetrics ?? true,
circuitBreaker: options.circuitBreaker,
onRetry: options.onRetry ?? (() => {}),
onSuccess: options.onSuccess ?? (() => {}),
onFailure: options.onFailure ?? (() => {}),
abortSignal: options.abortSignal
}
const context: RetryContext = {
attempt: 0,
maxAttempts: config.maxAttempts,
startTime: Date.now(),
aborted: false
}
let lastError: Error | undefined
// Check initial abort
if (config.abortSignal?.aborted) {
throw new RetryAbortedError(0)
}
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
context.attempt = attempt
// Check abort
if (config.abortSignal?.aborted) {
context.aborted = true
throw new RetryAbortedError(attempt)
}
// Check circuit breaker
if (config.circuitBreaker?.isOpen()) {
throw new Error(`Circuit breaker "${config.circuitBreaker.getName()}" is open`)
}
try {
// Execute operation
let result: T
if (config.timeout) {
result = await executeWithTimeout(() => Promise.resolve(operation(context)), config.timeout, config.abortSignal)
} else {
result = await operation(context)
}
// Success - only count as retry success if this wasn't the first attempt
if (config.collectMetrics && attempt > 1) {
// This was a successful retry (not first attempt)
metrics.retries.inc({ operation: config.operationName })
}
config.onSuccess(result, attempt)
return result
} catch (error) {
lastError = error as Error
context.lastError = lastError
// Check if should retry
const shouldRetry = await config.shouldRetry(lastError, attempt)
if (!shouldRetry || attempt >= config.maxAttempts) {
// Final failure - use dedicated retry exhausted metric
if (config.collectMetrics) {
metrics.retryExhausted.inc({ operation: config.operationName })
}
config.onFailure(lastError, attempt)
throw new RetryExhaustedError(lastError, attempt, config.operationName)
}
// Calculate delay
const delay = calculateDelay(
attempt,
config.baseDelay,
config.maxDelay,
config.backoffStrategy,
config.backoffMultiplier,
config.jitter
)
// Retry callback
await config.onRetry(lastError, attempt, delay)
if (config.collectMetrics) {
metrics.retryLatency.observe({ operation: config.operationName }, delay)
}
// Wait for delay
await sleep(delay, config.abortSignal)
}
}
// Should never reach here, but TypeScript needs this
throw new RetryExhaustedError(lastError || new Error('Unknown error'), config.maxAttempts, config.operationName)
}
/**
* Retry with detailed result
*/
export async function retryWithResult<T>(
operation: (context: RetryContext) => T | Promise<T>,
options: RetryOptions = {}
): Promise<RetryResult<T>> {
const startTime = Date.now()
let attempts = 0
let lastAttemptStart = startTime
try {
const result = await retry(context => {
attempts = context.attempt
lastAttemptStart = Date.now()
return operation(context)
}, options)
return {
success: true,
result,
attempts,
totalDuration: Date.now() - startTime,
lastAttemptDuration: Date.now() - lastAttemptStart
}
} catch (error) {
return {
success: false,
error: error as Error,
attempts,
totalDuration: Date.now() - startTime,
lastAttemptDuration: Date.now() - lastAttemptStart
}
}
}
/**
* Factory to create configured retry function
*/
export function createRetrier(defaultOptions: RetryOptions = {}) {
return <T>(operation: (context: RetryContext) => T | Promise<T>, options?: RetryOptions): Promise<T> => {
return retry(operation, { ...defaultOptions, ...options })
}
}
/**
* Decorator to add retry to method
*/
export function withRetry(options: RetryOptions = {}) {
return function (
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => unknown>
) {
const originalMethod = descriptor.value
if (!originalMethod) return descriptor
descriptor.value = async function (...args: unknown[]): Promise<unknown> {
return retry(() => originalMethod.apply(this, args), {
...options,
operationName: options.operationName || propertyKey
})
}
return descriptor
}
}
/**
* Wrapper for function with retry
*/
// eslint-disable-next-line space-before-function-paren
export function retryable<T extends (...args: unknown[]) => unknown>(
fn: T,
options: RetryOptions = {}
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
return async (...args: Parameters<T>): Promise<ReturnType<T>> => {
return retry(() => fn(...args), options) as Promise<ReturnType<T>>
}
}
/**
* Class to manage retries with state
*/
export class RetryManager extends EventEmitter {
private activeRetries: Map<string, { cancel: () => void; context: RetryContext }> = new Map()
private defaultOptions: RetryOptions
constructor(defaultOptions: RetryOptions = {}) {
super()
this.defaultOptions = defaultOptions
}
/**
* Execute operation with retry
*/
async execute<T>(
id: string,
operation: (context: RetryContext) => T | Promise<T>,
options?: RetryOptions
): Promise<T> {
// Cancel previous retry with same ID
this.cancel(id)
const abortController = new AbortController()
const mergedOptions = { ...this.defaultOptions, ...options, abortSignal: abortController.signal }
const retryPromise = retry(context => {
this.activeRetries.set(id, {
cancel: () => abortController.abort(),
context
})
this.emit('attempt', { id, attempt: context.attempt })
return operation(context)
}, mergedOptions)
try {
const result = await retryPromise
this.emit('success', { id })
return result
} catch (error) {
this.emit('failure', { id, error })
throw error
} finally {
this.activeRetries.delete(id)
}
}
/**
* Cancel in-progress retry
*/
cancel(id: string): boolean {
const active = this.activeRetries.get(id)
if (active) {
active.cancel()
this.activeRetries.delete(id)
this.emit('cancelled', { id })
return true
}
return false
}
/**
* Cancel all retries
*/
cancelAll(): void {
for (const [id, active] of this.activeRetries) {
active.cancel()
this.emit('cancelled', { id })
}
this.activeRetries.clear()
}
/**
* Check if there is an active retry
*/
isActive(id: string): boolean {
return this.activeRetries.has(id)
}
/**
* Return active retry context
*/
getContext(id: string): RetryContext | undefined {
return this.activeRetries.get(id)?.context
}
/**
* Return active retry IDs
*/
getActiveIds(): string[] {
return Array.from(this.activeRetries.keys())
}
}
/**
* Common predicates for shouldRetry
*/
export const retryPredicates = {
/** Always retry (up to max attempts) */
always: () => true,
/** Never retry */
never: () => false,
/** Retry only on network errors */
onNetworkError: (error: Error) => {
const networkErrors = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN']
return networkErrors.some(code => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
},
/** Retry only on specific errors */
onErrorCodes:
(codes: string[]) =>
(error: Error): boolean => {
return codes.some(code => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
},
/** Retry except on specific errors */
exceptErrorCodes:
(codes: string[]) =>
(error: Error): boolean => {
return !codes.some(code => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
},
/** Retry on HTTP 5xx errors or timeout */
onServerError: (error: Error) => {
const message = error.message.toLowerCase()
return (
message.includes('500') ||
message.includes('502') ||
message.includes('503') ||
message.includes('504') ||
message.includes('timeout')
)
},
/** Combine multiple predicates with OR */
or:
(...predicates: Array<(error: Error, attempt: number) => boolean>) =>
(error: Error, attempt: number): boolean => {
return predicates.some(p => p(error, attempt))
},
/** Combine multiple predicates with AND */
and:
(...predicates: Array<(error: Error, attempt: number) => boolean>) =>
(error: Error, attempt: number): boolean => {
return predicates.every(p => p(error, attempt))
}
}
/**
* Pre-defined retry configurations
*/
export const retryConfigs = {
/** Aggressive retry (many attempts, short delays) */
aggressive: {
maxAttempts: 10,
baseDelay: 100,
maxDelay: 5000,
backoffStrategy: 'exponential' as const,
jitter: 0.2
},
/** Conservative retry (few attempts, long delays) */
conservative: {
maxAttempts: 3,
baseDelay: 2000,
maxDelay: 60000,
backoffStrategy: 'exponential' as const,
jitter: 0.1
},
/** Fast retry (for short operations) */
fast: {
maxAttempts: 5,
baseDelay: 50,
maxDelay: 1000,
backoffStrategy: 'linear' as const,
jitter: 0.05
},
/** Retry for network operations */
network: {
maxAttempts: 5,
baseDelay: 1000,
maxDelay: 30000,
backoffStrategy: 'exponential' as const,
jitter: 0.1,
shouldRetry: retryPredicates.onNetworkError
},
/**
* RSocket-style retry with stepped delays
* Uses fixed delay array: 1s, 2s, 5s, 10s, 20s (with ±15% jitter)
*
* NOTE: Values are hardcoded instead of referencing RETRY_BACKOFF_DELAYS/RETRY_JITTER_FACTOR
* to prevent "Cannot access before initialization" errors in ESM environments.
* This occurs when modules are loaded in specific orders due to indirect circular imports
* (e.g., via prometheus-metrics.ts -> circuit-breaker.ts chain).
* Keep these values in sync with the constants above (lines 25, 31).
*/
rsocket: {
maxAttempts: 5, // = RETRY_BACKOFF_DELAYS.length
baseDelay: 1000, // = RETRY_BACKOFF_DELAYS[0]
maxDelay: 20000, // = RETRY_BACKOFF_DELAYS[4]
backoffStrategy: 'stepped' as const,
jitter: 0.15 // = RETRY_JITTER_FACTOR
}
}
/**
* Get retry delay with jitter applied
* Uses RETRY_BACKOFF_DELAYS and RETRY_JITTER_FACTOR defined locally
*
* @param attempt - Current attempt number (1-based)
* @returns Delay in ms with jitter applied
*/
export function getRetryDelayWithJitter(attempt: number): number {
const index = Math.min(Math.max(attempt - 1, 0), RETRY_BACKOFF_DELAYS.length - 1)
const baseDelay = RETRY_BACKOFF_DELAYS[index] ?? RETRY_BACKOFF_DELAYS[0] ?? 1000
const jitterRange = baseDelay * RETRY_JITTER_FACTOR
const jitter = (Math.random() * 2 - 1) * jitterRange // ±15%
return Math.round(baseDelay + jitter)
}
/**
* Get all retry delays with jitter for planning
* @returns Array of delays with jitter applied
*/
export function getAllRetryDelaysWithJitter(): number[] {
return RETRY_BACKOFF_DELAYS.map((_, i) => getRetryDelayWithJitter(i + 1))
}
export default retry
+45
View File
@@ -0,0 +1,45 @@
import type { BinaryNode } from '../WABinary'
/**
* Builds an ACK stanza for a received node.
* Pure function -- no I/O, no side effects.
*
* Mirrors WhatsApp Web's ACK construction:
* - WAWebHandleMsgSendAck.sendAck / sendNack
* - WAWebCreateNackFromStanza.createNackFromStanza
*/
export function buildAckStanza(node: BinaryNode, errorCode?: number, meId?: string): BinaryNode {
const { tag, attrs } = node
const stanza: BinaryNode = {
tag: 'ack',
attrs: {
id: attrs.id!,
to: attrs.from!,
class: tag
}
}
if (errorCode) {
stanza.attrs.error = errorCode.toString()
}
if (attrs.participant) {
stanza.attrs.participant = attrs.participant
}
if (attrs.recipient) {
stanza.attrs.recipient = attrs.recipient
}
// WA Web always includes type when present: `n.type || DROP_ATTR`
if (attrs.type) {
stanza.attrs.type = attrs.type
}
// WA Web WAWebHandleMsgSendAck.sendAck/sendNack always include `from` for message-class ACKs
if (tag === 'message' && meId) {
stanza.attrs.from = meId
}
return stanza
}
-779
View File
@@ -1,779 +0,0 @@
import { Boom } from '@hapi/boom'
import { createHash } from 'crypto'
import { zipSync } from 'fflate'
import { promises as fs } from 'fs'
import { proto } from '../../WAProto/index.js'
import type { MediaType } from '../Defaults/index.js'
import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js'
import { generateMessageIDV2 } from './generics.js'
import type { ILogger } from './logger.js'
import { encryptedStream, getImageProcessingLibrary } from './messages-media.js'
/**
* Verifica se um buffer é um arquivo WebP válido
* Valida os magic bytes: RIFF....WEBP
*
* @param buffer - Buffer to check
* @returns true if buffer is valid WebP format
*
* @example
* ```typescript
* const buffer = await readFile('image.webp')
* if (isWebPBuffer(buffer)) {
* console.log('Valid WebP file')
* }
* ```
*/
export const isWebPBuffer = (buffer: Buffer): boolean => {
if (buffer.length < 12) return false
// Verifica magic bytes RIFF (0-3) e WEBP (8-11)
const riffHeader = buffer.toString('ascii', 0, 4)
const webpHeader = buffer.toString('ascii', 8, 12)
return riffHeader === 'RIFF' && webpHeader === 'WEBP'
}
/**
* Detecta se um WebP é animado através da análise de chunks
*
* Analisa a estrutura do arquivo WebP procurando por:
* - VP8X header com animation flag (bit 1)
* - Chunks ANIM (animation) ou ANMF (animation frame)
*
* SECURITY: Implements robust validation to prevent:
* - Integer overflow attacks (malicious chunk sizes)
* - Out-of-bounds reads (buffer overflow)
* - Infinite loop DoS (iteration limit)
*
* @param buffer - WebP buffer to analyze
* @returns true if WebP is animated, false if static or malformed
*
* @example
* ```typescript
* const webpBuffer = await readFile('sticker.webp')
* if (isAnimatedWebP(webpBuffer)) {
* console.log('Animated sticker detected')
* }
* ```
*/
export const isAnimatedWebP = (buffer: Buffer): boolean => {
if (!isWebPBuffer(buffer)) return false
const MAX_CHUNK_SIZE = 100 * 1024 * 1024 // 100MB max per chunk
const MAX_ITERATIONS = 1000 // Prevent infinite loop
let offset = 12 // Skip RIFF header (12 bytes)
let iterations = 0
while (offset < buffer.length - 8 && iterations++ < MAX_ITERATIONS) {
const chunkFourCC = buffer.toString('ascii', offset, offset + 4)
const chunkSize = buffer.readUInt32LE(offset + 4)
// SECURITY: Validate chunk size to prevent integer overflow and buffer overflow
if (chunkSize < 0 || chunkSize > MAX_CHUNK_SIZE) {
// Invalid chunk size - treat as non-animated
return false
}
// SECURITY: Verify chunk fits within buffer bounds
if (offset + 8 + chunkSize > buffer.length) {
// Chunk extends beyond buffer - malformed file
return false
}
// VP8X extended header - check animation flag
if (chunkFourCC === 'VP8X' && offset + 8 < buffer.length) {
const flags = buffer[offset + 8]
// Bit 1 (0x02) = animation flag
if (flags && flags & 0x02) return true
}
// Animation chunks
if (chunkFourCC === 'ANIM' || chunkFourCC === 'ANMF') {
return true
}
// Move to next chunk (8 byte header + chunk size + padding)
offset += 8 + chunkSize + (chunkSize % 2)
}
return false
}
/**
* Converte uma imagem para WebP usando Sharp
* Preserva o buffer original se for WebP para manter EXIF e animações
*
* @param buffer - Image buffer to convert
* @param logger - Optional logger for debugging
* @returns Object with WebP buffer and animation status
*
* @throws {Boom} If Sharp is not installed and buffer is not WebP
*/
const convertToWebP = async (
buffer: Buffer,
logger?: ILogger
): Promise<{ webpBuffer: Buffer; isAnimated: boolean }> => {
// Se já é WebP, preserva o buffer original (mantém EXIF e animações)
if (isWebPBuffer(buffer)) {
const isAnimated = isAnimatedWebP(buffer)
logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer')
return { webpBuffer: buffer, isAnimated }
}
// Tenta usar Sharp para converter
const lib = await getImageProcessingLibrary()
if (!lib?.sharp) {
throw new Boom(
'Sharp library is required to convert non-WebP images to WebP format. Install with: yarn add sharp',
{ statusCode: 400 }
)
}
logger?.trace('Converting image to WebP using Sharp')
const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer()
return { webpBuffer, isAnimated: false }
}
/**
* Gera hash SHA256 em formato base64 URL-safe (RFC 4648)
* Usado para nomear arquivos de stickers no ZIP (auto-deduplicação)
*
* SECURITY: Correctly implements base64url encoding to prevent hash collisions:
* - '+' '-'
* - '/' '_' (DIFFERENT from '+' mapping)
* - '=' padding removed
*
* @param buffer - Buffer to hash
* @returns Base64 URL-safe SHA256 hash (RFC 4648 compliant)
*/
const generateSha256Hash = (buffer: Buffer): string => {
return createHash('sha256')
.update(buffer)
.digest('base64')
.replace(/\+/g, '-') // + becomes -
.replace(/\//g, '_') // / becomes _ (CRITICAL: different from + mapping!)
.replace(/=/g, '') // Remove padding
}
/**
* Converte WAMediaUpload para Buffer com limites de segurança
* Suporta Buffer, Stream, URL e Data URLs
*
* SECURITY: Implements protections against:
* - Memory exhaustion (size limits)
* - Slow read attacks (timeouts)
* - Resource DoS (stream cleanup)
*
* @param media - Media input (Buffer, Stream, URL or Data URL)
* @param context - Context for error messages (e.g., 'sticker', 'cover')
* @param options - Optional size limit and timeout
* @returns Buffer with media content
*
* @throws {Boom} If media format is invalid, too large, or timeout
*/
const mediaToBuffer = async (
media: WAMediaUpload,
context: string,
options?: { maxSize?: number; timeout?: number }
): Promise<Buffer> => {
const MAX_SIZE = options?.maxSize || 10 * 1024 * 1024 // 10MB default
const TIMEOUT = options?.timeout || 30000 // 30s default
if (Buffer.isBuffer(media)) {
// SECURITY: Validate buffer size
if (media.length > MAX_SIZE) {
throw new Boom(`${context} size (${(media.length / 1024).toFixed(2)}KB) exceeds ${MAX_SIZE / 1024}KB limit`, {
statusCode: 413
})
}
return media
} else if (typeof media === 'object' && 'url' in media) {
const url = media.url.toString()
// ENHANCEMENT: Support Data URLs (data:image/...)
if (url.startsWith('data:')) {
try {
const base64Data = url.split(',')[1]
if (!base64Data) {
throw new Boom(`Invalid data URL for ${context}: missing base64 data`, { statusCode: 400 })
}
const buffer = Buffer.from(base64Data, 'base64')
// SECURITY: Validate buffer size
if (buffer.length > MAX_SIZE) {
throw new Boom(
`${context} data URL size (${(buffer.length / 1024).toFixed(2)}KB) exceeds ${MAX_SIZE / 1024}KB limit`,
{ statusCode: 413 }
)
}
return buffer
} catch (error) {
if (error instanceof Boom) throw error
throw new Boom(`Failed to parse data URL for ${context}: ${(error as Error).message}`, {
statusCode: 400
})
}
}
// HTTP/HTTPS URLs - download with size limit and timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT)
try {
const response = await fetch(url, {
signal: controller.signal
})
if (!response.ok) {
throw new Boom(`Failed to download ${context} from URL: ${url}`, {
statusCode: 400,
data: { url, status: response.status }
})
}
// SECURITY: Check Content-Length header before downloading
const contentLength = response.headers.get('content-length')
if (contentLength && parseInt(contentLength) > MAX_SIZE) {
throw new Boom(
`${context} URL file size (${(parseInt(contentLength) / 1024).toFixed(2)}KB) exceeds ${MAX_SIZE / 1024}KB limit`,
{ statusCode: 413, data: { url, contentLength } }
)
}
// SECURITY: Stream download with size validation
const chunks: Buffer[] = []
let totalSize = 0
if (!response.body) {
throw new Boom(`${context} URL response has no body`, { statusCode: 400, data: { url } })
}
for await (const chunk of response.body as any) {
const buffer = Buffer.from(chunk)
totalSize += buffer.length
// SECURITY: Enforce size limit during download
if (totalSize > MAX_SIZE) {
throw new Boom(
`${context} download (${(totalSize / 1024).toFixed(2)}KB) exceeded ${MAX_SIZE / 1024}KB limit`,
{ statusCode: 413, data: { url } }
)
}
chunks.push(buffer)
}
return Buffer.concat(chunks)
} finally {
clearTimeout(timeoutId)
}
} else if (typeof media === 'object' && 'stream' in media) {
// SECURITY: Read stream with size limit and timeout
const chunks: Buffer[] = []
let totalSize = 0
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Boom(`${context} stream timeout after ${TIMEOUT}ms`, { statusCode: 408 })), TIMEOUT)
)
try {
await Promise.race([
(async () => {
for await (const chunk of media.stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
totalSize += buffer.length
// SECURITY: Check size limit
if (totalSize > MAX_SIZE) {
throw new Boom(
`${context} stream size (${(totalSize / 1024).toFixed(2)}KB) exceeds ${MAX_SIZE / 1024}KB limit`,
{ statusCode: 413 }
)
}
chunks.push(buffer)
}
})(),
timeoutPromise
])
return Buffer.concat(chunks)
} catch (error) {
// SECURITY: Cleanup on error
media.stream.destroy()
throw error
}
} else {
throw new Boom(`Invalid ${context} data format`, { statusCode: 400 })
}
}
export type PrepareStickerPackMessageOptions = {
/** Upload function to encrypt and upload media to WhatsApp servers */
upload: WAMediaUploadFunction
/** Optional logger for debugging */
logger?: ILogger
/** Timeout for media uploads */
mediaUploadTimeoutMs?: number
}
/**
* Prepara uma mensagem de sticker pack para envio
*
* **Processo:**
* 1. Valida número de stickers (3-30 conforme padrão WhatsApp oficial)
* 2. Processa cada sticker (converte para WebP se necessário)
* 3. Cria ZIP com stickers + cover (deduplicação automática por hash)
* 4. Criptografa ZIP usando AES-256-CBC + HMAC-SHA256
* 5. Gera thumbnail da capa (252x252 JPEG)
* 6. Faz upload do ZIP e thumbnail (reutiliza mesma mediaKey)
* 7. Retorna proto.Message.StickerPackMessage completo
*
* **Especificações WhatsApp:**
* - 3-30 stickers por pack (oficial)
* - WebP obrigatório
* - Recomendado: 100KB por sticker estático, 500KB animado
* - Tray icon: 252x252 pixels
*
* @param stickerPack - Sticker pack data with stickers, cover, name, publisher
* @param options - Upload function and optional logger
* @returns Prepared StickerPackMessage ready to send
*
* @throws {Boom} If validation fails (sticker count, size limits, format issues)
*
* @example
* ```typescript
* const stickerPackMessage = await prepareStickerPackMessage(
* {
* name: 'My Pack',
* publisher: 'Author',
* cover: coverBuffer,
* stickers: [
* { data: sticker1Buffer, emojis: ['😀'] },
* { data: sticker2Buffer, emojis: ['😎'] }
* ]
* },
* { upload: uploadFunction, logger }
* )
* ```
*/
export const prepareStickerPackMessage = async (
stickerPack: StickerPack,
options: PrepareStickerPackMessageOptions
): Promise<proto.Message.StickerPackMessage> => {
const { upload, logger, mediaUploadTimeoutMs } = options
const { stickers, cover, name, publisher, description, packId } = stickerPack
// Helper function to encrypt and upload media with guaranteed cleanup
// SECURITY FIX #5: Try/finally ensures temp file cleanup even on upload failure
const uploadMedia = async (buffer: Buffer, mediaType: MediaType, opts?: { mediaKey?: Uint8Array }) => {
let encFilePath: string | undefined
try {
// Encrypt the media
const encrypted = await encryptedStream(buffer, mediaType, {
logger,
mediaKey: opts?.mediaKey
})
encFilePath = encrypted.encFilePath
// Upload encrypted file
const result = await upload(encrypted.encFilePath, {
fileEncSha256B64: encrypted.fileEncSha256.toString('base64'),
mediaType,
timeoutMs: mediaUploadTimeoutMs
})
return {
mediaKey: encrypted.mediaKey,
fileSha256: encrypted.fileSha256,
fileEncSha256: encrypted.fileEncSha256,
directPath: result.directPath,
mediaKeyTimestamp: result.ts
}
} finally {
// SECURITY: Always cleanup temp file, even on error
if (encFilePath) {
try {
await fs.unlink(encFilePath)
logger?.trace({ encFilePath }, 'Cleaned up temporary encrypted file')
} catch (unlinkError) {
// Log but don't fail - file may not exist
logger?.warn({ encFilePath, error: unlinkError }, 'Failed to cleanup temp file')
}
}
}
}
// 1. Validações - Padrão WhatsApp oficial: 3-30 stickers
// SECURITY FIX #4: Validate actual valid stickers (not undefined/null)
const validStickers = stickers.filter((s): s is NonNullable<typeof s> => s !== null && s !== undefined)
if (validStickers.length < 3 || validStickers.length > 30) {
throw new Boom(
`Sticker pack must contain between 3 and 30 valid stickers per WhatsApp official spec. ` +
`Provided: ${validStickers.length} valid stickers ` +
`(${stickers.length} total, ${stickers.length - validStickers.length} invalid/undefined)`,
{ statusCode: 400 }
)
}
// Validação de nomes (max 128 caracteres)
if (name.length > 128) {
throw new Boom(`Pack name must be 128 characters or less. Current length: ${name.length}`, {
statusCode: 400
})
}
if (publisher.length > 128) {
throw new Boom(`Publisher name must be 128 characters or less. Current length: ${publisher.length}`, {
statusCode: 400
})
}
logger?.info({ stickerCount: stickers.length, name, publisher }, 'Preparing sticker pack message')
// 2. Gera ID do pack se não fornecido
const stickerPackId = packId || generateMessageIDV2()
// 3. Processa stickers e cria estrutura ZIP
// SECURITY FIX #6: Parallel processing for better performance (30 stickers = significant speedup)
// SECURITY FIX #7: Track deduplication to merge metadata correctly
const stickerData: Record<string, [Uint8Array, { level: 0 }]> = {}
const stickerMetadata: proto.Message.StickerPackMessage.ISticker[] = []
// Track metadata by hash to merge duplicates
const metadataByHash = new Map<string, proto.Message.StickerPackMessage.ISticker>()
// Process all stickers in parallel for performance
const processedStickers = await Promise.all(
stickers.map(async (sticker, i) => {
if (!sticker) return null // Skip undefined stickers
// SECURITY FIX #8: Better error context for debugging
try {
logger?.trace({ index: i }, 'Processing sticker')
// Obtém buffer do sticker
const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`)
// Converte para WebP
// eslint-disable-next-line prefer-const
let { webpBuffer, isAnimated } = await convertToWebP(buffer, logger)
// ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50)
const MAX_STICKER_SIZE = 1024 * 1024 // 1MB
const recommendedLimit = isAnimated ? 500 : 100
if (webpBuffer.length > MAX_STICKER_SIZE) {
logger?.warn(
{ index: i, sizeKB: (webpBuffer.length / 1024).toFixed(2) },
`Sticker ${i + 1} exceeds 1MB, attempting compression...`
)
const lib = await getImageProcessingLibrary()
if (lib?.sharp) {
// Try quality 70
try {
const compressed70 = await lib.sharp.default(buffer).webp({ quality: 70 }).toBuffer()
// eslint-disable-next-line max-depth
if (compressed70.length <= MAX_STICKER_SIZE) {
webpBuffer = compressed70
logger?.info(
{
index: i,
originalKB: (webpBuffer.length / 1024).toFixed(2),
compressedKB: (compressed70.length / 1024).toFixed(2)
},
`Sticker ${i + 1} compressed successfully (quality 70)`
)
} else {
// Try quality 50
const compressed50 = await lib.sharp.default(buffer).webp({ quality: 50 }).toBuffer()
// eslint-disable-next-line max-depth
if (compressed50.length <= MAX_STICKER_SIZE) {
webpBuffer = compressed50
logger?.info(
{
index: i,
originalKB: (webpBuffer.length / 1024).toFixed(2),
compressedKB: (compressed50.length / 1024).toFixed(2)
},
`Sticker ${i + 1} compressed successfully (quality 50)`
)
} else {
// Still too large
throw new Boom(
`Sticker ${i + 1} still exceeds 1MB after compression (${(compressed50.length / 1024).toFixed(2)}KB). ` +
`Please use a smaller image.`,
{ statusCode: 400 }
)
}
}
} catch (compressionError) {
// If compression fails, throw error about size
throw new Boom(
`Sticker ${i + 1} exceeds 1MB and compression failed: ${(compressionError as Error).message}`,
{ statusCode: 400 }
)
}
} else {
// No Sharp available, can't compress
throw new Boom(
`Sticker ${i + 1} exceeds the 1MB hard limit (${(webpBuffer.length / 1024).toFixed(2)}KB). ` +
`Sharp library required for auto-compression. Install with: yarn add sharp`,
{ statusCode: 400 }
)
}
}
// Check recommended size (warning only)
const finalSizeKB = webpBuffer.length / 1024
if (finalSizeKB > recommendedLimit) {
logger?.warn(
{ index: i, sizeKB: finalSizeKB, recommendedLimit, isAnimated },
`Sticker ${i + 1} exceeds WhatsApp recommended size (${recommendedLimit}KB). ` +
`This may cause slower sending or delivery issues.`
)
}
// Gera nome do arquivo: hash.webp (deduplicação automática)
const sha256Hash = generateSha256Hash(webpBuffer)
const fileName = `${sha256Hash}.webp`
logger?.trace(
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated },
'Sticker processed successfully'
)
return {
fileName,
webpBuffer,
isAnimated,
emojis: sticker.emojis || [],
accessibilityLabel: sticker.accessibilityLabel
}
} catch (error) {
// SECURITY FIX #8: Wrap errors with sticker context
throw new Boom(`Failed to process sticker ${i + 1}: ${(error as Error).message}`, {
statusCode: error instanceof Boom ? error.output.statusCode : 500,
data: { stickerIndex: i, originalError: error }
})
}
})
)
// Build stickerData and merge metadata for duplicates
let duplicateCount = 0
for (const result of processedStickers) {
if (!result) continue
const { fileName, webpBuffer, isAnimated, emojis, accessibilityLabel } = result
// SECURITY FIX #7: Check if this hash already exists (duplicate sticker)
const existingMetadata = metadataByHash.get(fileName)
if (existingMetadata) {
// Duplicate detected - merge metadata (combine emojis and labels)
duplicateCount++
// Merge emojis (deduplicate)
const mergedEmojis = Array.from(new Set([...(existingMetadata.emojis ?? []), ...emojis]))
existingMetadata.emojis = mergedEmojis
// Merge accessibility labels (concatenate with separator if both exist)
if (accessibilityLabel) {
if (existingMetadata.accessibilityLabel) {
existingMetadata.accessibilityLabel += ` / ${accessibilityLabel}`
} else {
existingMetadata.accessibilityLabel = accessibilityLabel
}
}
logger?.debug({ fileName, mergedEmojis, duplicateCount }, 'Duplicate sticker detected - merged metadata')
} else {
// New sticker - add to ZIP and create metadata
stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }]
const metadata: proto.Message.StickerPackMessage.ISticker = {
fileName,
isAnimated,
emojis,
accessibilityLabel,
isLottie: false,
mimetype: 'image/webp'
}
metadataByHash.set(fileName, metadata)
stickerMetadata.push(metadata)
}
}
if (duplicateCount > 0) {
logger?.info(
{ duplicateCount, uniqueStickers: stickerMetadata.length },
`Removed ${duplicateCount} duplicate stickers via deduplication`
)
}
// 4. Processa cover image (tray icon)
// SECURITY FIX #8: Error context for cover processing
let coverBuffer: Buffer
let coverWebP: Buffer
let coverFileName: string
try {
logger?.trace('Processing cover image')
coverBuffer = await mediaToBuffer(cover, 'cover image')
// Converte cover para WebP e adiciona ao ZIP
const result = await convertToWebP(coverBuffer, logger)
coverWebP = result.webpBuffer
coverFileName = `${stickerPackId}.webp`
stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }]
} catch (error) {
throw new Boom(`Failed to process cover image: ${(error as Error).message}`, {
statusCode: error instanceof Boom ? error.output.statusCode : 500,
data: { originalError: error }
})
}
// 5. Cria ZIP (level 0 = sem compressão para velocidade)
// SECURITY FIX #8: Error context for ZIP creation
let zipBuffer: Buffer
let uniqueFiles: number
try {
uniqueFiles = Object.keys(stickerData).length
logger?.trace({ totalFiles: uniqueFiles, includingCover: true }, 'Creating ZIP file')
zipBuffer = Buffer.from(zipSync(stickerData))
logger?.info({ zipSizeKB: (zipBuffer.length / 1024).toFixed(2) }, 'ZIP file created successfully')
// Validação de tamanho total (30MB limit para segurança)
const MAX_PACK_SIZE = 30 * 1024 * 1024
if (zipBuffer.length > MAX_PACK_SIZE) {
throw new Boom(
`Total pack size exceeds ${MAX_PACK_SIZE / 1024 / 1024}MB limit. ` +
`Current size: ${(zipBuffer.length / 1024 / 1024).toFixed(2)}MB. ` +
`Try compressing stickers or reducing pack size.`,
{ statusCode: 400 }
)
}
} catch (error) {
throw new Boom(`Failed to create ZIP archive: ${(error as Error).message}`, {
statusCode: error instanceof Boom ? error.output.statusCode : 500,
data: { originalError: error }
})
}
// 6. Upload do ZIP criptografado
// SECURITY FIX #8: Error context for sticker pack upload
let stickerPackUpload: Awaited<ReturnType<typeof uploadMedia>>
try {
logger?.trace('Uploading encrypted sticker pack ZIP')
stickerPackUpload = await uploadMedia(zipBuffer, 'sticker-pack')
} catch (error) {
throw new Boom(`Failed to upload sticker pack: ${(error as Error).message}`, {
statusCode: error instanceof Boom ? error.output.statusCode : 500,
data: { originalError: error }
})
}
// 7. Gera thumbnail 252x252 JPEG
// SECURITY FIX #8: Error context for thumbnail generation
let thumbnailBuffer: Buffer
try {
logger?.trace('Generating thumbnail (252x252 JPEG)')
const lib = await getImageProcessingLibrary()
if (!lib?.sharp) {
throw new Boom('Sharp library is required for thumbnail generation. Install with: yarn add sharp', {
statusCode: 400
})
}
thumbnailBuffer = await lib.sharp
.default(coverBuffer)
.resize(252, 252, { fit: 'cover', position: 'center' })
.jpeg({ quality: 85 })
.toBuffer()
logger?.trace({ thumbnailSizeKB: (thumbnailBuffer.length / 1024).toFixed(2) }, 'Thumbnail generated')
} catch (error) {
throw new Boom(`Failed to generate thumbnail: ${(error as Error).message}`, {
statusCode: error instanceof Boom ? error.output.statusCode : 500,
data: { originalError: error }
})
}
// 8. Upload do thumbnail (REUTILIZA mesma mediaKey - requerido pelo protocolo!)
// SECURITY FIX #8: Error context for thumbnail upload
let thumbUpload: Awaited<ReturnType<typeof uploadMedia>>
try {
logger?.trace('Uploading thumbnail with same mediaKey')
thumbUpload = await uploadMedia(thumbnailBuffer, 'thumbnail-sticker-pack', {
mediaKey: stickerPackUpload.mediaKey // CRÍTICO: mesma chave!
})
} catch (error) {
throw new Boom(`Failed to upload thumbnail: ${(error as Error).message}`, {
statusCode: error instanceof Boom ? error.output.statusCode : 500,
data: { originalError: error }
})
}
// 9. Monta mensagem protobuf
logger?.info(
{
packId: stickerPackId,
totalStickers: stickers.length,
uniqueFiles: uniqueFiles - 1, // minus cover
zipSizeKB: (zipBuffer.length / 1024).toFixed(2)
},
'Sticker pack message prepared successfully'
)
return proto.Message.StickerPackMessage.create({
// Metadata do pack
stickerPackId,
name,
publisher,
packDescription: description,
stickerPackOrigin: proto.Message.StickerPackMessage.StickerPackOrigin.USER_CREATED,
stickerPackSize: zipBuffer.length,
stickers: stickerMetadata,
// ZIP file (criptografado)
fileSha256: stickerPackUpload.fileSha256,
fileEncSha256: stickerPackUpload.fileEncSha256,
mediaKey: stickerPackUpload.mediaKey,
directPath: stickerPackUpload.directPath,
fileLength: zipBuffer.length,
mediaKeyTimestamp: stickerPackUpload.mediaKeyTimestamp,
// Tray icon info
trayIconFileName: coverFileName,
// Thumbnail (criptografado com mesma key)
thumbnailDirectPath: thumbUpload.directPath,
thumbnailSha256: createHash('sha256').update(thumbnailBuffer).digest(),
thumbnailEncSha256: thumbUpload.fileEncSha256,
thumbnailHeight: 252,
thumbnailWidth: 252,
imageDataHash: createHash('sha256').update(thumbnailBuffer).digest('base64')
})
}
-943
View File
@@ -1,943 +0,0 @@
/**
* Structured Logging System for InfiniteAPI
*
* Enterprise-grade features:
* - Environment variable configuration (BAILEYS_LOG_*)
* - Configurable log levels (trace, debug, info, warn, error, fatal)
* - JSON formatting for log analysis
* - Hierarchical context with child loggers
* - External system integration via hooks with circuit breaker
* - Logging metrics with Prometheus integration
* - Sensitive data sanitization
* - Log buffering for batch writes
* - Rate limiting to prevent flooding
* - Async logging queue for non-blocking operations
* - Proper resource cleanup (destroy)
*
* @module Utils/structured-logger
*/
import type { ILogger } from './logger.js'
// ============================================================================
// CONFIGURATION
// ============================================================================
/**
* Available log levels (ordered by severity)
*/
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent'
/**
* Numeric values for each log level
*/
export const LOG_LEVEL_VALUES: Record<LogLevel, number> = {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60,
silent: 100
}
/**
* Structured logger configuration
*/
export interface StructuredLoggerConfig {
/** Minimum log level to record */
level: LogLevel
/** Service/component name */
name?: string
/** Additional context to include in all logs */
context?: Record<string, unknown>
/** Format as JSON (true) or human-readable text (false) */
jsonFormat?: boolean
/** Fields to sanitize (passwords, tokens, etc.) */
redactFields?: string[]
/** Hook for sending logs to external systems */
externalHook?: (entry: LogEntry) => void | Promise<void>
/** Include stack trace in errors */
includeStackTrace?: boolean
/** Timezone for timestamps (default: UTC) */
timezone?: string
/** Enable log buffering for batch writes */
enableBuffering?: boolean
/** Buffer flush interval in ms (default: 1000) */
bufferFlushIntervalMs?: number
/** Maximum buffer size before auto-flush (default: 100) */
maxBufferSize?: number
/** Enable rate limiting (default: false) */
enableRateLimiting?: boolean
/** Max logs per second (default: 1000) */
maxLogsPerSecond?: number
/** Enable async logging queue (default: false) */
enableAsyncQueue?: boolean
/** Circuit breaker failure threshold for external hooks (default: 5) */
circuitBreakerThreshold?: number
/** Circuit breaker reset timeout in ms (default: 30000) */
circuitBreakerResetMs?: number
/** Enable Prometheus metrics integration (default: false) */
enableMetrics?: boolean
}
/**
* Internal resolved configuration with required fields
* externalHook remains optional since it may not be provided
*/
type ResolvedLoggerConfig = Omit<Required<StructuredLoggerConfig>, 'externalHook'> & {
externalHook?: (entry: LogEntry) => void | Promise<void>
}
/**
* Load configuration from environment variables
*/
export function loadLoggerConfig(): Partial<StructuredLoggerConfig> {
const level = process.env.BAILEYS_LOG_LEVEL as LogLevel | undefined
return {
level: level && level in LOG_LEVEL_VALUES ? level : undefined,
name: process.env.BAILEYS_LOG_NAME,
jsonFormat: process.env.BAILEYS_LOG_JSON === 'true' || process.env.NODE_ENV === 'production',
enableBuffering: process.env.BAILEYS_LOG_BUFFERING === 'true',
bufferFlushIntervalMs: process.env.BAILEYS_LOG_BUFFER_FLUSH_MS
? parseInt(process.env.BAILEYS_LOG_BUFFER_FLUSH_MS, 10)
: undefined,
maxBufferSize: process.env.BAILEYS_LOG_MAX_BUFFER_SIZE
? parseInt(process.env.BAILEYS_LOG_MAX_BUFFER_SIZE, 10)
: undefined,
enableRateLimiting: process.env.BAILEYS_LOG_RATE_LIMIT === 'true',
maxLogsPerSecond: process.env.BAILEYS_LOG_MAX_PER_SECOND
? parseInt(process.env.BAILEYS_LOG_MAX_PER_SECOND, 10)
: undefined,
enableAsyncQueue: process.env.BAILEYS_LOG_ASYNC === 'true',
enableMetrics: process.env.BAILEYS_LOG_METRICS === 'true',
includeStackTrace: process.env.BAILEYS_LOG_STACK_TRACE !== 'false'
}
}
/**
* Structured log entry
*/
export interface LogEntry {
/** ISO 8601 timestamp */
timestamp: string
/** Log level */
level: LogLevel
/** Numeric level value */
levelValue: number
/** Main message */
message: string
/** Logger/component name */
name?: string
/** Additional context */
context?: Record<string, unknown>
/** Logged object data */
data?: Record<string, unknown>
/** Stack trace (for errors) */
stack?: string
/** Correlation ID for tracing */
correlationId?: string
/** Operation duration in ms (if applicable) */
durationMs?: number
}
/**
* Logger metrics
*/
export interface LoggerMetrics {
totalLogs: number
logsByLevel: Record<LogLevel, number>
errorsCount: number
lastLogTimestamp?: string
/** Logs dropped due to rate limiting */
droppedLogs: number
/** Buffer flushes performed */
bufferFlushes: number
/** External hook failures */
hookFailures: number
/** Circuit breaker trips */
circuitBreakerTrips: number
/** Average log processing time in ms */
avgProcessingTimeMs: number
}
/**
* Logger statistics for monitoring
*/
export interface LoggerStatistics extends LoggerMetrics {
/** Buffer current size */
bufferSize: number
/** Rate limiter tokens available */
rateLimiterTokens: number
/** Circuit breaker state */
circuitBreakerState: 'closed' | 'open' | 'half-open'
/** Queue size (if async enabled) */
queueSize: number
/** Created timestamp */
createdAt: number
/** Uptime in ms */
uptimeMs: number
}
/**
* Default fields to sanitize
*/
const DEFAULT_REDACT_FIELDS = [
'password',
'passwd',
'secret',
'token',
'accessToken',
'refreshToken',
'apiKey',
'api_key',
'authorization',
'auth',
'credentials',
'privateKey',
'private_key'
]
// ============================================================================
// RATE LIMITER
// ============================================================================
/**
* Token bucket rate limiter
*/
class RateLimiter {
private tokens: number
private lastRefill: number
private readonly maxTokens: number
private readonly refillRate: number // tokens per ms
constructor(maxPerSecond: number) {
this.maxTokens = maxPerSecond
this.tokens = maxPerSecond
this.refillRate = maxPerSecond / 1000
this.lastRefill = Date.now()
}
tryAcquire(): boolean {
this.refill()
if (this.tokens >= 1) {
this.tokens--
return true
}
return false
}
private refill(): void {
const now = Date.now()
const elapsed = now - this.lastRefill
const tokensToAdd = elapsed * this.refillRate
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd)
this.lastRefill = now
}
getTokens(): number {
this.refill()
return Math.floor(this.tokens)
}
}
// ============================================================================
// CIRCUIT BREAKER
// ============================================================================
/**
* Circuit breaker for external hook protection
*/
class CircuitBreaker {
private failures = 0
private lastFailure = 0
private state: 'closed' | 'open' | 'half-open' = 'closed'
constructor(
private readonly threshold: number,
private readonly resetTimeoutMs: number
) {}
async execute<T>(operation: () => Promise<T>): Promise<T | null> {
if (this.state === 'open') {
if (Date.now() - this.lastFailure > this.resetTimeoutMs) {
this.state = 'half-open'
} else {
return null // Circuit is open, skip
}
}
try {
const result = await operation()
this.onSuccess()
return result
} catch (error) {
this.onFailure()
throw error
}
}
private onSuccess(): void {
this.failures = 0
this.state = 'closed'
}
private onFailure(): void {
this.failures++
this.lastFailure = Date.now()
if (this.failures >= this.threshold) {
this.state = 'open'
}
}
getState(): 'closed' | 'open' | 'half-open' {
// Check if should transition from open to half-open
if (this.state === 'open' && Date.now() - this.lastFailure > this.resetTimeoutMs) {
this.state = 'half-open'
}
return this.state
}
getFailures(): number {
return this.failures
}
}
// ============================================================================
// ASYNC LOG QUEUE
// ============================================================================
/**
* Async log processing queue
*/
class AsyncLogQueue {
private queue: Array<() => void> = []
private processing = false
private destroyed = false
enqueue(task: () => void): void {
if (this.destroyed) return
this.queue.push(task)
void this.processNext()
}
private async processNext(): Promise<void> {
if (this.processing || this.queue.length === 0 || this.destroyed) return
this.processing = true
while (this.queue.length > 0 && !this.destroyed) {
const task = this.queue.shift()
if (task) {
try {
task()
} catch {
// Silently ignore errors
}
}
// Yield to event loop periodically
if (this.queue.length > 0 && this.queue.length % 10 === 0) {
await new Promise(resolve => setImmediate(resolve))
}
}
this.processing = false
}
getSize(): number {
return this.queue.length
}
destroy(): void {
this.destroyed = true
this.queue = []
}
}
// ============================================================================
// MAIN CLASS
// ============================================================================
/**
* Structured Logger main class
*
* Enterprise-grade features:
* - Environment variable configuration
* - Log buffering for batch writes
* - Rate limiting to prevent flooding
* - Async logging queue
* - Circuit breaker for external hooks
* - Prometheus metrics integration
*
* @example
* ```typescript
* const logger = createStructuredLogger({
* level: 'info',
* name: 'my-service',
* jsonFormat: true,
* enableBuffering: true,
* enableRateLimiting: true
* })
*
* logger.info({ userId: '123' }, 'User logged in')
* logger.error(new Error('Connection failed'))
*
* // Cleanup when done
* logger.destroy()
* ```
*/
export class StructuredLogger implements ILogger {
private config: ResolvedLoggerConfig
private metrics: LoggerMetrics
private childContext: Record<string, unknown> = {}
private destroyed = false
private createdAt: number
// Buffer for batch writes
private buffer: LogEntry[] = []
private bufferFlushTimer: NodeJS.Timeout | null = null
// Rate limiter
private rateLimiter: RateLimiter | null = null
// Circuit breaker for external hook
private circuitBreaker: CircuitBreaker | null = null
// Async queue
private asyncQueue: AsyncLogQueue | null = null
// Performance tracking
private totalProcessingTime = 0
private processedLogs = 0
// Metrics module (lazy loaded)
// NOTE: Currently no metrics are recorded to this module - it's loaded but unused
private metricsModule: typeof import('./prometheus-metrics') | null = null
constructor(config: StructuredLoggerConfig) {
const envConfig = loadLoggerConfig()
this.createdAt = Date.now()
this.config = {
level: config.level ?? envConfig.level ?? 'info',
name: config.name ?? envConfig.name ?? 'app',
context: config.context || {},
jsonFormat: config.jsonFormat ?? envConfig.jsonFormat ?? true,
redactFields: [...DEFAULT_REDACT_FIELDS, ...(config.redactFields || [])],
externalHook: config.externalHook,
includeStackTrace: config.includeStackTrace ?? envConfig.includeStackTrace ?? true,
timezone: config.timezone || 'UTC',
enableBuffering: config.enableBuffering ?? envConfig.enableBuffering ?? false,
bufferFlushIntervalMs: config.bufferFlushIntervalMs ?? envConfig.bufferFlushIntervalMs ?? 1000,
maxBufferSize: config.maxBufferSize ?? envConfig.maxBufferSize ?? 100,
enableRateLimiting: config.enableRateLimiting ?? envConfig.enableRateLimiting ?? false,
maxLogsPerSecond: config.maxLogsPerSecond ?? envConfig.maxLogsPerSecond ?? 1000,
enableAsyncQueue: config.enableAsyncQueue ?? envConfig.enableAsyncQueue ?? false,
circuitBreakerThreshold: config.circuitBreakerThreshold ?? 5,
circuitBreakerResetMs: config.circuitBreakerResetMs ?? 30000,
enableMetrics: config.enableMetrics ?? envConfig.enableMetrics ?? false
}
this.metrics = {
totalLogs: 0,
logsByLevel: {
trace: 0,
debug: 0,
info: 0,
warn: 0,
error: 0,
fatal: 0,
silent: 0
},
errorsCount: 0,
droppedLogs: 0,
bufferFlushes: 0,
hookFailures: 0,
circuitBreakerTrips: 0,
avgProcessingTimeMs: 0
}
// Initialize rate limiter
if (this.config.enableRateLimiting) {
this.rateLimiter = new RateLimiter(this.config.maxLogsPerSecond)
}
// Initialize circuit breaker for external hook
if (this.config.externalHook) {
this.circuitBreaker = new CircuitBreaker(this.config.circuitBreakerThreshold, this.config.circuitBreakerResetMs)
}
// Initialize async queue
if (this.config.enableAsyncQueue) {
this.asyncQueue = new AsyncLogQueue()
}
// Initialize buffer flush timer
if (this.config.enableBuffering) {
this.bufferFlushTimer = setInterval(() => {
this.flushBuffer()
}, this.config.bufferFlushIntervalMs)
}
// Load metrics module if enabled (currently unused but loaded for future use)
if (this.config.enableMetrics) {
import('./prometheus-metrics')
.then(m => {
this.metricsModule = m
this.debug('📊 Prometheus metrics loaded for logger')
})
.catch(() => {
// Metrics module not available - silent fail
})
}
}
/**
* Get current log level (ILogger compatibility)
*/
get level(): string {
return this.config.level
}
/**
* Set log level
*/
set level(newLevel: string) {
if (newLevel in LOG_LEVEL_VALUES) {
this.config.level = newLevel as LogLevel
}
}
/**
* Check if logger has been destroyed
*/
isDestroyed(): boolean {
return this.destroyed
}
/**
* Create a child logger with additional context
*/
child(obj: Record<string, unknown>): StructuredLogger {
const childLogger = new StructuredLogger({
...this.config,
context: { ...this.config.context, ...this.childContext, ...obj }
})
childLogger.childContext = { ...this.childContext, ...obj }
return childLogger
}
/**
* Check if log level is enabled
*/
isLevelEnabled(level: LogLevel): boolean {
return LOG_LEVEL_VALUES[level] >= LOG_LEVEL_VALUES[this.config.level]
}
/**
* Main logging method
*/
private log(level: LogLevel, obj: unknown, msg?: string): void {
if (this.destroyed || !this.isLevelEnabled(level)) {
return
}
const startTime = Date.now()
// Rate limiting check
if (this.rateLimiter && !this.rateLimiter.tryAcquire()) {
this.metrics.droppedLogs++
return
}
const entry = this.createLogEntry(level, obj, msg)
// Update metrics
this.updateMetrics(level)
// Process log (sync or async)
const processLog = () => {
// Buffered output
if (this.config.enableBuffering) {
this.buffer.push(entry)
if (this.buffer.length >= this.config.maxBufferSize) {
this.flushBuffer()
}
} else {
this.output(entry)
}
// External hook with circuit breaker
const externalHook = this.config.externalHook
if (externalHook && this.circuitBreaker) {
this.circuitBreaker
.execute(async () => {
await Promise.resolve(externalHook(entry))
})
.catch(() => {
this.metrics.hookFailures++
if (this.circuitBreaker?.getState() === 'open') {
this.metrics.circuitBreakerTrips++
}
})
}
// Track processing time
this.totalProcessingTime += Date.now() - startTime
this.processedLogs++
this.metrics.avgProcessingTimeMs = this.totalProcessingTime / this.processedLogs
}
// Async or sync processing
if (this.asyncQueue) {
this.asyncQueue.enqueue(processLog)
} else {
processLog()
}
}
/**
* Flush buffered logs
*/
flushBuffer(): void {
if (this.buffer.length === 0) return
const entries = this.buffer
this.buffer = []
this.metrics.bufferFlushes++
for (const entry of entries) {
this.output(entry)
}
}
/**
* Create a structured log entry
*/
private createLogEntry(level: LogLevel, obj: unknown, msg?: string): LogEntry {
const timestamp = new Date().toISOString()
let message = msg || ''
let data: Record<string, unknown> | undefined
let stack: string | undefined
// Process object
if (obj instanceof Error) {
message = message || obj.message
if (this.config.includeStackTrace && obj.stack) {
stack = obj.stack
}
data = {
errorName: obj.name,
errorMessage: obj.message,
...(obj as unknown as Record<string, unknown>)
}
} else if (typeof obj === 'object' && obj !== null) {
data = this.sanitize(obj as Record<string, unknown>)
if (!message && 'msg' in (obj as Record<string, unknown>)) {
message = String((obj as Record<string, unknown>).msg)
}
} else if (typeof obj === 'string') {
message = message || obj
}
// Extract correlationId and durationMs if present
const correlationId = data?.correlationId as string | undefined
const durationMs = data?.durationMs as number | undefined
return {
timestamp,
level,
levelValue: LOG_LEVEL_VALUES[level],
message,
name: this.config.name,
context: Object.keys(this.config.context).length > 0 ? this.config.context : undefined,
data,
stack,
correlationId,
durationMs
}
}
/**
* Sanitize sensitive data
*/
private sanitize(obj: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const lowerKey = key.toLowerCase()
if (this.config.redactFields.some(field => lowerKey.includes(field.toLowerCase()))) {
sanitized[key] = '[REDACTED]'
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
sanitized[key] = this.sanitize(value as Record<string, unknown>)
} else {
sanitized[key] = value
}
}
return sanitized
}
/**
* Update internal metrics
*/
private updateMetrics(level: LogLevel): void {
this.metrics.totalLogs++
this.metrics.logsByLevel[level]++
this.metrics.lastLogTimestamp = new Date().toISOString()
if (level === 'error' || level === 'fatal') {
this.metrics.errorsCount++
}
}
/**
* Output log entry
*/
private output(entry: LogEntry): void {
const output = this.config.jsonFormat ? JSON.stringify(entry) : this.formatText(entry)
switch (entry.level) {
case 'trace':
case 'debug':
console.debug(output)
break
case 'info':
console.info(output)
break
case 'warn':
console.warn(output)
break
case 'error':
case 'fatal':
console.error(output)
break
}
}
/**
* Format log as human-readable text
*/
private formatText(entry: LogEntry): string {
const parts = [
`[${entry.timestamp}]`,
`[${entry.level.toUpperCase()}]`,
entry.name ? `[${entry.name}]` : '',
entry.correlationId ? `[${entry.correlationId}]` : '',
entry.message,
entry.durationMs !== undefined ? `(${entry.durationMs}ms)` : ''
]
let text = parts.filter(Boolean).join(' ')
if (entry.data && Object.keys(entry.data).length > 0) {
text += ` | ${JSON.stringify(entry.data)}`
}
if (entry.stack) {
text += `\n${entry.stack}`
}
return text
}
// Convenience methods for each log level
trace(obj: unknown, msg?: string): void {
this.log('trace', obj, msg)
}
debug(obj: unknown, msg?: string): void {
this.log('debug', obj, msg)
}
info(obj: unknown, msg?: string): void {
this.log('info', obj, msg)
}
warn(obj: unknown, msg?: string): void {
this.log('warn', obj, msg)
}
error(obj: unknown, msg?: string): void {
this.log('error', obj, msg)
}
fatal(obj: unknown, msg?: string): void {
this.log('fatal', obj, msg)
}
/**
* Log with temporary context
*/
withContext(context: Record<string, unknown>): StructuredLogger {
return this.child(context)
}
/**
* Log with correlation ID
*/
withCorrelationId(correlationId: string): StructuredLogger {
return this.child({ correlationId })
}
/**
* Log operation with duration tracking
*/
logOperation<T>(operationName: string, operation: () => T | Promise<T>, level: LogLevel = 'info'): T | Promise<T> {
const startTime = Date.now()
const contextLogger = this.child({ operation: operationName })
contextLogger.log(level, { event: 'operation_start' }, `Starting ${operationName}`)
const handleResult = (result: T): T => {
const durationMs = Date.now() - startTime
contextLogger.log(level, { event: 'operation_complete', durationMs }, `Completed ${operationName}`)
return result
}
const handleError = (error: Error): never => {
const durationMs = Date.now() - startTime
contextLogger.error({ event: 'operation_error', durationMs, error }, `Failed ${operationName}`)
throw error
}
try {
const result = operation()
if (result instanceof Promise) {
return result.then(handleResult).catch(handleError)
}
return handleResult(result)
} catch (error) {
return handleError(error as Error)
}
}
/**
* Get logger metrics
*/
getMetrics(): LoggerMetrics {
return { ...this.metrics }
}
/**
* Get comprehensive statistics
*/
getStatistics(): LoggerStatistics {
return {
...this.metrics,
bufferSize: this.buffer.length,
rateLimiterTokens: this.rateLimiter?.getTokens() ?? 0,
circuitBreakerState: this.circuitBreaker?.getState() ?? 'closed',
queueSize: this.asyncQueue?.getSize() ?? 0,
createdAt: this.createdAt,
uptimeMs: Date.now() - this.createdAt
}
}
/**
* Reset metrics
*/
resetMetrics(): void {
this.metrics = {
totalLogs: 0,
logsByLevel: {
trace: 0,
debug: 0,
info: 0,
warn: 0,
error: 0,
fatal: 0,
silent: 0
},
errorsCount: 0,
droppedLogs: 0,
bufferFlushes: 0,
hookFailures: 0,
circuitBreakerTrips: 0,
avgProcessingTimeMs: 0
}
this.totalProcessingTime = 0
this.processedLogs = 0
}
/**
* Destroy the logger and clean up resources
* CRITICAL: Call this when done to prevent memory leaks
*/
destroy(): void {
if (this.destroyed) return
this.destroyed = true
// Flush remaining buffer
this.flushBuffer()
// Clear buffer flush timer
if (this.bufferFlushTimer) {
clearInterval(this.bufferFlushTimer)
this.bufferFlushTimer = null
}
// Destroy async queue
if (this.asyncQueue) {
this.asyncQueue.destroy()
this.asyncQueue = null
}
// Clear references
this.rateLimiter = null
this.circuitBreaker = null
this.metricsModule = null
}
}
/**
* Factory to create structured logger
*/
export function createStructuredLogger(config: Partial<StructuredLoggerConfig> = {}): StructuredLogger {
return new StructuredLogger({
level: config.level || 'info',
...config
})
}
/**
* Default singleton logger
*/
let defaultLogger: StructuredLogger | null = null
export function getDefaultLogger(): StructuredLogger {
if (!defaultLogger) {
defaultLogger = createStructuredLogger({
level: 'info',
name: 'baileys',
jsonFormat: process.env.NODE_ENV === 'production'
})
}
return defaultLogger
}
export function setDefaultLogger(logger: StructuredLogger): void {
defaultLogger = logger
}
/**
* Utility to measure execution time
*/
export function createTimer(): { elapsed: () => number; elapsedMs: () => string } {
const start = process.hrtime.bigint()
return {
elapsed: () => Number(process.hrtime.bigint() - start) / 1_000_000,
elapsedMs: () => `${(Number(process.hrtime.bigint() - start) / 1_000_000).toFixed(2)}ms`
}
}
export default StructuredLogger
+1 -1
View File
@@ -56,7 +56,7 @@ export const processContactAction = (
if (lidJid && isLidUser(lidJid) && idIsPn) {
results.push({
event: 'lid-mapping.update',
data: [{ lid: lidJid, pn: id }]
data: { lid: lidJid, pn: id }
})
}
+4 -138
View File
@@ -1,67 +1,5 @@
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { BinaryNode } from '../WABinary'
import { getBinaryNodeChild, getBinaryNodeChildren, isLidUser, jidNormalizedUser } from '../WABinary'
/** 7 days in seconds — matches WA Web AB prop tctoken_duration */
const TC_TOKEN_BUCKET_DURATION = 604800
/** 4 buckets → ~28-day rolling window — matches WA Web AB prop tctoken_num_buckets */
const TC_TOKEN_NUM_BUCKETS = 4
/**
* Check if a received token is expired using WA Web's rolling bucket algorithm.
* Reference: WAWebTrustedContactsUtils.isTokenExpired
*
* Uses Receiver mode constants (tctoken_duration, tctoken_num_buckets).
* NOTE: WA Web distinguishes Sender vs Receiver mode via AB props
* (tctoken_duration_sender / tctoken_num_buckets_sender). Currently both
* use identical values (604800 / 4), so we use a single function for both.
* If WA ever diverges these, add a `mode` parameter here.
*/
export function isTcTokenExpired(timestamp: number | string | null | undefined): boolean {
if (timestamp === null || timestamp === undefined) return true
const ts = typeof timestamp === 'string' ? Number(timestamp) : timestamp
if (isNaN(ts)) return true
const now = Math.floor(Date.now() / 1000)
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
const cutoffBucket = currentBucket - (TC_TOKEN_NUM_BUCKETS - 1)
const cutoffTimestamp = cutoffBucket * TC_TOKEN_BUCKET_DURATION
return ts < cutoffTimestamp
}
/**
* Check if we should issue a new token to this contact (bucket boundary crossed).
* Reference: WAWebTrustedContactsUtils.shouldSendNewToken
*
* Returns true if senderTimestamp is null/undefined or in a previous bucket.
*/
export function shouldSendNewTcToken(senderTimestamp: number | undefined): boolean {
if (senderTimestamp === undefined) return true
const now = Math.floor(Date.now() / 1000)
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
const senderBucket = Math.floor(senderTimestamp / TC_TOKEN_BUCKET_DURATION)
return currentBucket > senderBucket
}
/**
* Resolve a JID to its LID for tctoken storage, mirroring how Signal sessions
* use LID keys via resolveLIDSignalAddress.
*
* WA Web always resolves to LID before storing/looking up tctokens:
* `senderLid ?? toLid(from)` (WAWebSetTcTokenChatAction.handleIncomingTcToken)
*
* @param jid - The JID to resolve (can be PN or LID)
* @param getLIDForPN - Resolver function (from lidMapping)
* @returns The LID if mapping exists, otherwise the original JID
*/
export async function resolveTcTokenJid(
jid: string,
getLIDForPN: (pn: string) => Promise<string | null>
): Promise<string> {
const normalized = jidNormalizedUser(jid)
if (isLidUser(normalized)) return normalized
const lid = await getLIDForPN(normalized)
return lid ?? normalized
}
type TcTokenParams = {
jid: string
@@ -69,29 +7,19 @@ type TcTokenParams = {
authState: {
keys: SignalKeyStoreWithTransaction
}
getLIDForPN?: (pn: string) => Promise<string | null>
}
export async function buildTcTokenFromJid({
authState,
jid,
baseContent = [],
getLIDForPN
baseContent = []
}: TcTokenParams): Promise<BinaryNode[] | undefined> {
try {
const storageJid = getLIDForPN ? await resolveTcTokenJid(jid, getLIDForPN) : jid
const tcTokenData = await authState.keys.get('tctoken', [storageJid])
const entry = tcTokenData?.[storageJid]
const tcTokenBuffer = entry?.token
const tcTokenData = await authState.keys.get('tctoken', [jid])
if (!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) {
// Opportunistic cleanup: remove expired token from store
if (tcTokenBuffer) {
await authState.keys.set({ tctoken: { [storageJid]: null } })
}
const tcTokenBuffer = tcTokenData?.[jid]?.token
return baseContent.length > 0 ? baseContent : undefined
}
if (!tcTokenBuffer) return baseContent.length > 0 ? baseContent : undefined
baseContent.push({
tag: 'tctoken',
@@ -104,65 +32,3 @@ export async function buildTcTokenFromJid({
return baseContent.length > 0 ? baseContent : undefined
}
}
type StoreTcTokensParams = {
result: BinaryNode
fallbackJid: string
keys: SignalKeyStoreWithTransaction
getLIDForPN: (pn: string) => Promise<string | null>
/** Optional callback when a new JID is stored (for index tracking) */
onNewJidStored?: (jid: string) => void
}
/**
* Parse and store tctoken(s) from an IQ result node.
* Includes timestamp monotonicity guard matching WA Web's handleIncomingTcToken.
* Used by both the blocking fetch (messages-send) and IQ response (messages-recv) paths.
*/
export async function storeTcTokensFromIqResult({
result,
fallbackJid,
keys,
getLIDForPN,
onNewJidStored
}: StoreTcTokensParams) {
const tokensNode = getBinaryNodeChild(result, 'tokens')
if (!tokensNode) return
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
for (const tokenNode of tokenNodes) {
if (tokenNode.attrs.type !== 'trusted_contact' || !(tokenNode.content instanceof Uint8Array)) {
continue
}
const rawJid = jidNormalizedUser(tokenNode.attrs.jid || fallbackJid)
const storageJid = await resolveTcTokenJid(rawJid, getLIDForPN)
const existingTcData = await keys.get('tctoken', [storageJid])
const existingEntry = existingTcData[storageJid]
// Timestamp monotonicity guard — only store if incoming timestamp >= existing
// Matches WA Web handleIncomingTcToken
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
const incomingTs = tokenNode.attrs.t ? Number(tokenNode.attrs.t) : 0
if (existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) {
continue
}
// Don't store timestamp-less tokens at all — isTcTokenExpired treats them
// as immediately expired regardless of whether an existing entry is present
if (!incomingTs) {
continue
}
await keys.set({
tctoken: {
[storageJid]: {
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t
}
}
})
onNewJidStored?.(storageJid)
}
}
-655
View File
@@ -1,655 +0,0 @@
/**
* Request Tracing Context
*
* Provides:
* - Unique trace ID generation
* - Context propagation between operations
* - Correlation IDs for request tracking
* - Performance timing
* - Span tracking for nested operations
* - Baggage for contextual data
*
* @module Utils/trace-context
*/
import { AsyncLocalStorage } from 'async_hooks'
import { randomBytes } from 'crypto'
/**
* Trace identifiers
*/
export interface TraceIds {
/** Unique trace ID (16 bytes hex) */
traceId: string
/** Current span ID (8 bytes hex) */
spanId: string
/** Parent span ID (optional) */
parentSpanId?: string
/** Correlation ID for logging */
correlationId: string
}
/**
* Baggage data (propagated context)
*/
export type Baggage = Record<string, string | number | boolean>
/**
* Span status
*/
export type SpanStatus = 'unset' | 'ok' | 'error'
/**
* Span represents a unit of work
*/
export interface Span {
/** Operation name */
name: string
/** Trace identifiers */
traceIds: TraceIds
/** Start timestamp (ms) */
startTime: number
/** End timestamp (ms) */
endTime?: number
/** Duration in ms */
duration?: number
/** Span status */
status: SpanStatus
/** Span attributes */
attributes: Record<string, unknown>
/** Events occurred during the span */
events: SpanEvent[]
/** Whether the span has ended */
ended: boolean
}
/**
* Event within a span
*/
export interface SpanEvent {
/** Event name */
name: string
/** Event timestamp */
timestamp: number
/** Event attributes */
attributes?: Record<string, unknown>
}
/**
* Complete trace context
*/
export interface TraceContext {
/** Trace identifiers */
traceIds: TraceIds
/** Baggage (propagated data) */
baggage: Baggage
/** Current span */
currentSpan?: Span
/** Span stack (for nested spans) */
spanStack: Span[]
/** Context creation timestamp */
createdAt: number
/** Additional metadata */
metadata: Record<string, unknown>
}
/**
* Options for creating a new context
*/
export interface CreateContextOptions {
/** Existing trace ID (for propagation) */
traceId?: string
/** Parent span ID */
parentSpanId?: string
/** Existing correlation ID */
correlationId?: string
/** Initial baggage */
baggage?: Baggage
/** Initial metadata */
metadata?: Record<string, unknown>
}
/**
* Options for creating a span
*/
export interface CreateSpanOptions {
/** Span name */
name: string
/** Initial attributes */
attributes?: Record<string, unknown>
/** Whether to be a child of the current span */
asChild?: boolean
}
/**
* Async storage for trace context
*/
const traceStorage = new AsyncLocalStorage<TraceContext>()
/**
* Generate a random hexadecimal ID
*/
function generateId(bytes: number): string {
return randomBytes(bytes).toString('hex')
}
/**
* Generate a trace ID (16 bytes = 32 chars hex)
*/
export function generateTraceId(): string {
return generateId(16)
}
/**
* Generate a span ID (8 bytes = 16 chars hex)
*/
export function generateSpanId(): string {
return generateId(8)
}
/**
* Generate a more readable correlation ID
*/
export function generateCorrelationId(): string {
const timestamp = Date.now().toString(36)
const random = generateId(4)
return `${timestamp}-${random}`
}
/**
* Create a new trace context
*/
export function createTraceContext(options: CreateContextOptions = {}): TraceContext {
const traceId = options.traceId || generateTraceId()
const spanId = generateSpanId()
const correlationId = options.correlationId || generateCorrelationId()
return {
traceIds: {
traceId,
spanId,
parentSpanId: options.parentSpanId,
correlationId
},
baggage: options.baggage || {},
spanStack: [],
createdAt: Date.now(),
metadata: options.metadata || {}
}
}
/**
* Get the current trace context
*/
export function getCurrentContext(): TraceContext | undefined {
return traceStorage.getStore()
}
/**
* Get the current trace context or create a new one
*/
export function getOrCreateContext(): TraceContext {
const existing = getCurrentContext()
if (existing) {
return existing
}
return createTraceContext()
}
/**
* Execute function with trace context
*/
export function runWithContext<T>(context: TraceContext, fn: () => T): T {
return traceStorage.run(context, fn)
}
/**
* Execute function with new trace context
*/
export function runWithNewContext<T>(options: CreateContextOptions, fn: () => T): T {
const context = createTraceContext(options)
return runWithContext(context, fn)
}
/**
* Execute async function with trace context
*/
export async function runWithContextAsync<T>(context: TraceContext, fn: () => Promise<T>): Promise<T> {
return traceStorage.run(context, fn)
}
/**
* Create a new span
*/
export function createSpan(options: CreateSpanOptions): Span {
const context = getCurrentContext()
const parentSpan = context?.currentSpan
const span: Span = {
name: options.name,
traceIds: {
traceId: context?.traceIds.traceId || generateTraceId(),
spanId: generateSpanId(),
parentSpanId: options.asChild && parentSpan ? parentSpan.traceIds.spanId : undefined,
correlationId: context?.traceIds.correlationId || generateCorrelationId()
},
startTime: Date.now(),
status: 'unset',
attributes: options.attributes || {},
events: [],
ended: false
}
return span
}
/**
* Start a span in the current context
*/
export function startSpan(options: CreateSpanOptions): Span {
const context = getOrCreateContext()
const span = createSpan({ ...options, asChild: true })
// Push current span to stack and set new one as current
if (context.currentSpan) {
context.spanStack.push(context.currentSpan)
}
context.currentSpan = span
return span
}
/**
* End a span
*/
export function endSpan(span: Span, status?: SpanStatus): void {
if (span.ended) {
return
}
span.endTime = Date.now()
span.duration = span.endTime - span.startTime
span.status = status || 'ok'
span.ended = true
// Pop span from stack in context
const context = getCurrentContext()
if (context?.currentSpan === span) {
context.currentSpan = context.spanStack.pop()
}
}
/**
* Add event to a span
*/
export function addSpanEvent(span: Span, name: string, attributes?: Record<string, unknown>): void {
if (span.ended) {
return
}
span.events.push({
name,
timestamp: Date.now(),
attributes
})
}
/**
* Set attributes on a span
*/
export function setSpanAttributes(span: Span, attributes: Record<string, unknown>): void {
if (span.ended) {
return
}
Object.assign(span.attributes, attributes)
}
/**
* Mark span as error
*/
export function setSpanError(span: Span, error: Error): void {
if (span.ended) {
return
}
span.status = 'error'
span.attributes.error = true
span.attributes.errorMessage = error.message
span.attributes.errorName = error.name
if (error.stack) {
span.attributes.errorStack = error.stack
}
addSpanEvent(span, 'exception', {
'exception.type': error.name,
'exception.message': error.message
})
}
/**
* Decorator for automatic function tracing
*/
export function traced(name?: string) {
return function <T extends (...args: unknown[]) => unknown>(
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T> {
const originalMethod = descriptor.value
if (!originalMethod) {
return descriptor
}
const spanName = name || propertyKey
descriptor.value = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
const span = startSpan({ name: spanName })
try {
const result = originalMethod.apply(this, args) as ReturnType<T>
if (result instanceof Promise) {
return result
.then(value => {
endSpan(span, 'ok')
return value
})
.catch(error => {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}) as ReturnType<T>
}
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
} as T
return descriptor
}
}
/**
* Wrapper for tracing a function
*/
// eslint-disable-next-line space-before-function-paren
export function traceFunction<T extends (...args: unknown[]) => unknown>(name: string, fn: T): T {
return function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
const span = startSpan({ name })
try {
const result = fn.apply(this, args) as ReturnType<T>
if (result instanceof Promise) {
return result
.then(value => {
endSpan(span, 'ok')
return value
})
.catch(error => {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}) as ReturnType<T>
}
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
} as T
}
/**
* Execute operation with automatic span
*/
export async function withSpan<T>(
name: string,
operation: (span: Span) => Promise<T>,
attributes?: Record<string, unknown>
): Promise<T> {
const span = startSpan({ name, attributes })
try {
const result = await operation(span)
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
}
/**
* Execute sync operation with automatic span
*/
export function withSpanSync<T>(name: string, operation: (span: Span) => T, attributes?: Record<string, unknown>): T {
const span = startSpan({ name, attributes })
try {
const result = operation(span)
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
}
// === Baggage Management ===
/**
* Set item in baggage
*/
export function setBaggage(key: string, value: string | number | boolean): void {
const context = getCurrentContext()
if (context) {
context.baggage[key] = value
}
}
/**
* Get item from baggage
*/
export function getBaggage(key: string): string | number | boolean | undefined {
const context = getCurrentContext()
return context?.baggage[key]
}
/**
* Get all baggage
*/
export function getAllBaggage(): Baggage {
const context = getCurrentContext()
return context?.baggage || {}
}
/**
* Remove item from baggage
*/
export function removeBaggage(key: string): void {
const context = getCurrentContext()
if (context) {
delete context.baggage[key]
}
}
// === Header Utilities ===
/**
* Standard headers for trace propagation
*/
export const TRACE_HEADERS = {
TRACE_ID: 'x-trace-id',
SPAN_ID: 'x-span-id',
PARENT_SPAN_ID: 'x-parent-span-id',
CORRELATION_ID: 'x-correlation-id',
BAGGAGE: 'baggage'
} as const
/**
* Inject context into HTTP headers
*/
export function injectTraceHeaders(headers: Record<string, string>): Record<string, string> {
const context = getCurrentContext()
if (!context) {
return headers
}
const result = { ...headers }
result[TRACE_HEADERS.TRACE_ID] = context.traceIds.traceId
result[TRACE_HEADERS.SPAN_ID] = context.traceIds.spanId
result[TRACE_HEADERS.CORRELATION_ID] = context.traceIds.correlationId
if (context.traceIds.parentSpanId) {
result[TRACE_HEADERS.PARENT_SPAN_ID] = context.traceIds.parentSpanId
}
// Baggage as key=value list
if (Object.keys(context.baggage).length > 0) {
result[TRACE_HEADERS.BAGGAGE] = Object.entries(context.baggage)
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
.join(',')
}
return result
}
/**
* Extract context from HTTP headers
*/
export function extractTraceHeaders(headers: Record<string, string | undefined>): CreateContextOptions {
const options: CreateContextOptions = {}
if (headers[TRACE_HEADERS.TRACE_ID]) {
options.traceId = headers[TRACE_HEADERS.TRACE_ID]
}
if (headers[TRACE_HEADERS.PARENT_SPAN_ID]) {
options.parentSpanId = headers[TRACE_HEADERS.PARENT_SPAN_ID]
}
if (headers[TRACE_HEADERS.CORRELATION_ID]) {
options.correlationId = headers[TRACE_HEADERS.CORRELATION_ID]
}
// Parse baggage
const baggageHeader = headers[TRACE_HEADERS.BAGGAGE]
if (baggageHeader) {
options.baggage = {}
const pairs = baggageHeader.split(',')
for (const pair of pairs) {
const parts = pair.split('=')
const key = parts[0]
const value = parts[1]
if (key && value) {
options.baggage[key.trim()] = decodeURIComponent(value.trim())
}
}
}
return options
}
/**
* Export trace context for serialization
*/
export function exportContext(context: TraceContext): string {
return JSON.stringify({
traceIds: context.traceIds,
baggage: context.baggage,
metadata: context.metadata
})
}
/**
* Import trace context from serialized string
*/
export function importContext(serialized: string): CreateContextOptions {
try {
const data = JSON.parse(serialized)
return {
traceId: data.traceIds?.traceId,
parentSpanId: data.traceIds?.spanId,
correlationId: data.traceIds?.correlationId,
baggage: data.baggage,
metadata: data.metadata
}
} catch {
return {}
}
}
// === Timer Utilities ===
/**
* High precision timer
*/
export interface PrecisionTimer {
/** Return elapsed time in milliseconds */
elapsed(): number
/** Return formatted elapsed time */
elapsedFormatted(): string
/** Stop the timer and return duration */
stop(): number
}
/**
* Create a high precision timer
*/
export function createPrecisionTimer(): PrecisionTimer {
const start = process.hrtime.bigint()
let stopped = false
let finalDuration = 0
return {
elapsed(): number {
if (stopped) return finalDuration
return Number(process.hrtime.bigint() - start) / 1_000_000
},
elapsedFormatted(): string {
const ms = this.elapsed()
if (ms < 1) return `${(ms * 1000).toFixed(2)}µs`
if (ms < 1000) return `${ms.toFixed(2)}ms`
return `${(ms / 1000).toFixed(2)}s`
},
stop(): number {
if (!stopped) {
finalDuration = Number(process.hrtime.bigint() - start) / 1_000_000
stopped = true
}
return finalDuration
}
}
}
export default {
createTraceContext,
getCurrentContext,
getOrCreateContext,
runWithContext,
runWithNewContext,
createSpan,
startSpan,
endSpan,
withSpan,
withSpanSync,
injectTraceHeaders,
extractTraceHeaders,
createPrecisionTimer
}
-414
View File
@@ -1,414 +0,0 @@
/**
* Unified Session Telemetry Implementation
*
* This module implements WhatsApp's unified_session telemetry feature to reduce
* detection of unofficial clients. The implementation is inspired by:
* - whatsmeow PR #1057 (Go implementation)
* - Baileys PR #2294 (TypeScript implementation)
*
* The unified_session is a time-based identifier that mimics the behavior of
* official WhatsApp Web clients, potentially reducing account restriction warnings.
*
* @module Utils/unified-session
* @see https://github.com/tulir/whatsmeow/pull/1057
* @see https://github.com/WhiskeySockets/Baileys/pull/2294
*/
import type { BinaryNode } from '../WABinary/types.js'
import { CircuitBreaker, CircuitOpenError, createConnectionCircuitBreaker } from './circuit-breaker.js'
import type { ILogger } from './logger.js'
import { metrics } from './prometheus-metrics.js'
// ============================================
// Time Constants (Enterprise-grade)
// ============================================
/**
* Time constants in milliseconds for unified session calculations.
* Defined locally to avoid circular dependency issues with Defaults/index.ts
*/
const TimeMs = {
/** One second in milliseconds */
Second: 1_000,
/** One minute in milliseconds */
Minute: 60_000,
/** One hour in milliseconds */
Hour: 3_600_000,
/** One day in milliseconds */
Day: 86_400_000,
/** One week in milliseconds */
Week: 604_800_000
} as const
/**
* Offset for session ID calculation (3 days in ms).
* This matches the WhatsApp Web official client behavior.
*/
const SESSION_OFFSET_MS = 3 * TimeMs.Day
// ============================================
// Types
// ============================================
/**
* Unified Session Manager options
*/
export interface UnifiedSessionOptions {
/** Whether unified session telemetry is enabled */
enabled?: boolean
/** Logger instance for debugging */
logger?: ILogger
/** Enable circuit breaker protection for send operations */
enableCircuitBreaker?: boolean
/** Function to send binary nodes to WhatsApp */
sendNode?: (node: BinaryNode) => Promise<void>
}
/**
* Unified Session state
*/
export interface UnifiedSessionState {
/** Server time offset in milliseconds (server time - local time) */
serverTimeOffset: number
/** Last time unified_session was sent (Unix timestamp ms) */
lastSentTime: number
/** Total number of unified_session messages sent */
sendCount: number
/** Whether the session manager is initialized */
isInitialized: boolean
}
/**
* Trigger types for unified session sending
*/
export type UnifiedSessionTrigger = 'login' | 'pairing' | 'presence' | 'manual'
// ============================================
// Core Implementation
// ============================================
/**
* Unified Session Manager
*
* Manages the unified_session telemetry feature with:
* - Server time synchronization
* - Rate limiting (prevents spam)
* - Circuit breaker protection
* - Prometheus metrics integration
* - Structured logging
*
* @example
* ```typescript
* const sessionManager = new UnifiedSessionManager({
* enabled: true,
* logger: myLogger,
* sendNode: (node) => sock.sendNode(node)
* })
*
* // Update server time offset when receiving server timestamp
* sessionManager.updateServerTimeOffset(serverTimeAttr)
*
* // Send unified_session on login
* await sessionManager.send('login')
* ```
*/
export class UnifiedSessionManager {
private state: UnifiedSessionState = {
serverTimeOffset: 0,
lastSentTime: 0,
sendCount: 0,
isInitialized: false
}
private readonly options: Required<UnifiedSessionOptions>
private circuitBreaker: CircuitBreaker | null = null
/** Minimum interval between unified_session sends (1 minute) */
private static readonly MIN_SEND_INTERVAL_MS = TimeMs.Minute
constructor(options: UnifiedSessionOptions = {}) {
this.options = {
enabled: options.enabled ?? true,
logger: options.logger ?? (console as unknown as ILogger),
enableCircuitBreaker: options.enableCircuitBreaker ?? true,
sendNode:
options.sendNode ??
(async () => {
throw new Error('sendNode function not configured')
})
}
// Initialize circuit breaker if enabled
if (this.options.enableCircuitBreaker) {
this.circuitBreaker = createConnectionCircuitBreaker({
name: 'unified-session',
failureThreshold: 3,
failureWindow: 60000,
resetTimeout: 30000,
successThreshold: 1,
timeout: 10000,
onStateChange: (from, to) => {
this.options.logger.debug?.({ from, to }, 'Unified session circuit breaker state changed')
},
onOpen: () => {
this.options.logger.warn?.('Unified session circuit breaker OPENED')
metrics.circuitBreakerTrips?.inc({ name: 'unified-session' })
}
})
}
this.state.isInitialized = true
this.options.logger.debug?.('UnifiedSessionManager initialized')
}
/**
* Update the server time offset from a received server timestamp.
*
* WhatsApp includes a 't' attribute in some nodes containing the server's
* Unix timestamp (in seconds). We use this to calculate the offset between
* server time and local time.
*
* @param serverTime - Server timestamp (seconds) from node.attrs.t
*/
updateServerTimeOffset(serverTime: string | number | undefined): void {
if (serverTime === undefined || serverTime === null) {
return
}
const serverTimeNum = typeof serverTime === 'string' ? parseInt(serverTime, 10) : serverTime
if (isNaN(serverTimeNum) || serverTimeNum <= 0) {
return
}
// Server time is in seconds, convert to ms
const serverTimeMs = serverTimeNum * 1000
const localTimeMs = Date.now()
const newOffset = serverTimeMs - localTimeMs
// Reject outliers: if the new offset differs from the current stable
// offset by more than 30 seconds, this timestamp is likely stale
// (e.g. replayed device notification with old 't' value).
// The first sample (offset === 0) is always accepted.
const MAX_DRIFT_MS = 30_000
if (this.state.serverTimeOffset !== 0 && Math.abs(newOffset - this.state.serverTimeOffset) > MAX_DRIFT_MS) {
return
}
// Only update if the offset changed significantly (>1 second)
if (Math.abs(newOffset - this.state.serverTimeOffset) > 1000) {
this.state.serverTimeOffset = newOffset
this.options.logger.trace?.({ newOffset, serverTime: serverTimeNum }, 'Server time offset updated')
// Record metric
metrics.socketEvents?.inc({ event: 'server_time_sync' })
}
}
/**
* Calculate the unified session ID.
*
* The algorithm matches WhatsApp Web's official implementation:
* - Takes current time adjusted by server offset
* - Adds 3-day offset
* - Modulo 7 days (one week cycle)
* - Returns as string
*
* @returns The unified session ID as a string
*/
getSessionId(): string {
const adjustedTime = Date.now() + this.state.serverTimeOffset
const sessionId = (adjustedTime + SESSION_OFFSET_MS) % TimeMs.Week
return String(Math.floor(sessionId))
}
/**
* Check if enough time has passed since the last send.
* Prevents spamming the server with unified_session messages.
*/
private canSend(): boolean {
const timeSinceLastSend = Date.now() - this.state.lastSentTime
return timeSinceLastSend >= UnifiedSessionManager.MIN_SEND_INTERVAL_MS
}
/**
* Send the unified_session telemetry to WhatsApp.
*
* This should be called at specific trigger points:
* - After successful login (CB:success)
* - After successful pairing (CB:iq,,pair-success)
* - When sending 'available' presence
*
* @param trigger - What triggered this send (for logging/metrics)
* @returns Promise that resolves when sent, or void if skipped
*/
async send(trigger: UnifiedSessionTrigger = 'manual'): Promise<void> {
// Check if enabled
if (!this.options.enabled) {
this.options.logger.trace?.('Unified session is disabled, skipping')
return
}
// Rate limiting (except for login which always sends)
if (trigger !== 'login' && !this.canSend()) {
this.options.logger.trace?.(
{ trigger, lastSent: this.state.lastSentTime },
'Unified session rate limited, skipping'
)
return
}
const sessionId = this.getSessionId()
const startTime = Date.now()
const node: BinaryNode = {
tag: 'ib',
attrs: {},
content: [
{
tag: 'unified_session',
attrs: { id: sessionId }
}
]
}
const sendOperation = async (): Promise<void> => {
await this.options.sendNode(node)
// Update state on success
this.state.lastSentTime = Date.now()
this.state.sendCount++
const latency = Date.now() - startTime
this.options.logger.debug?.(
{ sessionId, trigger, latency, sendCount: this.state.sendCount },
'Unified session telemetry sent'
)
// Record metrics
metrics.socketEvents?.inc({ event: 'unified_session_sent' })
}
try {
// Execute with circuit breaker if available
if (this.circuitBreaker) {
await this.circuitBreaker.execute(sendOperation)
} else {
await sendOperation()
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
if (error instanceof CircuitOpenError) {
this.options.logger.warn?.({ trigger, circuitState: error.state }, 'Unified session blocked by circuit breaker')
} else {
this.options.logger.warn?.({ trigger, error: errorMessage }, 'Failed to send unified session telemetry')
}
// Record failure metric
metrics.errors?.inc({ category: 'unified_session', code: 'send_failed' })
// Don't rethrow - unified_session is non-critical
}
}
/**
* Get the current state for debugging/monitoring.
*/
getState(): Readonly<UnifiedSessionState> {
return { ...this.state }
}
/**
* Reset the manager state (useful for testing or reconnection).
*/
reset(): void {
this.state = {
serverTimeOffset: 0,
lastSentTime: 0,
sendCount: 0,
isInitialized: true
}
this.circuitBreaker?.reset()
this.options.logger.debug?.('UnifiedSessionManager reset')
}
/**
* Destroy the manager and clean up resources.
*/
destroy(): void {
this.circuitBreaker?.destroy()
this.state.isInitialized = false
this.options.logger.debug?.('UnifiedSessionManager destroyed')
}
}
// ============================================
// Factory Function
// ============================================
/**
* Create a new UnifiedSessionManager instance.
*
* @param options - Configuration options
* @returns A new UnifiedSessionManager instance
*
* @example
* ```typescript
* const sessionManager = createUnifiedSessionManager({
* enabled: config.enableUnifiedSession,
* logger: config.logger,
* sendNode: sock.sendNode
* })
* ```
*/
export function createUnifiedSessionManager(options: UnifiedSessionOptions = {}): UnifiedSessionManager {
return new UnifiedSessionManager(options)
}
// ============================================
// Utility Functions
// ============================================
/**
* Extract server time from a binary node's attributes.
* WhatsApp includes 't' attribute in various nodes.
*
* @param node - Binary node with potential time attribute
* @returns Server time in seconds, or undefined if not present
*/
export function extractServerTime(node: BinaryNode): number | undefined {
const timeAttr = node.attrs?.t
if (timeAttr === undefined || timeAttr === null) {
return undefined
}
const parsed =
typeof timeAttr === 'string' ? parseInt(timeAttr, 10) : typeof timeAttr === 'number' ? timeAttr : undefined
if (parsed === undefined || isNaN(parsed) || parsed <= 0) {
return undefined
}
return parsed
}
/**
* Check if unified_session should be enabled based on environment.
* Can be controlled via environment variable for testing.
*
* @returns true if unified_session should be enabled
*/
export function shouldEnableUnifiedSession(): boolean {
const envValue = process.env.BAILEYS_UNIFIED_SESSION_ENABLED
if (envValue !== undefined) {
return envValue.toLowerCase() === 'true' || envValue === '1'
}
// Default: enabled
return true
}
export default UnifiedSessionManager
+12 -29
View File
@@ -64,12 +64,7 @@ const getClientPayload = (config: SocketConfig) => {
}
export const generateLoginNode = (userJid: string, config: SocketConfig): proto.IClientPayload => {
const decoded = jidDecode(userJid)
if (!decoded) {
throw new Boom('Invalid user JID', { statusCode: 400 })
}
const { user, device } = decoded
const { user, device } = jidDecode(userJid)!
const payload: proto.IClientPayload = {
...getClientPayload(config),
passive: true,
@@ -158,9 +153,6 @@ export const configureSuccessfulPairing = (
}: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'>
) => {
const msgId = stanza.attrs.id
if (!msgId) {
throw new Boom('Missing message ID', { statusCode: 400 })
}
const pairSuccessNode = getBinaryNodeChild(stanza, 'pair-success')
@@ -176,51 +168,42 @@ export const configureSuccessfulPairing = (
const bizName = businessNode?.attrs.name
const jid = deviceNode.attrs.jid
const lid = deviceNode.attrs.lid
if (!jid || !lid) {
throw new Boom('Missing JID or LID in device node', { statusCode: 400 })
}
const { details, hmac, accountType } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentityNode.content as Buffer)
if (!details || !hmac) {
throw new Boom('Missing ADV signature fields', { statusCode: 400 })
}
let hmacPrefix = Buffer.from([])
if (accountType !== undefined && accountType === proto.ADVEncryptionType.HOSTED) {
hmacPrefix = WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
}
const advSign = hmacSign(Buffer.concat([hmacPrefix, details]), Buffer.from(advSecretKey, 'base64'))
if (Buffer.compare(hmac, advSign) !== 0) {
const advSign = hmacSign(Buffer.concat([hmacPrefix, details!]), Buffer.from(advSecretKey, 'base64'))
if (Buffer.compare(hmac!, advSign) !== 0) {
throw new Boom('Invalid account signature')
}
const account = proto.ADVSignedDeviceIdentity.decode(details)
const account = proto.ADVSignedDeviceIdentity.decode(details!)
const { accountSignatureKey, accountSignature, details: deviceDetails } = account
if (!accountSignatureKey || !accountSignature || !deviceDetails) {
throw new Boom('Missing ADV account fields', { statusCode: 400 })
}
const deviceIdentity = proto.ADVDeviceIdentity.decode(deviceDetails)
const deviceIdentity = proto.ADVDeviceIdentity.decode(deviceDetails!)
const accountSignaturePrefix =
deviceIdentity.deviceType === proto.ADVEncryptionType.HOSTED
? WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
: WA_ADV_ACCOUNT_SIG_PREFIX
const accountMsg = Buffer.concat([accountSignaturePrefix, deviceDetails, signedIdentityKey.public])
if (!Curve.verify(accountSignatureKey, accountMsg, accountSignature)) {
const accountMsg = Buffer.concat([accountSignaturePrefix, deviceDetails!, signedIdentityKey.public])
if (!Curve.verify(accountSignatureKey!, accountMsg, accountSignature!)) {
throw new Boom('Failed to verify account signature')
}
const deviceMsg = Buffer.concat([
WA_ADV_DEVICE_SIG_PREFIX,
deviceDetails,
deviceDetails!,
signedIdentityKey.public,
accountSignatureKey
accountSignatureKey!
])
account.deviceSignature = Curve.sign(signedIdentityKey.private, deviceMsg)
const identity = createSignalIdentity(lid, accountSignatureKey)
const identity = createSignalIdentity(lid!, accountSignatureKey!)
const accountEnc = encodeSignedDeviceIdentity(account, false)
const reply: BinaryNode = {
@@ -228,7 +211,7 @@ export const configureSuccessfulPairing = (
attrs: {
to: S_WHATSAPP_NET,
type: 'result',
id: msgId
id: msgId!
},
content: [
{
@@ -247,7 +230,7 @@ export const configureSuccessfulPairing = (
const authUpdate: Partial<AuthenticationCreds> = {
account,
me: { id: jid, name: bizName, lid },
me: { id: jid!, name: bizName, lid },
signalIdentities: [...(signalIdentities || []), identity],
platform: platformNode?.attrs.name
}
-276
View File
@@ -1,276 +0,0 @@
import { promises as fs } from 'fs'
import { join } from 'path'
import type { WAVersion } from '../Types'
import { fetchLatestWaWebVersion } from './generics'
/**
* 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, logger?: VersionCacheLogger): Promise<void> {
try {
await fs.writeFile(filePath, JSON.stringify(entry, null, 2), 'utf-8')
} catch (error) {
// Log write errors for debugging - cache is optional but failures should be visible
logger?.warn({ error, filePath }, 'Failed to write version cache file')
}
}
/**
* 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, logger).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) && memoryCache) {
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) && fileCache) {
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
}
}
-40
View File
@@ -1,40 +0,0 @@
type WasmBridgeModule = typeof import('whatsapp-rust-bridge')
let _bridge: WasmBridgeModule | undefined
// Start loading eagerly using .then() instead of top-level await.
// This prevents the whatsapp-rust-bridge top-level await from propagating
// through the ESM graph, which would break CJS require() consumers.
const _bridgeReady = import('whatsapp-rust-bridge').then(m => {
_bridge = m
return m
})
export { _bridgeReady as wasmBridgeReady }
function getBridge(): WasmBridgeModule {
if (!_bridge) {
throw new Error(
'whatsapp-rust-bridge not yet loaded. ' + 'Ensure async operations have started before calling crypto functions.'
)
}
return _bridge
}
export const hkdf: WasmBridgeModule['hkdf'] = (...args) => getBridge().hkdf(...args)
export const md5: WasmBridgeModule['md5'] = (...args) => getBridge().md5(...args)
export const expandAppStateKeys: WasmBridgeModule['expandAppStateKeys'] = (...args) =>
getBridge().expandAppStateKeys(...args)
let _ltHash: InstanceType<WasmBridgeModule['LTHashAntiTampering']> | undefined
export function getLTHashAntiTampering(): InstanceType<WasmBridgeModule['LTHashAntiTampering']> {
if (!_ltHash) {
_ltHash = new (getBridge().LTHashAntiTampering)()
}
return _ltHash
}
+4 -13
View File
@@ -55,12 +55,12 @@ export const jidEncode = (user: string | number | null, server: JidServer, devic
export const jidDecode = (jid: string | undefined): FullJid | undefined => {
// todo: investigate how to implement hosted ids in this case
const sepIdx = typeof jid === 'string' ? jid.indexOf('@') : -1
if (sepIdx < 0 || !jid) {
if (sepIdx < 0) {
return undefined
}
const server = jid.slice(sepIdx + 1)
const userCombined = jid.slice(0, sepIdx)
const server = jid!.slice(sepIdx + 1)
const userCombined = jid!.slice(0, sepIdx)
const [userAgent, device] = userCombined.split(':')
const [user, agent] = userAgent!.split('_')
@@ -105,10 +105,6 @@ export const isJidNewsletter = (jid: string | undefined) => jid?.endsWith('@news
export const isHostedPnUser = (jid: string | undefined) => jid?.endsWith('@hosted')
/** is the jid a hosted LID */
export const isHostedLidUser = (jid: string | undefined) => jid?.endsWith('@hosted.lid')
/** is the jid any LID (regular or hosted) */
export const isAnyLidUser = (jid: string | undefined) => !!(isLidUser(jid) || isHostedLidUser(jid))
/** is the jid any PN user (regular or hosted) */
export const isAnyPnUser = (jid: string | undefined) => !!(isPnUser(jid) || isHostedPnUser(jid))
const botRegexp = /^1313555\d{4}$|^131655500\d{2}$/
@@ -127,11 +123,6 @@ export const jidNormalizedUser = (jid: string | undefined) => {
export const transferDevice = (fromJid: string, toJid: string) => {
const fromDecoded = jidDecode(fromJid)
const deviceId = fromDecoded?.device || 0
const toDecoded = jidDecode(toJid)
if (!toDecoded) {
throw new Error(`transferDevice: failed to decode toJid "${toJid}"`)
}
const { server, user } = toDecoded
const { server, user } = jidDecode(toJid)!
return jidEncode(user, server, deviceId)
}
+1 -6
View File
@@ -78,12 +78,7 @@ function encodeEvents(binaryInfo: BinaryInfo) {
}
const fieldFlag = extended ? FLAG_EVENT : FLAG_FIELD | FLAG_EXTENDED
// eslint-disable-next-line eqeqeq
if (id == null) {
continue
}
binaryInfo.buffer.push(serializeData(id, value, fieldFlag))
binaryInfo.buffer.push(serializeData(id!, value, fieldFlag))
}
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ export class USyncQuery {
}
parseUSyncQueryResult(result: BinaryNode | undefined): USyncQueryResult | undefined {
if (result?.attrs.type !== 'result') {
if (!result || result.attrs.type !== 'result') {
return
}
-255
View File
@@ -44,259 +44,4 @@ describe('LIDMappingStore', () => {
expect(result).toBeNull()
})
})
describe('getLIDsForPNs', () => {
it('should resolve multiple PNs in a single batch', async () => {
const pnOne = '11111@s.whatsapp.net'
const pnTwo = '22222:5@s.whatsapp.net'
// @ts-ignore
mockKeys.get.mockResolvedValue({ '11111': 'aaaaa', '22222': 'bbbbb' } as SignalDataTypeMap['lid-mapping'])
const result = await lidMappingStore.getLIDsForPNs([pnOne, pnTwo])
expect(result).toEqual(
expect.arrayContaining([
{ pn: pnOne, lid: 'aaaaa@lid' },
{ pn: pnTwo, lid: 'bbbbb:5@lid' }
])
)
})
})
describe('getPNsForLIDs', () => {
it('should resolve multiple LIDs in a single batch', async () => {
const lidOne = '33333@lid'
const lidTwo = '44444:99@hosted.lid'
// @ts-ignore
mockKeys.get.mockResolvedValue({
'33333_reverse': '77777',
'44444_reverse': '88888'
} as SignalDataTypeMap['lid-mapping'])
const result = await lidMappingStore.getPNsForLIDs([lidOne, lidTwo])
expect(result).toEqual(
expect.arrayContaining([
{ lid: lidOne, pn: '77777@s.whatsapp.net' },
{ lid: lidTwo, pn: '88888:99@hosted' }
])
)
})
})
// ========================================================================
// M3: REQUEST COALESCING TESTS
// ========================================================================
describe('Request Coalescing (M3)', () => {
it('should deduplicate concurrent getLIDForPN calls for same PN', async () => {
const pn = '12345@s.whatsapp.net'
const lidUser = 'aaaaa'
// @ts-ignore - Mock DB lookup to return mapping
mockKeys.get.mockResolvedValue({ '12345': lidUser } as SignalDataTypeMap['lid-mapping'])
// Make 10 concurrent calls for the same PN
const promises = Array(10)
.fill(null)
.map(() => lidMappingStore.getLIDForPN(pn))
// All should resolve to same result
const results = await Promise.all(promises)
expect(results.every(r => r === `${lidUser}@lid`)).toBe(true)
// But DB should only be queried ONCE (not 10 times)
// The batch method getLIDsForPNs calls keys.get once
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
it('should deduplicate concurrent getPNForLID calls for same LID', async () => {
const lid = '54321@lid'
const pnUser = 'bbbbb'
// @ts-ignore - Mock DB lookup to return reverse mapping
mockKeys.get.mockResolvedValue({ '54321_reverse': pnUser } as SignalDataTypeMap['lid-mapping'])
// Make 10 concurrent calls for the same LID
const promises = Array(10)
.fill(null)
.map(() => lidMappingStore.getPNForLID(lid))
// All should resolve to same result
const results = await Promise.all(promises)
expect(results.every(r => r === `${pnUser}@s.whatsapp.net`)).toBe(true)
// But DB should only be queried ONCE (not 10 times)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
it('should NOT coalesce calls for different PNs', async () => {
const pn1 = '11111@s.whatsapp.net'
const pn2 = '22222@s.whatsapp.net'
// @ts-ignore - Mock to return different mappings
mockKeys.get.mockResolvedValue({ '11111': 'aaaaa', '22222': 'bbbbb' } as SignalDataTypeMap['lid-mapping'])
// Concurrent calls for DIFFERENT PNs
const [result1, result2] = await Promise.all([lidMappingStore.getLIDForPN(pn1), lidMappingStore.getLIDForPN(pn2)])
expect(result1).toBe('aaaaa@lid')
expect(result2).toBe('bbbbb@lid')
// Should make 2 separate DB calls (no coalescing)
expect(mockKeys.get).toHaveBeenCalledTimes(2)
})
})
// ========================================================================
// V4: DESTROYED FLAG & OPERATION COUNTER TESTS
// ========================================================================
describe('Destroyed Flag Protection (V4, M2)', () => {
it('should reject operations after destroy()', async () => {
lidMappingStore.destroy()
// All operations should throw after destroy
await expect(lidMappingStore.getLIDForPN('12345@s.whatsapp.net')).rejects.toThrow(
'LIDMappingStore has been destroyed'
)
await expect(lidMappingStore.getPNForLID('54321@lid')).rejects.toThrow('LIDMappingStore has been destroyed')
await expect(lidMappingStore.storeLIDPNMappings([{ lid: 'a@lid', pn: 'b@s.whatsapp.net' }])).rejects.toThrow(
'LIDMappingStore has been destroyed'
)
})
it('should allow destroy() to be called multiple times safely', () => {
// First destroy
lidMappingStore.destroy()
// Second destroy should not throw (reentrancy guard)
expect(() => lidMappingStore.destroy()).not.toThrow()
// Third destroy should also be safe
expect(() => lidMappingStore.destroy()).not.toThrow()
})
it('should complete active operations before destroying resources (graceful degradation)', async () => {
const pn = '12345@s.whatsapp.net'
// Mock slow DB operation (simulates long-running operation)
let operationStarted = false
let operationCompleted = false
// @ts-ignore
mockKeys.get.mockImplementation(async () => {
operationStarted = true
await new Promise(resolve => setTimeout(resolve, 100)) // 100ms delay
operationCompleted = true
return { '12345': 'aaaaa' } as unknown as SignalDataTypeMap['lid-mapping']
})
// Start operation
const operationPromise = lidMappingStore.getLIDForPN(pn)
// Wait a bit to ensure operation has started
await new Promise(resolve => setTimeout(resolve, 10))
expect(operationStarted).toBe(true)
expect(operationCompleted).toBe(false)
// Call destroy while operation is in progress
lidMappingStore.destroy()
// Operation should still complete successfully (graceful degradation)
const result = await operationPromise
expect(result).toBe('aaaaa@lid')
expect(operationCompleted).toBe(true)
// But new operations should be rejected
await expect(lidMappingStore.getLIDForPN(pn)).rejects.toThrow('LIDMappingStore has been destroyed')
})
})
// ========================================================================
// CACHE & OPTIMIZATION TESTS
// ========================================================================
describe('Cache Behavior', () => {
it('should use cache for subsequent lookups (no DB hit)', async () => {
const pn = '12345@s.whatsapp.net'
const lidUser = 'aaaaa'
// @ts-ignore - First lookup hits DB
mockKeys.get.mockResolvedValue({ '12345': lidUser } as SignalDataTypeMap['lid-mapping'])
// First lookup - cache miss, DB hit
const result1 = await lidMappingStore.getLIDForPN(pn)
expect(result1).toBe(`${lidUser}@lid`)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
// Second lookup - cache hit, no DB call
const result2 = await lidMappingStore.getLIDForPN(pn)
expect(result2).toBe(`${lidUser}@lid`)
// DB should still only have been called once (cache hit)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
it('should clear cache on destroy()', async () => {
const pn = '12345@s.whatsapp.net'
// @ts-ignore
mockKeys.get.mockResolvedValue({ '12345': 'aaaaa' } as SignalDataTypeMap['lid-mapping'])
// Populate cache
await lidMappingStore.getLIDForPN(pn)
// Destroy should clear cache
lidMappingStore.destroy()
// Create new store
const newStore = new LIDMappingStore(mockKeys, logger, mockPnToLIDFunc)
// New lookup should hit DB again (cache was cleared)
jest.clearAllMocks() // Reset call count
await newStore.getLIDForPN(pn)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
})
// ========================================================================
// EDGE CASES & ERROR HANDLING
// ========================================================================
describe('Edge Cases', () => {
it('should handle invalid JIDs gracefully', async () => {
const result1 = await lidMappingStore.getLIDForPN('invalid')
expect(result1).toBeNull()
const result2 = await lidMappingStore.getPNForLID('invalid')
expect(result2).toBeNull()
})
it('should handle empty results from DB', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({} as SignalDataTypeMap['lid-mapping'])
const result = await lidMappingStore.getLIDForPN('12345@s.whatsapp.net')
expect(result).toBeNull()
})
it('should handle batch operations with mixed valid/invalid JIDs', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({ '12345': 'aaaaa' } as SignalDataTypeMap['lid-mapping'])
const result = await lidMappingStore.getLIDsForPNs([
'12345@s.whatsapp.net', // Valid
'invalid', // Invalid
'67890@s.whatsapp.net' // Valid but not in DB
])
// Should only return valid results
expect(result).toEqual([{ pn: '12345@s.whatsapp.net', lid: 'aaaaa@lid' }])
})
})
})
@@ -1,543 +0,0 @@
import { jest } from '@jest/globals'
import P from 'pino'
import type { SessionActivityMetadata } from '../../Signal/session-activity-tracker'
import { makeSessionActivityTracker } from '../../Signal/session-activity-tracker'
import type { SignalKeyStoreWithTransaction } from '../../Types'
const mockKeys: jest.Mocked<SignalKeyStoreWithTransaction> = {
get: jest.fn() as any,
set: jest.fn() as any,
transaction: jest.fn(async (work: () => any) => await work()) as any,
isInTransaction: jest.fn() as any
}
const logger = P({ level: 'silent' })
describe('SessionActivityTracker', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
})
describe('recordActivity', () => {
it('should record activity in memory cache', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
tracker.recordActivity(jid)
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(1)
expect(stats.cacheSize).toBe(1)
})
it('should update existing activity timestamp', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
tracker.recordActivity(jid)
jest.advanceTimersByTime(5000) // 5 seconds later
tracker.recordActivity(jid)
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(2)
expect(stats.cacheSize).toBe(1) // Still one unique JID
})
it('should not record activity when disabled', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(0)
expect(stats.cacheSize).toBe(0)
})
it('should handle multiple JIDs', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.recordActivity('5511888888888@s.whatsapp.net')
tracker.recordActivity('123456789@lid')
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(3)
expect(stats.cacheSize).toBe(3)
})
})
describe('getLastActivity', () => {
it('should return activity from cache (cache hit)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const beforeTime = Date.now()
tracker.recordActivity(jid)
const lastActivity = await tracker.getLastActivity(jid)
expect(lastActivity).toBeGreaterThanOrEqual(beforeTime)
expect(mockKeys.get).not.toHaveBeenCalled() // Cache hit, no DB call
})
it('should fallback to disk when not in cache (cache miss)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const diskTimestamp = Date.now() - 10000
// @ts-ignore
mockKeys.get.mockResolvedValue({
'session-activity:5511999999999@s.whatsapp.net': {
lastActivityAt: diskTimestamp,
createdAt: diskTimestamp
} as SessionActivityMetadata
})
const lastActivity = await tracker.getLastActivity(jid)
expect(lastActivity).toBe(diskTimestamp)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
it('should return undefined when activity not found', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// @ts-ignore
mockKeys.get.mockResolvedValue({})
const lastActivity = await tracker.getLastActivity('nonexistent@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
it('should return undefined when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const lastActivity = await tracker.getLastActivity('5511999999999@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
it('should handle disk read errors gracefully', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// @ts-ignore
mockKeys.get.mockRejectedValue(new Error('Disk read error'))
const lastActivity = await tracker.getLastActivity('5511999999999@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
})
describe('getAllActivities', () => {
it('should return all activities from disk and cache', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const now = Date.now()
// Simulate disk data
// @ts-ignore
mockKeys.get.mockResolvedValue({
'session-activity:5511999999999@s.whatsapp.net': {
lastActivityAt: now - 10000,
createdAt: now - 20000
} as SessionActivityMetadata,
'session-activity:5511888888888@s.whatsapp.net': {
lastActivityAt: now - 5000,
createdAt: now - 15000
} as SessionActivityMetadata
})
// Add new activity to cache
tracker.recordActivity('123456789@lid')
const activities = await tracker.getAllActivities()
expect(activities.size).toBe(3)
expect(activities.has('5511999999999@s.whatsapp.net')).toBe(true)
expect(activities.has('5511888888888@s.whatsapp.net')).toBe(true)
expect(activities.has('123456789@lid')).toBe(true)
})
it('should prioritize cache over disk (cache is more recent)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const diskTimestamp = Date.now() - 10000
// Simulate old disk data
// @ts-ignore
mockKeys.get.mockResolvedValue({
[`session-activity:${jid}`]: {
lastActivityAt: diskTimestamp,
createdAt: diskTimestamp
} as SessionActivityMetadata
})
// Record new activity in cache
const beforeCache = Date.now()
tracker.recordActivity(jid)
const activities = await tracker.getAllActivities()
const cacheTimestamp = activities.get(jid)
expect(cacheTimestamp).toBeGreaterThanOrEqual(beforeCache)
expect(cacheTimestamp).not.toBe(diskTimestamp) // Cache wins
})
it('should return empty map when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const activities = await tracker.getAllActivities()
expect(activities.size).toBe(0)
})
it('should handle disk read errors gracefully', async () => {
// Use real timers for this test
jest.useRealTimers()
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Simulate disk error
// @ts-ignore
mockKeys.get.mockRejectedValue(new Error('Disk read error'))
// getAllActivities should return empty map (logs warning but doesn't throw)
const activities = await tracker.getAllActivities()
expect(activities).toBeInstanceOf(Map)
expect(activities.size).toBe(0) // Current implementation doesn't return cache on disk error
// Restore fake timers
jest.useFakeTimers()
})
})
describe('flush', () => {
it('should flush cache to disk in batch', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid1 = '5511999999999@s.whatsapp.net'
const jid2 = '5511888888888@s.whatsapp.net'
tracker.recordActivity(jid1)
tracker.recordActivity(jid2)
await tracker.flush()
// Should call transaction once for batch write
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalledTimes(1)
// Verify batch update structure
const setCall = mockKeys.set.mock.calls[0]?.[0]
// @ts-ignore
expect(setCall['session-activity']).toBeDefined()
// @ts-ignore
expect(setCall['session-activity'][`session-activity:${jid1}`]).toBeDefined()
// @ts-ignore
expect(setCall['session-activity'][`session-activity:${jid2}`]).toBeDefined()
})
it('should clear cache after successful flush', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
await tracker.flush()
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(0) // Cache cleared
expect(stats.totalFlushes).toBe(1)
})
it('should not flush when cache is empty', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
await tracker.flush()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should not flush when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
await tracker.flush()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should keep cache on flush failure (retry on next flush)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Simulate flush error
mockKeys.transaction.mockRejectedValueOnce(new Error('Flush failed'))
await tracker.flush()
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(1) // Cache NOT cleared
expect(stats.totalFlushes).toBe(0) // Flush failed
})
it('should update statistics after successful flush', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
const beforeFlush = Date.now()
await tracker.flush()
const afterFlush = Date.now()
const stats = tracker.getStats()
expect(stats.totalFlushes).toBe(1)
expect(stats.lastFlushAt).toBeGreaterThanOrEqual(beforeFlush)
expect(stats.lastFlushAt).toBeLessThanOrEqual(afterFlush)
expect(stats.lastFlushDuration).toBeGreaterThanOrEqual(0)
})
})
describe('start/stop lifecycle', () => {
it('should start periodic flush', async () => {
// Use real timers for this test
jest.useRealTimers()
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record activity so there's something to flush
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.start()
// Wait for initial flush to complete
await new Promise(resolve => setTimeout(resolve, 50))
// Should attempt initial flush
expect(mockKeys.transaction).toHaveBeenCalled()
// Cleanup
await tracker.stop()
// Restore fake timers
jest.useFakeTimers()
})
it('should flush periodically after start', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Advance time to trigger flush
jest.advanceTimersByTime(60000)
await Promise.resolve() // Let flush promise resolve
expect(mockKeys.transaction).toHaveBeenCalled()
})
it('should not start when disabled', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should not start twice (guard against multiple start calls)', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Second start should be ignored
tracker.start()
// Should not trigger another initial flush
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should stop periodic flush and flush pending data', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Stop should flush pending data
await tracker.stop()
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalled()
})
it('should stop periodic flush without error when no pending data', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
await tracker.stop()
// No flush needed (no pending data)
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
})
describe('getStats', () => {
it('should return current statistics', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.recordActivity('5511888888888@s.whatsapp.net')
const stats = tracker.getStats()
expect(stats.enabled).toBe(true)
expect(stats.totalUpdates).toBe(2)
expect(stats.cacheSize).toBe(2)
expect(stats.totalFlushes).toBe(0) // No flush yet
expect(stats.lastFlushAt).toBe(0)
expect(stats.lastFlushDuration).toBe(0)
})
it('should show disabled status', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const stats = tracker.getStats()
expect(stats.enabled).toBe(false)
})
})
describe('Performance', () => {
it('should handle high-volume activity recording (1000 messages)', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const startTime = Date.now()
// Simulate 1000 messages
for (let i = 0; i < 1000; i++) {
tracker.recordActivity(`5511${i.toString().padStart(9, '0')}@s.whatsapp.net`)
}
const duration = Date.now() - startTime
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(1000)
expect(stats.cacheSize).toBe(1000)
// Should be very fast (<100ms for 1000 updates)
expect(duration).toBeLessThan(100)
})
it('should batch flush multiple activities in single transaction', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record 100 activities
for (let i = 0; i < 100; i++) {
tracker.recordActivity(`5511${i.toString().padStart(9, '0')}@s.whatsapp.net`)
}
await tracker.flush()
// Should use single transaction for all 100
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalledTimes(1)
const stats = tracker.getStats()
expect(stats.totalFlushes).toBe(1)
})
})
describe('Edge Cases', () => {
it('should handle JID with special characters', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const specialJids = [
'5511999999999:1@s.whatsapp.net', // Device ID
'123456789@lid', // LID
'5511999999999:99@hosted', // Hosted device
'123456789:5@hosted.lid' // Hosted LID
]
specialJids.forEach(jid => tracker.recordActivity(jid))
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(4)
})
it('should handle rapid updates to same JID', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
// Rapid updates (10 in quick succession)
for (let i = 0; i < 10; i++) {
tracker.recordActivity(jid)
}
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(10)
expect(stats.cacheSize).toBe(1) // Still one JID
})
it('should handle flush during active recording', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Flush clears the cache
await tracker.flush()
// Record new activity after flush
tracker.recordActivity('5511888888888@s.whatsapp.net')
// New activity should be in cache
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(1) // Only the new one
})
})
})
@@ -1,457 +0,0 @@
import { jest } from '@jest/globals'
import P from 'pino'
import type { LIDMappingStore } from '../../Signal/lid-mapping'
import type { SessionActivityTracker } from '../../Signal/session-activity-tracker'
import { makeSessionCleanup } from '../../Signal/session-cleanup'
import type { SignalKeyStoreWithTransaction } from '../../Types'
const mockKeys: jest.Mocked<SignalKeyStoreWithTransaction> = {
get: jest.fn<SignalKeyStoreWithTransaction['get']>() as any,
set: jest.fn<SignalKeyStoreWithTransaction['set']>(),
transaction: jest.fn<SignalKeyStoreWithTransaction['transaction']>(async (work: () => any) => await work()) as any,
isInTransaction: jest.fn<SignalKeyStoreWithTransaction['isInTransaction']>()
}
const mockLidMapping: jest.Mocked<Pick<LIDMappingStore, 'getPNForLID'>> = {
getPNForLID: jest.fn<LIDMappingStore['getPNForLID']>() as any
}
const mockActivityTracker: jest.Mocked<Pick<SessionActivityTracker, 'getAllActivities'>> = {
getAllActivities: jest.fn<SessionActivityTracker['getAllActivities']>() as any
}
const logger = P({ level: 'silent' })
describe('SessionCleanup', () => {
const HOUR_MS = 3600000
const DAY_MS = 86400000
beforeEach(() => {
jest.clearAllMocks()
})
describe('LID Orphan Cleanup (24h threshold)', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24,
cleanupOnStartup: false,
autoCleanCorrupted: false
}
it('should delete LID orphan after 24h of inactivity', async () => {
const now = Date.now()
const lastActivity = now - 25 * HOUR_MS // 25 hours ago
// Mock sessions: 1 LID orphan
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
// Mock: No PN mapping (orphan)
mockLidMapping.getPNForLID.mockResolvedValue(null)
// Mock: Activity 25h ago
mockActivityTracker.getAllActivities.mockResolvedValue(new Map([['123456789@lid', lastActivity]]))
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
expect(mockKeys.set).toHaveBeenCalledWith({
session: { '123456789_2.0': null }
})
})
it('should NOT delete LID orphan before 24h', async () => {
const now = Date.now()
const lastActivity = now - 23 * HOUR_MS // 23 hours ago
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
mockActivityTracker.getAllActivities.mockResolvedValue(new Map([['123456789@lid', lastActivity]]))
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
expect(mockKeys.set).not.toHaveBeenCalled()
})
it('should delete LID orphan with no activity tracking', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
// No activity tracked
mockActivityTracker.getAllActivities.mockResolvedValue(new Map())
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
})
it('should NOT delete LID with valid PN mapping', async () => {
const now = Date.now()
const lastActivity = now - 25 * HOUR_MS
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
// Has PN mapping - not orphan
mockLidMapping.getPNForLID.mockResolvedValue('5511999999999@s.whatsapp.net')
mockActivityTracker.getAllActivities.mockResolvedValue(new Map([['123456789@lid', lastActivity]]))
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
})
describe('Secondary Device Cleanup (15 days threshold)', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24,
cleanupOnStartup: false,
autoCleanCorrupted: false
}
it('should delete secondary device (Web/Desktop) after 15 days', async () => {
const now = Date.now()
const lastActivity = now - 16 * DAY_MS // 16 days ago
// Mock: Secondary device (device ID = 1)
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.1': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['5511999999999:1@s.whatsapp.net', lastActivity]])
)
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.secondaryDevicesDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
})
it('should NOT delete secondary device before 15 days', async () => {
const now = Date.now()
const lastActivity = now - 14 * DAY_MS // 14 days ago
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.1': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['5511999999999:1@s.whatsapp.net', lastActivity]])
)
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.secondaryDevicesDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
it('should NOT delete secondary device without activity tracking', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.1': Buffer.from('session-data')
})
// No activity tracked - grace period
mockActivityTracker.getAllActivities.mockResolvedValue(new Map())
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.secondaryDevicesDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
})
describe('Primary Device Cleanup (30 days threshold)', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24,
cleanupOnStartup: false,
autoCleanCorrupted: false
}
it('should delete primary device after 30 days', async () => {
const now = Date.now()
const lastActivity = now - 31 * DAY_MS // 31 days ago
// Mock: Primary device (device ID = 0)
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.0': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(new Map([['5511999999999@s.whatsapp.net', lastActivity]]))
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.primaryDevicesDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
})
it('should NOT delete primary device before 30 days', async () => {
const now = Date.now()
const lastActivity = now - 29 * DAY_MS // 29 days ago
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.0': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(new Map([['5511999999999@s.whatsapp.net', lastActivity]]))
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.primaryDevicesDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
})
describe('Boundary Conditions', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24,
cleanupOnStartup: false,
autoCleanCorrupted: false
}
it('should handle exactly 24h for LID orphan (boundary)', async () => {
const fixedNow = 1700000000000 // Fixed timestamp to avoid race conditions
const lastActivity = fixedNow - 24 * HOUR_MS // Exactly 24h
// Mock Date.now() to return consistent value
const originalDateNow = Date.now
Date.now = jest.fn(() => fixedNow)
try {
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
mockActivityTracker.getAllActivities.mockResolvedValue(new Map([['123456789@lid', lastActivity]]))
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
// Exactly 24h should NOT delete (> threshold, not >=)
expect(stats.lidOrphansDeleted).toBe(0)
} finally {
// Restore original Date.now
Date.now = originalDateNow
}
})
it('should handle empty session list', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({})
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.totalScanned).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
it('should handle null sessionActivityTracker gracefully', async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _ = Date.now()
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
// Pass null tracker
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, null, logger, config)
const stats = await cleanup.runCleanup()
// Should still work, but with no activity data
expect(stats.totalScanned).toBe(1)
// LID orphan with no activity tracking gets deleted
expect(stats.lidOrphansDeleted).toBe(1)
})
})
describe('Mixed Scenarios', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24,
cleanupOnStartup: false,
autoCleanCorrupted: false
}
it('should delete multiple sessions of different types', async () => {
const now = Date.now()
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('lid-orphan'),
'5511999999999_0.1': Buffer.from('secondary-inactive'),
'5511888888888_0.0': Buffer.from('primary-inactive'),
'5511777777777_0.0': Buffer.from('primary-active')
})
mockLidMapping.getPNForLID.mockImplementation(async (lid: string) => {
if (lid === '123456789@lid') return null // Orphan
return '5511999999999@s.whatsapp.net'
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([
['123456789@lid', now - 25 * HOUR_MS], // LID orphan: 25h ago
['5511999999999:1@s.whatsapp.net', now - 16 * DAY_MS], // Secondary: 16 days ago
['5511888888888@s.whatsapp.net', now - 31 * DAY_MS], // Primary: 31 days ago
['5511777777777@s.whatsapp.net', now - 5 * DAY_MS] // Primary: 5 days ago (active)
])
)
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.totalScanned).toBe(4)
expect(stats.lidOrphansDeleted).toBe(1)
expect(stats.secondaryDevicesDeleted).toBe(1)
expect(stats.primaryDevicesDeleted).toBe(1)
expect(stats.totalDeleted).toBe(3)
})
})
describe('Configuration', () => {
it('should respect disabled cleanup', async () => {
const config = {
enabled: false,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24,
cleanupOnStartup: false,
autoCleanCorrupted: false
}
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
const cleanup = makeSessionCleanup(mockKeys, mockLidMapping as any, mockActivityTracker as any, logger, config)
const stats = await cleanup.runCleanup()
expect(stats.totalScanned).toBe(0)
expect(mockKeys.get).not.toHaveBeenCalled()
})
it('should respect custom thresholds', async () => {
const customConfig = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 5, // Custom: 5 days
primaryDeviceInactiveDays: 10, // Custom: 10 days
lidOrphanHours: 12, // Custom: 12 hours
cleanupOnStartup: false,
autoCleanCorrupted: false
}
const now = Date.now()
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('lid-orphan'),
'5511999999999_0.1': Buffer.from('secondary')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([
['123456789@lid', now - 13 * HOUR_MS], // 13h ago
['5511999999999:1@s.whatsapp.net', now - 6 * DAY_MS] // 6 days ago
])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
customConfig
)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(1) // 13h > 12h threshold
expect(stats.secondaryDevicesDeleted).toBe(1) // 6d > 5d threshold
expect(stats.totalDeleted).toBe(2)
})
})
})
@@ -1,334 +0,0 @@
import { jest } from '@jest/globals'
/**
* Tests for the error 463 retry logic in handleBadAck.
*
* Since handleBadAck is a closure inside makeMessagesRecvSocket, we extract
* the core retry logic into a standalone function that mirrors the real
* implementation and test it directly.
*/
interface MockKey {
remoteJid: string
fromMe: boolean
id: string
}
type GetMessageFn = (key: MockKey) => Promise<any>
type RelayMessageFn = (jid: string, msg: any, opts: any) => Promise<void>
type EmitFn = (event: string, data: any) => void
interface MockMessageRetryManager {
getRecentMessage: (jid: string, msgId: string) => { message: any } | undefined
}
/** Mirrors jidNormalizedUser: strips device suffix from JID user part */
function jidNormalizedUser(jid: string): string {
const atIdx = jid.indexOf('@')
if (atIdx < 0) return jid
const user = jid.slice(0, atIdx)
const server = jid.slice(atIdx + 1)
const normalizedUser = user.includes(':') ? user.split(':')[0] : user
return `${normalizedUser}@${server}`
}
/** Mirrors the handleBadAck error-463 retry logic */
async function handleBadAck463(
attrs: { id: string; from: string; error: string },
tcTokenRetriedMsgIds: Set<string>,
getMessage: GetMessageFn,
relayMessage: RelayMessageFn,
emit: EmitFn,
delayFn: (ms: number) => Promise<void>,
messageRetryManager?: MockMessageRetryManager
): Promise<{ action: string }> {
const msgId = attrs.id
const jid = jidNormalizedUser(attrs.from)
const key: MockKey = { remoteJid: attrs.from, fromMe: true, id: msgId }
if (attrs.error === '463') {
const retryKey = `${jid}:${msgId}`
if (msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) {
tcTokenRetriedMsgIds.add(retryKey)
// Each entry auto-expires after 60s — naturally bounded under normal use
setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000)
const msg =
(await getMessage(key)) ??
// Fallback: ack can arrive <30ms after send, before store persists
messageRetryManager?.getRecentMessage(jid, msgId)?.message
if (msg) {
try {
await delayFn(1500)
await relayMessage(jid, msg, {
messageId: msgId,
useUserDevicesCache: true
})
return { action: 'retry_succeeded' }
} catch {
// fall through to ERROR
}
}
}
}
emit('messages.update', [{ key, update: { status: 'ERROR', messageStubParameters: [attrs.error] } }])
return { action: 'error_emitted' }
}
describe('handleBadAck error 463 retry', () => {
let tcTokenRetriedMsgIds: Set<string>
let mockGetMessage: jest.Mock<GetMessageFn>
let mockRelayMessage: jest.Mock<RelayMessageFn>
let mockEmit: jest.Mock<EmitFn>
let mockDelay: jest.Mock<(ms: number) => Promise<void>>
const baseAttrs = { id: 'msg-001', from: '1234@s.whatsapp.net', error: '463' }
beforeEach(() => {
tcTokenRetriedMsgIds = new Set()
mockGetMessage = jest.fn()
mockRelayMessage = jest.fn()
mockEmit = jest.fn()
mockDelay = jest.fn<(ms: number) => Promise<void>>().mockResolvedValue(undefined)
})
it('should retry once on 463 when getMessage returns content', async () => {
const fakeMsg = { conversation: 'hello' }
mockGetMessage.mockResolvedValue(fakeMsg)
mockRelayMessage.mockResolvedValue(undefined)
const result = await handleBadAck463(
baseAttrs,
tcTokenRetriedMsgIds,
mockGetMessage,
mockRelayMessage,
mockEmit,
mockDelay
)
expect(result.action).toBe('retry_succeeded')
expect(mockGetMessage).toHaveBeenCalledTimes(1)
expect(mockRelayMessage).toHaveBeenCalledWith(baseAttrs.from, fakeMsg, {
messageId: baseAttrs.id,
useUserDevicesCache: true
})
expect(mockDelay).toHaveBeenCalledWith(1500)
expect(mockEmit).not.toHaveBeenCalled()
})
it('should NOT retry when getMessage returns undefined', async () => {
mockGetMessage.mockResolvedValue(undefined)
const result = await handleBadAck463(
baseAttrs,
tcTokenRetriedMsgIds,
mockGetMessage,
mockRelayMessage,
mockEmit,
mockDelay
)
expect(result.action).toBe('error_emitted')
expect(mockRelayMessage).not.toHaveBeenCalled()
expect(mockEmit).toHaveBeenCalledTimes(1)
})
it('should NOT retry same message ID twice (loop guard)', async () => {
const fakeMsg = { conversation: 'hello' }
mockGetMessage.mockResolvedValue(fakeMsg)
mockRelayMessage.mockResolvedValue(undefined)
// First attempt succeeds
await handleBadAck463(baseAttrs, tcTokenRetriedMsgIds, mockGetMessage, mockRelayMessage, mockEmit, mockDelay)
const retryKey = `${jidNormalizedUser(baseAttrs.from)}:${baseAttrs.id}`
expect(tcTokenRetriedMsgIds.has(retryKey)).toBe(true)
// Second attempt with same ID — should not retry
mockGetMessage.mockClear()
mockRelayMessage.mockClear()
const result = await handleBadAck463(
baseAttrs,
tcTokenRetriedMsgIds,
mockGetMessage,
mockRelayMessage,
mockEmit,
mockDelay
)
expect(result.action).toBe('error_emitted')
expect(mockGetMessage).not.toHaveBeenCalled()
expect(mockRelayMessage).not.toHaveBeenCalled()
expect(mockEmit).toHaveBeenCalledTimes(1)
})
it('should emit ERROR status when retry fails', async () => {
const fakeMsg = { conversation: 'hello' }
mockGetMessage.mockResolvedValue(fakeMsg)
mockRelayMessage.mockRejectedValue(new Error('send failed'))
const result = await handleBadAck463(
baseAttrs,
tcTokenRetriedMsgIds,
mockGetMessage,
mockRelayMessage,
mockEmit,
mockDelay
)
expect(result.action).toBe('error_emitted')
expect(mockEmit).toHaveBeenCalledTimes(1)
expect(mockEmit).toHaveBeenCalledWith(
'messages.update',
expect.arrayContaining([
expect.objectContaining({
update: expect.objectContaining({ status: 'ERROR' })
})
])
)
})
it('should not retry for non-463 errors', async () => {
for (const errorCode of ['479', '421']) {
mockGetMessage.mockClear()
mockRelayMessage.mockClear()
mockEmit.mockClear()
const attrs = { ...baseAttrs, error: errorCode }
const result = await handleBadAck463(
attrs,
tcTokenRetriedMsgIds,
mockGetMessage,
mockRelayMessage,
mockEmit,
mockDelay
)
expect(result.action).toBe('error_emitted')
expect(mockGetMessage).not.toHaveBeenCalled()
expect(mockRelayMessage).not.toHaveBeenCalled()
}
})
it('should allow retry for different message IDs', async () => {
const fakeMsg = { conversation: 'hello' }
mockGetMessage.mockResolvedValue(fakeMsg)
mockRelayMessage.mockResolvedValue(undefined)
const result1 = await handleBadAck463(
{ ...baseAttrs, id: 'msg-A' },
tcTokenRetriedMsgIds,
mockGetMessage,
mockRelayMessage,
mockEmit,
mockDelay
)
const result2 = await handleBadAck463(
{ ...baseAttrs, id: 'msg-B' },
tcTokenRetriedMsgIds,
mockGetMessage,
mockRelayMessage,
mockEmit,
mockDelay
)
expect(result1.action).toBe('retry_succeeded')
expect(result2.action).toBe('retry_succeeded')
expect(mockRelayMessage).toHaveBeenCalledTimes(2)
})
it('should use jid:msgId composite key to isolate retries per destination', async () => {
const fakeMsg = { conversation: 'hello' }
mockGetMessage.mockResolvedValue(fakeMsg)
mockRelayMessage.mockResolvedValue(undefined)
const jid1 = '1111@s.whatsapp.net'
const jid2 = '2222@s.whatsapp.net'
const sharedMsgId = 'shared-msg'
// Retry from jid1 should not block retry from jid2 for the same msgId
await handleBadAck463(
{ id: sharedMsgId, from: jid1, error: '463' },
tcTokenRetriedMsgIds,
mockGetMessage,
mockRelayMessage,
mockEmit,
mockDelay
)
mockRelayMessage.mockClear()
const result = await handleBadAck463(
{ id: sharedMsgId, from: jid2, error: '463' },
tcTokenRetriedMsgIds,
mockGetMessage,
mockRelayMessage,
mockEmit,
mockDelay
)
expect(result.action).toBe('retry_succeeded')
expect(mockRelayMessage).toHaveBeenCalledTimes(1)
})
it('should fall back to messageRetryManager when getMessage returns undefined', async () => {
const cachedMsg = { conversation: 'cached' }
const mockRetryManager: MockMessageRetryManager = {
getRecentMessage: jest
.fn<(jid: string, msgId: string) => { message: any } | undefined>()
.mockReturnValue({ message: cachedMsg })
}
mockGetMessage.mockResolvedValue(undefined)
mockRelayMessage.mockResolvedValue(undefined)
const result = await handleBadAck463(
baseAttrs,
tcTokenRetriedMsgIds,
mockGetMessage,
mockRelayMessage,
mockEmit,
mockDelay,
mockRetryManager
)
expect(result.action).toBe('retry_succeeded')
expect(mockRelayMessage).toHaveBeenCalledWith(baseAttrs.from, cachedMsg, {
messageId: baseAttrs.id,
useUserDevicesCache: true
})
})
it('should expire retry key after 60s, allowing future retries', async () => {
jest.useFakeTimers()
const fakeMsg = { conversation: 'hello' }
mockGetMessage.mockResolvedValue(fakeMsg)
mockRelayMessage.mockResolvedValue(undefined)
// First attempt — adds retryKey and registers 60s TTL
await handleBadAck463(baseAttrs, tcTokenRetriedMsgIds, mockGetMessage, mockRelayMessage, mockEmit, mockDelay)
const retryKey = `${jidNormalizedUser(baseAttrs.from)}:${baseAttrs.id}`
expect(tcTokenRetriedMsgIds.has(retryKey)).toBe(true)
// Advance time by 60s — TTL should fire and remove the key
jest.advanceTimersByTime(60_000)
expect(tcTokenRetriedMsgIds.has(retryKey)).toBe(false)
// After expiry, the same message can be retried again
mockGetMessage.mockClear()
mockRelayMessage.mockClear()
mockEmit.mockClear()
const result = await handleBadAck463(
baseAttrs,
tcTokenRetriedMsgIds,
mockGetMessage,
mockRelayMessage,
mockEmit,
mockDelay
)
expect(result.action).toBe('retry_succeeded')
jest.useRealTimers()
})
})
@@ -1,11 +1,7 @@
import NodeCache from '@cacheable/node-cache'
import { jest } from '@jest/globals'
import P from 'pino'
import {
handleIdentityChange,
type IdentityChangeContext,
type IdentityChangeResult
} from '../../Utils/identity-change-handler'
import { handleIdentityChange, type IdentityChangeContext } from '../../Utils/identity-change-handler'
import { type BinaryNode } from '../../WABinary'
const logger = P({ level: 'silent' })
@@ -17,7 +13,7 @@ describe('Identity Change Handling', () => {
let mockValidateSession: jest.Mock<ValidateSessionFn>
let mockAssertSessions: jest.Mock<AssertSessionsFn>
let identityAssertDebounce: NodeCache<boolean>
let mockMeId: string | undefined
let mockMeId: string
let mockMeLid: string | undefined
function createIdentityChangeNode(from: string, offline?: string): BinaryNode {
@@ -75,7 +71,6 @@ describe('Identity Change Handling', () => {
const result = await handleIdentityChange(node, createContext())
expect(result.action).toBe('session_refreshed')
expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(true)
})
it('should skip self-primary identity (PN match)', async () => {
@@ -94,38 +89,22 @@ describe('Identity Change Handling', () => {
expect(result.action).toBe('skipped_self_primary')
})
it('should skip self-primary identity when only meLid exists', async () => {
// FIX: Test for the case where meId is undefined but meLid matches
mockMeId = undefined
mockMeLid = 'mylid@lid'
const node = createIdentityChangeNode('mylid@lid')
const result = await handleIdentityChange(node, createContext())
expect(mockValidateSession).not.toHaveBeenCalled()
expect(result.action).toBe('skipped_self_primary')
})
it('should create session when no existing session exists', async () => {
// FIX: Identity change is the signal to rebuild session, even if none exists
// This is critical for key reset or device restore scenarios
it('should skip when no existing session', async () => {
mockValidateSession.mockResolvedValue({ exists: false })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('user@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext())
expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true)
expect(result.action).toBe('session_refreshed')
expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(false)
expect(mockAssertSessions).not.toHaveBeenCalled()
expect(result.action).toBe('skipped_no_session')
})
it('should skip session refresh during offline processing', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
const node = createIdentityChangeNode('user@s.whatsapp.net', '0')
const result = await handleIdentityChange(node, createContext())
// FIX: validateSession should not be called for offline notifications
// because we skip before reaching that point
expect(mockAssertSessions).not.toHaveBeenCalled()
expect(result.action).toBe('skipped_offline')
})
@@ -139,7 +118,6 @@ describe('Identity Change Handling', () => {
expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true)
expect(result.action).toBe('session_refreshed')
expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(true)
})
})
@@ -169,24 +147,6 @@ describe('Identity Change Handling', () => {
expect(result2.action).toBe('session_refreshed')
expect(mockAssertSessions).toHaveBeenCalledTimes(2)
})
it('should NOT set debounce cache when skipping due to offline', async () => {
// FIX: Debounce should only be set when we actually attempt refresh
const node = createIdentityChangeNode('user@s.whatsapp.net', '0')
const result1 = await handleIdentityChange(node, createContext())
expect(result1.action).toBe('skipped_offline')
// Now process the same JID online - it should NOT be debounced
mockValidateSession.mockResolvedValue({ exists: true })
mockAssertSessions.mockResolvedValue(true)
const onlineNode = createIdentityChangeNode('user@s.whatsapp.net')
const result2 = await handleIdentityChange(onlineNode, createContext())
expect(result2.action).toBe('session_refreshed')
expect(mockAssertSessions).toHaveBeenCalledTimes(1)
})
})
describe('Error Handling', () => {
@@ -243,58 +203,5 @@ describe('Identity Change Handling', () => {
expect(mockValidateSession).toHaveBeenCalledWith('12345@lid')
expect(result.action).toBe('session_refreshed')
})
it('should process when both meId and meLid are undefined', async () => {
mockMeId = undefined
mockMeLid = undefined
mockValidateSession.mockResolvedValue({ exists: true })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('anyuser@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext())
expect(result.action).toBe('session_refreshed')
})
})
describe('Result Types', () => {
it('should include hadExistingSession in session_refreshed result', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('user@s.whatsapp.net')
const result = (await handleIdentityChange(node, createContext())) as Extract<
IdentityChangeResult,
{ action: 'session_refreshed' }
>
expect(result.action).toBe('session_refreshed')
expect(result.hadExistingSession).toBe(true)
})
it('should return hadExistingSession=false when creating new session', async () => {
mockValidateSession.mockResolvedValue({ exists: false })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('user@s.whatsapp.net')
const result = (await handleIdentityChange(node, createContext())) as Extract<
IdentityChangeResult,
{ action: 'session_refreshed' }
>
expect(result.action).toBe('session_refreshed')
expect(result.hadExistingSession).toBe(false)
})
it('should include device number in skipped_companion_device result', async () => {
const node = createIdentityChangeNode('user:5@s.whatsapp.net')
const result = (await handleIdentityChange(node, createContext())) as Extract<
IdentityChangeResult,
{ action: 'skipped_companion_device' }
>
expect(result.action).toBe('skipped_companion_device')
expect(result.device).toBe(5)
})
})
})
@@ -1,254 +0,0 @@
import { jest } from '@jest/globals'
/**
* Tests for the offline-buffer safety timer introduced in socket.ts.
*
* The timer caps how long the CB:ib,,offline phase can block live message
* delivery. Its behaviour spans three interaction points that must all be
* correct for the feature to work safely:
*
* 1. startBuffer() called inside process.nextTick when the socket connects
* and creds.me?.id is set. Arms the timer.
* 2. onOffline() called when CB:ib,,offline arrives (happy path).
* Must cancel the timer and flush exactly once.
* 3. onClose() called inside end() when the socket closes for any
* reason. Must cancel the timer so the callback cannot
* call ev.flush() on a dead session.
*
* Because these closures live deep inside makeSocket we mirror their logic
* here as standalone functions, exactly the same approach used in
* bad-ack-handling.test.ts.
*/
const OFFLINE_BUFFER_TIMEOUT_MS = 2_000
/** Mirrors the state variables declared at the top of makeSocket */
interface OfflineBufferState {
didStartBuffer: boolean
offlineBufferTimeout: NodeJS.Timeout | undefined
}
function makeState(): OfflineBufferState {
return { didStartBuffer: false, offlineBufferTimeout: undefined }
}
/**
* Mirrors the process.nextTick block that arms the offline-buffer timer.
* Only called when creds.me?.id is set (reconnection path).
*/
function startBuffer(state: OfflineBufferState, flush: () => void, warn: () => void): void {
state.didStartBuffer = true
state.offlineBufferTimeout = setTimeout(() => {
state.offlineBufferTimeout = undefined
if (state.didStartBuffer) {
warn()
flush()
state.didStartBuffer = false
}
}, OFFLINE_BUFFER_TIMEOUT_MS)
}
/**
* Mirrors the CB:ib,,offline handler the happy path where the server
* delivers all offline notifications before the safety timer fires.
*/
function onOffline(state: OfflineBufferState, flush: () => void): void {
if (state.offlineBufferTimeout) {
clearTimeout(state.offlineBufferTimeout)
state.offlineBufferTimeout = undefined
}
if (state.didStartBuffer) {
flush()
state.didStartBuffer = false
}
}
/**
* Mirrors the relevant portion of end() clears the timer and resets the
* flag so a closing socket cannot emit stale events after the fact.
*/
function onClose(state: OfflineBufferState): void {
if (state.offlineBufferTimeout) {
clearTimeout(state.offlineBufferTimeout)
state.offlineBufferTimeout = undefined
}
state.didStartBuffer = false
}
// ---------------------------------------------------------------------------
describe('offline-buffer safety timer (socket.ts)', () => {
let state: OfflineBufferState
let mockFlush: jest.Mock
let mockWarn: jest.Mock
beforeEach(() => {
jest.useFakeTimers()
state = makeState()
mockFlush = jest.fn()
mockWarn = jest.fn()
})
afterEach(() => {
// Clean up any remaining timer to avoid cross-test interference
if (state.offlineBufferTimeout) {
clearTimeout(state.offlineBufferTimeout)
}
jest.useRealTimers()
})
// -------------------------------------------------------------------------
// 1. Timeout path — CB:ib,,offline never arrives within 2 s
// -------------------------------------------------------------------------
it('fires after 2 s and flushes when CB:ib,,offline is delayed', () => {
startBuffer(state, mockFlush, mockWarn)
expect(mockFlush).not.toHaveBeenCalled()
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
expect(mockWarn).toHaveBeenCalledTimes(1)
expect(mockFlush).toHaveBeenCalledTimes(1)
})
it('resets didStartBuffer to false after the timeout fires', () => {
startBuffer(state, mockFlush, mockWarn)
expect(state.didStartBuffer).toBe(true)
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
expect(state.didStartBuffer).toBe(false)
})
it('sets offlineBufferTimeout to undefined after the callback executes', () => {
startBuffer(state, mockFlush, mockWarn)
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
expect(state.offlineBufferTimeout).toBeUndefined()
})
it('does not flush if didStartBuffer is already false when timeout fires', () => {
startBuffer(state, mockFlush, mockWarn)
// Simulate external reset (e.g. onClose was called before the timer fired)
state.didStartBuffer = false
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
expect(mockFlush).not.toHaveBeenCalled()
expect(mockWarn).not.toHaveBeenCalled()
})
// -------------------------------------------------------------------------
// 2. Happy path — CB:ib,,offline arrives before the 2 s timer fires
// -------------------------------------------------------------------------
it('CB:ib,,offline cancels the timer and flushes exactly once', () => {
startBuffer(state, mockFlush, mockWarn)
// Server responds before the 2 s timeout
jest.advanceTimersByTime(1_000)
onOffline(state, mockFlush)
// Timer should be cancelled — advancing past 2 s must not cause a second flush
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
expect(mockFlush).toHaveBeenCalledTimes(1)
expect(mockWarn).not.toHaveBeenCalled()
})
it('CB:ib,,offline resets didStartBuffer to false', () => {
startBuffer(state, mockFlush, mockWarn)
onOffline(state, mockFlush)
expect(state.didStartBuffer).toBe(false)
})
it('CB:ib,,offline clears offlineBufferTimeout reference', () => {
startBuffer(state, mockFlush, mockWarn)
onOffline(state, mockFlush)
expect(state.offlineBufferTimeout).toBeUndefined()
})
it('CB:ib,,offline is idempotent when called twice (no double flush)', () => {
startBuffer(state, mockFlush, mockWarn)
onOffline(state, mockFlush)
onOffline(state, mockFlush) // spurious second call
expect(mockFlush).toHaveBeenCalledTimes(1)
})
// -------------------------------------------------------------------------
// 3. Socket close path — end() called before CB:ib,,offline or timer fires
// -------------------------------------------------------------------------
it('end() cancels the timer so the callback never flushes after socket close', () => {
startBuffer(state, mockFlush, mockWarn)
onClose(state)
// Timer must be gone — advancing past 2 s must not trigger any flush
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
expect(mockFlush).not.toHaveBeenCalled()
expect(mockWarn).not.toHaveBeenCalled()
})
it('end() resets didStartBuffer to false', () => {
startBuffer(state, mockFlush, mockWarn)
expect(state.didStartBuffer).toBe(true)
onClose(state)
expect(state.didStartBuffer).toBe(false)
})
it('end() clears offlineBufferTimeout reference', () => {
startBuffer(state, mockFlush, mockWarn)
onClose(state)
expect(state.offlineBufferTimeout).toBeUndefined()
})
it('end() is safe to call when no buffer was started', () => {
// State has never been touched — must not throw
expect(() => onClose(state)).not.toThrow()
expect(state.didStartBuffer).toBe(false)
expect(state.offlineBufferTimeout).toBeUndefined()
})
it('end() after CB:ib,,offline has already arrived is a no-op', () => {
startBuffer(state, mockFlush, mockWarn)
onOffline(state, mockFlush)
// end() should not throw and should leave state clean
expect(() => onClose(state)).not.toThrow()
expect(state.offlineBufferTimeout).toBeUndefined()
expect(state.didStartBuffer).toBe(false)
})
// -------------------------------------------------------------------------
// 4. Boundary / timing edge cases
// -------------------------------------------------------------------------
it('does not flush before exactly 2 s have elapsed', () => {
startBuffer(state, mockFlush, mockWarn)
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS - 1)
expect(mockFlush).not.toHaveBeenCalled()
})
it('flushes at exactly the 2 s boundary', () => {
startBuffer(state, mockFlush, mockWarn)
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
expect(mockFlush).toHaveBeenCalledTimes(1)
})
})
@@ -1,112 +0,0 @@
import { Boom } from '@hapi/boom'
import { proto } from '../../../WAProto/index.js'
import {
decodeSyncdMutations,
decodeSyncdPatch,
decodeSyncdSnapshot,
isAppStateSyncIrrecoverable,
isMissingKeyError,
MAX_SYNC_ATTEMPTS,
newLTHashState
} from '../../Utils/chat-utils'
const missingKeyFn = async () => null
describe('App State Sync', () => {
describe('missing key errors are marked with isMissingKey (Blocked in WA Web)', () => {
it('decodeSyncdPatch throws with isMissingKey on missing key', async () => {
const msg: proto.ISyncdPatch = {
keyId: { id: Buffer.from('missing-key') },
mutations: [],
version: { version: 1 as any },
snapshotMac: Buffer.alloc(32),
patchMac: Buffer.alloc(32)
}
try {
await decodeSyncdPatch(msg, 'regular_low', newLTHashState(), missingKeyFn, () => {}, true)
fail('should have thrown')
} catch (error: any) {
expect(isMissingKeyError(error)).toBe(true)
}
})
it('decodeSyncdSnapshot throws with isMissingKey on missing snapshot key', async () => {
const snapshot: proto.ISyncdSnapshot = {
version: { version: 1 as any },
records: [],
keyId: { id: Buffer.from('missing-key') },
mac: Buffer.alloc(32)
}
try {
await decodeSyncdSnapshot('regular_low', snapshot, missingKeyFn, undefined, true)
fail('should have thrown')
} catch (error: any) {
expect(isMissingKeyError(error)).toBe(true)
}
})
it('decodeSyncdMutations throws with isMissingKey on missing mutation key', async () => {
const records: proto.ISyncdRecord[] = [
{
keyId: { id: Buffer.from('missing-key') },
value: { blob: Buffer.alloc(64) },
index: { blob: Buffer.alloc(32) }
}
]
try {
await decodeSyncdMutations(records, newLTHashState(), missingKeyFn, () => {}, true)
fail('should have thrown')
} catch (error: any) {
expect(isMissingKeyError(error)).toBe(true)
}
})
it('missing key errors are NOT irrecoverable on first attempt', async () => {
const error = new Boom('missing key', { data: { isMissingKey: true } })
expect(isMissingKeyError(error)).toBe(true)
expect(isAppStateSyncIrrecoverable(error, 1)).toBe(false)
})
})
describe('isAppStateSyncIrrecoverable', () => {
it('should NOT be irrecoverable for status 400 (dead code path removed)', () => {
expect(isAppStateSyncIrrecoverable(new Boom('test', { statusCode: 400 }), 1)).toBe(false)
})
it('should NOT be irrecoverable for status 404 (missing key is Blocked, not Fatal)', () => {
expect(isAppStateSyncIrrecoverable(new Boom('test', { statusCode: 404 }), 1)).toBe(false)
})
it('should NOT be irrecoverable for status 405', () => {
expect(isAppStateSyncIrrecoverable(new Boom('test', { statusCode: 405 }), 1)).toBe(false)
})
it('should NOT be irrecoverable for status 406', () => {
expect(isAppStateSyncIrrecoverable(new Boom('test', { statusCode: 406 }), 1)).toBe(false)
})
it('should be irrecoverable for TypeError', () => {
expect(isAppStateSyncIrrecoverable(new TypeError('WASM crash'), 1)).toBe(true)
})
it('should be irrecoverable when attempts >= MAX_SYNC_ATTEMPTS', () => {
expect(isAppStateSyncIrrecoverable(new Error('generic'), MAX_SYNC_ATTEMPTS)).toBe(true)
})
it('should NOT be irrecoverable for generic error below max attempts', () => {
expect(isAppStateSyncIrrecoverable(new Error('generic'), 1)).toBe(false)
})
it('should NOT be irrecoverable for non-fatal status codes', () => {
expect(isAppStateSyncIrrecoverable(new Boom('server error', { statusCode: 500 }), 1)).toBe(false)
})
it('should handle null/undefined error gracefully', () => {
expect(isAppStateSyncIrrecoverable(null, MAX_SYNC_ATTEMPTS)).toBe(true)
expect(isAppStateSyncIrrecoverable(undefined, 1)).toBe(false)
})
})
})
@@ -1,64 +0,0 @@
import type { LTHashState } from '../../Types'
import { ensureLTHashStateVersion } from '../../Utils/chat-utils'
describe('ensureLTHashStateVersion', () => {
const makeState = (version: any): LTHashState => ({
version,
hash: Buffer.alloc(128),
indexValueMap: { someKey: { valueMac: Buffer.from([1, 2, 3]) } }
})
it('should return state unchanged for valid numeric version', () => {
const state = makeState(5)
const result = ensureLTHashStateVersion(state)
expect(result).toBe(state)
expect(result.version).toBe(5)
})
it('should fix undefined version to 0', () => {
const state = makeState(undefined)
const result = ensureLTHashStateVersion(state)
expect(result.version).toBe(0)
})
it('should fix null version to 0', () => {
const state = makeState(null)
const result = ensureLTHashStateVersion(state)
expect(result.version).toBe(0)
})
it('should fix NaN version to 0', () => {
const state = makeState(NaN)
const result = ensureLTHashStateVersion(state)
expect(result.version).toBe(0)
})
it('should fix string version to 0', () => {
const state = makeState('3' as any)
const result = ensureLTHashStateVersion(state)
expect(result.version).toBe(0)
})
it('should keep version 0 as-is', () => {
const state = makeState(0)
const result = ensureLTHashStateVersion(state)
expect(result).toBe(state)
expect(result.version).toBe(0)
})
it('should preserve other state fields', () => {
const state = makeState(undefined)
const originalHash = state.hash
const originalMap = state.indexValueMap
const result = ensureLTHashStateVersion(state)
expect(result.hash).toBe(originalHash)
expect(result.indexValueMap).toBe(originalMap)
})
it('should allow .toString() after fix without throwing', () => {
const state = makeState(undefined)
ensureLTHashStateVersion(state)
expect(() => state.version.toString()).not.toThrow()
expect(state.version.toString()).toBe('0')
})
})
@@ -1,640 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* Testes unitários para baileys-event-stream.ts
*/
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
import {
BaileysEventStream,
createEventStream,
eventFilters,
type EventPriority,
eventTransformers,
type StreamEvent
} from '../../Utils/baileys-event-stream.js'
describe('BaileysEventStream', () => {
let stream: BaileysEventStream
beforeEach(() => {
stream = createEventStream({
maxBufferSize: 100,
batchSize: 10,
collectMetrics: false
})
})
afterEach(() => {
stream.destroy()
})
describe('push events', () => {
it('should push event to stream', () => {
stream.pause()
const result = stream.push('messages.upsert', { message: 'test' })
expect(result).toBe(true)
expect(stream.getStats().bufferSize).toBeGreaterThan(0)
stream.resume()
})
it('should assign priority based on event type', done => {
stream.on('*', event => {
expect(event.priority).toBe('critical')
done()
})
stream.push('connection.update', { state: 'open' })
})
it('should use custom priority when provided', done => {
stream.on('*', event => {
expect(event.priority).toBe('low')
done()
})
stream.push('messages.upsert', { message: 'test' }, { priority: 'low' })
})
it('should assign correct category', done => {
stream.on('*', event => {
expect(event.category).toBe('message')
done()
})
stream.push('messages.upsert', { message: 'test' })
})
it('should reject events when buffer is full', () => {
const smallStream = createEventStream({
maxBufferSize: 5,
enableBackpressure: true,
highWaterMark: 3,
collectMetrics: false
})
smallStream.pause() // Prevent processing
for (let i = 0; i < 5; i++) {
smallStream.push('messages.upsert', { index: i })
}
const result = smallStream.push('messages.upsert', { overflow: true })
expect(result).toBe(false)
expect(smallStream.getStats().totalDropped).toBe(1)
smallStream.destroy()
})
})
describe('event handlers', () => {
it('should call handler for specific event type', async () => {
const handler = jest.fn() as any
stream.on('messages.upsert', handler)
stream.push('messages.upsert', { message: 'test' })
// Wait for processing
await new Promise(resolve => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalledTimes(1)
})
it('should call global handler for all events', async () => {
const handler = jest.fn() as any
stream.on('*', handler)
stream.push('messages.upsert', { message: 'test' })
stream.push('connection.update', { state: 'open' })
await new Promise(resolve => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalledTimes(2)
})
it('should support once handler', async () => {
const handler = jest.fn() as any
stream.once('messages.upsert', handler)
stream.push('messages.upsert', { first: true })
stream.push('messages.upsert', { second: true })
await new Promise(resolve => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalledTimes(1)
})
it('should remove handler with off', async () => {
const handler = jest.fn() as any
stream.on('messages.upsert', handler)
stream.off('messages.upsert', handler)
stream.push('messages.upsert', { message: 'test' })
await new Promise(resolve => setTimeout(resolve, 50))
expect(handler).not.toHaveBeenCalled()
})
})
describe('pause and resume', () => {
it('should pause processing', async () => {
const handler = jest.fn() as any
stream.on('messages.upsert', handler)
stream.pause()
expect(stream.isPaused()).toBe(true)
stream.push('messages.upsert', { message: 'test' })
await new Promise(resolve => setTimeout(resolve, 50))
expect(handler).not.toHaveBeenCalled()
})
it('should resume processing', async () => {
const handler = jest.fn() as any
stream.on('messages.upsert', handler)
stream.pause()
stream.push('messages.upsert', { message: 'test' })
stream.resume()
await new Promise(resolve => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalled()
})
})
describe('flush', () => {
it('should process all buffered events', async () => {
const handler = jest.fn() as any
stream.on('messages.upsert', handler)
for (let i = 0; i < 5; i++) {
stream.push('messages.upsert', { index: i })
}
// Wait for async processing
await new Promise(resolve => setTimeout(resolve, 100))
expect(handler).toHaveBeenCalledTimes(5)
})
})
describe('filters', () => {
it('should filter events before processing', async () => {
const handler = jest.fn() as any
stream.addFilter(event => (event.data as any).include === true)
stream.on('*', handler)
stream.push('messages.upsert', { include: true })
stream.push('messages.upsert', { include: false })
await new Promise(resolve => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalledTimes(1)
})
it('should remove filter', async () => {
const filter = (_event: StreamEvent) => false
stream.addFilter(filter)
stream.removeFilter(filter)
const handler = jest.fn() as any
stream.on('*', handler)
stream.push('messages.upsert', { test: true })
await new Promise(resolve => setTimeout(resolve, 50))
expect(handler).toHaveBeenCalled()
})
})
describe('transformers', () => {
it('should transform events before processing', async () => {
stream.addTransformer(event => ({
...event,
metadata: { ...event.metadata, transformed: true }
}))
let receivedEvent: StreamEvent | null = null
stream.on('*', event => {
receivedEvent = event
})
stream.push('messages.upsert', { message: 'test' })
await new Promise(resolve => setTimeout(resolve, 50))
expect((receivedEvent as any)?.metadata?.transformed).toBe(true)
})
})
describe('dead letter queue', () => {
it('should move failed events to DLQ after max retries', async () => {
const failingHandler = jest.fn(() => {
throw new Error('Processing failed')
})
const dlqStream = createEventStream({
maxRetries: 2,
batchSize: 1,
collectMetrics: false
})
dlqStream.on('messages.upsert', failingHandler)
dlqStream.push('messages.upsert', { will: 'fail' })
await new Promise(resolve => setTimeout(resolve, 200))
const dlq = dlqStream.getDeadLetterQueue()
expect(dlq.length).toBeGreaterThan(0)
dlqStream.destroy()
})
it('should clear DLQ', () => {
stream.push('test', { data: 'test' })
// Manually add to DLQ for testing
const dlq = stream.getDeadLetterQueue()
stream.clearDeadLetterQueue()
expect(stream.getStats().deadLetterQueueSize).toBe(0)
})
})
describe('statistics', () => {
it('should track event statistics', async () => {
stream.on('*', () => {})
stream.push('messages.upsert', { a: 1 })
stream.push('connection.update', { b: 2 })
stream.push('messages.update', { c: 3 })
await new Promise(resolve => setTimeout(resolve, 50))
const stats = stream.getStats()
expect(stats.totalReceived).toBe(3)
expect(stats.totalProcessed).toBe(3)
expect(stats.eventsByType['messages.upsert']).toBe(1)
expect(stats.eventsByType['connection.update']).toBe(1)
})
it('should reset statistics', async () => {
stream.on('*', () => {})
stream.push('messages.upsert', { test: true })
await new Promise(resolve => setTimeout(resolve, 50))
stream.resetStats()
const stats = stream.getStats()
expect(stats.totalReceived).toBe(0)
expect(stats.totalProcessed).toBe(0)
})
})
describe('priority ordering', () => {
it('should process critical events before normal events', async () => {
const processedOrder: EventPriority[] = []
stream.pause()
stream.on('*', event => {
processedOrder.push(event.priority)
})
// Add in reverse priority order
stream.push('presence.update', {}, { priority: 'low' })
stream.push('messages.upsert', {}, { priority: 'normal' })
stream.push('connection.update', {}, { priority: 'critical' })
stream.push('call', {}, { priority: 'high' })
stream.resume()
await new Promise(resolve => setTimeout(resolve, 50))
await stream.flush()
expect(processedOrder[0]).toBe('critical')
expect(processedOrder[1]).toBe('high')
expect(processedOrder[2]).toBe('normal')
expect(processedOrder[3]).toBe('low')
})
})
describe('backpressure', () => {
it('should emit backpressure event when high water mark reached', done => {
const bpStream = createEventStream({
maxBufferSize: 100,
highWaterMark: 5,
enableBackpressure: true,
collectMetrics: false
})
bpStream.pause()
bpStream.on('backpressure', () => {
expect(bpStream.getStats().isBackpressured).toBe(true)
bpStream.destroy()
done()
})
for (let i = 0; i < 10; i++) {
bpStream.push('messages.upsert', { index: i })
}
})
it('should emit drain event when below low water mark', done => {
const bpStream = createEventStream({
maxBufferSize: 100,
highWaterMark: 5,
lowWaterMark: 2,
enableBackpressure: true,
batchSize: 10,
collectMetrics: false
})
bpStream.pause()
for (let i = 0; i < 10; i++) {
bpStream.push('messages.upsert', { index: i })
}
bpStream.on('*', () => {})
bpStream.on('drain', () => {
bpStream.destroy()
done()
})
bpStream.resume()
})
})
describe('clear', () => {
it('should clear buffer', () => {
stream.pause()
for (let i = 0; i < 10; i++) {
stream.push('messages.upsert', { index: i })
}
stream.clear()
expect(stream.getStats().bufferSize).toBe(0)
})
})
})
describe('eventFilters', () => {
describe('byType', () => {
it('should filter by event type', () => {
const filter = eventFilters.byType('messages.upsert', 'messages.update')
const matchingEvent: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'message'
}
const nonMatchingEvent: StreamEvent = {
id: '2',
type: 'connection.update',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'connection'
}
expect(filter(matchingEvent)).toBe(true)
expect(filter(nonMatchingEvent)).toBe(false)
})
})
describe('byCategory', () => {
it('should filter by category', () => {
const filter = eventFilters.byCategory('message', 'connection')
const matchingEvent: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'message'
}
const nonMatchingEvent: StreamEvent = {
id: '2',
type: 'presence.update',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'presence'
}
expect(filter(matchingEvent)).toBe(true)
expect(filter(nonMatchingEvent)).toBe(false)
})
})
describe('byMinPriority', () => {
it('should filter by minimum priority', () => {
const filter = eventFilters.byMinPriority('high')
const criticalEvent: StreamEvent = {
id: '1',
type: 'connection.update',
data: {},
timestamp: Date.now(),
priority: 'critical',
category: 'connection'
}
const lowEvent: StreamEvent = {
id: '2',
type: 'presence.update',
data: {},
timestamp: Date.now(),
priority: 'low',
category: 'presence'
}
expect(filter(criticalEvent)).toBe(true)
expect(filter(lowEvent)).toBe(false)
})
})
describe('recentOnly', () => {
it('should filter old events', () => {
const filter = eventFilters.recentOnly(1000)
const recentEvent: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'message'
}
const oldEvent: StreamEvent = {
id: '2',
type: 'messages.upsert',
data: {},
timestamp: Date.now() - 5000,
priority: 'normal',
category: 'message'
}
expect(filter(recentEvent)).toBe(true)
expect(filter(oldEvent)).toBe(false)
})
})
describe('and', () => {
it('should combine filters with AND', () => {
const filter = eventFilters.and(eventFilters.byType('messages.upsert'), eventFilters.byMinPriority('high'))
const matchingEvent: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'high',
category: 'message'
}
const partialMatch: StreamEvent = {
id: '2',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'low',
category: 'message'
}
expect(filter(matchingEvent)).toBe(true)
expect(filter(partialMatch)).toBe(false)
})
})
describe('or', () => {
it('should combine filters with OR', () => {
const filter = eventFilters.or(eventFilters.byType('messages.upsert'), eventFilters.byCategory('connection'))
const typeMatch: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'message'
}
const categoryMatch: StreamEvent = {
id: '2',
type: 'connection.update',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'connection'
}
const noMatch: StreamEvent = {
id: '3',
type: 'presence.update',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'presence'
}
expect(filter(typeMatch)).toBe(true)
expect(filter(categoryMatch)).toBe(true)
expect(filter(noMatch)).toBe(false)
})
})
})
describe('eventTransformers', () => {
describe('addProcessingTimestamp', () => {
it('should add processing timestamp', () => {
const transformer = eventTransformers.addProcessingTimestamp()
const event: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now() - 1000,
priority: 'normal',
category: 'message'
}
const transformed = transformer(event)
expect(transformed.metadata?.processingTimestamp).toBeDefined()
expect(transformed.metadata?.processingTimestamp).toBeGreaterThan(event.timestamp)
})
})
describe('addTraceId', () => {
it('should add trace ID', () => {
const transformer = eventTransformers.addTraceId(() => 'trace-123')
const event: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: {},
timestamp: Date.now(),
priority: 'normal',
category: 'message'
}
const transformed = transformer(event)
expect(transformed.metadata?.traceId).toBe('trace-123')
})
})
describe('elevatepriorityIf', () => {
it('should elevate priority when condition is met', () => {
const transformer = eventTransformers.elevatepriorityIf(
event => (event.data as { urgent?: boolean }).urgent === true,
'critical'
)
const urgentEvent: StreamEvent = {
id: '1',
type: 'messages.upsert',
data: { urgent: true },
timestamp: Date.now(),
priority: 'normal',
category: 'message'
}
const normalEvent: StreamEvent = {
id: '2',
type: 'messages.upsert',
data: { urgent: false },
timestamp: Date.now(),
priority: 'normal',
category: 'message'
}
expect(transformer(urgentEvent).priority).toBe('critical')
expect(transformer(normalEvent).priority).toBe('normal')
})
})
})

Some files were not shown because too many files have changed in this diff Show More