Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef365246ea |
+1
-18
@@ -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.
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -19,4 +19,3 @@ test.ts
|
||||
TestData
|
||||
wa-logs*
|
||||
coverage
|
||||
AUDIT_REPORT_*.md
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
nodeLinker: node-modules
|
||||
httpRetry: 10
|
||||
httpTimeout: 600000
|
||||
|
||||
+7
-10
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+360
-916
File diff suppressed because it is too large
Load Diff
Vendored
+95
-1392
File diff suppressed because it is too large
Load Diff
+574
-10120
File diff suppressed because it is too large
Load Diff
@@ -1,822 +0,0 @@
|
||||
# Mensagens Interativas — Guia Completo
|
||||
|
||||
Guia de referência para envio e consumo de todos os tipos de mensagens interativas suportados pela API.
|
||||
|
||||
---
|
||||
|
||||
## Compatibilidade por Plataforma
|
||||
|
||||
| Tipo | Android | iOS | WA Web |
|
||||
|---|---|---|---|
|
||||
| Menu Texto | ✅ | ✅ | ✅ |
|
||||
| Botões Quick Reply | ✅ | ✅ | ✅ |
|
||||
| Botões CTA (URL / Copy / Call) | ✅ | ✅ | ✅ |
|
||||
| Lista Dropdown | ✅ | ✅ | ✅ |
|
||||
| Enquete/Poll | ✅ | ✅ | ✅ |
|
||||
| Carrossel com Imagens | ✅ | ✅ | ✅ |
|
||||
|
||||
> **Nota:** Todos os tipos exigem conta **WhatsApp Business** ativa e **não-hosted** (registrada via QR/pair code). Contas hosted (Meta Business Cloud API) podem ter restrições de entrega de mensagens interativas impostas pelo servidor WA.
|
||||
|
||||
---
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Menu Texto](#1-menu-texto)
|
||||
2. [Botões Quick Reply](#2-botões-quick-reply)
|
||||
3. [Botões CTA — URL, Copy, Call](#3-botões-cta--url-copy-call)
|
||||
4. [Lista Dropdown](#4-lista-dropdown)
|
||||
5. [Enquete / Poll](#5-enquete--poll)
|
||||
6. [Apenas Botões Reply sem CTA](#6-apenas-botões-reply-sem-cta)
|
||||
7. [Apenas CTAs sem Quick Reply](#7-apenas-ctas-sem-quick-reply)
|
||||
8. [Carrossel com Imagens](#8-carrossel-com-imagens)
|
||||
9. [Como Consumir Respostas via Webhook](#9-como-consumir-respostas-via-webhook)
|
||||
10. [Limites e Restrições](#10-limites-e-restrições)
|
||||
|
||||
---
|
||||
|
||||
## 1. Menu Texto
|
||||
|
||||
Envia uma mensagem de texto simples com uma lista numerada de opções. Funciona em todas as plataformas sem binary nodes.
|
||||
|
||||
### Curl
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_menu \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "minha-instancia",
|
||||
"to": "5511900000001",
|
||||
"title": "Menu de Opções",
|
||||
"text": "Escolha uma opção:",
|
||||
"options": ["Opção 1", "Opção 2", "Opção 3"],
|
||||
"footer": "Responda com o número"
|
||||
}'
|
||||
```
|
||||
|
||||
### Parâmetros
|
||||
|
||||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
|---|---|---|---|
|
||||
| `instance` | string | ✅ | Nome da instância conectada |
|
||||
| `to` | string | ✅ | Número do destinatário (DDI+DDD+número) |
|
||||
| `title` | string | ❌ | Título do menu |
|
||||
| `text` | string | ✅ | Texto da mensagem |
|
||||
| `options` | string[] | ✅ | Lista de opções (sem limite fixo) |
|
||||
| `footer` | string | ❌ | Texto de rodapé |
|
||||
|
||||
### Resposta da API
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
### Como o destinatário vê
|
||||
|
||||
O usuário recebe uma mensagem de texto com as opções numeradas e responde digitando o número correspondente. Não há interatividade nativa — a resposta chega como mensagem de texto comum.
|
||||
|
||||
---
|
||||
|
||||
## 2. Botões Quick Reply
|
||||
|
||||
Botões de resposta rápida que o usuário toca para selecionar. Testado com até **16 botões**. Renderiza nativamente em Android, iOS e WA Web.
|
||||
|
||||
### Curl
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_buttons_helpers \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "minha-instancia",
|
||||
"to": "5511900000001",
|
||||
"text": "👋 Olá! Como posso ajudar?",
|
||||
"footer": "Atendimento 24h",
|
||||
"buttons": [
|
||||
{"id": "vendas", "text": "🛒 Fazer Pedido"},
|
||||
{"id": "suporte", "text": "🔧 Suporte Técnico"},
|
||||
{"id": "financeiro", "text": "💰 Financeiro"},
|
||||
{"id": "comercial", "text": "📊 Comercial"},
|
||||
{"id": "contabil", "text": "📋 Contábil"},
|
||||
{"id": "rh", "text": "👥 Recursos Humanos"},
|
||||
{"id": "secretaria", "text": "📞 Secretaria"},
|
||||
{"id": "diplomas", "text": "🎓 Diplomas"},
|
||||
{"id": "diretoria", "text": "🏛️ Diretoria"},
|
||||
{"id": "compliance", "text": "✅ Compliance"},
|
||||
{"id": "juridico", "text": "⚖️ Jurídico"},
|
||||
{"id": "social", "text": "🤝 Ass. Social"},
|
||||
{"id": "contratos", "text": "📄 Contratos"},
|
||||
{"id": "ti", "text": "💻 Tecnologia da Informação"},
|
||||
{"id": "assessoria", "text": "📣 Assessoria"},
|
||||
{"id": "voip", "text": "📡 VOIP"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Parâmetros
|
||||
|
||||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
|---|---|---|---|
|
||||
| `instance` | string | ✅ | Nome da instância |
|
||||
| `to` | string | ✅ | Número do destinatário |
|
||||
| `text` | string | ✅ | Texto principal |
|
||||
| `footer` | string | ❌ | Rodapé |
|
||||
| `buttons` | array | ✅ | Lista de botões (ver abaixo) |
|
||||
| `buttons[].id` | string | ✅ | ID do botão (retornado no webhook) |
|
||||
| `buttons[].text` | string | ✅ | Texto exibido no botão |
|
||||
|
||||
### Webhook recebido ao clicar
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "messages.upsert",
|
||||
"data": {
|
||||
"messages": [{
|
||||
"key": { "remoteJid": "5511900000001@s.whatsapp.net", "fromMe": false },
|
||||
"message": {
|
||||
"interactiveResponseMessage": {
|
||||
"body": { "text": "🛒 Fazer Pedido" },
|
||||
"nativeFlowResponseMessage": {
|
||||
"name": "quick_reply",
|
||||
"paramsJson": "{\"id\":\"vendas\"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Como ler a resposta
|
||||
|
||||
```javascript
|
||||
function parseButtonReply(msg) {
|
||||
const interactive = msg.message?.interactiveResponseMessage
|
||||
if (!interactive) return null
|
||||
|
||||
const params = JSON.parse(
|
||||
interactive.nativeFlowResponseMessage?.paramsJson || '{}'
|
||||
)
|
||||
return {
|
||||
buttonId: params.id,
|
||||
buttonText: interactive.body?.text
|
||||
}
|
||||
}
|
||||
|
||||
// Exemplo:
|
||||
// { buttonId: 'vendas', buttonText: '🛒 Fazer Pedido' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Botões CTA — URL, Copy, Call
|
||||
|
||||
Botões de ação especiais: abrir URL, copiar código (PIX, cupom, etc.) e ligar. Podem ser combinados na mesma mensagem.
|
||||
|
||||
### Curl — Combinação URL + Copy + Call
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_interactive_helpers \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "minha-instancia",
|
||||
"to": "5511900000001",
|
||||
"text": "💳 Pagamento via PIX\nValor: R$ 150,00\nPedido: #12345",
|
||||
"footer": "Obrigado pela preferência!",
|
||||
"buttons": [
|
||||
{
|
||||
"type": "call",
|
||||
"text": "📞 Ligar Suporte",
|
||||
"phoneNumber": "+5511900000002"
|
||||
},
|
||||
{
|
||||
"type": "copy",
|
||||
"text": "📋 Copiar Chave PIX",
|
||||
"copyCode": "00020126580014br.gov.bcb.pix0136123e4567-e89b-12d3-a456-426614174000"
|
||||
},
|
||||
{
|
||||
"type": "url",
|
||||
"text": "🔗 Ver Pedido",
|
||||
"url": "https://sualoja.com.br/pedido/12345"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Parâmetros de cada tipo de botão
|
||||
|
||||
**Tipo `url`:**
|
||||
| Campo | Tipo | Descrição |
|
||||
|---|---|---|
|
||||
| `type` | `"url"` | Tipo do botão |
|
||||
| `text` | string | Texto do botão |
|
||||
| `url` | string | URL a abrir (http/https) |
|
||||
|
||||
**Tipo `copy`:**
|
||||
| Campo | Tipo | Descrição |
|
||||
|---|---|---|
|
||||
| `type` | `"copy"` | Tipo do botão |
|
||||
| `text` | string | Texto do botão |
|
||||
| `copyCode` | string | Texto copiado ao clicar |
|
||||
|
||||
**Tipo `call`:**
|
||||
| Campo | Tipo | Descrição |
|
||||
|---|---|---|
|
||||
| `type` | `"call"` | Tipo do botão |
|
||||
| `text` | string | Texto do botão |
|
||||
| `phoneNumber` | string | Número a ligar (com +DDI) |
|
||||
|
||||
### Webhook recebido ao clicar em URL
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"interactiveResponseMessage": {
|
||||
"body": { "text": "🔗 Ver Pedido" },
|
||||
"nativeFlowResponseMessage": {
|
||||
"name": "cta_open_url",
|
||||
"paramsJson": "{\"url\":\"https://sualoja.com.br/pedido/12345\",\"from_notification\":false}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook recebido ao clicar em Copy
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"interactiveResponseMessage": {
|
||||
"nativeFlowResponseMessage": {
|
||||
"name": "cta_copy",
|
||||
"paramsJson": "{\"copy_code\":\"00020126580014br.gov.bcb.pix...\"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Como ler a resposta CTA
|
||||
|
||||
```javascript
|
||||
function parseCTAReply(msg) {
|
||||
const flow = msg.message?.interactiveResponseMessage?.nativeFlowResponseMessage
|
||||
if (!flow) return null
|
||||
|
||||
const params = JSON.parse(flow.paramsJson || '{}')
|
||||
switch (flow.name) {
|
||||
case 'cta_open_url': return { action: 'url', url: params.url }
|
||||
case 'cta_copy': return { action: 'copy', code: params.copy_code }
|
||||
case 'cta_call': return { action: 'call', phone: params.phone_number }
|
||||
default: return { action: flow.name, params }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Lista Dropdown
|
||||
|
||||
Menu suspenso com seções e itens selecionáveis. Suporta até **10 seções × 3 linhas = 30 itens** no total.
|
||||
|
||||
### Curl — Cardápio completo (10 seções, 30 itens)
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_list_helpers \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "minha-instancia",
|
||||
"to": "5511900000001",
|
||||
"text": "Cardápio completo do restaurante.\nEscolha uma categoria abaixo:",
|
||||
"footer": "Delivery grátis acima de R$ 50",
|
||||
"buttonText": "Ver Cardápio",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Hambúrgueres",
|
||||
"rows": [
|
||||
{"id": "burger_1", "title": "Clássico", "description": "Pão, carne 180g, queijo, alface, tomate - R$ 28,90"},
|
||||
{"id": "burger_2", "title": "Bacon Lovers", "description": "Pão, carne 180g, bacon crocante, cheddar - R$ 34,90"},
|
||||
{"id": "burger_3", "title": "Veggie Burger", "description": "Pão, hambúrguer de grão de bico, rúcula - R$ 32,90"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Pizzas",
|
||||
"rows": [
|
||||
{"id": "pizza_1", "title": "Margherita", "description": "Molho de tomate, mussarela, manjericão - R$ 49,90"},
|
||||
{"id": "pizza_2", "title": "Pepperoni", "description": "Molho, mussarela, pepperoni fatiado - R$ 54,90"},
|
||||
{"id": "pizza_3", "title": "Quatro Queijos","description": "Mussarela, gorgonzola, parmesão, brie - R$ 52,90"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Massas",
|
||||
"rows": [
|
||||
{"id": "massa_1", "title": "Espaguete Bolonhesa", "description": "Massa al dente com molho bolonhesa caseiro - R$ 38,90"},
|
||||
{"id": "massa_2", "title": "Fettuccine Alfredo", "description": "Fettuccine com molho branco cremoso - R$ 42,90"},
|
||||
{"id": "massa_3", "title": "Lasanha Especial", "description": "Camadas de massa, carne, presunto, queijo - R$ 45,90"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Saladas",
|
||||
"rows": [
|
||||
{"id": "salada_1", "title": "Caesar", "description": "Alface romana, croutons, parmesão, molho - R$ 32,90"},
|
||||
{"id": "salada_2", "title": "Tropical", "description": "Mix de folhas, manga, palmito, molho - R$ 29,90"},
|
||||
{"id": "salada_3", "title": "Caprese", "description": "Tomate, mussarela búfala, manjericão - R$ 34,90"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Frutos do Mar",
|
||||
"rows": [
|
||||
{"id": "mar_1", "title": "Camarão Grelhado", "description": "Camarões grelhados com manteiga e alho - R$ 62,90"},
|
||||
{"id": "mar_2", "title": "Filé de Salmão", "description": "Salmão grelhado com legumes no vapor - R$ 58,90"},
|
||||
{"id": "mar_3", "title": "Moqueca de Peixe", "description": "Peixe, leite de coco, dendê, pimentão - R$ 55,90"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Sobremesas",
|
||||
"rows": [
|
||||
{"id": "doce_1", "title": "Petit Gâteau", "description": "Bolo de chocolate com sorvete de creme - R$ 28,90"},
|
||||
{"id": "doce_2", "title": "Pudim", "description": "Pudim de leite condensado tradicional - R$ 18,90"},
|
||||
{"id": "doce_3", "title": "Açaí 500ml", "description": "Açaí com granola, banana e leite ninho - R$ 24,90"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Bebidas",
|
||||
"rows": [
|
||||
{"id": "bebida_1", "title": "Refrigerante 350ml", "description": "Coca-Cola, Guaraná, Sprite, Fanta - R$ 6,90"},
|
||||
{"id": "bebida_2", "title": "Suco Natural 500ml", "description": "Laranja, limão, maracujá, abacaxi - R$ 12,90"},
|
||||
{"id": "bebida_3", "title": "Água Mineral", "description": "Com ou sem gás 500ml - R$ 4,90"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Cervejas",
|
||||
"rows": [
|
||||
{"id": "cerveja_1", "title": "Pilsen 600ml", "description": "Brahma, Skol, Antarctica - R$ 12,90"},
|
||||
{"id": "cerveja_2", "title": "IPA 473ml", "description": "Colorado, Lagunitas, Goose Island - R$ 18,90"},
|
||||
{"id": "cerveja_3", "title": "Weiss 500ml", "description": "Erdinger, Paulaner, Blue Moon - R$ 22,90"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Vinhos",
|
||||
"rows": [
|
||||
{"id": "vinho_1", "title": "Tinto Seco", "description": "Cabernet Sauvignon, taça 150ml - R$ 25,90"},
|
||||
{"id": "vinho_2", "title": "Branco Suave", "description": "Chardonnay, taça 150ml - R$ 23,90"},
|
||||
{"id": "vinho_3", "title": "Rosé", "description": "Rosé Provence, taça 150ml - R$ 27,90"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Combos",
|
||||
"rows": [
|
||||
{"id": "combo_1", "title": "Combo Single", "description": "1 hambúrguer + batata + refri - R$ 39,90"},
|
||||
{"id": "combo_2", "title": "Combo Casal", "description": "2 hambúrgueres + batata grande + 2 refri - R$ 69,90"},
|
||||
{"id": "combo_3", "title": "Combo Família", "description": "4 hambúrgueres + 2 batatas + jarra - R$ 119,90"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Parâmetros
|
||||
|
||||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
|---|---|---|---|
|
||||
| `text` | string | ✅ | Texto da mensagem |
|
||||
| `footer` | string | ❌ | Rodapé |
|
||||
| `buttonText` | string | ✅ | Texto do botão que abre a lista (max 20 chars) |
|
||||
| `sections` | array | ✅ | Seções da lista (max 10) |
|
||||
| `sections[].title` | string | ✅ | Título da seção (max 24 chars) |
|
||||
| `sections[].rows` | array | ✅ | Itens da seção (max 3 por seção) |
|
||||
| `rows[].id` | string | ✅ | ID do item (retornado no webhook) |
|
||||
| `rows[].title` | string | ✅ | Título do item (max 24 chars) |
|
||||
| `rows[].description` | string | ❌ | Descrição do item (max 72 chars) |
|
||||
|
||||
### Webhook recebido ao selecionar item
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"interactiveResponseMessage": {
|
||||
"body": { "text": "Clássico" },
|
||||
"nativeFlowResponseMessage": {
|
||||
"name": "single_select",
|
||||
"paramsJson": "{\"id\":\"burger_1\",\"title\":\"Clássico\"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Como ler a resposta
|
||||
|
||||
```javascript
|
||||
function parseListReply(msg) {
|
||||
const flow = msg.message?.interactiveResponseMessage?.nativeFlowResponseMessage
|
||||
if (flow?.name !== 'single_select') return null
|
||||
|
||||
const params = JSON.parse(flow.paramsJson || '{}')
|
||||
return {
|
||||
id: params.id,
|
||||
title: params.title
|
||||
}
|
||||
}
|
||||
|
||||
// Exemplo: { id: 'burger_1', title: 'Clássico' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Enquete / Poll
|
||||
|
||||
Enquete nativa do WhatsApp. Funciona em todas as plataformas sem restrições. O usuário seleciona uma ou mais opções e o resultado é atualizado em tempo real.
|
||||
|
||||
### Curl
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_poll \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "minha-instancia",
|
||||
"to": "5511900000001",
|
||||
"name": "Qual sua linguagem favorita?",
|
||||
"options": ["JavaScript", "Python", "TypeScript", "Go"],
|
||||
"selectableCount": 1
|
||||
}'
|
||||
```
|
||||
|
||||
### Parâmetros
|
||||
|
||||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
|---|---|---|---|
|
||||
| `name` | string | ✅ | Pergunta da enquete |
|
||||
| `options` | string[] | ✅ | Opções (min 2, max 12) |
|
||||
| `selectableCount` | number | ✅ | Quantas opções o usuário pode marcar (0 = ilimitado) |
|
||||
|
||||
### Webhook recebido ao votar
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "messages.upsert",
|
||||
"data": {
|
||||
"messages": [{
|
||||
"key": { "remoteJid": "5511900000001@s.whatsapp.net" },
|
||||
"message": {
|
||||
"pollUpdateMessage": {
|
||||
"pollCreationMessageKey": {
|
||||
"id": "ID_DA_ENQUETE_ORIGINAL"
|
||||
},
|
||||
"vote": {
|
||||
"selectedOptions": ["TypeScript"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Como ler a resposta
|
||||
|
||||
```javascript
|
||||
function parsePollVote(msg) {
|
||||
const poll = msg.message?.pollUpdateMessage
|
||||
if (!poll) return null
|
||||
|
||||
return {
|
||||
pollId: poll.pollCreationMessageKey?.id,
|
||||
selectedOptions: poll.vote?.selectedOptions || []
|
||||
}
|
||||
}
|
||||
|
||||
// Exemplo: { pollId: '3EB0...', selectedOptions: ['TypeScript'] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Apenas Botões Reply sem CTA
|
||||
|
||||
Botões simples de confirmação/seleção sem nenhum botão de ação externa.
|
||||
|
||||
### Curl
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_buttons_helpers \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "minha-instancia",
|
||||
"to": "5511900000001",
|
||||
"text": "Confirma o pedido #12345?",
|
||||
"footer": "Pedido no valor de R$ 150,00",
|
||||
"buttons": [
|
||||
{"id": "confirmar", "text": "✅ Confirmar"},
|
||||
{"id": "cancelar", "text": "❌ Cancelar"},
|
||||
{"id": "adiar", "text": "⏰ Adiar"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
O webhook e a leitura da resposta seguem o mesmo padrão da [Seção 2](#2-botões-quick-reply).
|
||||
|
||||
---
|
||||
|
||||
## 7. Apenas CTAs sem Quick Reply
|
||||
|
||||
Mensagem com somente botões de ação (URL / Call) sem botões de resposta rápida.
|
||||
|
||||
### Curl
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_interactive_helpers \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "minha-instancia",
|
||||
"to": "5511900000001",
|
||||
"text": "🏪 Loja Virtual\nConfira nossos canais de atendimento:",
|
||||
"footer": "Atendimento 24h",
|
||||
"buttons": [
|
||||
{"type": "url", "text": "🌐 Site Oficial", "url": "https://www.sualoja.com.br"},
|
||||
{"type": "url", "text": "📸 Instagram", "url": "https://instagram.com/sualoja"},
|
||||
{"type": "call", "text": "📞 WhatsApp Vendas", "phoneNumber": "+5511900000002"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
O webhook e a leitura da resposta seguem o mesmo padrão da [Seção 3](#3-botões-cta--url-copy-call).
|
||||
|
||||
---
|
||||
|
||||
## 8. Carrossel com Imagens
|
||||
|
||||
Cards deslizáveis com imagem, texto e botões. Suporta até **10 cards**, cada um com até **2 botões**. Renderiza nativamente em Android, iOS e WA Web.
|
||||
|
||||
### Curl — 10 cards (limite máximo)
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_carousel_helpers \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "minha-instancia",
|
||||
"to": "5511900000001",
|
||||
"text": "🛍️ Ofertas Especiais",
|
||||
"footer": "Loja Virtual — Entrega Grátis",
|
||||
"cards": [
|
||||
{
|
||||
"body": "iPhone 15 Pro Max 256GB\nR$ 8.999,00 à vista\n12x R$ 833,25",
|
||||
"footer": "Frete Grátis",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1592750475338-74b7b21085ab?w=400",
|
||||
"buttons": [{"id": "buy_1", "text": "Comprar"}, {"id": "info_1", "text": "Detalhes"}]
|
||||
},
|
||||
{
|
||||
"body": "MacBook Air M3 8GB 256GB\nR$ 12.499,00 à vista\n12x R$ 1.166,58",
|
||||
"footer": "Garantia 1 ano",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=400",
|
||||
"buttons": [{"id": "buy_2", "text": "Comprar"}, {"id": "info_2", "text": "Detalhes"}]
|
||||
},
|
||||
{
|
||||
"body": "Apple Watch Series 9 45mm\nR$ 5.299,00 à vista\n12x R$ 491,58",
|
||||
"footer": "Pronta Entrega",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1546868871-7041f2a55e12?w=400",
|
||||
"buttons": [{"id": "buy_3", "text": "Comprar"}, {"id": "info_3", "text": "Detalhes"}]
|
||||
},
|
||||
{
|
||||
"body": "AirPods Pro 2ª Geração\nR$ 2.499,00 à vista\n12x R$ 233,25",
|
||||
"footer": "Cancelamento de Ruído",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1600294037681-c80b4cb5b434?w=400",
|
||||
"buttons": [{"id": "buy_4", "text": "Comprar"}, {"id": "info_4", "text": "Detalhes"}]
|
||||
},
|
||||
{
|
||||
"body": "iPad Pro M2 11pol 128GB\nR$ 9.999,00 à vista\n12x R$ 916,58",
|
||||
"footer": "Chip M2",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1544244015-0df4b3ffc6b0?w=400",
|
||||
"buttons": [{"id": "buy_5", "text": "Comprar"}, {"id": "info_5", "text": "Detalhes"}]
|
||||
},
|
||||
{
|
||||
"body": "Samsung Galaxy S24 Ultra\nR$ 7.499,00 à vista\n12x R$ 691,58",
|
||||
"footer": "Câmera 200MP",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1610945265064-0e34e5519bbf?w=400",
|
||||
"buttons": [{"id": "buy_6", "text": "Comprar"}, {"id": "info_6", "text": "Detalhes"}]
|
||||
},
|
||||
{
|
||||
"body": "Sony WH-1000XM5\nR$ 2.299,00 à vista\n12x R$ 208,25",
|
||||
"footer": "Melhor ANC do mercado",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400",
|
||||
"buttons": [{"id": "buy_7", "text": "Comprar"}, {"id": "info_7", "text": "Detalhes"}]
|
||||
},
|
||||
{
|
||||
"body": "Nintendo Switch OLED\nR$ 2.699,00 à vista\n12x R$ 249,92",
|
||||
"footer": "Com Joy-Con",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1578303512597-81e6cc155b3e?w=400",
|
||||
"buttons": [{"id": "buy_8", "text": "Comprar"}, {"id": "info_8", "text": "Detalhes"}]
|
||||
},
|
||||
{
|
||||
"body": "PlayStation 5 Slim\nR$ 4.499,00 à vista\n12x R$ 416,58",
|
||||
"footer": "1TB SSD",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1606144042614-b2417e99c4e3?w=400",
|
||||
"buttons": [{"id": "buy_9", "text": "Comprar"}, {"id": "info_9", "text": "Detalhes"}]
|
||||
},
|
||||
{
|
||||
"body": "DJI Mini 4 Pro Drone\nR$ 6.999,00 à vista\n12x R$ 641,58",
|
||||
"footer": "4K 60fps",
|
||||
"imageUrl": "https://images.unsplash.com/photo-1473968512647-3e447244af8f?w=400",
|
||||
"buttons": [{"id": "buy_10", "text": "Comprar"}, {"id": "info_10", "text": "Detalhes"}]
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Parâmetros
|
||||
|
||||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
|---|---|---|---|
|
||||
| `text` | string | ✅ | Texto acima do carrossel |
|
||||
| `footer` | string | ❌ | Rodapé |
|
||||
| `cards` | array | ✅ | Cards (min 2, max 10) |
|
||||
| `cards[].body` | string | ✅ | Texto do card |
|
||||
| `cards[].footer` | string | ❌ | Rodapé do card |
|
||||
| `cards[].imageUrl` | string | ✅ | URL da imagem do card |
|
||||
| `cards[].buttons` | array | ✅ | Botões do card (max 2) |
|
||||
| `buttons[].id` | string | ✅ | ID do botão |
|
||||
| `buttons[].text` | string | ✅ | Texto do botão |
|
||||
|
||||
### Webhook recebido ao clicar em botão do carrossel
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"interactiveResponseMessage": {
|
||||
"body": { "text": "Comprar" },
|
||||
"nativeFlowResponseMessage": {
|
||||
"name": "quick_reply",
|
||||
"paramsJson": "{\"id\":\"buy_3\"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Como ler a resposta
|
||||
|
||||
```javascript
|
||||
function parseCarouselReply(msg) {
|
||||
const flow = msg.message?.interactiveResponseMessage?.nativeFlowResponseMessage
|
||||
if (flow?.name !== 'quick_reply') return null
|
||||
|
||||
const params = JSON.parse(flow.paramsJson || '{}')
|
||||
return {
|
||||
buttonId: params.id,
|
||||
buttonText: msg.message.interactiveResponseMessage.body?.text
|
||||
}
|
||||
}
|
||||
|
||||
// Exemplo: { buttonId: 'buy_3', buttonText: 'Comprar' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Como Consumir Respostas via Webhook
|
||||
|
||||
### 9.1 Estrutura geral do evento
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "messages.upsert",
|
||||
"instance": "minha-instancia",
|
||||
"data": {
|
||||
"messages": [{ ...mensagem... }],
|
||||
"type": "notify"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Roteador universal de respostas interativas
|
||||
|
||||
```javascript
|
||||
function parseInteractiveReply(msg) {
|
||||
const interactive = msg.message?.interactiveResponseMessage
|
||||
if (!interactive) return null
|
||||
|
||||
const flow = interactive.nativeFlowResponseMessage
|
||||
const body = interactive.body?.text
|
||||
const params = JSON.parse(flow?.paramsJson || '{}')
|
||||
|
||||
switch (flow?.name) {
|
||||
// Botão quick_reply ou carrossel
|
||||
case 'quick_reply':
|
||||
return { type: 'button', id: params.id, text: body }
|
||||
|
||||
// Lista dropdown
|
||||
case 'single_select':
|
||||
return { type: 'list', id: params.id, title: params.title }
|
||||
|
||||
// CTA — abrir URL
|
||||
case 'cta_open_url':
|
||||
return { type: 'url', url: params.url }
|
||||
|
||||
// CTA — copiar código
|
||||
case 'cta_copy':
|
||||
return { type: 'copy', code: params.copy_code }
|
||||
|
||||
// CTA — ligar
|
||||
case 'cta_call':
|
||||
return { type: 'call', phone: params.phone_number }
|
||||
|
||||
default:
|
||||
return { type: 'unknown', name: flow?.name, params }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Enquete — roteador
|
||||
|
||||
```javascript
|
||||
function parsePollUpdate(msg) {
|
||||
const poll = msg.message?.pollUpdateMessage
|
||||
if (!poll) return null
|
||||
|
||||
return {
|
||||
type: 'poll_vote',
|
||||
pollId: poll.pollCreationMessageKey?.id,
|
||||
selectedOptions: poll.vote?.selectedOptions || []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.4 Handler completo de webhook
|
||||
|
||||
```javascript
|
||||
app.post('/webhook', (req, res) => {
|
||||
const { event, instance, data } = req.body
|
||||
|
||||
if (event !== 'messages.upsert') return res.sendStatus(200)
|
||||
|
||||
for (const msg of data.messages) {
|
||||
const from = msg.key.remoteJid.replace('@s.whatsapp.net', '')
|
||||
const fromMe = msg.key.fromMe
|
||||
|
||||
if (fromMe) continue // ignora mensagens enviadas pela própria instância
|
||||
|
||||
// Verificar tipo de resposta interativa
|
||||
const reply = parseInteractiveReply(msg) || parsePollUpdate(msg)
|
||||
|
||||
if (reply) {
|
||||
console.log(`[${instance}] Resposta de ${from}:`, reply)
|
||||
handleInteractiveReply(instance, from, reply)
|
||||
}
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
function handleInteractiveReply(instance, from, reply) {
|
||||
switch (reply.type) {
|
||||
case 'button':
|
||||
console.log(`Botão clicado: ${reply.id} — "${reply.text}"`)
|
||||
break
|
||||
case 'list':
|
||||
console.log(`Item selecionado: ${reply.id} — "${reply.title}"`)
|
||||
break
|
||||
case 'url':
|
||||
console.log(`URL aberta: ${reply.url}`)
|
||||
break
|
||||
case 'copy':
|
||||
console.log(`Código copiado: ${reply.code}`)
|
||||
break
|
||||
case 'poll_vote':
|
||||
console.log(`Voto em enquete ${reply.pollId}:`, reply.selectedOptions)
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Limites e Restrições
|
||||
|
||||
### Limites por tipo
|
||||
|
||||
| Tipo | Limite |
|
||||
|---|---|
|
||||
| Botões Quick Reply | Testado com até 16 botões |
|
||||
| Lista — seções | Máximo 10 seções |
|
||||
| Lista — itens por seção | Máximo 3 itens |
|
||||
| Lista — total de itens | Máximo 30 itens |
|
||||
| Lista — título de seção | Máximo 24 caracteres |
|
||||
| Lista — título de item | Máximo 24 caracteres |
|
||||
| Lista — descrição de item | Máximo 72 caracteres |
|
||||
| Lista — texto do botão | Máximo 20 caracteres |
|
||||
| Carrossel — cards | Mínimo 2, máximo 10 |
|
||||
| Carrossel — botões por card | Máximo 2 botões |
|
||||
| Poll — opções | Mínimo 2, máximo 12 |
|
||||
|
||||
### Restrições de conta
|
||||
|
||||
| Restrição | Detalhe |
|
||||
|---|---|
|
||||
| Conta hosted (Meta Business Cloud API) | Mensagens interativas podem ser bloqueadas pelo servidor WA — política do servidor, sem workaround |
|
||||
| Conta pessoal (não-Business) | Servidor WA pode rejeitar o envio de mensagens interativas |
|
||||
| Conta Business não verificada | Funciona, mas pode ter limitações de alcance |
|
||||
@@ -1,531 +0,0 @@
|
||||
# Mensagens View-Once (Visualização Única) — Guia de Implementação
|
||||
|
||||
**Data:** 2026-03-23
|
||||
**Versão:** 1.0
|
||||
|
||||
---
|
||||
|
||||
## 1. O que é View-Once
|
||||
|
||||
Mensagem de visualização única é um tipo especial de mídia (imagem, vídeo ou áudio) que o destinatário pode abrir apenas uma vez. Após abrir, o conteúdo some permanentemente.
|
||||
|
||||
---
|
||||
|
||||
> ## ⚠️ RESTRIÇÃO CRÍTICA — Contas Hosted (Meta Business Cloud API)
|
||||
>
|
||||
> **Contas registradas como "hosted" na Meta Business Cloud API têm view-once bloqueado pelo servidor do WhatsApp.**
|
||||
>
|
||||
> Essa restrição se aplica a **todos os clientes** (Android, iOS, WA Web) e **não há como contornar por código** — é uma política imposta diretamente pelo servidor WA para contas hosted.
|
||||
>
|
||||
> **Como identificar se sua conta é hosted:**
|
||||
> - A conta foi criada via Meta Business Cloud API (WABA)
|
||||
> - Aparece como "Meta" ou "Business Cloud" nas configurações do WhatsApp Business Manager
|
||||
>
|
||||
> **Contas não-hosted** (registradas diretamente via QR code ou pair code) **funcionam normalmente** com view-once.
|
||||
|
||||
---
|
||||
|
||||
**Comportamento em cada dispositivo após envio via API (contas não-hosted):**
|
||||
|
||||
| Dispositivo | O que aparece |
|
||||
|---|---|
|
||||
| Destinatário (Android/iOS) | Recebe a mídia com ícone de olhinho — pode abrir uma vez |
|
||||
| Android principal do remetente | Mostra na conversa que a mensagem foi enviada (ícone de olhinho) |
|
||||
| WA Web do remetente | "Aguardando mensagem. Abra o WhatsApp no seu celular." |
|
||||
|
||||
> **Nota:** A mensagem "Aguardando mensagem..." no WA Web é o comportamento correto e esperado quando o envio vem de um companion (API). É uma limitação do protocolo WhatsApp — o servidor só permite que o celular principal notifique companions com o conteúdo da view-once.
|
||||
|
||||
---
|
||||
|
||||
## 2. Como Enviar via API
|
||||
|
||||
### 2.1 Imagem
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_image \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "nome-da-instancia",
|
||||
"to": "5511999999999",
|
||||
"caption": "Olha só isso!",
|
||||
"imageUrl": "https://exemplo.com/foto.jpg",
|
||||
"viewOnce": true
|
||||
}'
|
||||
```
|
||||
|
||||
**Resposta de sucesso:**
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
### 2.2 Vídeo
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_video \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "nome-da-instancia",
|
||||
"to": "5511999999999",
|
||||
"caption": "Assista uma vez",
|
||||
"videoUrl": "https://exemplo.com/video.mp4",
|
||||
"viewOnce": true
|
||||
}'
|
||||
```
|
||||
|
||||
### 2.3 Áudio
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_audio \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "nome-da-instancia",
|
||||
"to": "5511999999999",
|
||||
"audioUrl": "https://exemplo.com/audio.ogg",
|
||||
"ptt": false,
|
||||
"viewOnce": true
|
||||
}'
|
||||
```
|
||||
|
||||
### 2.4 Usando Base64 (sem URL pública)
|
||||
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/messages/send_image \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: SEU_API_KEY" \
|
||||
-d '{
|
||||
"instance": "nome-da-instancia",
|
||||
"to": "5511999999999",
|
||||
"imageBase64": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB...",
|
||||
"viewOnce": true
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Como Receber via Webhook
|
||||
|
||||
Quando um contato envia uma mensagem de visualização única para o número conectado na API, o webhook recebe o evento com `key.isViewOnce = true`.
|
||||
|
||||
### 3.1 Estrutura do evento recebido
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "messages.upsert",
|
||||
"instance": "nome-da-instancia",
|
||||
"data": {
|
||||
"messages": [
|
||||
{
|
||||
"key": {
|
||||
"remoteJid": "5511999999999@s.whatsapp.net",
|
||||
"fromMe": false,
|
||||
"id": "3EB0ABCDEF123456",
|
||||
"isViewOnce": true
|
||||
},
|
||||
"message": {
|
||||
"viewOnceMessageV2": {
|
||||
"message": {
|
||||
"imageMessage": {
|
||||
"url": "https://mmg.whatsapp.net/...",
|
||||
"mimetype": "image/jpeg",
|
||||
"mediaKey": "base64...",
|
||||
"fileEncSha256": "base64...",
|
||||
"directPath": "/v/...",
|
||||
"viewOnce": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"messageTimestamp": 1742700000,
|
||||
"pushName": "João Silva"
|
||||
}
|
||||
],
|
||||
"type": "notify"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Como identificar que é view-once
|
||||
|
||||
O campo principal para identificar é `key.isViewOnce = true`. Existem dois caminhos para confirmar:
|
||||
|
||||
```javascript
|
||||
function isViewOnce(msg) {
|
||||
// Caminho 1: flag direta (mais simples)
|
||||
if (msg.key?.isViewOnce) return true
|
||||
|
||||
// Caminho 2: verificar o wrapper da mensagem
|
||||
const inner =
|
||||
msg.message?.viewOnceMessageV2?.message ||
|
||||
msg.message?.viewOnceMessage?.message ||
|
||||
msg.message?.viewOnceMessageV2Extension?.message
|
||||
|
||||
return !!(
|
||||
inner?.imageMessage?.viewOnce ||
|
||||
inner?.videoMessage?.viewOnce ||
|
||||
inner?.audioMessage?.viewOnce
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Como acessar a mídia
|
||||
|
||||
```javascript
|
||||
function getViewOnceMedia(msg) {
|
||||
const inner =
|
||||
msg.message?.viewOnceMessageV2?.message ||
|
||||
msg.message?.viewOnceMessage?.message ||
|
||||
msg.message?.viewOnceMessageV2Extension?.message
|
||||
|
||||
if (!inner) return null
|
||||
|
||||
if (inner.imageMessage) return { type: 'image', media: inner.imageMessage }
|
||||
if (inner.videoMessage) return { type: 'video', media: inner.videoMessage }
|
||||
if (inner.audioMessage) return { type: 'audio', media: inner.audioMessage }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Uso:
|
||||
const media = getViewOnceMedia(msg)
|
||||
if (media) {
|
||||
console.log(`View-once ${media.type}`)
|
||||
console.log('URL:', media.media.url)
|
||||
console.log('DirectPath:', media.media.directPath)
|
||||
console.log('MediaKey:', media.media.mediaKey) // necessário para download
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Como Exibir numa Ferramenta (Frontend/CRM)
|
||||
|
||||
### 4.1 Lógica de detecção e exibição
|
||||
|
||||
```javascript
|
||||
function renderMessage(msg) {
|
||||
if (!isViewOnce(msg)) {
|
||||
// Renderizar mensagem normal
|
||||
return renderNormalMessage(msg)
|
||||
}
|
||||
|
||||
const media = getViewOnceMedia(msg)
|
||||
if (!media) return
|
||||
|
||||
// Verificar se já foi aberta (controle local da ferramenta)
|
||||
const alreadyOpened = localStorage.getItem(`vo_${msg.key.id}`)
|
||||
|
||||
if (alreadyOpened) {
|
||||
// Mostrar placeholder de "já visualizado"
|
||||
return renderViewOncePlaceholder(media.type, msg.key.fromMe)
|
||||
}
|
||||
|
||||
if (msg.key.fromMe) {
|
||||
// Mensagem enviada por mim — nunca mostrar conteúdo
|
||||
return renderSentViewOnce(media.type)
|
||||
}
|
||||
|
||||
// Mensagem recebida — mostrar botão para "abrir uma vez"
|
||||
return renderViewOnceButton(media.type, msg)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Componente de exibição — mensagem recebida
|
||||
|
||||
```javascript
|
||||
function renderViewOnceButton(type, msg) {
|
||||
const icons = { image: '📷', video: '🎥', audio: '🎵' }
|
||||
const labels = { image: 'Foto', video: 'Vídeo', áudio: 'Áudio' }
|
||||
|
||||
return `
|
||||
<div class="view-once-bubble received">
|
||||
<span class="view-once-icon">${icons[type]}</span>
|
||||
<span class="view-once-label">${labels[type]} de visualização única</span>
|
||||
<button onclick="openViewOnce('${msg.key.id}', '${type}')">
|
||||
Abrir
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async function openViewOnce(msgId, type) {
|
||||
// 1. Marcar como aberta ANTES de mostrar (só uma chance)
|
||||
localStorage.setItem(`vo_${msgId}`, '1')
|
||||
|
||||
// 2. Baixar e mostrar a mídia
|
||||
const mediaUrl = await downloadViewOnceMedia(msgId)
|
||||
showMedia(type, mediaUrl)
|
||||
|
||||
// 3. Após fechar/visualizar, substituir pelo placeholder
|
||||
onMediaClosed(() => {
|
||||
replaceWithPlaceholder(msgId, type)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Componente de exibição — mensagem enviada
|
||||
|
||||
```javascript
|
||||
function renderSentViewOnce(type) {
|
||||
const labels = { image: 'Foto', video: 'Vídeo', audio: 'Áudio' }
|
||||
return `
|
||||
<div class="view-once-bubble sent">
|
||||
<span class="view-once-icon">👁️</span>
|
||||
<span>${labels[type]} de visualização única</span>
|
||||
<span class="view-once-status">Enviada</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Placeholder após visualização
|
||||
|
||||
```javascript
|
||||
function renderViewOncePlaceholder(type, fromMe) {
|
||||
const labels = { image: 'foto', video: 'vídeo', audio: 'áudio' }
|
||||
const text = fromMe
|
||||
? `Você enviou uma ${labels[type]} de visualização única`
|
||||
: `${labels[type]} de visualização única aberta`
|
||||
|
||||
return `
|
||||
<div class="view-once-bubble placeholder">
|
||||
<span class="view-once-icon">🚫</span>
|
||||
<span>${text}</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 CSS sugerido
|
||||
|
||||
```css
|
||||
.view-once-bubble {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.view-once-bubble.received {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.view-once-bubble.sent {
|
||||
background: #dcf8c6;
|
||||
color: #333;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.view-once-bubble.placeholder {
|
||||
background: #e8e8e8;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.view-once-bubble button {
|
||||
background: #25d366;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Código Implementado na API
|
||||
|
||||
### 5.1 Geração da mensagem — `src/Utils/messages.ts`
|
||||
|
||||
Quando `viewOnce: true` é passado, a mídia é encapsulada no wrapper moderno `viewOnceMessageV2` (campo 55 do proto) e o flag `viewOnce=true` é setado na mídia interna:
|
||||
|
||||
```typescript
|
||||
if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
|
||||
// Seta viewOnce=true na mensagem de mídia interna
|
||||
if (m.imageMessage) m.imageMessage.viewOnce = true
|
||||
else if (m.videoMessage) m.videoMessage.viewOnce = true
|
||||
else if (m.audioMessage) m.audioMessage.viewOnce = true
|
||||
// Usa viewOnceMessageV2 (campo 55) — formato atual do WA
|
||||
m = { viewOnceMessageV2: { message: m } }
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Envio — `src/Socket/messages-send.ts`
|
||||
|
||||
**Detecção de view-once real** (não confunde com botões/listas que também usam o wrapper `viewOnceMessage`):
|
||||
|
||||
```typescript
|
||||
// Detecta view-once real pela flag viewOnce na mídia interna.
|
||||
// viewOnceMessage* wrappers são também usados por botões/listas/carrosséis
|
||||
// (que carregam interactiveMessage dentro) — esses NÃO têm viewOnce=true na mídia.
|
||||
const viewOnceInner =
|
||||
message.viewOnceMessageV2?.message ||
|
||||
message.viewOnceMessage?.message ||
|
||||
message.viewOnceMessageV2Extension?.message
|
||||
const isViewOnceMsg = !!(
|
||||
viewOnceInner?.imageMessage?.viewOnce ||
|
||||
viewOnceInner?.videoMessage?.viewOnce ||
|
||||
viewOnceInner?.audioMessage?.viewOnce
|
||||
)
|
||||
|
||||
// Para view-once: DSM enviado apenas para o celular principal (device=0).
|
||||
// Companions (device>0) são omitidos — o servidor WA gera
|
||||
// <unavailable type="view_once"/> automaticamente para eles.
|
||||
const viewOnceMeRecipients = isViewOnceMsg
|
||||
? meRecipients.filter(jid => !jidDecode(jid)?.device)
|
||||
: meRecipients
|
||||
|
||||
// assertSessions apenas para quem vai realmente receber
|
||||
await assertSessions([...viewOnceMeRecipients, ...otherRecipients])
|
||||
|
||||
const [meNodes, otherNodes] = await Promise.all([
|
||||
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
|
||||
createParticipantNodes(otherRecipients, message, extraAttrs)
|
||||
])
|
||||
participants.push(...meNodes)
|
||||
participants.push(...otherNodes)
|
||||
|
||||
// phash recalculado com os participantes reais
|
||||
const phashRecipients = isViewOnceMsg
|
||||
? [...viewOnceMeRecipients, ...otherRecipients]
|
||||
: [...meRecipients, ...otherRecipients]
|
||||
if (phashRecipients.length > 0) {
|
||||
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
|
||||
}
|
||||
```
|
||||
|
||||
**`getMediaType()` — garante que vídeo e áudio view-once sejam entregues:**
|
||||
|
||||
```typescript
|
||||
const getMediaType = (message: proto.IMessage) => {
|
||||
// Desencapsula wrapper view-once antes de verificar o tipo de mídia.
|
||||
// Sem isso, message.videoMessage e message.audioMessage são undefined
|
||||
// (estão dentro do wrapper) e o atributo mediatype ficaria ausente no
|
||||
// enc node — o servidor WA descarta silenciosamente vídeo/áudio view-once.
|
||||
const inner =
|
||||
message.viewOnceMessage?.message ||
|
||||
message.viewOnceMessageV2?.message ||
|
||||
message.viewOnceMessageV2Extension?.message
|
||||
if (inner) {
|
||||
return getMediaType(inner)
|
||||
}
|
||||
|
||||
if (message.imageMessage) return 'image'
|
||||
else if (message.videoMessage) return 'video'
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Recepção — `src/Utils/decode-wa-message.ts`
|
||||
|
||||
Quando a API recebe uma view-once como companion (linked device), o servidor envia dois stanzas:
|
||||
|
||||
- **Stanza 1** (`enc`): conteúdo completo com metadados da mídia
|
||||
- **Stanza 2** (`unavailable type="view_once"`): sinal de sync
|
||||
|
||||
O stanza 1 é detectado e marcado corretamente:
|
||||
|
||||
```typescript
|
||||
// Detecta view-once na stanza 1 recebida por linked device.
|
||||
// O wrapper viewOnceMessage também é usado por mensagens interativas
|
||||
// (interactiveMessage, listMessage) — esses NÃO têm viewOnce=true na mídia.
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Fluxo Completo
|
||||
|
||||
```
|
||||
API envia view-once para destinatário
|
||||
│
|
||||
├── Protobuf gerado:
|
||||
│ viewOnceMessageV2 {
|
||||
│ message {
|
||||
│ imageMessage { viewOnce: true, url, mediaKey, ... }
|
||||
│ }
|
||||
│ }
|
||||
│
|
||||
├── Stanza enviado ao servidor WA:
|
||||
│ <message to="destinatario@s.whatsapp.net" type="media">
|
||||
│ <enc type="pkmsg">...viewOnceMessageV2 cifrado...</enc> ← para o destinatário
|
||||
│ <participants>
|
||||
│ <to jid="meu-numero:0@lid"> ← celular principal
|
||||
│ <enc type="msg">...DSM{viewOnceMessageV2}...</enc>
|
||||
│ </to>
|
||||
│ <!-- device>0 omitidos — servidor gera unavailable -->
|
||||
│ </participants>
|
||||
│ </message>
|
||||
│
|
||||
└── Distribuição pelo servidor:
|
||||
├── Destinatário → recebe enc → abre uma vez ✅
|
||||
├── Celular principal → recebe DSM → mostra "você enviou" ✅
|
||||
└── WA Web → recebe <unavailable> → "Aguardando..." ✅
|
||||
|
||||
|
||||
Destinatário envia view-once de volta para a API
|
||||
│
|
||||
├── Stanza recebido pela API (stanza 1 — enc com conteúdo):
|
||||
│ <message from="destinatario@s.whatsapp.net" type="media">
|
||||
│ <enc type="msg">...viewOnceMessageV2 cifrado...</enc>
|
||||
│ </message>
|
||||
│
|
||||
├── decode-wa-message.ts:
|
||||
│ └── Descriptografa → detecta viewOnce=true → seta key.isViewOnce=true
|
||||
│
|
||||
├── Stanza recebido (stanza 2 — unavailable, ignorado):
|
||||
│ <message from="...">
|
||||
│ <unavailable type="view_once"/>
|
||||
│ </message>
|
||||
│
|
||||
└── Webhook emitido:
|
||||
messages.upsert → msg.key.isViewOnce = true ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Limitações
|
||||
|
||||
| Situação | Comportamento | Motivo |
|
||||
|---|---|---|
|
||||
| **Conta hosted (Meta Business Cloud API)** | **View-once bloqueado para todos os clientes** | **Política do servidor WA para contas hosted — sem workaround** |
|
||||
| WA Web do remetente | "Aguardando mensagem. Abra no celular." | Servidor WA só aceita `<unavailable>` vindo do celular principal, não de companions |
|
||||
| View-once de documento/sticker | Não suportado | Limitação do protocolo WA — só imagem, vídeo e áudio |
|
||||
| `error_479` nos logs | Normal, não é erro | TcToken é atualizado após o envio de view-once |
|
||||
| Companion não consegue abrir | Por design | O conteúdo não é entregue para linked devices |
|
||||
|
||||
---
|
||||
|
||||
## 8. Checklist de Validação
|
||||
|
||||
**Envio:**
|
||||
- [ ] Destinatário recebe com ícone de olhinho
|
||||
- [ ] Destinatário consegue abrir a mídia
|
||||
- [ ] Após abrir, a mídia some
|
||||
- [ ] Celular principal do remetente mostra "você enviou" com ícone de olhinho
|
||||
- [ ] WA Web do remetente mostra "Aguardando mensagem..."
|
||||
- [ ] Funciona para imagem, vídeo e áudio
|
||||
- [ ] Logs mostram `📤 Message sent` (error_479 é normal)
|
||||
|
||||
**Recepção:**
|
||||
- [ ] Webhook recebe `key.isViewOnce = true`
|
||||
- [ ] `viewOnceMessageV2.message.imageMessage` (ou video/audio) está presente
|
||||
- [ ] API não crasha ao receber view-once de outro dispositivo
|
||||
- [ ] Mensagem interativa (botão/lista) enviada após view-once não é afetada
|
||||
+1
-1
@@ -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",
|
||||
}],
|
||||
|
||||
@@ -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
@@ -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)/)',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
Generated
-11742
File diff suppressed because it is too large
Load Diff
+24
-26
@@ -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.3",
|
||||
"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": {
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
{"version":[2,3000,1038147544]}
|
||||
{"version":[2,3000,1033846690]}
|
||||
|
||||
+24
-203
@@ -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, 1033846690]
|
||||
|
||||
export const UNAUTHORIZED_CODES = [401, 403, 419]
|
||||
|
||||
@@ -27,30 +25,13 @@ 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'
|
||||
|
||||
/**
|
||||
* Protocol mode: 'web' (default) or 'native' (experimental)
|
||||
* - web: WA\x06\x03 — standard WhatsApp Web protocol
|
||||
* - native: WAM\x05 — native Android protocol (may enable view-once media on companions)
|
||||
*
|
||||
* Set via BAILEYS_PROTOCOL env var:
|
||||
* BAILEYS_PROTOCOL=native
|
||||
*/
|
||||
const PROTOCOL_MODE = process.env.BAILEYS_PROTOCOL?.trim().toLowerCase() === 'native' ? 'native' : 'web'
|
||||
|
||||
export const DICT_VERSION = PROTOCOL_MODE === 'native' ? 5 : 3
|
||||
export const DICT_VERSION = 3
|
||||
export const KEY_BUNDLE_TYPE = Buffer.from([5])
|
||||
|
||||
// WA\x06\x03 = web protocol, WAM\x05 = native Android protocol
|
||||
export const NOISE_WA_HEADER = PROTOCOL_MODE === 'native'
|
||||
? Buffer.from([87, 65, 77, DICT_VERSION]) // WAM\x05
|
||||
: Buffer.from([87, 65, 6, DICT_VERSION]) // WA\x06\x03
|
||||
|
||||
export { PROTOCOL_MODE }
|
||||
export const NOISE_WA_HEADER = Buffer.from([87, 65, 6, DICT_VERSION]) // last is "DICT_VERSION"
|
||||
/** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */
|
||||
export const URL_REGEX = /https:\/\/(?![^:@\/\s]+:[^:@\/\s]+@)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(:\d+)?(\/[^\s]*)?/g
|
||||
|
||||
@@ -70,69 +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
|
||||
|
||||
/**
|
||||
* Resolves the default browser tuple from the BAILEYS_BROWSER env var.
|
||||
* Default: Android companion (SMB_ANDROID) — matches upstream PR #2201.
|
||||
* Pair code auto-detects Android and falls back to Chrome in socket.ts.
|
||||
*
|
||||
* unset / 'android' → Browsers.android('14')
|
||||
* 'android:15' → Browsers.android('15')
|
||||
* 'chrome' / 'macos' → Browsers.macOS('Chrome')
|
||||
*/
|
||||
const resolveDefaultBrowser = (): [string, string, string] => {
|
||||
const env = process.env.BAILEYS_BROWSER?.trim().toLowerCase()
|
||||
if (env === 'chrome' || env === 'macos') {
|
||||
return Browsers.macOS('Chrome')
|
||||
}
|
||||
|
||||
if (env?.startsWith('android:')) {
|
||||
const apiLevel = env.split(':')[1] || '14'
|
||||
return Browsers.android(apiLevel)
|
||||
}
|
||||
|
||||
return Browsers.android('14')
|
||||
}
|
||||
|
||||
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
|
||||
version: version as WAVersion,
|
||||
versionCheckIntervalMs: SIX_HOURS_MS,
|
||||
browser: resolveDefaultBrowser(),
|
||||
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,
|
||||
@@ -141,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 } = {
|
||||
@@ -169,22 +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',
|
||||
// View-once types — same upload path as regular media (server CDN routing TBD)
|
||||
'viewonce-image': '/mms/image',
|
||||
'viewonce-video': '/mms/video',
|
||||
'viewonce-audio': '/mms/audio'
|
||||
}
|
||||
|
||||
export const NEWSLETTER_MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
|
||||
image: '/newsletter/newsletter-image',
|
||||
video: '/newsletter/newsletter-video',
|
||||
document: '/newsletter/newsletter-document',
|
||||
audio: '/newsletter/newsletter-audio',
|
||||
sticker: '/newsletter/newsletter-image',
|
||||
'thumbnail-link': '/newsletter/newsletter-image'
|
||||
'biz-cover-photo': '/pps/biz-cover-photo'
|
||||
}
|
||||
|
||||
export const MEDIA_HKDF_KEY_MAPPING = {
|
||||
@@ -206,36 +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',
|
||||
// View-once types — same HKDF keys as regular types (recipients decrypt with normal keys)
|
||||
'viewonce-image': 'Image',
|
||||
'viewonce-video': 'Video',
|
||||
'viewonce-audio': 'Audio'
|
||||
'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
|
||||
|
||||
// Replenishment threshold: when server count drops below this, top-up back to INITIAL_PREKEY_COUNT
|
||||
export const MIN_PREKEY_COUNT = 200
|
||||
|
||||
// Initial pool size matching WA Business (CDP IDB capture: prekey-store = 812 on registration)
|
||||
// Rounded to 800 for cleanliness; replenishment always tops up to this value
|
||||
export const INITIAL_PREKEY_COUNT = 800
|
||||
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
|
||||
@@ -243,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+200
-726
File diff suppressed because it is too large
Load Diff
+232
-1044
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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[]) => {
|
||||
|
||||
+89
-476
@@ -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,26 +35,19 @@ import {
|
||||
decodePatches,
|
||||
decodeSyncdSnapshot,
|
||||
encodeSyncdPatch,
|
||||
ensureLTHashStateVersion,
|
||||
extractSyncdPatches,
|
||||
generateProfilePicture,
|
||||
getHistoryMsg,
|
||||
isAppStateSyncIrrecoverable,
|
||||
isMissingKeyError,
|
||||
MAX_SYNC_ATTEMPTS,
|
||||
newLTHashState,
|
||||
processSyncAction,
|
||||
resolveLidToPn
|
||||
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,
|
||||
@@ -69,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 {
|
||||
@@ -90,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>({
|
||||
@@ -143,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({
|
||||
@@ -308,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']!
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -361,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
|
||||
@@ -397,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
|
||||
@@ -454,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: {
|
||||
@@ -501,7 +381,10 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
content: [
|
||||
{
|
||||
tag: 'item',
|
||||
attrs: itemAttrs
|
||||
attrs: {
|
||||
action,
|
||||
jid
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -582,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
|
||||
)
|
||||
@@ -597,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)
|
||||
@@ -619,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
|
||||
}
|
||||
@@ -629,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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -713,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,17 +625,8 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
}, authState?.creds?.me?.id || 'resync-app-state')
|
||||
|
||||
const { onMutation } = newAppStateChunkHandler(isInitialSync)
|
||||
const lidMapping = signalRepository.lidMapping
|
||||
for (const key in globalMutationMap) {
|
||||
const mutation = globalMutationMap[key]
|
||||
if (!mutation) continue
|
||||
// Normalize LID→PN in sync action index[1] (chat/contact ID)
|
||||
if (mutation.index[1] && isAnyLidUser(mutation.index[1])) {
|
||||
const resolved = await resolveLidToPn(mutation.index[1], lidMapping, logger)
|
||||
if (resolved) mutation.index[1] = resolved
|
||||
}
|
||||
|
||||
onMutation(mutation)
|
||||
onMutation(globalMutationMap[key]!)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -781,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',
|
||||
@@ -810,7 +651,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
type: 'get',
|
||||
xmlns: 'w:profile:picture'
|
||||
},
|
||||
content
|
||||
content: tcTokenContent
|
||||
},
|
||||
timeoutMs
|
||||
)
|
||||
@@ -841,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',
|
||||
@@ -858,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: [
|
||||
{
|
||||
@@ -902,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',
|
||||
@@ -920,16 +741,12 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
})
|
||||
}
|
||||
|
||||
const handlePresenceUpdate = async ({ tag, attrs, content }: BinaryNode) => {
|
||||
const handlePresenceUpdate = ({ tag, attrs, content }: BinaryNode) => {
|
||||
let presence: PresenceData | undefined
|
||||
const rawJid = attrs.from
|
||||
const rawParticipant = attrs.participant || attrs.from
|
||||
if (!rawJid) {
|
||||
logger.warn({ attrs }, 'handlePresenceUpdate: jid (attrs.from) is missing, skipping')
|
||||
return
|
||||
}
|
||||
const jid = attrs.from
|
||||
const participant = attrs.participant || attrs.from
|
||||
|
||||
if (shouldIgnoreJid(rawJid) && rawJid !== S_WHATSAPP_NET) {
|
||||
if (shouldIgnoreJid(jid!) && jid !== S_WHATSAPP_NET) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -940,17 +757,12 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
}
|
||||
} else if (Array.isArray(content)) {
|
||||
const [firstChild] = content
|
||||
if (!firstChild) {
|
||||
logger.warn({ jid: rawJid }, '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'
|
||||
}
|
||||
|
||||
@@ -960,18 +772,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
}
|
||||
|
||||
if (presence) {
|
||||
if (!rawParticipant) {
|
||||
logger.warn({ jid: rawJid }, 'handlePresenceUpdate: participant is missing, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve LID→PN so consumers always see phone-number JIDs
|
||||
const lidMapping = signalRepository.lidMapping
|
||||
const [jid, participant] = await Promise.all([
|
||||
resolveLidToPn(rawJid, lidMapping, logger),
|
||||
resolveLidToPn(rawParticipant, lidMapping, logger)
|
||||
])
|
||||
|
||||
ev.emit('presence.update', { id: jid!, presences: { [participant!]: presence } })
|
||||
}
|
||||
}
|
||||
@@ -993,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
|
||||
@@ -1046,35 +846,28 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
undefined,
|
||||
logger
|
||||
)
|
||||
const lidMapping = signalRepository.lidMapping
|
||||
for (const key in mutationMap) {
|
||||
const mutation = mutationMap[key]!
|
||||
// Normalize LID→PN in sync action index[1] (chat/contact ID)
|
||||
if (mutation.index[1] && isAnyLidUser(mutation.index[1])) {
|
||||
const resolved = await resolveLidToPn(mutation.index[1], lidMapping, logger)
|
||||
if (resolved) mutation.index[1] = resolved
|
||||
}
|
||||
|
||||
onMutation(mutation)
|
||||
onMutation(mutationMap[key]!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** fetch AB props */
|
||||
/** sending non-abt props may fix QR scan fail if server expects */
|
||||
const fetchProps = async () => {
|
||||
//TODO: implement both protocol 1 and protocol 2 prop fetching, specially for abKey for WM
|
||||
const resultNode = await query({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: S_WHATSAPP_NET,
|
||||
xmlns: 'abt',
|
||||
xmlns: 'w',
|
||||
type: 'get'
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'props',
|
||||
attrs: {
|
||||
protocol: '1',
|
||||
...(authState?.creds?.lastPropHash ? { hash: authState.creds.lastPropHash } : {})
|
||||
protocol: '2',
|
||||
hash: authState?.creds?.lastPropHash || ''
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1286,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) {
|
||||
@@ -1356,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)
|
||||
|
||||
@@ -1398,12 +1138,8 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('CB:presence', (node: BinaryNode) => {
|
||||
handlePresenceUpdate(node).catch(err => onUnexpectedError(err, 'handling presence update'))
|
||||
})
|
||||
ws.on('CB:chatstate', (node: BinaryNode) => {
|
||||
handlePresenceUpdate(node).catch(err => onUnexpectedError(err, 'handling chatstate update'))
|
||||
})
|
||||
ws.on('CB:presence', handlePresenceUpdate)
|
||||
ws.on('CB:chatstate', handlePresenceUpdate)
|
||||
|
||||
ws.on('CB:ib,,dirty', async (node: BinaryNode) => {
|
||||
const { attrs } = getBinaryNodeChild(node, 'dirty')!
|
||||
@@ -1441,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
|
||||
@@ -1505,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)
|
||||
@@ -1521,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')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+11
-53
@@ -1,7 +1,7 @@
|
||||
import { proto } from '../../WAProto/index.js'
|
||||
import type { GroupMetadata, GroupParticipant, ParticipantAction, SocketConfig, WAMessageKey } from '../Types'
|
||||
import { WAMessageAddressingMode, WAMessageStubType } from '../Types'
|
||||
import { generateMessageIDV2, resolveLidToPn, unixTimestampSeconds } from '../Utils'
|
||||
import { generateMessageIDV2, unixTimestampSeconds } from '../Utils'
|
||||
import {
|
||||
type BinaryNode,
|
||||
getBinaryNodeChild,
|
||||
@@ -17,43 +17,6 @@ import { makeChatsSocket } from './chats'
|
||||
export const makeGroupsSocket = (config: SocketConfig) => {
|
||||
const sock = makeChatsSocket(config)
|
||||
const { authState, ev, query, upsertMessage } = sock
|
||||
const { signalRepository } = sock
|
||||
const { logger } = config
|
||||
|
||||
/** Normalize group metadata participant IDs from LID to PN */
|
||||
const normalizeGroupMetadata = async (metadata: GroupMetadata): Promise<GroupMetadata> => {
|
||||
const lidMapping = signalRepository.lidMapping
|
||||
|
||||
// Resolve all participant LIDs in parallel for better performance on large groups
|
||||
await Promise.all(metadata.participants.map(async (p) => {
|
||||
if (isLidUser(p.id)) {
|
||||
if (p.phoneNumber) {
|
||||
p.lid = p.id
|
||||
p.id = p.phoneNumber
|
||||
} else {
|
||||
const resolved = await resolveLidToPn(p.id, lidMapping, logger)
|
||||
if (resolved && resolved !== p.id) {
|
||||
p.lid = p.id
|
||||
p.id = resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Normalize owner/subjectOwner if LID (parallel)
|
||||
const [resolvedOwner, resolvedSubjectOwner] = await Promise.all([
|
||||
metadata.owner && isLidUser(metadata.owner)
|
||||
? (metadata.ownerPn || resolveLidToPn(metadata.owner, lidMapping, logger))
|
||||
: null,
|
||||
metadata.subjectOwner && isLidUser(metadata.subjectOwner)
|
||||
? (metadata.subjectOwnerPn || resolveLidToPn(metadata.subjectOwner, lidMapping, logger))
|
||||
: null
|
||||
])
|
||||
if (resolvedOwner) metadata.owner = resolvedOwner
|
||||
if (resolvedSubjectOwner) metadata.subjectOwner = resolvedSubjectOwner
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
const groupQuery = async (jid: string, type: 'get' | 'set', content: BinaryNode[]) =>
|
||||
query({
|
||||
@@ -68,7 +31,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
|
||||
|
||||
const groupMetadata = async (jid: string) => {
|
||||
const result = await groupQuery(jid, 'get', [{ tag: 'query', attrs: { request: 'interactive' } }])
|
||||
return normalizeGroupMetadata(extractGroupMetadata(result))
|
||||
return extractGroupMetadata(result)
|
||||
}
|
||||
|
||||
const groupFetchAllParticipating = async () => {
|
||||
@@ -95,15 +58,16 @@ export const makeGroupsSocket = (config: SocketConfig) => {
|
||||
if (groupsChild) {
|
||||
const groups = getBinaryNodeChildren(groupsChild, 'group')
|
||||
for (const groupNode of groups) {
|
||||
const meta = await normalizeGroupMetadata(extractGroupMetadata({
|
||||
const meta = extractGroupMetadata({
|
||||
tag: 'result',
|
||||
attrs: {},
|
||||
content: [groupNode]
|
||||
}))
|
||||
})
|
||||
data[meta.id] = meta
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: properly parse LID / PN DATA
|
||||
sock.ev.emit('groups.update', Object.values(data))
|
||||
|
||||
return data
|
||||
@@ -137,7 +101,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
|
||||
}))
|
||||
}
|
||||
])
|
||||
return normalizeGroupMetadata(extractGroupMetadata(result))
|
||||
return extractGroupMetadata(result)
|
||||
},
|
||||
groupLeave: async (id: string) => {
|
||||
await groupQuery('@g.us', 'set', [
|
||||
@@ -313,7 +277,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
|
||||
),
|
||||
groupGetInviteInfo: async (code: string) => {
|
||||
const results = await groupQuery('@g.us', 'get', [{ tag: 'invite', attrs: { code } }])
|
||||
return normalizeGroupMetadata(extractGroupMetadata(results))
|
||||
return extractGroupMetadata(results)
|
||||
},
|
||||
groupToggleEphemeral: async (jid: string, ephemeralExpiration: number) => {
|
||||
const content: BinaryNode = ephemeralExpiration
|
||||
@@ -343,40 +307,35 @@ export const extractGroupMetadata = (result: BinaryNode) => {
|
||||
let descId: string | undefined
|
||||
let descOwner: string | undefined
|
||||
let descOwnerPn: string | undefined
|
||||
let descOwnerUsername: string | undefined
|
||||
let descTime: number | undefined
|
||||
if (descChild) {
|
||||
desc = getBinaryNodeChildString(descChild, 'body')
|
||||
descOwner = descChild.attrs.participant ? jidNormalizedUser(descChild.attrs.participant) : undefined
|
||||
descOwnerPn = descChild.attrs.participant_pn ? jidNormalizedUser(descChild.attrs.participant_pn) : undefined
|
||||
descOwnerUsername = descChild.attrs.participant_username || undefined
|
||||
descTime = +descChild.attrs.t!
|
||||
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,
|
||||
subjectOwnerUsername: group.attrs.s_o_username,
|
||||
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,
|
||||
ownerUsername: group.attrs.creator_username || undefined,
|
||||
owner_country_code: group.attrs.creator_country_code,
|
||||
desc,
|
||||
descId,
|
||||
descOwner,
|
||||
descOwnerPn,
|
||||
descOwnerUsername,
|
||||
descTime,
|
||||
linkedParent: getBinaryNodeChild(group, 'linked_parent')?.attrs.jid || undefined,
|
||||
restrict: !!getBinaryNodeChild(group, 'locked'),
|
||||
@@ -391,7 +350,6 @@ export const extractGroupMetadata = (result: BinaryNode) => {
|
||||
id: attrs.jid!,
|
||||
phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined,
|
||||
lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined,
|
||||
username: attrs.participant_username || attrs.username || undefined,
|
||||
admin: (attrs.type || null) as GroupParticipant['admin']
|
||||
}
|
||||
}),
|
||||
|
||||
+1
-206
@@ -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
|
||||
|
||||
+272
-1900
File diff suppressed because it is too large
Load Diff
+92
-1283
File diff suppressed because it is too large
Load Diff
+11
-24
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,11 +110,11 @@ export const makeNewsletterSocket = (config: SocketConfig) => {
|
||||
},
|
||||
|
||||
newsletterMute: (jid: string) => {
|
||||
return executeWMexQuery({ input: { newsletter_id: jid, type: 'MUTE_ADMIN_ACTIVITY', value: 'OFF' } }, QueryIds.MUTE, XWAPaths.xwa2_newsletter_mute_v2)
|
||||
return executeWMexQuery({ newsletter_id: jid }, QueryIds.MUTE, XWAPaths.xwa2_newsletter_mute_v2)
|
||||
},
|
||||
|
||||
newsletterUnmute: (jid: string) => {
|
||||
return executeWMexQuery({ input: { newsletter_id: jid, type: 'MUTE_ADMIN_ACTIVITY', value: 'ON' } }, QueryIds.UNMUTE, XWAPaths.xwa2_newsletter_unmute_v2)
|
||||
return executeWMexQuery({ newsletter_id: jid }, QueryIds.UNMUTE, XWAPaths.xwa2_newsletter_unmute_v2)
|
||||
},
|
||||
|
||||
newsletterUpdateName: async (jid: string, name: string) => {
|
||||
|
||||
+124
-831
File diff suppressed because it is too large
Load Diff
+1
-3
@@ -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; realIssueTimestamp?: number | null }
|
||||
/** 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 = {
|
||||
|
||||
+2
-45
@@ -1,34 +1,9 @@
|
||||
export type WACallUpdateType =
|
||||
| 'offer'
|
||||
| 'ringing'
|
||||
| 'timeout'
|
||||
| 'reject'
|
||||
| 'accept'
|
||||
| 'terminate'
|
||||
| 'preaccept'
|
||||
| 'transport'
|
||||
| 'relaylatency'
|
||||
| 'group_update'
|
||||
| 'reminder'
|
||||
| 'heartbeat'
|
||||
| 'mute_v2'
|
||||
| 'enc_rekey'
|
||||
| 'video'
|
||||
| 'relay'
|
||||
|
||||
export type WACallParticipant = {
|
||||
jid?: string
|
||||
/** 'connected' | 'invited' | 'left' etc. */
|
||||
state?: string
|
||||
/** Phone number in s.whatsapp.net format */
|
||||
userPn?: string
|
||||
/** 'admin' for group call link creator */
|
||||
type?: string
|
||||
}
|
||||
export type WACallUpdateType = 'offer' | 'ringing' | 'timeout' | 'reject' | 'accept' | 'terminate'
|
||||
|
||||
export type WACallEvent = {
|
||||
chatId: string
|
||||
from: string
|
||||
callerPn?: string
|
||||
isGroup?: boolean
|
||||
groupJid?: string
|
||||
id: string
|
||||
@@ -37,22 +12,4 @@ export type WACallEvent = {
|
||||
status: WACallUpdateType
|
||||
offline: boolean
|
||||
latencyMs?: number
|
||||
/** Phone number of the caller (sanitized to fix Brazilian landline bug) */
|
||||
callerPn?: string
|
||||
/** Call link token (forms URL: https://call.whatsapp.com/video/<token>) */
|
||||
linkToken?: string
|
||||
/** JID of who created the call link */
|
||||
linkCreator?: string
|
||||
/** Phone number of link creator */
|
||||
linkCreatorPn?: string
|
||||
/** Media type: 'video' or 'audio' */
|
||||
media?: string
|
||||
/** Max participants for group/link calls */
|
||||
connectedLimit?: number
|
||||
/** Participants in a group/link call */
|
||||
participants?: WACallParticipant[]
|
||||
/** Call duration in ms (from terminate/call_summary) */
|
||||
duration?: number
|
||||
/** Terminate reason (e.g. 'group_call_ended', 'accepted_elsewhere', 'timeout') */
|
||||
terminateReason?: string
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ export interface Contact {
|
||||
name?: string
|
||||
/** name of the contact, the contact has set on their own on WA */
|
||||
notify?: string
|
||||
/** username associated with this contact, when provided by WA */
|
||||
username?: string
|
||||
/** I have no idea */
|
||||
verifiedName?: string
|
||||
// Baileys Added
|
||||
|
||||
+2
-65
@@ -27,31 +27,17 @@ export type BaileysEventMap = {
|
||||
chats: Chat[]
|
||||
contacts: Contact[]
|
||||
messages: WAMessage[]
|
||||
/** Past participants for group chats (people who left/were removed). userJid is always a phone number (PN), never a LID. */
|
||||
pastParticipants?: proto.IPastParticipants[] | null
|
||||
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 */
|
||||
@@ -81,7 +67,6 @@ export type BaileysEventMap = {
|
||||
id: string
|
||||
author: string
|
||||
authorPn?: string
|
||||
authorUsername?: string
|
||||
participants: GroupParticipant[]
|
||||
action: ParticipantAction
|
||||
}
|
||||
@@ -89,7 +74,6 @@ export type BaileysEventMap = {
|
||||
id: string
|
||||
author: string
|
||||
authorPn?: string
|
||||
authorUsername?: string
|
||||
participant: string
|
||||
participantPn?: string
|
||||
action: RequestJoinAction
|
||||
@@ -124,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 }
|
||||
@@ -151,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,13 +130,10 @@ export type BufferedEventData = {
|
||||
chats: { [jid: string]: Chat }
|
||||
contacts: { [jid: string]: Contact }
|
||||
messages: { [uqId: string]: WAMessage }
|
||||
/** Keyed by groupJid for O(1) deduplication across chunks */
|
||||
pastParticipants: { [groupJid: string]: proto.IPastParticipant[] }
|
||||
empty: boolean
|
||||
isLatest: boolean
|
||||
progress?: number | null
|
||||
syncType?: proto.HistorySync.HistorySyncType
|
||||
chunkOrder?: number | null
|
||||
peerDataRequestSessionId?: string
|
||||
}
|
||||
chatUpserts: { [jid: string]: Chat }
|
||||
@@ -209,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
|
||||
|
||||
@@ -20,20 +20,17 @@ export interface GroupMetadata {
|
||||
addressingMode?: WAMessageAddressingMode
|
||||
owner: string | undefined
|
||||
ownerPn?: string | undefined
|
||||
ownerUsername?: string | undefined
|
||||
owner_country_code?: string | undefined
|
||||
subject: string
|
||||
/** group subject owner */
|
||||
subjectOwner?: string
|
||||
subjectOwnerPn?: string
|
||||
subjectOwnerUsername?: string
|
||||
/** group subject modification date */
|
||||
subjectTime?: number
|
||||
creation?: number
|
||||
desc?: string
|
||||
descOwner?: string
|
||||
descOwnerPn?: string
|
||||
descOwnerUsername?: string
|
||||
descId?: string
|
||||
descTime?: number
|
||||
/** if this group is part of a community, it returns the jid of the community to which it belongs */
|
||||
@@ -59,7 +56,6 @@ export interface GroupMetadata {
|
||||
/** the person who added you to group or changed some setting in group */
|
||||
author?: string
|
||||
authorPn?: string
|
||||
authorUsername?: string
|
||||
}
|
||||
|
||||
export interface WAGroupCreateResponse {
|
||||
|
||||
+4
-677
@@ -19,23 +19,11 @@ export type WAContactMessage = proto.Message.IContactMessage
|
||||
export type WAContactsArrayMessage = proto.Message.IContactsArrayMessage
|
||||
export type WAMessageKey = proto.IMessageKey & {
|
||||
remoteJidAlt?: string
|
||||
remoteJidUsername?: string
|
||||
participantAlt?: string
|
||||
participantUsername?: string
|
||||
server_id?: string
|
||||
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
|
||||
@@ -51,61 +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 (WebP or Lottie/WAS format) */
|
||||
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
|
||||
/** Force Lottie format detection (auto-detected if omitted) */
|
||||
isLottie?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -157,7 +90,6 @@ export type MessageReceiptType =
|
||||
| 'sender'
|
||||
| 'inactive'
|
||||
| 'played'
|
||||
| 'view_once_read'
|
||||
| undefined
|
||||
|
||||
export type MediaConnInfo = {
|
||||
@@ -181,8 +113,6 @@ export interface WAUrlInfo {
|
||||
type Mentionable = {
|
||||
/** list of jids that are mentioned in the accompanying text */
|
||||
mentions?: string[]
|
||||
/** mention all participants in the group */
|
||||
mentionAll?: boolean
|
||||
}
|
||||
type Contextable = {
|
||||
/** add contextInfo to the message */
|
||||
@@ -265,9 +195,7 @@ export type AnyMediaMessageContent = (
|
||||
fileName?: string
|
||||
caption?: string
|
||||
} & Contextable)
|
||||
) & { mimetype?: string } & Editable &
|
||||
Partial<Buttonable> &
|
||||
Partial<Templatable>
|
||||
) & { mimetype?: string } & Editable
|
||||
|
||||
export type ButtonReplyInfo = {
|
||||
displayText: string
|
||||
@@ -287,454 +215,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 }
|
||||
| ({
|
||||
@@ -776,158 +263,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
|
||||
) &
|
||||
@@ -995,16 +330,8 @@ export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions &
|
||||
|
||||
export type WAMediaUploadFunction = (
|
||||
encFilePath: string,
|
||||
opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number; newsletter?: boolean }
|
||||
) => Promise<{
|
||||
mediaUrl: string | undefined
|
||||
directPath: string
|
||||
meta_hmac?: string
|
||||
ts?: number
|
||||
fbid?: number
|
||||
thumbnailDirectPath?: string
|
||||
thumbnailSha256?: string
|
||||
}>
|
||||
opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number }
|
||||
) => Promise<{ mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number }>
|
||||
|
||||
export type MediaGenerationOptions = {
|
||||
logger?: ILogger
|
||||
|
||||
@@ -4,10 +4,10 @@ export enum XWAPaths {
|
||||
xwa2_newsletter_view = 'xwa2_newsletter_view',
|
||||
xwa2_newsletter_metadata = 'xwa2_newsletter',
|
||||
xwa2_newsletter_admin_count = 'xwa2_newsletter_admin',
|
||||
xwa2_newsletter_mute_v2 = 'xwa2_newsletter_update_user_setting',
|
||||
xwa2_newsletter_unmute_v2 = 'xwa2_newsletter_update_user_setting',
|
||||
xwa2_newsletter_follow = 'xwa2_newsletter_join_v2',
|
||||
xwa2_newsletter_unfollow = 'xwa2_newsletter_leave_v2',
|
||||
xwa2_newsletter_mute_v2 = 'xwa2_newsletter_mute_v2',
|
||||
xwa2_newsletter_unmute_v2 = 'xwa2_newsletter_unmute_v2',
|
||||
xwa2_newsletter_follow = 'xwa2_newsletter_follow',
|
||||
xwa2_newsletter_unfollow = 'xwa2_newsletter_unfollow',
|
||||
xwa2_newsletter_change_owner = 'xwa2_newsletter_change_owner',
|
||||
xwa2_newsletter_demote = 'xwa2_newsletter_demote',
|
||||
xwa2_newsletter_delete_v2 = 'xwa2_newsletter_delete_v2'
|
||||
@@ -17,10 +17,10 @@ export enum QueryIds {
|
||||
UPDATE_METADATA = '24250201037901610',
|
||||
METADATA = '6563316087068696',
|
||||
SUBSCRIBERS = '9783111038412085',
|
||||
FOLLOW = '24404358912487870',
|
||||
UNFOLLOW = '9767147403369991',
|
||||
MUTE = '31938993655691868',
|
||||
UNMUTE = '31938993655691868',
|
||||
FOLLOW = '7871414976211147',
|
||||
UNFOLLOW = '7238632346214362',
|
||||
MUTE = '29766401636284406',
|
||||
UNMUTE = '9864994326891137',
|
||||
ADMIN_COUNT = '7130823597031706',
|
||||
CHANGE_OWNER = '7341777602580933',
|
||||
DEMOTE = '6551828931592903',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'
|
||||
@@ -36,8 +35,7 @@ export enum DisconnectReason {
|
||||
restartRequired = 515,
|
||||
multideviceMismatch = 411,
|
||||
forbidden = 403,
|
||||
unavailableService = 503,
|
||||
sessionInvalidated = 516
|
||||
unavailableService = 503
|
||||
}
|
||||
|
||||
export type WAInitResponse = {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+30
-389
@@ -1,406 +1,34 @@
|
||||
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 (name→value and value→name).
|
||||
* 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()])
|
||||
}
|
||||
}
|
||||
|
||||
const map = new Map(entries)
|
||||
// ANDROID → ANDROID_PHONE alias (DeviceProps.PlatformType has ANDROID_PHONE but not ANDROID)
|
||||
if (!map.has('ANDROID') && map.has('ANDROID_PHONE')) {
|
||||
map.set('ANDROID', map.get('ANDROID_PHONE')!)
|
||||
}
|
||||
|
||||
return map
|
||||
})()
|
||||
|
||||
// ============================================================
|
||||
// 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()],
|
||||
android: (apiLevel: string): [string, string, string] => [apiLevel, 'Android', '']
|
||||
} 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()],
|
||||
/** Android companion device. apiLevel is the Android API level (e.g. '14') */
|
||||
android: (apiLevel: string) => [apiLevel, 'Android', '']
|
||||
}
|
||||
|
||||
/**
|
||||
* 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', 'android')
|
||||
*
|
||||
* @example
|
||||
* isValidBrowserPreset('ubuntu') // true
|
||||
* isValidBrowserPreset('macOS') // true
|
||||
* isValidBrowserPreset('invalid') // false
|
||||
* isValidBrowserPreset('toString') // false (inherited property)
|
||||
*
|
||||
* @example
|
||||
* if (isValidBrowserPreset(userInput)) {
|
||||
* const config = Browsers[userInput]('MyApp')
|
||||
* }
|
||||
*/
|
||||
/**
|
||||
* Checks if the browser tuple represents an Android companion device.
|
||||
*
|
||||
* @param browser - Browser tuple [os, platform, version]
|
||||
* @returns True if platform is 'Android' (case-insensitive)
|
||||
*/
|
||||
@@ -408,6 +36,19 @@ export const isAndroidBrowser = (browser: [string, string, string]): boolean =>
|
||||
return browser[1]?.toUpperCase() === 'ANDROID'
|
||||
}
|
||||
|
||||
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]
|
||||
if (platformType !== undefined) {
|
||||
return platformType.toString()
|
||||
}
|
||||
|
||||
// 'ANDROID' is not in the PlatformType enum — map to ANDROID_PHONE
|
||||
if (browser.toUpperCase() === 'ANDROID') {
|
||||
const androidPhone = proto.DeviceProps.PlatformType['ANDROID_PHONE' as any]
|
||||
if (androidPhone !== undefined) {
|
||||
return androidPhone.toString()
|
||||
}
|
||||
}
|
||||
|
||||
return '1' // Chrome
|
||||
}
|
||||
|
||||
@@ -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
-226
@@ -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', {
|
||||
@@ -1083,7 +936,6 @@ export const processSyncAction = (
|
||||
action.lidContactAction.firstName ||
|
||||
action.lidContactAction.username ||
|
||||
undefined,
|
||||
username: action.lidContactAction.username || undefined,
|
||||
lid: id!,
|
||||
phoneNumber: undefined
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
+31
-203
@@ -13,12 +13,11 @@ import {
|
||||
isJidNewsletter,
|
||||
isJidStatusBroadcast,
|
||||
isLidUser,
|
||||
isPnUser,
|
||||
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)) {
|
||||
@@ -52,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 = {
|
||||
@@ -106,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 =
|
||||
@@ -169,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
|
||||
|
||||
@@ -183,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')
|
||||
@@ -209,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 {
|
||||
@@ -242,14 +196,10 @@ export function decodeMessageNode(stanza: BinaryNode, meId: string, meLid: strin
|
||||
const key: WAMessageKey = {
|
||||
remoteJid: chatId,
|
||||
remoteJidAlt: !isJidGroup(chatId) ? addressingContext.senderAlt : undefined,
|
||||
remoteJidUsername: !isJidGroup(chatId)
|
||||
? stanza.attrs.peer_recipient_username || stanza.attrs.recipient_username
|
||||
: undefined,
|
||||
fromMe,
|
||||
id: msgId,
|
||||
participant,
|
||||
participantAlt: isJidGroup(chatId) ? addressingContext.senderAlt : undefined,
|
||||
participantUsername: stanza.attrs.participant ? stanza.attrs.participant_username : undefined,
|
||||
addressingMode: addressingContext.addressingMode,
|
||||
...(msgType === 'newsletter' && stanza.attrs.server_id ? { server_id: stanza.attrs.server_id } : {})
|
||||
}
|
||||
@@ -257,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)
|
||||
}
|
||||
@@ -278,7 +228,7 @@ export const decryptMessageNode = (
|
||||
meId: string,
|
||||
meLid: string,
|
||||
repository: SignalRepositoryWithLIDStore,
|
||||
logger: ILogger,
|
||||
logger: ILogger
|
||||
) => {
|
||||
const { fullMessage, author, sender } = decodeMessageNode(stanza, meId, meLid)
|
||||
return {
|
||||
@@ -291,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') {
|
||||
@@ -327,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
|
||||
@@ -396,87 +319,20 @@ 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 after ${err.attempts} attempts. Retry+pkmsg flow will recover.`
|
||||
)
|
||||
} else {
|
||||
// First occurrence - log as warning since auto-recovery will attempt
|
||||
logger.warn(errorContext, '⚠️ Corrupted session detected - attempting auto-recovery')
|
||||
}
|
||||
|
||||
// Session cleanup is deferred to retry exhaustion (safety net).
|
||||
// The Signal Protocol handles recovery naturally via retry+pkmsg:
|
||||
// Bad MAC -> retry receipt -> sender re-sends as pkmsg -> new session.
|
||||
// Deleting sessions here (hot path) causes cascading failures when
|
||||
// multiple messages from the same contact arrive simultaneously.
|
||||
// See: messages-recv.ts sendRetryRequest() for deferred cleanup.
|
||||
} 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()]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,31 +353,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 for a specific device JID.
|
||||
* WABA behavior: DELETE sessions WHERE recipient_id=? AND device_id=?
|
||||
* Only deletes the exact device that was corrupted, not all devices.
|
||||
*
|
||||
* NOTE: This should NOT be called on every Bad MAC error (hot path).
|
||||
* Instead, let the retry+pkmsg flow handle recovery naturally (like WhatsApp does).
|
||||
* Only call this as a safety net when retries are exhausted.
|
||||
*/
|
||||
export async function cleanupCorruptedSession(
|
||||
jid: string,
|
||||
repository: SignalRepositoryWithLIDStore,
|
||||
logger: ILogger
|
||||
): Promise<number> {
|
||||
await repository.deleteSession([jid])
|
||||
logger.info({ jid }, 'Cleaned up corrupted session for specific device')
|
||||
return 1
|
||||
}
|
||||
|
||||
+70
-803
File diff suppressed because it is too large
Load Diff
+34
-96
@@ -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, 1033846690]
|
||||
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
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -230,22 +235,14 @@ export const bindWaitForConnectionUpdate = (ev: BaileysEventEmitter) => bindWait
|
||||
* utility that fetches latest baileys version from the master branch.
|
||||
* Use to ensure your WA connection is always on the latest version
|
||||
*/
|
||||
export const fetchLatestBaileysVersion = async (options: RequestInit & { timeout?: number } = {}) => {
|
||||
export const fetchLatestBaileysVersion = async (options: RequestInit = {}) => {
|
||||
const URL = 'https://raw.githubusercontent.com/WhiskeySockets/Baileys/master/src/Defaults/index.ts'
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeout ?? 5000)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(URL, {
|
||||
dispatcher: options.dispatcher,
|
||||
method: 'GET',
|
||||
headers: options.headers,
|
||||
signal: controller.signal
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
const response = await fetch(URL, {
|
||||
dispatcher: options.dispatcher,
|
||||
method: 'GET',
|
||||
headers: options.headers
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Boom(`Failed to fetch latest Baileys version: ${response.statusText}`, { statusCode: response.status })
|
||||
}
|
||||
@@ -254,18 +251,10 @@ export const fetchLatestBaileysVersion = async (options: RequestInit & { timeout
|
||||
// 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,
|
||||
@@ -363,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) => {
|
||||
@@ -429,36 +397,6 @@ export const getCallStatusFromNode = ({ tag, attrs }: BinaryNode) => {
|
||||
case 'accept':
|
||||
status = 'accept'
|
||||
break
|
||||
case 'preaccept':
|
||||
status = 'preaccept'
|
||||
break
|
||||
case 'transport':
|
||||
status = 'transport'
|
||||
break
|
||||
case 'relaylatency':
|
||||
status = 'relaylatency'
|
||||
break
|
||||
case 'group_update':
|
||||
status = 'group_update'
|
||||
break
|
||||
case 'reminder':
|
||||
status = 'reminder'
|
||||
break
|
||||
case 'heartbeat':
|
||||
status = 'heartbeat'
|
||||
break
|
||||
case 'mute_v2':
|
||||
status = 'mute_v2'
|
||||
break
|
||||
case 'enc_rekey':
|
||||
status = 'enc_rekey'
|
||||
break
|
||||
case 'video':
|
||||
status = 'video'
|
||||
break
|
||||
case 'relay':
|
||||
status = 'relay'
|
||||
break
|
||||
default:
|
||||
status = 'ringing'
|
||||
break
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
+46
-343
@@ -3,30 +3,32 @@ 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'
|
||||
import { DEFAULT_ORIGIN } from '../Defaults'
|
||||
import { downloadContentFromMessage, getUrlFromDirectPath } from './messages-media'
|
||||
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[] = []
|
||||
@@ -43,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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,54 +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,
|
||||
username: 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 }]
|
||||
@@ -355,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]
|
||||
})
|
||||
}
|
||||
@@ -366,53 +121,23 @@ 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())
|
||||
|
||||
// Normalize pastParticipants: resolve LID userJids → PN so the consumer always
|
||||
// receives a phone number, never an opaque LID identifier.
|
||||
// Uses the lidPnMap built above (populated from phoneNumberToLidMappings + conversations).
|
||||
// If a LID cannot be resolved, the original value is kept rather than dropping the participant.
|
||||
const pastParticipants: proto.IPastParticipants[] = (item.pastParticipants ?? []).map(group => ({
|
||||
groupJid: group.groupJid,
|
||||
pastParticipants: (group.pastParticipants ?? []).map(participant => {
|
||||
const userJid = participant.userJid
|
||||
if (!userJid || !isAnyLidUser(userJid)) return participant
|
||||
const mapping = lidPnMap.get(jidNormalizedUser(userJid))
|
||||
return mapping?.pn ? { ...participant, userJid: mapping.pn } : participant
|
||||
})
|
||||
}))
|
||||
|
||||
return {
|
||||
chats,
|
||||
contacts,
|
||||
messages,
|
||||
pastParticipants,
|
||||
lidPnMappings,
|
||||
syncType: item.syncType,
|
||||
progress: item.progress
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -425,34 +150,12 @@ export const downloadAndProcessHistorySyncNotification = async (
|
||||
historyMsg = await downloadHistory(msg, options)
|
||||
}
|
||||
|
||||
const result = processHistoryMessage(historyMsg, logger)
|
||||
|
||||
// Mirror WA Desktop behaviour: DELETE the CDN blob only after processing succeeds.
|
||||
// Doing this earlier (e.g. inside downloadHistory) risks permanent history loss if
|
||||
// processing throws — the server copy would be gone and retry after reconnect would fail.
|
||||
if (msg.directPath) {
|
||||
const cdnUrl = getUrlFromDirectPath(msg.directPath)
|
||||
fetch(cdnUrl, {
|
||||
...options,
|
||||
method: 'DELETE',
|
||||
headers: { ...((options as RequestInit).headers ?? {}), Origin: DEFAULT_ORIGIN }
|
||||
}).catch(() => {
|
||||
// non-fatal — server will expire it anyway
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,4 @@ 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'
|
||||
|
||||
@@ -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
@@ -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()}"` })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,64 +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 session recreation, but rate-limited to prevent loops.
|
||||
// When multiple messages fail with Bad MAC simultaneously, only the first
|
||||
// should trigger recreation; subsequent ones within the cooldown window
|
||||
// piggyback on the same new session established by the retry+pkmsg flow.
|
||||
// IMMEDIATE recreation for MAC errors - session is definitely out of sync
|
||||
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
|
||||
const now = Date.now()
|
||||
const prevTime = this.sessionRecreateHistory.get(jid)
|
||||
const MAC_ERROR_COOLDOWN_MS = 1_000 // 1 second — WABA recovers faster
|
||||
|
||||
if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) {
|
||||
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
|
||||
this.logger.debug(
|
||||
{ jid, errorCode: reasonName, msSinceLast: now - prevTime },
|
||||
'MAC error session recreation skipped — cooldown active'
|
||||
)
|
||||
return {
|
||||
reason: '',
|
||||
recreate: false
|
||||
}
|
||||
}
|
||||
|
||||
this.sessionRecreateHistory.set(jid, now)
|
||||
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, recreating session')
|
||||
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)
|
||||
|
||||
@@ -269,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
|
||||
@@ -280,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 === '') {
|
||||
@@ -295,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
|
||||
*/
|
||||
|
||||
+30
-82
@@ -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'
|
||||
@@ -10,13 +10,7 @@ import { join } from 'path'
|
||||
import { Readable, Transform } from 'stream'
|
||||
import { URL } from 'url'
|
||||
import { proto } from '../../WAProto/index.js'
|
||||
import {
|
||||
DEFAULT_ORIGIN,
|
||||
MEDIA_HKDF_KEY_MAPPING,
|
||||
MEDIA_PATH_MAP,
|
||||
type MediaType,
|
||||
NEWSLETTER_MEDIA_PATH_MAP
|
||||
} from '../Defaults'
|
||||
import { DEFAULT_ORIGIN, MEDIA_HKDF_KEY_MAPPING, MEDIA_PATH_MAP, type MediaType } from '../Defaults'
|
||||
import type {
|
||||
BaileysEventMap,
|
||||
DownloadableMessage,
|
||||
@@ -37,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(() => {})])
|
||||
|
||||
@@ -104,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')
|
||||
}
|
||||
@@ -133,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) => {
|
||||
@@ -166,7 +152,7 @@ export const extractImageThumb = async (bufferOrFilePath: Readable | Buffer | st
|
||||
height: dimensions.height
|
||||
}
|
||||
}
|
||||
} else if ('jimp' in lib && typeof lib.jimp?.Jimp === 'function') {
|
||||
} else if ('jimp' in lib && typeof lib.jimp?.Jimp === 'object') {
|
||||
const jimp = await (lib.jimp.Jimp as any).read(bufferOrFilePath)
|
||||
const dimensions = {
|
||||
width: jimp.width,
|
||||
@@ -394,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)
|
||||
@@ -424,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')
|
||||
|
||||
@@ -547,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)
|
||||
}
|
||||
@@ -605,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))
|
||||
|
||||
@@ -687,10 +666,6 @@ type MediaUploadResult = {
|
||||
meta_hmac?: string
|
||||
ts?: number
|
||||
fbid?: number
|
||||
thumbnail_info?: {
|
||||
thumbnail_sha256?: string
|
||||
thumbnail_direct_path?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type UploadParams = {
|
||||
@@ -835,21 +810,11 @@ export const getWAUploadToServer = (
|
||||
{ customUploadHosts, fetchAgent, logger, options }: SocketConfig,
|
||||
refreshMediaConn: (force: boolean) => Promise<MediaConnInfo>
|
||||
): WAMediaUploadFunction => {
|
||||
return async (filePath, { mediaType, fileEncSha256B64, timeoutMs, newsletter }) => {
|
||||
return async (filePath, { mediaType, fileEncSha256B64, timeoutMs }) => {
|
||||
// send a query JSON to obtain the url & auth token to upload our media
|
||||
let uploadInfo = await refreshMediaConn(false)
|
||||
|
||||
let urls:
|
||||
| {
|
||||
mediaUrl: string
|
||||
directPath: string
|
||||
meta_hmac?: string
|
||||
ts?: number
|
||||
fbid?: number
|
||||
thumbnailDirectPath?: string
|
||||
thumbnailSha256?: string
|
||||
}
|
||||
| undefined
|
||||
let urls: { mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number } | undefined
|
||||
const hosts = [...customUploadHosts, ...uploadInfo.hosts]
|
||||
|
||||
fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64)
|
||||
@@ -871,11 +836,7 @@ export const getWAUploadToServer = (
|
||||
logger.debug(`uploading to "${hostname}"`)
|
||||
|
||||
const auth = encodeURIComponent(uploadInfo.auth)
|
||||
const mediaPath = (newsletter ? NEWSLETTER_MEDIA_PATH_MAP[mediaType] : undefined) || MEDIA_PATH_MAP[mediaType]
|
||||
let url = `https://${hostname}${mediaPath}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
|
||||
if (newsletter) {
|
||||
url += '&server_thumb_gen=1'
|
||||
}
|
||||
const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
|
||||
|
||||
let result: MediaUploadResult | undefined
|
||||
try {
|
||||
@@ -892,13 +853,11 @@ export const getWAUploadToServer = (
|
||||
|
||||
if (result?.url || result?.direct_path) {
|
||||
urls = {
|
||||
mediaUrl: result.url || result.direct_path!,
|
||||
mediaUrl: result.url!,
|
||||
directPath: result.direct_path!,
|
||||
meta_hmac: result.meta_hmac,
|
||||
fbid: result.fbid,
|
||||
ts: result.ts,
|
||||
thumbnailDirectPath: result.thumbnail_info?.thumbnail_direct_path,
|
||||
thumbnailSha256: result.thumbnail_info?.thumbnail_sha256
|
||||
ts: result.ts
|
||||
}
|
||||
break
|
||||
} else {
|
||||
@@ -930,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'
|
||||
},
|
||||
@@ -967,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
|
||||
@@ -980,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: {
|
||||
@@ -996,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)
|
||||
|
||||
+43
-1260
File diff suppressed because it is too large
Load Diff
+12
-28
@@ -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
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
+53
-305
@@ -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,
|
||||
@@ -19,13 +16,10 @@ import type {
|
||||
WAMessage,
|
||||
WAMessageKey
|
||||
} from '../Types'
|
||||
import type { LIDMappingStore } from '../Signal/lid-mapping'
|
||||
import { WAMessageStubType } from '../Types'
|
||||
import { getContentType, normalizeMessageContent } from '../Utils/messages'
|
||||
import {
|
||||
areJidsSameUser,
|
||||
isAnyLidUser,
|
||||
isAnyPnUser,
|
||||
isHostedLidUser,
|
||||
isHostedPnUser,
|
||||
isJidBroadcast,
|
||||
@@ -39,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
|
||||
@@ -86,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) {
|
||||
@@ -116,116 +103,18 @@ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) =>
|
||||
msgKey.remoteJid = message.key.remoteJid
|
||||
// set participant of the message
|
||||
msgKey.participant = msgKey.participant || message.key.participant
|
||||
} else {
|
||||
// fromMe reactions/polls: normalise remoteJid to match the chat JID
|
||||
// ensures DM reaction keys are consistent with group behavior
|
||||
msgKey.remoteJid = message.key.remoteJid
|
||||
// in groups, normalise participant for own messages too
|
||||
if (message.key.participant) {
|
||||
msgKey.participant = msgKey.participant || message.key.participant
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a LID JID to its PN equivalent using the LID mapping store.
|
||||
* Returns the original JID if it's not a LID or if no mapping is found.
|
||||
* Safe to call with any JID type (group, newsletter, PN, etc.).
|
||||
*/
|
||||
export const resolveLidToPn = async (
|
||||
jid: string | undefined | null,
|
||||
lidMapping: LIDMappingStore,
|
||||
logger?: ILogger
|
||||
): Promise<string | undefined> => {
|
||||
if (!jid) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (isAnyLidUser(jid)) {
|
||||
const pn = await lidMapping.getPNForLID(jid)
|
||||
if (pn) {
|
||||
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN')
|
||||
}
|
||||
|
||||
return pn || jid
|
||||
}
|
||||
|
||||
return jid
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a WAMessageKey by resolving LID→PN for remoteJid and participant.
|
||||
*/
|
||||
export const normalizeKeyLidToPn = async (
|
||||
key: WAMessageKey,
|
||||
lidMapping: LIDMappingStore,
|
||||
logger?: ILogger
|
||||
): Promise<void> => {
|
||||
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([
|
||||
resolveLidToPn(key.remoteJid, lidMapping, logger),
|
||||
resolveLidToPn(key.participant, lidMapping, logger)
|
||||
])
|
||||
if (resolvedRemoteJid) {
|
||||
key.remoteJid = resolvedRemoteJid
|
||||
}
|
||||
|
||||
if (resolvedParticipant) {
|
||||
key.participant = resolvedParticipant
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeMessageJids = async (
|
||||
message: WAMessage,
|
||||
signalRepository: SignalRepositoryWithLIDStore,
|
||||
logger?: ILogger
|
||||
): Promise<void> => {
|
||||
const lidMapping = signalRepository.lidMapping
|
||||
const key = message.key
|
||||
|
||||
// FAST PATH: Use alt JIDs directly when available (avoids LIDMappingStore race condition).
|
||||
// The stanza always carries both formats (LID + PN) in the attributes.
|
||||
// When addressing_mode=lid, remoteJid is LID and remoteJidAlt is PN.
|
||||
// When addressing_mode=pn, remoteJid is already PN (no conversion needed).
|
||||
if (key.remoteJid && isAnyLidUser(key.remoteJid) && key.remoteJidAlt && isAnyPnUser(key.remoteJidAlt)) {
|
||||
logger?.debug({ lid: key.remoteJid, pn: key.remoteJidAlt }, 'Resolved remoteJid LID→PN via alt (fast path)')
|
||||
key.remoteJid = key.remoteJidAlt
|
||||
}
|
||||
|
||||
if (key.participant && isAnyLidUser(key.participant) && key.participantAlt && isAnyPnUser(key.participantAlt)) {
|
||||
logger?.debug({ lid: key.participant, pn: key.participantAlt }, 'Resolved participant LID→PN via alt (fast path)')
|
||||
key.participant = key.participantAlt
|
||||
}
|
||||
|
||||
// SLOW PATH: Resolve any remaining LIDs via LIDMappingStore lookup
|
||||
await normalizeKeyLidToPn(key, lidMapping, logger)
|
||||
|
||||
// Also normalize participantAlt (the alternative JID format — can be LID when addressing_mode=pn)
|
||||
if (key.participantAlt && isAnyLidUser(key.participantAlt)) {
|
||||
const resolved = await resolveLidToPn(key.participantAlt, lidMapping, logger)
|
||||
if (resolved) {
|
||||
key.participantAlt = resolved
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize nested message keys (reaction, poll) that may contain LID JIDs
|
||||
const content = normalizeMessageContent(message.message)
|
||||
if (content?.reactionMessage?.key) {
|
||||
await normalizeKeyLidToPn(content.reactionMessage.key, lidMapping, logger)
|
||||
}
|
||||
|
||||
if (content?.pollUpdateMessage?.pollCreationMessageKey) {
|
||||
await normalizeKeyLidToPn(content.pollUpdateMessage.pollCreationMessageKey, lidMapping, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 &&
|
||||
@@ -291,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) {
|
||||
@@ -325,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) {
|
||||
@@ -351,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)) }
|
||||
@@ -385,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
|
||||
|
||||
@@ -416,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
|
||||
}
|
||||
@@ -492,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 }
|
||||
}
|
||||
@@ -505,115 +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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
let finalMsg: WAMessage
|
||||
//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')
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
finalMsg = cachedData as WAMessage
|
||||
} else {
|
||||
finalMsg = webMessageInfo as WAMessage
|
||||
}
|
||||
|
||||
// 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'
|
||||
)
|
||||
logger?.debug({ msgId, requestId: response.stanzaId }, 'received placeholder resend')
|
||||
|
||||
// Normalize LID→PN in PDO-recovered message key before emitting
|
||||
// eslint-disable-next-line max-depth
|
||||
if (webMessageInfo.key && signalRepository) {
|
||||
await normalizeKeyLidToPn(webMessageInfo.key as WAMessageKey, signalRepository.lidMapping, logger)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -649,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')
|
||||
@@ -671,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
|
||||
@@ -684,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)
|
||||
@@ -706,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
|
||||
)
|
||||
|
||||
@@ -735,13 +495,9 @@ const processMessage = async (
|
||||
response: responseMsg
|
||||
}
|
||||
|
||||
// Normalize creationMsgKey JIDs for the emitted event
|
||||
const normalizedCreationKey = { ...creationMsgKey }
|
||||
await normalizeKeyLidToPn(normalizedCreationKey, signalRepository.lidMapping, logger)
|
||||
|
||||
ev.emit('messages.update', [
|
||||
{
|
||||
key: normalizedCreationKey,
|
||||
key: creationMsgKey,
|
||||
update: {
|
||||
eventResponses: [eventResponse]
|
||||
}
|
||||
@@ -763,19 +519,12 @@ const processMessage = async (
|
||||
id: jid,
|
||||
author: message.key.participant!,
|
||||
authorPn: message.key.participantAlt!,
|
||||
authorUsername: message.key.participantUsername!,
|
||||
participants,
|
||||
action
|
||||
})
|
||||
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
|
||||
ev.emit('groups.update', [
|
||||
{
|
||||
id: jid,
|
||||
...update,
|
||||
author: message.key.participant ?? undefined,
|
||||
authorPn: message.key.participantAlt,
|
||||
authorUsername: message.key.participantUsername
|
||||
}
|
||||
{ id: jid, ...update, author: message.key.participant ?? undefined, authorPn: message.key.participantAlt }
|
||||
])
|
||||
}
|
||||
|
||||
@@ -784,7 +533,6 @@ const processMessage = async (
|
||||
id: jid,
|
||||
author: message.key.participant!,
|
||||
authorPn: message.key.participantAlt!,
|
||||
authorUsername: message.key.participantUsername!,
|
||||
participant: participant.lid,
|
||||
participantPn: participant.pn,
|
||||
action,
|
||||
@@ -792,7 +540,7 @@ const processMessage = async (
|
||||
})
|
||||
}
|
||||
|
||||
const participantsIncludesMe = () => participants.find(p => areJidsSameUser(meId, p.id) || areJidsSameUser(meId, p.phoneNumber))
|
||||
const participantsIncludesMe = () => participants.find(jid => areJidsSameUser(meId, jid.phoneNumber)) // ADD SUPPORT FOR LID
|
||||
|
||||
switch (message.messageStubType) {
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -1,880 +0,0 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import { createHash } from 'crypto'
|
||||
import { zipSync } from 'fflate'
|
||||
import { promises as fs } from 'fs'
|
||||
import { gzipSync, gunzipSync } from 'zlib'
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta se um buffer é Lottie JSON (raw ou gzip-compressed/WAS)
|
||||
*
|
||||
* WABA usa mimetype `application/was` para stickers Lottie.
|
||||
* WAS (WhatsApp Animated Sticker) = gzip-compressed Lottie JSON.
|
||||
*
|
||||
* Detecção:
|
||||
* - Gzip (0x1f 0x8b): descomprime e verifica se é Lottie JSON
|
||||
* - JSON bruto: verifica campos Lottie obrigatórios (v, ip, op, layers)
|
||||
*
|
||||
* @param buffer - Buffer to check
|
||||
* @returns true if buffer is Lottie/WAS format
|
||||
*/
|
||||
export const isLottieBuffer = (buffer: Buffer): boolean => {
|
||||
if (buffer.length < 2) return false
|
||||
|
||||
let jsonBuffer: Buffer
|
||||
|
||||
// Check if gzip-compressed (WAS format)
|
||||
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
||||
try {
|
||||
// SECURITY: Limit decompressed output to 50MB to prevent decompression bombs
|
||||
jsonBuffer = gunzipSync(buffer, { maxOutputLength: 50 * 1024 * 1024 }) as Buffer
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} else if (buffer[0] === 0x7b) {
|
||||
// Starts with '{' - could be raw Lottie JSON
|
||||
jsonBuffer = buffer
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate Lottie JSON structure: must have v, ip, op, AND layers
|
||||
try {
|
||||
const str = jsonBuffer.toString('utf8', 0, Math.min(jsonBuffer.length, 4096))
|
||||
// Quick check for ALL required Lottie fields
|
||||
return str.includes('"v"') && str.includes('"layers"') && str.includes('"ip"') && str.includes('"op"')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converte uma imagem para WebP usando Sharp
|
||||
* Preserva o buffer original se já 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; isLottie: boolean }> => {
|
||||
// Lottie/WAS: ensure gzip-compressed format (WAS = gzip Lottie JSON)
|
||||
if (isLottieBuffer(buffer)) {
|
||||
let wasBuffer = buffer
|
||||
// Raw Lottie JSON (starts with '{') must be gzipped to produce valid WAS
|
||||
if (buffer[0] === 0x7b) {
|
||||
logger?.trace('Raw Lottie JSON detected, gzip-compressing to WAS format')
|
||||
wasBuffer = gzipSync(buffer) as Buffer
|
||||
}
|
||||
|
||||
logger?.trace('Input is Lottie/WAS format')
|
||||
return { webpBuffer: wasBuffer, isAnimated: true, isLottie: true }
|
||||
}
|
||||
|
||||
// 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, isLottie: false }
|
||||
}
|
||||
|
||||
// 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, isLottie: 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 ou Lottie/WAS (application/was)
|
||||
* - Stickers: 512x512 pixels (auto-resize)
|
||||
* - Recomendado: 100KB por sticker estático, 500KB animado
|
||||
* - Tray icon: 96x96 pixels (PNG no ZIP)
|
||||
* - Thumbnail: 252x252 pixels (JPEG, upload separado)
|
||||
*
|
||||
* @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}`)
|
||||
|
||||
// Detecta formato e converte se necessário
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { webpBuffer, isAnimated, isLottie } = await convertToWebP(buffer, logger)
|
||||
|
||||
// Validate explicit isLottie flag — must match detected format
|
||||
if (sticker.isLottie !== undefined && sticker.isLottie !== isLottie) {
|
||||
throw new Boom(
|
||||
`Sticker ${i + 1}: explicit isLottie=${sticker.isLottie} does not match detected format (detected=${isLottie})`,
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// WABA: Enforce 512x512 dimensions for WebP stickers (Lottie is vector, skip)
|
||||
if (!isLottie && isWebPBuffer(webpBuffer)) {
|
||||
const lib = await getImageProcessingLibrary()
|
||||
if (lib?.sharp) {
|
||||
const metadata = await lib.sharp.default(webpBuffer).metadata()
|
||||
if (metadata.width !== 512 || metadata.height !== 512) {
|
||||
logger?.trace(
|
||||
{ index: i, width: metadata.width, height: metadata.height },
|
||||
'Resizing sticker to 512x512 (WABA standard)'
|
||||
)
|
||||
webpBuffer = await lib.sharp
|
||||
.default(webpBuffer)
|
||||
.resize(512, 512, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.webp()
|
||||
.toBuffer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(webpBuffer).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(webpBuffer).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 ou hash.was (WABA: plain_file_hash.ext)
|
||||
const sha256Hash = generateSha256Hash(webpBuffer)
|
||||
const extension = isLottie ? 'was' : 'webp'
|
||||
const fileName = `${sha256Hash}.${extension}`
|
||||
|
||||
logger?.trace(
|
||||
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated, isLottie },
|
||||
'Sticker processed successfully'
|
||||
)
|
||||
|
||||
return {
|
||||
fileName,
|
||||
webpBuffer,
|
||||
isAnimated,
|
||||
isLottie,
|
||||
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, isLottie, 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 }]
|
||||
|
||||
// WABA: mimetype é 'application/was' para Lottie, 'image/webp' para WebP
|
||||
const metadata: proto.Message.StickerPackMessage.ISticker = {
|
||||
fileName,
|
||||
isAnimated,
|
||||
emojis,
|
||||
accessibilityLabel,
|
||||
isLottie,
|
||||
mimetype: isLottie ? 'application/was' : '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')
|
||||
|
||||
// Tray icon uses PNG format, 96x96 pixels (official client standard)
|
||||
const lib = await getImageProcessingLibrary()
|
||||
if (!lib?.sharp) {
|
||||
throw new Boom(
|
||||
'Sharp library is required for cover/tray icon processing. Install with: yarn add sharp',
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
coverWebP = await lib.sharp
|
||||
.default(coverBuffer)
|
||||
.resize(96, 96, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
coverFileName = `${stickerPackId}.png`
|
||||
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')
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -46,7 +46,6 @@ export const processContactAction = (
|
||||
{
|
||||
id,
|
||||
name: action.fullName || action.firstName || action.username || undefined,
|
||||
username: action.username || undefined,
|
||||
lid: lidJid || undefined,
|
||||
phoneNumber
|
||||
}
|
||||
@@ -57,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
-149
@@ -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,76 +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
|
||||
}
|
||||
|
||||
const tokenEntry = {
|
||||
...existingEntry,
|
||||
token: Buffer.from(tokenNode.content),
|
||||
timestamp: tokenNode.attrs.t,
|
||||
// WABA Android: resets real_issue_timestamp to null when storing a new token
|
||||
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null)
|
||||
realIssueTimestamp: null
|
||||
}
|
||||
|
||||
// Store under resolved storageJid AND under fallbackJid (PN) for reliable lookup
|
||||
// The read path may resolve to a different LID than the store path
|
||||
const normalizedFallback = jidNormalizedUser(fallbackJid)
|
||||
const keysToStore: Record<string, typeof tokenEntry | null> = {
|
||||
[storageJid]: tokenEntry
|
||||
}
|
||||
if (normalizedFallback !== storageJid) {
|
||||
keysToStore[normalizedFallback] = tokenEntry
|
||||
}
|
||||
|
||||
await keys.set({ tctoken: keysToStore })
|
||||
onNewJidStored?.(storageJid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -3,7 +3,6 @@ import { createHash } from 'crypto'
|
||||
import { proto } from '../../WAProto/index.js'
|
||||
import {
|
||||
KEY_BUNDLE_TYPE,
|
||||
PROTOCOL_MODE,
|
||||
WA_ADV_ACCOUNT_SIG_PREFIX,
|
||||
WA_ADV_DEVICE_SIG_PREFIX,
|
||||
WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
|
||||
@@ -15,13 +14,17 @@ import { encodeBigEndian } from './generics'
|
||||
import { createSignalIdentity } from './signal'
|
||||
|
||||
const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
|
||||
// Always use MACOS platform for UserAgent — we connect via web protocol
|
||||
// (WA\x06\x03) so the server expects a web-compatible identity.
|
||||
// Using WEB causes 405 errors; using SMB_ANDROID breaks pair code.
|
||||
// Android identity is only set in DeviceProps (registration node).
|
||||
return {
|
||||
appVersion: {
|
||||
primary: config.version[0],
|
||||
secondary: config.version[1],
|
||||
tertiary: config.version[2]
|
||||
},
|
||||
platform: proto.ClientPayload.UserAgent.Platform.WEB,
|
||||
platform: proto.ClientPayload.UserAgent.Platform.MACOS,
|
||||
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
|
||||
osVersion: '0.1',
|
||||
device: 'Desktop',
|
||||
@@ -59,10 +62,7 @@ const getClientPayload = (config: SocketConfig) => {
|
||||
userAgent: getUserAgent(config)
|
||||
}
|
||||
|
||||
// Native protocol (WAM\x05) does not use WebInfo — only web protocol does
|
||||
if (PROTOCOL_MODE !== 'native') {
|
||||
payload.webInfo = getWebInfo(config)
|
||||
}
|
||||
payload.webInfo = getWebInfo(config)
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -83,6 +83,11 @@ export const generateLoginNode = (userJid: string, config: SocketConfig): proto.
|
||||
|
||||
const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => {
|
||||
const platformType = platform.toUpperCase()
|
||||
// 'ANDROID' is not in PlatformType enum — map to ANDROID_PHONE
|
||||
if (platformType === 'ANDROID') {
|
||||
return proto.DeviceProps.PlatformType.ANDROID_PHONE
|
||||
}
|
||||
|
||||
return (
|
||||
proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] ||
|
||||
proto.DeviceProps.PlatformType.CHROME
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,37 +13,11 @@ export class USyncContactProtocol implements USyncQueryProtocol {
|
||||
}
|
||||
|
||||
getUserElement(user: USyncUser): BinaryNode {
|
||||
if (user.phone) {
|
||||
return {
|
||||
tag: 'contact',
|
||||
attrs: {},
|
||||
content: user.phone
|
||||
}
|
||||
}
|
||||
|
||||
if (user.username) {
|
||||
return {
|
||||
tag: 'contact',
|
||||
attrs: {
|
||||
username: user.username,
|
||||
...(user.usernameKey ? { pin: user.usernameKey } : {}),
|
||||
...(user.lid ? { lid: user.lid } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (user.type) {
|
||||
return {
|
||||
tag: 'contact',
|
||||
attrs: {
|
||||
type: user.type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: Implement type / username fields (not yet supported)
|
||||
return {
|
||||
tag: 'contact',
|
||||
attrs: {}
|
||||
attrs: {},
|
||||
content: user.phone
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { USyncQueryProtocol } from '../../Types/USync'
|
||||
import { assertNodeErrorFree, type BinaryNode } from '../../WABinary'
|
||||
import { USyncUser } from '../USyncUser'
|
||||
|
||||
export class USyncUsernameProtocol implements USyncQueryProtocol {
|
||||
name = 'username'
|
||||
|
||||
getQueryElement(): BinaryNode {
|
||||
return {
|
||||
tag: 'username',
|
||||
attrs: {}
|
||||
}
|
||||
}
|
||||
|
||||
getUserElement(user: USyncUser): BinaryNode | null {
|
||||
void user
|
||||
return null
|
||||
}
|
||||
|
||||
parser(node: BinaryNode): string | null {
|
||||
if (node.tag === 'username') {
|
||||
assertNodeErrorFree(node)
|
||||
return typeof node.content === 'string' ? node.content : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,3 @@ export * from './USyncDeviceProtocol'
|
||||
export * from './USyncContactProtocol'
|
||||
export * from './USyncStatusProtocol'
|
||||
export * from './USyncDisappearingModeProtocol'
|
||||
export * from './USyncUsernameProtocol'
|
||||
|
||||
+10
-23
@@ -6,12 +6,11 @@ import {
|
||||
USyncContactProtocol,
|
||||
USyncDeviceProtocol,
|
||||
USyncDisappearingModeProtocol,
|
||||
USyncStatusProtocol,
|
||||
USyncUsernameProtocol
|
||||
USyncStatusProtocol
|
||||
} from './Protocols'
|
||||
import { USyncUser } from './USyncUser'
|
||||
|
||||
export type USyncQueryResultList = { [protocol: string]: unknown; id: string; rawId?: number }
|
||||
export type USyncQueryResultList = { [protocol: string]: unknown; id: string }
|
||||
|
||||
export type USyncQueryResult = {
|
||||
list: USyncQueryResultList[]
|
||||
@@ -47,7 +46,7 @@ export class USyncQuery {
|
||||
}
|
||||
|
||||
parseUSyncQueryResult(result: BinaryNode | undefined): USyncQueryResult | undefined {
|
||||
if (result?.attrs.type !== 'result') {
|
||||
if (!result || result.attrs.type !== 'result') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,8 +68,10 @@ export class USyncQuery {
|
||||
//TODO: see if there are any errors in the result node
|
||||
//const resultNode = getBinaryNodeChild(usyncNode, 'result')
|
||||
|
||||
const parseNodeList = (content: BinaryNode[]): USyncQueryResultList[] =>
|
||||
content.reduce((acc: USyncQueryResultList[], node) => {
|
||||
const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined
|
||||
|
||||
if (listNode?.content && Array.isArray(listNode.content)) {
|
||||
queryResult.list = listNode.content.reduce((acc: USyncQueryResultList[], node) => {
|
||||
const id = node?.attrs.jid
|
||||
if (id) {
|
||||
const data = Array.isArray(node?.content)
|
||||
@@ -88,24 +89,15 @@ export class USyncQuery {
|
||||
.filter(([, b]) => b !== null) as [string, unknown][]
|
||||
)
|
||||
: {}
|
||||
const rawIdAttr = node?.attrs?.['raw_id']
|
||||
const rawId = rawIdAttr !== undefined ? Number(rawIdAttr) : undefined
|
||||
acc.push({ ...data, id, ...(rawId !== undefined && !isNaN(rawId) ? { rawId } : {}) })
|
||||
acc.push({ ...data, id })
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined
|
||||
if (listNode?.content && Array.isArray(listNode.content)) {
|
||||
queryResult.list = parseNodeList(listNode.content)
|
||||
}
|
||||
|
||||
const sideListNode = usyncNode ? getBinaryNodeChild(usyncNode, 'side_list') : undefined
|
||||
if (sideListNode?.content && Array.isArray(sideListNode.content)) {
|
||||
queryResult.sideList = parseNodeList(sideListNode.content)
|
||||
}
|
||||
|
||||
//TODO: implement side list
|
||||
//const sideListNode = getBinaryNodeChild(usyncNode, 'side_list')
|
||||
return queryResult
|
||||
}
|
||||
|
||||
@@ -138,9 +130,4 @@ export class USyncQuery {
|
||||
this.protocols.push(new USyncLIDProtocol())
|
||||
return this
|
||||
}
|
||||
|
||||
withUsernameProtocol() {
|
||||
this.protocols.push(new USyncUsernameProtocol())
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ export class USyncUser {
|
||||
id?: string
|
||||
lid?: string
|
||||
phone?: string
|
||||
username?: string
|
||||
usernameKey?: string
|
||||
type?: string
|
||||
personaId?: string
|
||||
|
||||
@@ -22,16 +20,6 @@ export class USyncUser {
|
||||
return this
|
||||
}
|
||||
|
||||
withUsername(username: string) {
|
||||
this.username = username
|
||||
return this
|
||||
}
|
||||
|
||||
withUsernameKey(usernameKey: string) {
|
||||
this.usernameKey = usernameKey
|
||||
return this
|
||||
}
|
||||
|
||||
withType(type: string) {
|
||||
this.type = type
|
||||
return this
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user