Compare commits

..

1 Commits

Author SHA1 Message Date
Renato Alcara ef365246ea feat: add Android browser preset for companion device registration
Adds `Browsers.android(apiLevel)` preset that registers the companion
device as Android in WhatsApp's "Linked Devices" list, enabling
view-once messages and Android-specific features.

Inspired by PR #2201 (WhiskeySockets/Baileys#2201) which introduced
the concept, but with critical fixes discovered through extensive
reverse engineering and real-device testing.

## Usage

  const sock = makeWASocket({
    browser: Browsers.android('14'),
    auth: state
  })

The device appears as "Android (14)" in the phone's Linked Devices list.

## Investigation and Findings

### Two different platform fields

WhatsApp uses two separate platform identifiers that serve different
purposes:

1. `companion_platform_id` (pair code stanza) — validated by the server
   BEFORE showing the confirmation prompt on the phone. Controls whether
   the server accepts the pairing request.

2. `DeviceProps.PlatformType` (registration node) — sent AFTER pairing
   confirmation. Determines the display name in "Linked Devices".

These are independent: you can pair with Chrome (1) as companion but
register with ANDROID_PHONE (16) in DeviceProps, and the device will
correctly appear as "Android" in linked devices.

### Pair code platform testing results

Tested with real WhatsApp server via web protocol (WA\x06\x03):

| companion_platform_id | Result                                    |
|-----------------------|-------------------------------------------|
| Chrome (1)            | WORKS — phone shows "connect device?" prompt |
| ANDROID_PHONE (16)    | TIMEOUT — server silently ignores stanza    |
| UWP (21)              | REJECTED — "cannot connect device" error    |

When we sent `companion_platform_id: "16"` (ANDROID_PHONE), the server
did not respond at all — the request timed out after 30s with no
`link_code_pairing_ref` response. With Chrome ("1"), it responds
immediately.

This confirms: ANDROID_PHONE(16) is a primary device type, not a
companion type. The server expects companions with this type to use
the native Android protocol (WAM\x05 with key_attestation TEE
certificates). Since Baileys uses the web protocol (WA\x06\x03),
the server rejects it.

### UserAgent.Platform testing results

The original PR #2201 used `UserAgent.Platform = SMB_ANDROID` for
Android browser. Testing revealed this breaks pair code registration:

- With `SMB_ANDROID`: pair code generates, phone shows confirmation,
  user clicks "Yes", but registration FAILS with "cannot connect
  device" error. The mismatch between web protocol handshake
  (WA\x06\x03) and Android UserAgent causes the server to reject
  the registration.

- With `MACOS`: pair code works end-to-end. Device connects
  successfully and appears as "Android (14)" in linked devices.

Additionally, the upstream default `UserAgent.Platform = WEB` causes
405 connection failures on some server endpoints. Using `MACOS` (24)
resolves this.

### WhatsApp Desktop Windows investigation (Frida + binary analysis)

To understand how official WhatsApp clients handle platform identity,
we reverse-engineered WhatsApp Desktop for Windows:

- WhatsApp Desktop is a WebView2 wrapper loading
  `web.whatsapp.com?windows=1` — NOT a native client
- Uses the same web protocol (WA\x06\x03) as Chrome
- Registers with `DeviceProps.PlatformType = UWP (21)` to appear as
  "Windows" in linked devices
- Always shows as "Windows" regardless of QR or pair code connection

This confirmed our approach: the display name comes from DeviceProps,
not from the pair code stanza.

### Relevant protocol enums

ClientPayload.UserAgent.Platform (connection identity):
  MACOS = 24 (what we use — web-compatible)
  SMB_ANDROID = 10 (breaks pair code — DO NOT USE)
  WEB = 15 (causes 405 errors)

DeviceProps.PlatformType (linked device display name):
  CHROME = 1, FIREFOX = 2, SAFARI = 5, EDGE = 6
  DESKTOP = 7, CATALINA = 12, ANDROID_PHONE = 16, UWP = 21

## Changes

- Types/index.ts: add `android(apiLevel)` to BrowsersMap type
- browser-utils.ts: add `Browsers.android()` preset,
  `isAndroidBrowser()` helper, fix `getPlatformId()` to resolve
  ANDROID -> ANDROID_PHONE (16) since the enum only has
  ANDROID_PHONE, not ANDROID
- validate-connection.ts: use MACOS platform in UserAgent (fixes 405
  and pair code), map ANDROID -> ANDROID_PHONE in getPlatformType()
- socket.ts: auto-detect Android browser in pair code and fall back
  to Chrome (1) as companion_platform_id. Non-Android browsers are
  unaffected and continue using their native platform ID.

## How it works

With `Browsers.android('14')`:
- `getUserAgent()` -> platform: MACOS, device: Desktop (web identity)
- `getClientPayload()` -> includes webInfo as normal
- `getPlatformType('Android')` -> ANDROID_PHONE (in DeviceProps)
- Pair code -> companion_platform_id: '1' (Chrome fallback)
- QR code -> no override needed, works directly
- Result: device appears as "Android (14)" in Linked Devices
2026-03-02 22:28:01 -03:00
133 changed files with 7170 additions and 62772 deletions
+1 -18
View File
@@ -1,23 +1,6 @@
---
## 🚀 InfiniteAPI é o core do InfiniteZAP (SaaS gerenciado)
Este repositório é o **motor (core)** usado pelo **InfiniteZap** — uma plataforma onde você **só se preocupa com a operação**, e a gente cuida do resto:
**Infra gerenciada** (deploy, atualizações, monitoramento e backups)
**Builds validados do nosso fork** com correções e melhorias contínuas
**Acesso antecipado** a novos recursos e hotfixes antes do rollout geral
**Suporte operacional** para manter tudo estável em produção
➡️ Teste Gratuito por 15 dias 👉: **https://www.infinitezap.com.br**
> Nota: este repositório é um fork sob licença MIT.
> Não é afiliado ao WhatsApp e não representa o projeto oficial.
---
<h1><img alt="Baileys logo" src="https://raw.githubusercontent.com/WhiskeySockets/Baileys/refs/heads/master/Media/logo.png" height="75"/></h1>
> [!CAUTION]
> NOTICE OF BREAKING CHANGE.
>
+4 -21
View File
@@ -24,33 +24,16 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Fix Git config
run: git config --global core.autocrlf input
- name: Configure Git for HTTPS
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Force git to use HTTPS with token instead of SSH for GitHub repositories
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "git@github.com:"
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
.yarn/cache
.yarn/install-state.gz
key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock', 'package.json') }}
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-${{ hashFiles('package.json') }}-
${{ runner.os }}-yarn-
- name: Install dependencies
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_TERMINAL_PROMPT: 0
run: yarn install --immutable
- name: Build project
+2 -19
View File
@@ -24,34 +24,17 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Configure Git for HTTPS
run: |
# Reescreve SSH -> HTTPS para repositórios públicos do GitHub (sem token, evita erros de autenticação)
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
node_modules
.yarn/install-state.gz
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_TERMINAL_PROMPT: 0
YARN_HTTP_TIMEOUT: 600000
YARN_HTTP_RETRY: 10
run: yarn install --immutable
- name: Auto-fix formatting
run: yarn lint:fix
continue-on-error: true
- name: Check linting
run: yarn lint
+2 -14
View File
@@ -33,23 +33,11 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Configure Git for HTTPS
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Force git to use HTTPS instead of SSH for GitHub repositories
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
# Configure git to use GITHUB_TOKEN for authentication
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
node_modules
.yarn/install-state.gz
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
- uses: mshick/add-pr-comment@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
repo-token: ${{ secrets.PERSONAL_TOKEN }}
message-id: pr-test
message: |
Thanks for opening this pull request and contributing to the project!
+2 -14
View File
@@ -32,23 +32,11 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Configure Git for HTTPS
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Force git to use HTTPS instead of SSH for GitHub repositories
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
# Configure git to use GITHUB_TOKEN for authentication
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
node_modules
.yarn/install-state.gz
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
+2 -16
View File
@@ -25,30 +25,16 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Fix Git config
run: git config --global core.autocrlf input
- name: Configure Git for HTTPS
run: |
# Reescreve SSH -> HTTPS para repositórios públicos do GitHub (sem token, evita erros de autenticação)
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
node_modules
.yarn/install-state.gz
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_TERMINAL_PROMPT: 0
run: yarn install --immutable
- name: Run tests
+5 -189
View File
@@ -2,16 +2,12 @@ name: Update WAProto
on:
schedule:
- cron: '0 3 * * *'
- cron: '10 1 * * *'
workflow_dispatch:
# Dynamic run name with run number
run-name: "Update WAProto #${{ github.run_number }}"
permissions:
contents: write
pull-requests: write
actions: write
jobs:
update-proto:
@@ -31,16 +27,6 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Configure Git for HTTPS
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Force git to use HTTPS instead of SSH for GitHub repositories
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
# Configure git to use GITHUB_TOKEN for authentication
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
- name: Install packages
run: |
yarn install --immutable
@@ -54,10 +40,6 @@ jobs:
WA_JS_URL=$(cat wa-logs.txt | perl -n -e'/Found source JS URL\: (.+)/ && print $1')
echo "wa_version=$WA_VERSION" >> $GITHUB_OUTPUT
echo "wa_js_url=$WA_JS_URL" >> $GITHUB_OUTPUT
# Display version prominently
echo "📱 WhatsApp Proto Version: v$WA_VERSION"
echo "🔗 Source JS URL: $WA_JS_URL"
- name: GenerateStatics
run: yarn gen:protobuf
@@ -66,183 +48,17 @@ jobs:
run: |
WA_VERSION="${{steps.wa_proto_info.outputs.wa_version}}"
WA_NUMBERS=$(echo $WA_VERSION | sed "s/\./, /g")
echo "{\"version\": [$WA_NUMBERS]}" > src/Defaults/baileys-version.json
# Confirm update
echo "✅ Updated baileys-version.json to v$WA_VERSION"
cat src/Defaults/baileys-version.json
echo -e "{\n\t\"version\": [$WA_NUMBERS]\n}" > src/Defaults/baileys-version.json
- name: Create Pull Request
id: create_pr
uses: peter-evans/create-pull-request@v5
with:
commit-message: 'chore: update proto/version to v${{steps.wa_proto_info.outputs.wa_version}}'
title: 'chore: update WhatsApp proto to v${{steps.wa_proto_info.outputs.wa_version}}'
commit-message: 'chore: updated proto/version to v${{steps.wa_proto_info.outputs.wa_version}}'
title: 'Whatsapp v${{steps.wa_proto_info.outputs.wa_version}} proto/version change'
branch: 'update-proto/stable'
delete-branch: true
labels: 'update-proto'
body: |
## 📦 Automated WhatsApp Proto Update
**Version:** `v${{steps.wa_proto_info.outputs.wa_version}}`
**Source JS URL:** ${{steps.wa_proto_info.outputs.wa_js_url}}
### Changes
- ✅ Updated `WAProto/*` (protobuf definitions)
- ✅ Updated `src/Defaults/baileys-version.json`
### Update Frequency
Daily at 03:00 UTC (00:00 BRT)
body: "Automated changes\nFound source JS URL: ${{steps.wa_proto_info.outputs.wa_js_url}}\nCurrent version: v${{steps.wa_proto_info.outputs.wa_version}}"
add-paths: |
WAProto/*
src/Defaults/baileys-version.json
- name: Enable Auto-merge
if: steps.create_pr.outputs.pull-request-number != ''
env:
GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ steps.create_pr.outputs.pull-request-number }}"
WA_VERSION="${{ steps.wa_proto_info.outputs.wa_version }}"
echo "🔀 Enabling auto-merge for PR #$PR_NUMBER (v$WA_VERSION)..."
# Enable auto-merge with squash method
set +e
AUTO_MERGE_OUTPUT=$(gh pr merge "$PR_NUMBER" \
--repo "${{ github.repository }}" \
--squash \
--auto \
--delete-branch 2>&1)
AUTO_MERGE_EXIT=$?
set -e
if [ $AUTO_MERGE_EXIT -eq 0 ]; then
echo "✅ Auto-merge enabled for PR #$PR_NUMBER (v$WA_VERSION)"
echo "$AUTO_MERGE_OUTPUT"
echo "⏳ PR will be merged automatically once all checks pass"
else
echo "::warning::Failed to enable auto-merge for PR #$PR_NUMBER (v$WA_VERSION)"
echo "::group::Auto-merge Error Output"
echo "$AUTO_MERGE_OUTPUT"
echo "::endgroup::"
# Fall back to waiting for checks and then merging
echo "🔄 Falling back to wait-and-merge strategy..."
# Wait for checks to start
echo "⏳ Waiting 30 seconds for checks to start..."
sleep 30
# Wait for checks to complete (max 5 minutes)
echo "⏳ Waiting for checks to complete..."
WAIT_COUNT=0
MAX_WAIT=60 # 60 * 5 seconds = 5 minutes
while [ $WAIT_COUNT -lt $MAX_WAIT ]; do
set +e
CHECK_STATUS=$(gh pr checks "$PR_NUMBER" --repo "${{ github.repository }}" 2>&1)
CHECK_EXIT=$?
set -e
if [ $CHECK_EXIT -eq 0 ]; then
echo "📊 Current check status:"
echo "$CHECK_STATUS"
# Check if all checks passed
if echo "$CHECK_STATUS" | grep -q "All checks have passed"; then
echo "✅ All checks passed!"
break
fi
# Check if any check failed
if echo "$CHECK_STATUS" | grep -qE "(fail|failure|error)"; then
echo "❌ Some checks failed, aborting merge"
exit 1
fi
fi
WAIT_COUNT=$((WAIT_COUNT + 1))
echo "⏳ Waiting... ($WAIT_COUNT/$MAX_WAIT)"
sleep 5
done
if [ $WAIT_COUNT -ge $MAX_WAIT ]; then
echo "::warning::Timeout waiting for checks to complete"
echo "::warning::PR #$PR_NUMBER requires manual merge: https://github.com/${{ github.repository }}/pull/$PR_NUMBER"
exit 0
fi
# Try to merge now that checks passed
echo "🔀 Attempting to merge PR #$PR_NUMBER..."
set +e
MERGE_OUTPUT=$(gh pr merge "$PR_NUMBER" \
--repo "${{ github.repository }}" \
--squash \
--delete-branch \
--yes 2>&1)
MERGE_EXIT=$?
set -e
if [ $MERGE_EXIT -eq 0 ]; then
echo "✅ PR #$PR_NUMBER (v$WA_VERSION) merged successfully"
echo "$MERGE_OUTPUT"
else
echo "::error::Failed to merge PR #$PR_NUMBER (v$WA_VERSION)"
echo "::group::Merge Error Output"
echo "$MERGE_OUTPUT"
echo "::endgroup::"
# Try with admin flag
echo "🔧 Attempting merge with admin override..."
set +e
ADMIN_OUTPUT=$(gh pr merge "$PR_NUMBER" \
--repo "${{ github.repository }}" \
--squash \
--delete-branch \
--admin 2>&1)
ADMIN_EXIT=$?
set -e
if [ $ADMIN_EXIT -eq 0 ]; then
echo "✅ PR #$PR_NUMBER (v$WA_VERSION) merged with admin override"
echo "$ADMIN_OUTPUT"
else
echo "::error::Failed to merge even with admin override"
echo "::group::Admin Merge Error Output"
echo "$ADMIN_OUTPUT"
echo "::endgroup::"
echo "::warning::Consider configuring a Personal Access Token (PAT) with full repo permissions as GH_PAT secret"
echo "::warning::PR #$PR_NUMBER requires manual merge: https://github.com/${{ github.repository }}/pull/$PR_NUMBER"
exit 0
fi
fi
fi
- name: Summary
if: always()
run: |
echo "### 📦 WAProto Update" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
WA_VERSION="${{ steps.wa_proto_info.outputs.wa_version }}"
WA_JS_URL="${{ steps.wa_proto_info.outputs.wa_js_url }}"
PR_NUMBER="${{ steps.create_pr.outputs.pull-request-number }}"
echo "**Version:** \`v$WA_VERSION\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -n "$PR_NUMBER" ]; then
echo "✅ **PR #$PR_NUMBER created with auto-merge enabled**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Changes:**" >> $GITHUB_STEP_SUMMARY
echo "- 📦 Updated WAProto/* (protobuf definitions)" >> $GITHUB_STEP_SUMMARY
echo "- 📝 Updated baileys-version.json" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Source:** [$WA_JS_URL]($WA_JS_URL)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "🔀 PR will merge automatically once checks pass" >> $GITHUB_STEP_SUMMARY
else
echo "️ No changes detected - already up to date" >> $GITHUB_STEP_SUMMARY
fi
+21 -248
View File
@@ -2,23 +2,13 @@ name: Update WhatsApp Version
on:
schedule:
# Run daily at 09:00 UTC (optimized timing after WhatsApp deploys)
- cron: '0 9 * * *'
# Run on the 1st of every month at 02:00 UTC
- cron: '0 0 * * 0'
workflow_dispatch:
inputs:
force:
description: 'Force create PR even if version unchanged (useful for re-triggering)'
required: false
default: 'false'
type: boolean
# Dynamic run name with version
run-name: "Update WhatsApp Version ${{ github.run_number }}"
permissions:
contents: write
pull-requests: write
actions: write
jobs:
update-version:
@@ -26,10 +16,10 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Setup Node and Corepack
uses: actions/setup-node@v4
uses: actions/setup-node@v3.6.0
with:
node-version: 20.x
@@ -38,30 +28,11 @@ jobs:
corepack enable
corepack prepare yarn@4.x --activate
- name: Configure Git for HTTPS
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Force git to use HTTPS instead of SSH for GitHub repositories
# IMPORTANT: Order matters! More specific rules first, then general ones
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"
# Configure authentication (last so it doesn't interfere with SSH -> HTTPS conversion)
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
# Verify git config applied correctly
echo "✅ Git URL rewrites configured for HTTPS"
git config --global --get-regexp url
- name: Restore Yarn Cache
uses: actions/cache@v4
uses: actions/cache@v3
id: yarn-cache
with:
path: |
node_modules
.yarn/cache
.yarn/install-state.gz
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
@@ -71,60 +42,23 @@ jobs:
- name: Update WhatsApp Web Version
id: update_version
run: |
# Run the update script, capture exit code
# Exit codes: 0=success, 1=error, 2=fetch failed (fallback used)
set +e
yarn update:version
EXIT_CODE=$?
set -e
echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
# Read and output the updated version
VERSION=$(cat src/Defaults/baileys-version.json | grep -oP '"version":\s*\[\s*\K[0-9,\s]+' | tr -d ' ')
VERSION_STRING=$(echo $VERSION | sed 's/,/./g')
echo "version=$VERSION_STRING" >> $GITHUB_OUTPUT
echo "📱 WhatsApp Version: $VERSION_STRING"
# Only fail on fatal errors (exit code 1)
if [ $EXIT_CODE -eq 1 ]; then
echo "::error::Script failed with fatal error"
exit 1
fi
# Exit code 2 means fetch failed but fallback was used - this is a warning
if [ $EXIT_CODE -eq 2 ]; then
echo "::warning::Could not fetch latest version from WhatsApp servers"
fi
run: yarn update:version
- name: Check for changes
id: check_changes
run: |
FORCE="${{ inputs.force }}"
if git diff --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
if [ "$FORCE" == "true" ]; then
echo "force_mode=true" >> $GITHUB_OUTPUT
echo "::notice::Force mode enabled - will create PR even without changes"
else
echo "force_mode=false" >> $GITHUB_OUTPUT
fi
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "force_mode=false" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
id: create_pr
if: steps.check_changes.outputs.has_changes == 'true' || steps.check_changes.outputs.force_mode == 'true'
if: steps.check_changes.outputs.has_changes == 'true'
env:
GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH_NAME="update-version/stable"
FORCE="${{ inputs.force }}"
VERSION="${{ steps.update_version.outputs.version }}"
# Configure git
git config user.name "github-actions[bot]"
@@ -133,183 +67,22 @@ jobs:
# Check if branch exists and delete it
git push origin --delete "$BRANCH_NAME" 2>/dev/null || true
# Create new branch
# Create new branch, commit, and push
git checkout -b "$BRANCH_NAME"
# Only add the single source of truth file
git add src/Defaults/baileys-version.json
# Create commit (allow empty if force mode)
if [ "$FORCE" == "true" ] && git diff --cached --quiet; then
git commit --allow-empty -m "chore: force WhatsApp Web version check"
else
git commit -m "chore: update WhatsApp Web version to v$VERSION"
fi
git add src/Defaults/baileys-version.json src/Defaults/index.ts src/Utils/generics.ts
git commit -m "chore: update WhatsApp Web version"
git push origin "$BRANCH_NAME"
# Create PR using GitHub CLI with version in title
PR_TITLE="chore: update WhatsApp Web version to v$VERSION"
PR_BODY="Automated WhatsApp Web version update.
# Create PR using GitHub CLI
gh pr create \
--title "chore: update WhatsApp Web version" \
--body "Automated WhatsApp Web version update.
This PR updates the WhatsApp Web client revision to the latest version fetched from \`web.whatsapp.com\`.
**Version:** \`$VERSION\`
**Single source of truth:**
- \`src/Defaults/baileys-version.json\` (other files import from here)
**Update frequency:** Daily at 09:00 UTC"
if [ "$FORCE" == "true" ]; then
PR_BODY="$PR_BODY
> **Note:** This PR was created with force mode enabled."
fi
# Create PR (compatible with older gh CLI versions)
set +e
PR_URL=$(gh pr create \
--title "$PR_TITLE" \
--body "$PR_BODY" \
**Files updated:**
- \`src/Defaults/baileys-version.json\`
- \`src/Defaults/index.ts\`
- \`src/Utils/generics.ts\`" \
--base master \
--head "$BRANCH_NAME" 2>&1)
PR_CREATE_EXIT=$?
set -e
if [ $PR_CREATE_EXIT -eq 0 ]; then
echo "✅ PR created successfully: $PR_URL"
# Extract PR number from URL
PR_NUMBER=$(echo "$PR_URL" | grep -oP '\d+$')
echo "📋 PR Number: #$PR_NUMBER"
# Save PR number for next step
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
else
# PR creation failed - could be duplicate or other error
echo "⚠️ Could not create PR. Checking if PR already exists..."
# Check for existing PR (without --json for compatibility)
EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --state open 2>/dev/null | head -1)
if [ -n "$EXISTING_PR" ]; then
echo "️ PR already exists for branch $BRANCH_NAME"
echo "$EXISTING_PR"
# Extract PR number from existing PR
PR_NUMBER=$(echo "$EXISTING_PR" | grep -oP '^\d+')
if [ -n "$PR_NUMBER" ]; then
echo "📋 Existing PR Number: #$PR_NUMBER"
# Save PR number for auto-merge step
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
fi
else
echo "❌ PR creation failed with error:"
echo "$PR_URL"
fi
fi
- name: Auto-merge PR
if: steps.create_pr.outputs.pr_number != ''
env:
GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ steps.create_pr.outputs.pr_number }}"
echo "🔀 Attempting to merge PR #$PR_NUMBER via squash..."
# Check PR status
set +e
PR_STATE=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json state --jq '.state' 2>&1)
PR_VIEW_EXIT=$?
set -e
if [ $PR_VIEW_EXIT -ne 0 ] || [ -z "$PR_STATE" ]; then
echo "⚠️ Could not retrieve PR state, attempting merge anyway..."
else
echo "📊 PR State: $PR_STATE"
if [ "$PR_STATE" != "OPEN" ]; then
echo "⚠️ PR is not open (state: $PR_STATE), skipping merge"
exit 0
fi
fi
# Brief wait for GitHub to compute PR mergeability
sleep 5
# Check if PR is mergeable
set +e
MERGEABLE=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json mergeable --jq '.mergeable' 2>&1)
set -e
echo "📊 Mergeable: $MERGEABLE"
# Attempt merge with error handling
set +e
MERGE_OUTPUT=$(gh pr merge "$PR_NUMBER" \
--repo "${{ github.repository }}" \
--squash \
--delete-branch \
--yes 2>&1)
MERGE_EXIT=$?
set -e
if [ $MERGE_EXIT -eq 0 ]; then
echo "✅ PR #$PR_NUMBER merged successfully"
echo "$MERGE_OUTPUT"
else
echo "::warning::Standard merge failed for PR #$PR_NUMBER"
echo "::group::Merge Error Output"
echo "$MERGE_OUTPUT"
echo "::endgroup::"
# Try with admin flag (bypasses branch protection if user has admin rights)
echo "🔧 Attempting merge with admin override..."
set +e
ADMIN_OUTPUT=$(gh pr merge "$PR_NUMBER" \
--repo "${{ github.repository }}" \
--squash \
--delete-branch \
--admin 2>&1)
ADMIN_EXIT=$?
set -e
if [ $ADMIN_EXIT -eq 0 ]; then
echo "✅ PR #$PR_NUMBER merged successfully with admin override"
echo "$ADMIN_OUTPUT"
else
echo "::error::Failed to merge PR #$PR_NUMBER even with admin override"
echo "::group::Admin Merge Error Output"
echo "$ADMIN_OUTPUT"
echo "::endgroup::"
echo "::warning::Consider configuring a Personal Access Token (PAT) with full repo permissions as GH_PAT secret"
echo "::warning::PR #$PR_NUMBER was created but requires manual merge: https://github.com/${{ github.repository }}/pull/$PR_NUMBER"
# Don't exit with error to avoid blocking the workflow
exit 0
fi
fi
- name: Summary
run: |
echo "### WhatsApp Version Update" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
EXIT_CODE="${{ steps.update_version.outputs.exit_code }}"
HAS_CHANGES="${{ steps.check_changes.outputs.has_changes }}"
FORCE_MODE="${{ steps.check_changes.outputs.force_mode }}"
VERSION="${{ steps.update_version.outputs.version }}"
echo "**Version:** \`$VERSION\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$EXIT_CODE" == "2" ]; then
echo "⚠️ Warning: Could not fetch latest version from WhatsApp servers" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
if [ "$HAS_CHANGES" == "true" ]; then
echo "✅ New version detected and PR created" >> $GITHUB_STEP_SUMMARY
elif [ "$FORCE_MODE" == "true" ]; then
echo "🔄 Force mode: PR created (no version change)" >> $GITHUB_STEP_SUMMARY
else
echo "️ Already up to date" >> $GITHUB_STEP_SUMMARY
fi
--head "$BRANCH_NAME" || echo "PR already exists or could not be created"
-1
View File
@@ -19,4 +19,3 @@ test.ts
TestData
wa-logs*
coverage
AUDIT_REPORT_*.md
-1
View File
@@ -1 +0,0 @@
legacy-peer-deps=true
-2
View File
@@ -1,3 +1 @@
nodeLinker: node-modules
httpRetry: 10
httpTimeout: 600000
+7 -10
View File
@@ -1,10 +1,7 @@
import { Boom } from '@hapi/boom'
import NodeCache from '@cacheable/node-cache'
import readline from 'readline'
import makeWASocket, { AnyMessageContent, BinaryInfo, CacheStore, DEFAULT_CONNECTION_CONFIG, delay, DisconnectReason, downloadAndProcessHistorySyncNotification, encodeWAM, fetchLatestBaileysVersion, generateMessageIDV2, getAggregateVotesInPollMessage, getHistoryMsg, isJidNewsletter, jidDecode, makeCacheableSignalKeyStore, normalizeMessageContent, PatchedMessageWithRecipientJID, proto, useMultiFileAuthState, WAMessageContent, WAMessageKey } from '../src'
//import MAIN_LOGGER from '../src/Utils/logger'
import open from 'open'
import fs from 'fs'
import makeWASocket, { CacheStore, DEFAULT_CONNECTION_CONFIG, DisconnectReason, fetchLatestBaileysVersion, generateMessageIDV2, getAggregateVotesInPollMessage, isJidNewsletter, makeCacheableSignalKeyStore, proto, useMultiFileAuthState, WAMessageContent, WAMessageKey } from '../src'
import P from 'pino'
const logger = P({
@@ -149,11 +146,11 @@ const startSock = async() => {
// go to an old chat and send this
if (text == "onDemandHistSync") {
const messageId = await sock.fetchMessageHistory(50, msg.key, msg.messageTimestamp ?? 0)
const messageId = await sock.fetchMessageHistory(50, msg.key, msg.messageTimestamp!)
logger.debug({ id: messageId }, 'requested on-demand history resync')
}
if (!msg.key.fromMe && doReplies && !isJidNewsletter(msg.key?.remoteJid ?? '')) {
if (!msg.key.fromMe && doReplies && !isJidNewsletter(msg.key?.remoteJid!)) {
const id = generateMessageIDV2(sock.user?.id)
logger.debug({id, orig_id: msg.key.id }, 'replying to message')
await sock.sendMessage(msg.key.remoteJid!, { text: 'pong '+msg.key.id }, {messageId: id })
@@ -188,7 +185,7 @@ const startSock = async() => {
}
if (events['contacts.upsert']) {
logger.debug(events['contacts.upsert'], 'contacts.upsert event fired')
logger.debug(events['message-receipt.update'])
}
if(events['messages.reaction']) {
@@ -208,18 +205,18 @@ const startSock = async() => {
if(typeof contact.imgUrl !== 'undefined') {
const newUrl = contact.imgUrl === null
? null
: await sock.profilePictureUrl(contact.id ?? '').catch(() => null)
: await sock!.profilePictureUrl(contact.id!).catch(() => null)
logger.debug({id: contact.id, newUrl}, `contact has a new profile pic` )
}
}
}
if(events['chats.delete']) {
logger.debug(events['chats.delete'], 'chats.delete event fired')
logger.debug('chats deleted ', events['chats.delete'])
}
if(events['group.member-tag.update']) {
logger.debug(events['group.member-tag.update'], 'group.member-tag.update event fired')
logger.debug('group member tag update', JSON.stringify(events['group.member-tag.update'], undefined, 2))
}
}
)
+360 -916
View File
File diff suppressed because it is too large Load Diff
+18 -26
View File
@@ -18,21 +18,14 @@ try {
'\tif (typeof value === "number") {\n' +
'\t\treturn String(value);\n' +
'\t}\n' +
'\t// Fast path: convert Long {low, high} directly via native BigInt\n' +
'\t// BigInt.toString() is a native C++ operation, much faster than Long\'s pure JS division loops\n' +
'\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' +
'\t\t// Normalize high to signed int32 so the sign-bit check works for inputs\n' +
'\t\t// where high is stored unsigned (e.g. raw {low, high} JSON).\n' +
'\t\tconst high = value.high | 0;\n' +
'\t\tconst lo = BigInt(value.low >>> 0);\n' +
'\t\tconst hi = BigInt(value.high >>> 0);\n' +
'\t\tconst combined = (hi << 32n) | lo;\n' +
'\t\tif (!unsigned && high < 0) {\n' +
'\t\t\treturn (combined - (1n << 64n)).toString();\n' +
'\t\t}\n' +
'\t\treturn combined.toString();\n' +
'\tif (!$util.Long) {\n' +
'\t\treturn String(value);\n' +
'\t}\n' +
'\treturn String(value);\n' +
'\tconst normalized = $util.Long.fromValue(value);\n' +
'\tconst prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"\n' +
'\t\t? normalized.toUnsigned()\n' +
'\t\t: normalized;\n' +
'\treturn prepared.toString();\n' +
'}\n\n'
const longToNumberHelper =
'function longToNumber(value, unsigned) {\n' +
@@ -40,20 +33,19 @@ try {
'\t\treturn value;\n' +
'\t}\n' +
'\tif (typeof value === "string") {\n' +
'\t\tconst numeric = Number(value);\n' +
'\t\treturn numeric;\n' +
'\t}\n' +
'\tif (!$util.Long) {\n' +
'\t\treturn Number(value);\n' +
'\t}\n' +
'\t// Fast path: convert Long {low, high} directly via native BigInt\n' +
'\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' +
'\t\tconst high = value.high | 0;\n' +
'\t\tconst lo = BigInt(value.low >>> 0);\n' +
'\t\tconst hi = BigInt(value.high >>> 0);\n' +
'\t\tconst combined = (hi << 32n) | lo;\n' +
'\t\tif (!unsigned && high < 0) {\n' +
'\t\t\treturn Number(combined - (1n << 64n));\n' +
'\t\t}\n' +
'\t\treturn Number(combined);\n' +
'\t}\n' +
'\treturn Number(value);\n' +
'\tconst normalized = $util.Long.fromValue(value);\n' +
'\tconst prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"\n' +
'\t\t? normalized.toUnsigned()\n' +
'\t\t: typeof normalized.toSigned === "function"\n' +
'\t\t\t? normalized.toSigned()\n' +
'\t\t\t: normalized;\n' +
'\treturn prepared.toNumber();\n' +
'}\n\n'
if (!content.includes('function longToString(')) {
+95 -1392
View File
File diff suppressed because it is too large Load Diff
+592 -10146
View File
File diff suppressed because it is too large Load Diff
-822
View File
@@ -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 |
-531
View File
@@ -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
View File
@@ -67,7 +67,7 @@ export default defineConfig([globalIgnores([
named: "never",
asyncArrow: "always",
}],
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": ["error", {
caughtErrors: "none",
}],
-72
View File
@@ -1,72 +0,0 @@
#!/bin/bash
#
# Script para aplicar o fix de listMessage no rsalcara/InfiniteAPI
# Corrige: biz node (product_list v2) + conversão nativeFlow → listMessage + logs
#
set -e
echo "============================================"
echo " FIX LIST MESSAGE - rsalcara/InfiniteAPI"
echo "============================================"
echo ""
# Verificar se estamos dentro do repo rsalcara/InfiniteAPI
if [ ! -f "src/Socket/messages-send.ts" ]; then
echo "ERRO: Execute este script dentro do diretório rsalcara/InfiniteAPI"
echo "Exemplo: cd /path/to/rsalcara/InfiniteAPI && bash fix-list-message.sh"
exit 1
fi
echo "[1/4] Adicionando remote infinitezap..."
git remote remove infinitezap 2>/dev/null || true
git remote add infinitezap https://github.com/infinitezap/Teste_InfiniteAPI.git
echo "[2/4] Buscando branch com o fix..."
git fetch infinitezap claude/fix-message-delivery-XlMLH
echo "[3/4] Cherry-picking os 2 commits do fix..."
echo " -> bd7c691: biz node diferenciado (product_list v2)"
echo " -> 191776a: conversão nativeFlowMessage → listMessage"
git cherry-pick bd7c691 191776a --strategy-option=theirs || {
echo ""
echo "Se houve conflito, resolvendo com a versão do fix..."
git checkout --theirs src/Socket/messages-send.ts 2>/dev/null || true
git add src/Socket/messages-send.ts
git cherry-pick --continue --no-edit 2>/dev/null || true
}
echo ""
echo "[4/4] Verificando que o fix está presente..."
echo ""
FILE="src/Socket/messages-send.ts"
CHECK1=$(grep -c "product_list" "$FILE" 2>/dev/null || echo "0")
CHECK2=$(grep -c "\[BIZ NODE\]" "$FILE" 2>/dev/null || echo "0")
CHECK3=$(grep -c "\[STANZA\]" "$FILE" 2>/dev/null || echo "0")
CHECK4=$(grep -c "\[LIST CONVERT\]" "$FILE" 2>/dev/null || echo "0")
echo " product_list (biz node lista): $CHECK1 ocorrências"
echo " [BIZ NODE] (log diagnóstico): $CHECK2 ocorrências"
echo " [STANZA] (log stanza): $CHECK3 ocorrências"
echo " [LIST CONVERT] (conversão): $CHECK4 ocorrências"
echo ""
if [ "$CHECK1" -gt "0" ] && [ "$CHECK2" -gt "0" ] && [ "$CHECK3" -gt "0" ] && [ "$CHECK4" -gt "0" ]; then
echo "============================================"
echo " FIX APLICADO COM SUCESSO!"
echo "============================================"
echo ""
echo "Agora faça o build e deploy:"
echo " npm run build"
echo " git push origin master"
echo ""
else
echo "============================================"
echo " ATENÇÃO: Alguma parte do fix está faltando!"
echo "============================================"
echo ""
echo "Verifique manualmente o arquivo:"
echo " $FILE"
echo ""
fi
+2 -13
View File
@@ -17,26 +17,15 @@ const config: Config = {
useESM: true,
tsconfig: {
module: 'esnext',
allowJs: true,
verbatimModuleSyntax: false,
allowImportingTsExtensions: false,
},
},
],
'^.+\\.js$': [
'ts-jest',
{
useESM: true,
tsconfig: {
module: 'esnext',
allowJs: true,
verbatimModuleSyntax: false,
},
},
],
'^.+\\.js$': ['ts-jest', { useESM: true }],
},
transformIgnorePatterns: [
'node_modules/(?!(protobufjs|long|@protobufjs|@types/long|whatsapp-rust-bridge)/)',
'node_modules/(?!(protobufjs|long|@protobufjs|@types/long)/)',
],
}
-11742
View File
File diff suppressed because it is too large Load Diff
+24 -26
View File
@@ -28,7 +28,7 @@
"changelog:preview": "conventional-changelog -p angular -u",
"changelog:update": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
"example": "tsx ./Example/example.ts",
"gen:protobuf": "cd WAProto && sh GenerateStatics.sh",
"gen:protobuf": "sh WAProto/GenerateStatics.sh",
"format": "prettier --write \"src/**/*.{ts,js,json,md}\"",
"lint": "tsc && eslint src --ext .js,.ts",
"lint:fix": "npm run format && npm run lint -- --fix",
@@ -41,57 +41,55 @@
"update:version": "tsx ./scripts/update-version.ts"
},
"dependencies": {
"@cacheable/node-cache": "^2.0.1",
"@hapi/boom": "^10.0.1",
"@cacheable/node-cache": "^1.4.0",
"@hapi/boom": "^9.1.3",
"async-mutex": "^0.5.0",
"fflate": "^0.8.2",
"libsignal": "github:whiskeysockets/libsignal-node",
"lru-cache": "^11.2.6",
"music-metadata": "^11.12.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": {
+1 -6
View File
@@ -499,12 +499,7 @@ async function findAppModules() {
const decodedProto = Object.keys(decodedProtoMap).sort();
const sortedStr = decodedProto.map((d) => decodedProtoMap[d]).join('\n');
let decodedProtoStr = `syntax = "proto3";\npackage proto;\n\n/// WhatsApp Version: ${whatsAppVersion}\n\n${sortedStr}`;
// Fix: proto3 doesn't support 'required' keyword (only proto2 does)
// Convert all 'required' fields to 'optional' for proto3 compatibility
decodedProtoStr = decodedProtoStr.replace(/(\n\s+)required /g, '$1optional ');
const decodedProtoStr = `syntax = "proto3";\npackage proto;\n\n/// WhatsApp Version: ${whatsAppVersion}\n\n${sortedStr}`;
const destinationPath = '../WAProto/WAProto.proto';
await fs.writeFile(destinationPath, decodedProtoStr);
+94 -272
View File
@@ -1,258 +1,40 @@
#!/usr/bin/env node
/**
* Script to update WhatsApp Web version with robust error handling.
* Fetches the latest version from web.whatsapp.com with:
* - Retry with exponential backoff
* - Multiple source endpoints for redundancy
* - Version validation before updating
*
* Updates: src/Defaults/baileys-version.json (SINGLE SOURCE OF TRUTH)
* Script to update WhatsApp Web version across the codebase.
* Fetches the latest version from web.whatsapp.com and updates:
* - src/Defaults/baileys-version.json
* - src/Defaults/index.ts
* - src/Utils/generics.ts
*
* Usage: yarn update:version
*
* Environment variables:
* - WA_MIN_REVISION: Minimum valid revision number (default: 1000000000)
* - WA_MAX_REVISION: Maximum valid revision number (default: 9999999999)
*
* Exit codes:
* - 0: Success (version updated or already up to date)
* - 1: Fatal error (script failure)
* - 2: Fetch failed (all sources failed, fallback version used)
*/
import { readFileSync, writeFileSync, appendFileSync } from 'fs'
import { readFileSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import { fetchLatestWaWebVersion } from '../src/Utils/generics.ts'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const ROOT_DIR = join(__dirname, '..')
const VERSION_FILE_PATH = join(ROOT_DIR, 'src/Defaults/baileys-version.json')
type WAVersion = [number, number, number]
interface VersionResult {
version: WAVersion
isLatest: boolean
source?: string
error?: unknown
}
/**
* Configuration for the version update script.
*
* Revision bounds explanation:
* - WhatsApp Web uses a numeric "client_revision" that increments with each release
* - As of 2024, revisions are in the 10-digit range (e.g., 1032141294)
* - minRevision (1000000000) ensures we don't accept clearly invalid small numbers
* that could result from parsing errors
* - maxRevision (9999999999) is the maximum 10-digit number, providing an upper sanity check
*
* These bounds can be overridden via environment variables if WhatsApp changes their
* versioning scheme in the future.
*/
const CONFIG = {
maxRetries: 3,
retryDelayMs: 2000, // Base delay, will be multiplied by attempt number
requestTimeoutMs: 10000,
// Version sanity checks - can be overridden via environment variables
minRevision: parseInt(process.env.WA_MIN_REVISION || '1000000000', 10),
maxRevision: parseInt(process.env.WA_MAX_REVISION || '9999999999', 10),
}
/**
* Sleep for specified milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Fetch with timeout
*/
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise<Response> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
function updateBaileysVersionJson(version: [number, number, number]): boolean {
const filePath = join(ROOT_DIR, 'src/Defaults/baileys-version.json')
const content = {
version
}
try {
const response = await fetch(url, {
...options,
signal: controller.signal
})
return response
} finally {
clearTimeout(timeout)
}
}
/**
* Validates that a revision number looks reasonable.
* WhatsApp Web revisions are typically 10-digit numbers.
*
* @param revision - The revision number to validate
* @returns true if the revision is within expected bounds
*/
function isValidRevision(revision: number): boolean {
return (
Number.isInteger(revision) &&
revision >= CONFIG.minRevision &&
revision <= CONFIG.maxRevision
)
}
/**
* Fetches version from WhatsApp Web Service Worker (primary source)
*/
async function fetchFromServiceWorker(): Promise<VersionResult> {
const url = 'https://web.whatsapp.com/sw.js'
const headers = {
'sec-fetch-site': 'none',
'sec-fetch-mode': 'navigate',
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'accept': '*/*',
'accept-language': 'en-US,en;q=0.9',
}
const response = await fetchWithTimeout(url, { method: 'GET', headers }, CONFIG.requestTimeoutMs)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.text()
// Try multiple patterns for robustness
const patterns = [
/\\?"client_revision\\?":\s*(\d+)/,
/"client_revision":\s*(\d+)/,
/client_revision["']?\s*:\s*(\d+)/,
]
for (const regex of patterns) {
const match = data.match(regex)
if (match?.[1]) {
const revision = parseInt(match[1], 10)
if (isValidRevision(revision)) {
return {
version: [2, 3000, revision] as WAVersion,
isLatest: true,
source: 'sw.js'
}
}
}
}
throw new Error('Could not find valid client_revision in sw.js')
}
/**
* Fetches version from WhatsApp Web bootstrap (backup source)
*/
async function fetchFromBootstrap(): Promise<VersionResult> {
const url = 'https://web.whatsapp.com/'
const headers = {
'sec-fetch-site': 'none',
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'accept': 'text/html,application/xhtml+xml',
}
const response = await fetchWithTimeout(url, { method: 'GET', headers }, CONFIG.requestTimeoutMs)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.text()
// Look for version in HTML/JS bootstrap
const patterns = [
/\"client_revision\":(\d+)/,
/clientRevision['":\s]+(\d+)/i,
]
for (const regex of patterns) {
const match = data.match(regex)
if (match?.[1]) {
const revision = parseInt(match[1], 10)
if (isValidRevision(revision)) {
return {
version: [2, 3000, revision] as WAVersion,
isLatest: true,
source: 'bootstrap'
}
}
}
}
throw new Error('Could not find valid client_revision in bootstrap page')
}
/**
* Fetches the latest WhatsApp Web version with retry and fallback
*/
async function fetchLatestWaWebVersion(): Promise<VersionResult> {
// Read current version as fallback
const currentContent = readFileSync(VERSION_FILE_PATH, 'utf-8')
const fallbackVersion = JSON.parse(currentContent).version as WAVersion
const sources = [
{ name: 'Service Worker', fetch: fetchFromServiceWorker },
{ name: 'Bootstrap Page', fetch: fetchFromBootstrap },
]
const errors: string[] = []
for (const source of sources) {
for (let attempt = 1; attempt <= CONFIG.maxRetries; attempt++) {
try {
console.log(` Attempting ${source.name} (attempt ${attempt}/${CONFIG.maxRetries})...`)
const result = await source.fetch()
console.log(` ✓ Success from ${source.name}`)
return result
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
errors.push(`${source.name} attempt ${attempt}: ${errorMsg}`)
console.log(`${source.name} failed: ${errorMsg}`)
if (attempt < CONFIG.maxRetries) {
const delay = CONFIG.retryDelayMs * attempt
console.log(` Waiting ${delay}ms before retry...`)
await sleep(delay)
}
}
}
}
console.log('\n⚠ All sources failed, using fallback version')
return {
version: fallbackVersion,
isLatest: false,
error: { message: 'All fetch attempts failed', details: errors }
}
}
function updateBaileysVersionJson(version: WAVersion, source?: string): boolean {
const content = { version }
try {
const currentContent = readFileSync(VERSION_FILE_PATH, 'utf-8')
const currentContent = readFileSync(filePath, 'utf-8')
const currentVersion = JSON.parse(currentContent).version as number[]
if (
currentVersion[0] === version[0] &&
currentVersion[1] === version[1] &&
currentVersion[2] === version[2]
) {
if (currentVersion[0] === version[0] && currentVersion[1] === version[1] && currentVersion[2] === version[2]) {
console.log(`✓ baileys-version.json already up to date`)
return false
}
writeFileSync(VERSION_FILE_PATH, JSON.stringify(content) + '\n')
writeFileSync(filePath, JSON.stringify(content) + '\n')
console.log(`✓ Updated baileys-version.json: [${currentVersion.join(', ')}] → [${version.join(', ')}]`)
if (source) {
console.log(` Source: ${source}`)
}
console.log(` (index.ts and generics.ts automatically use the new version via import)`)
return true
} catch (error) {
console.error(`✗ Failed to update baileys-version.json:`, error)
@@ -260,65 +42,105 @@ function updateBaileysVersionJson(version: WAVersion, source?: string): boolean
}
}
function updateGenerics(version: [number, number, number]): boolean {
const filePath = join(ROOT_DIR, 'src/Utils/generics.ts')
try {
const content = readFileSync(filePath, 'utf-8')
const versionRegex = /const baileysVersion = \[(\d+),\s*(\d+),\s*(\d+)\]/
const match = content.match(versionRegex)
if (!match) {
throw new Error('Could not find baileysVersion declaration in generics.ts')
}
const currentVersion = [+match[1]!, +match[2]!, +match[3]!]
if (currentVersion[0] === version[0] && currentVersion[1] === version[1] && currentVersion[2] === version[2]) {
console.log(`✓ src/Utils/generics.ts already up to date`)
return false
}
const newContent = content.replace(
versionRegex,
`const baileysVersion = [${version[0]}, ${version[1]}, ${version[2]}]`
)
writeFileSync(filePath, newContent)
console.log(`✓ Updated src/Utils/generics.ts: [${currentVersion.join(', ')}] → [${version.join(', ')}]`)
return true
} catch (error) {
console.error(`✗ Failed to update src/Utils/generics.ts:`, error)
throw error
}
}
function updateIndex(version: [number, number, number]): boolean {
const filePath = join(ROOT_DIR, 'src/Defaults/index.ts')
try {
const content = readFileSync(filePath, 'utf-8')
const versionRegex = /const version = \[(\d+),\s*(\d+),\s*(\d+)\]/
const match = content.match(versionRegex)
if (!match) {
throw new Error('Could not find version declaration in index.ts')
}
const currentVersion = [+match[1]!, +match[2]!, +match[3]!]
if (currentVersion[0] === version[0] && currentVersion[1] === version[1] && currentVersion[2] === version[2]) {
console.log(`✓ src/Defaults/index.ts already up to date`)
return false
}
const newContent = content.replace(versionRegex, `const version = [${version[0]}, ${version[1]}, ${version[2]}]`)
writeFileSync(filePath, newContent)
console.log(`✓ Updated src/Defaults/index.ts: [${currentVersion.join(', ')}] → [${version.join(', ')}]`)
return true
} catch (error) {
console.error(`✗ Failed to update src/Defaults/index.ts:`, error)
throw error
}
}
async function main() {
console.log('╔════════════════════════════════════════════════╗')
console.log('║ WhatsApp Web Version Update Script ║')
console.log('╚════════════════════════════════════════════════╝\n')
// Log configuration
console.log('Configuration:')
console.log(` Min revision: ${CONFIG.minRevision}`)
console.log(` Max revision: ${CONFIG.maxRevision}`)
console.log(` Max retries: ${CONFIG.maxRetries}`)
console.log(` Request timeout: ${CONFIG.requestTimeoutMs}ms\n`)
console.log('Fetching latest WhatsApp Web version...\n')
const result = await fetchLatestWaWebVersion()
console.log('')
if (result.isLatest) {
console.log(`Latest version: [${result.version.join(', ')}]`)
if (result.source) {
console.log(`Source: ${result.source}\n`)
}
} else {
console.log(`⚠ Using fallback version: [${result.version.join(', ')}]`)
console.log(`Reason: ${JSON.stringify(result.error)}\n`)
if (!result.isLatest) {
console.error('Failed to fetch latest version:', result.error)
process.exit(1)
}
const hasUpdates = updateBaileysVersionJson(result.version, result.source)
console.log(`Latest version: [${result.version.join(', ')}]\n`)
const updates = [
updateBaileysVersionJson(result.version),
updateGenerics(result.version),
updateIndex(result.version)
]
const hasUpdates = updates.some(Boolean)
console.log('')
if (hasUpdates) {
console.log('═══════════════════════════════════════════════')
console.log('Version update complete!')
console.log('═══════════════════════════════════════════════')
// Set GitHub Actions output if running in CI
if (process.env.GITHUB_OUTPUT) {
const { appendFileSync } = await import('fs')
appendFileSync(process.env.GITHUB_OUTPUT, `updated=true\n`)
appendFileSync(process.env.GITHUB_OUTPUT, `version=${result.version.join('.')}\n`)
appendFileSync(process.env.GITHUB_OUTPUT, `source=${result.source || 'fallback'}\n`)
}
} else {
console.log('Already up to date. No changes needed.')
console.log('All files are already up to date.')
if (process.env.GITHUB_OUTPUT) {
const { appendFileSync } = await import('fs')
appendFileSync(process.env.GITHUB_OUTPUT, `updated=false\n`)
}
}
// Exit with code 2 if fetch failed (Fix #3)
// This allows CI to distinguish between:
// - 0: Success
// - 1: Script error
// - 2: Fetch failed but fallback used
if (!result.isLatest) {
console.log('\n⚠ Warning: Could not fetch latest version from WhatsApp servers')
console.log('Exit code 2: Fetch failed, fallback version used')
if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, `fetch_failed=true\n`)
}
process.exit(2)
}
}
main().catch(error => {
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1038167900]}
{"version":[2,3000,1033846690]}
+24 -203
View File
@@ -3,10 +3,8 @@ import { makeLibSignalRepository } from '../Signal/libsignal'
import type { AuthenticationState, SocketConfig, WAVersion } from '../Types'
import { Browsers } from '../Utils/browser-utils'
import logger from '../Utils/logger'
// Single source of truth for WhatsApp Web version - imported from JSON
import baileysVersionData from './baileys-version.json' with { type: 'json' }
const version = baileysVersionData.version
const version = [2, 3000, 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
}
+7 -12
View File
@@ -27,7 +27,7 @@ export class SenderKeyMessage extends CiphertextMessage {
super()
if (serialized) {
const version = serialized[0] ?? 0
const version = serialized[0]!
const message = serialized.slice(1, serialized.length - this.SIGNATURE_LENGTH)
const signature = serialized.slice(-1 * this.SIGNATURE_LENGTH)
const senderKeyMessage = proto.SenderKeyMessage.decode(message).toJSON() as SenderKeyMessageStructure
@@ -42,27 +42,22 @@ export class SenderKeyMessage extends CiphertextMessage {
: senderKeyMessage.ciphertext
this.signature = signature
} else {
// eslint-disable-next-line eqeqeq
if (ciphertext == null || keyId == null || iteration == null || signatureKey == null) {
throw new Error('Missing required parameters for SenderKeyMessage construction')
}
const version = (((this.CURRENT_VERSION << 4) | this.CURRENT_VERSION) & 0xff) % 256
const ciphertextBuffer = Buffer.from(ciphertext)
const ciphertextBuffer = Buffer.from(ciphertext!)
const message = proto.SenderKeyMessage.encode(
proto.SenderKeyMessage.create({
id: keyId,
iteration: iteration,
id: keyId!,
iteration: iteration!,
ciphertext: ciphertextBuffer
})
).finish()
const signature = this.getSignature(signatureKey, Buffer.concat([Buffer.from([version]), message]))
const signature = this.getSignature(signatureKey!, Buffer.concat([Buffer.from([version]), message]))
this.serialized = Buffer.concat([Buffer.from([version]), message, Buffer.from(signature)])
this.messageVersion = this.CURRENT_VERSION
this.keyId = keyId
this.iteration = iteration
this.keyId = keyId!
this.iteration = iteration!
this.ciphertext = ciphertextBuffer
this.signature = signature
}
+200 -726
View File
File diff suppressed because it is too large Load Diff
+232 -1044
View File
File diff suppressed because it is too large Load Diff
-274
View File
@@ -1,274 +0,0 @@
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { ILogger } from '../Utils/logger'
/**
* Session activity tracker configuration
*/
export interface SessionActivityConfig {
/** Flush interval in milliseconds (default: 60s) */
flushIntervalMs: number
/** Enable activity tracking (default: true) */
enabled: boolean
}
/**
* Default configuration for session activity tracking
*/
export const DEFAULT_SESSION_ACTIVITY_CONFIG: SessionActivityConfig = {
flushIntervalMs: parseInt(process.env.BAILEYS_SESSION_ACTIVITY_FLUSH_MS || '60000', 10), // 1 minute
enabled: process.env.BAILEYS_SESSION_ACTIVITY_ENABLED !== 'false'
}
/**
* Session activity metadata stored in key store
*/
export interface SessionActivityMetadata {
/** Last activity timestamp (Unix milliseconds) */
lastActivityAt: number
/** Session created timestamp (Unix milliseconds) */
createdAt?: number
}
/**
* Session Activity Tracker
*
* Tracks when sessions were last used (message sent/received) to enable
* cleanup of inactive sessions.
*
* PERFORMANCE OPTIMIZATION:
* - In-memory cache: Updates stored in Map (no disk I/O per message)
* - Periodic flush: Writes to disk every 60s (configurable)
* - Batch writes: All updates written in single transaction
*
* OVERHEAD: <0.1ms per message (just Map.set() in memory)
*
* @example
* const tracker = makeSessionActivityTracker(keys, logger)
* tracker.start()
*
* // On message send/receive:
* tracker.recordActivity('5511999999999@s.whatsapp.net')
*
* // On cleanup:
* const lastActivity = await tracker.getLastActivity('5511999999999@s.whatsapp.net')
* if (Date.now() - lastActivity > 30_DAYS) {
* // Delete session
* }
*/
export const makeSessionActivityTracker = (
keys: SignalKeyStoreWithTransaction,
logger: ILogger,
config: SessionActivityConfig = DEFAULT_SESSION_ACTIVITY_CONFIG
) => {
// In-memory cache: JID -> timestamp
const activityCache = new Map<string, number>()
// Dirty flag: tracks if cache has unflushed changes
let isDirty = false
// Flush interval timer
let flushInterval: ReturnType<typeof setInterval> | null = null
// Statistics
const stats = {
totalUpdates: 0,
totalFlushes: 0,
lastFlushAt: 0,
lastFlushDuration: 0,
cacheSize: 0
}
/**
* Record activity for a JID
* Updates in-memory cache only (fast, <0.1ms)
*/
const recordActivity = (jid: string): void => {
if (!config.enabled) return
const now = Date.now()
activityCache.set(jid, now)
isDirty = true
stats.totalUpdates++
stats.cacheSize = activityCache.size
}
/**
* Get last activity timestamp for a JID
* Checks in-memory cache first, then disk
*/
const getLastActivity = async (jid: string): Promise<number | undefined> => {
if (!config.enabled) return undefined
// Check cache first
const cached = activityCache.get(jid)
if (cached) return cached
// Fallback to disk
try {
const key = `session-activity:${jid}`
const data = await keys.get('session-activity' as any, [key])
const metadata = data[key] as SessionActivityMetadata | undefined
return metadata?.lastActivityAt
} catch (error) {
logger.warn({ error, jid }, 'Failed to get session activity from disk')
return undefined
}
}
/**
* Get all session activities (for cleanup)
* Returns Map of JID -> lastActivityAt
*/
const getAllActivities = async (): Promise<Map<string, number>> => {
if (!config.enabled) return new Map()
const result = new Map<string, number>()
try {
// Get all session activity keys from disk
const allData = await keys.get('session-activity' as any, [])
for (const [key, value] of Object.entries(allData)) {
if (key.startsWith('session-activity:')) {
const jid = key.replace('session-activity:', '')
const metadata = value as SessionActivityMetadata
if (metadata?.lastActivityAt) {
result.set(jid, metadata.lastActivityAt)
}
}
}
// Merge with in-memory cache (cache is more recent)
for (const [jid, timestamp] of activityCache.entries()) {
result.set(jid, timestamp)
}
} catch (error) {
logger.warn({ error }, 'Failed to get all session activities')
}
return result
}
/**
* Flush in-memory cache to disk
* Writes all pending updates in single transaction
*/
const flush = async (): Promise<void> => {
if (!config.enabled || !isDirty || activityCache.size === 0) {
return
}
const startTime = Date.now()
try {
// Prepare batch update
const updates: { [key: string]: SessionActivityMetadata } = {}
for (const [jid, timestamp] of activityCache.entries()) {
const key = `session-activity:${jid}`
updates[key] = {
lastActivityAt: timestamp,
createdAt: timestamp // First activity = creation time
}
}
// Single transaction for all updates
await keys.transaction(async () => {
await keys.set({ 'session-activity': updates } as any)
}, 'session-activity-flush')
isDirty = false
stats.totalFlushes++
stats.lastFlushAt = Date.now()
stats.lastFlushDuration = Date.now() - startTime
logger.debug(
{
count: activityCache.size,
duration: stats.lastFlushDuration
},
'💾 Session activity flushed to disk'
)
// Clear cache after successful flush
activityCache.clear()
stats.cacheSize = 0
} catch (error) {
logger.error({ error, count: activityCache.size }, 'Failed to flush session activity')
// Keep cache for retry on next flush
}
}
/**
* Start periodic flush
*/
const start = (): void => {
if (!config.enabled) {
logger.info('Session activity tracking is disabled')
return
}
if (flushInterval) {
logger.warn('Session activity tracker already started')
return
}
logger.info(
{
flushIntervalMs: config.flushIntervalMs,
flushIntervalSeconds: config.flushIntervalMs / 1000
},
'⏱️ Session activity tracker started'
)
// Flush immediately on start (recover any pending data)
flush().catch(err => {
logger.warn({ err }, 'Initial flush failed')
})
// Schedule periodic flush
flushInterval = setInterval(() => {
flush().catch(err => {
logger.warn({ err }, 'Periodic flush failed')
})
}, config.flushIntervalMs)
}
/**
* Stop periodic flush and flush pending data
*/
const stop = async (): Promise<void> => {
if (flushInterval) {
clearInterval(flushInterval)
flushInterval = null
}
// Final flush before stopping
await flush()
logger.info('Session activity tracker stopped')
}
/**
* Get tracker statistics
*/
const getStats = () => ({
enabled: config.enabled,
...stats
})
return {
recordActivity,
getLastActivity,
getAllActivities,
flush,
start,
stop,
getStats
}
}
/**
* Session activity tracker type
*/
export type SessionActivityTracker = ReturnType<typeof makeSessionActivityTracker>
-449
View File
@@ -1,449 +0,0 @@
/* eslint-disable max-depth, @typescript-eslint/no-unused-vars */
import { DEFAULT_SESSION_CLEANUP_CONFIG } from '../Defaults'
import type { SessionCleanupConfig, SessionCleanupStats, SignalKeyStoreWithTransaction } from '../Types'
import type { ILogger } from '../Utils/logger'
import { jidDecode } from '../WABinary'
import type { LIDMappingStore } from './lid-mapping'
import type { SessionActivityTracker } from './session-activity-tracker'
// Re-export for backward compatibility
export type { SessionCleanupConfig, SessionCleanupStats }
/**
* Session metadata for cleanup decisions
*/
interface SessionMetadata {
jid: string
isLID: boolean
isPrimary: boolean
lastActivityMs?: number
createdAtMs?: number
hasLIDMapping?: boolean
}
/**
* Creates a session cleanup manager
*
* SAFETY GUARANTEES:
* - Does NOT affect WebSocket connections (only local database)
* - Does NOT cause message loss (Signal Protocol auto-recreates sessions)
* - Runs in low-traffic hours (configurable, default 3am)
* - Atomic transactions (all-or-nothing)
* - Comprehensive logging and statistics
*
* CLEANUP RULES:
* 1. Secondary devices (Web, Desktop) - Inactive > X days (default: 15)
* 2. Primary devices - Inactive > Y days (default: 30)
* 3. LID orphans (no PN mapping) - Inactive > Z hours (default: 24)
*
* @param keys - Signal key store with transaction support
* @param lidMapping - LID mapping store for orphan detection
* @param sessionActivityTracker - Session activity tracker for timestamp-based cleanup
* @param logger - Structured logger instance
* @param config - Cleanup configuration (uses defaults from env)
*/
export const makeSessionCleanup = (
keys: SignalKeyStoreWithTransaction,
lidMapping: LIDMappingStore,
sessionActivityTracker: SessionActivityTracker | null,
logger: ILogger,
config: SessionCleanupConfig = DEFAULT_SESSION_CLEANUP_CONFIG
) => {
let cleanupInterval: ReturnType<typeof setInterval> | null = null
let initialTimeout: ReturnType<typeof setTimeout> | null = null
let lastCleanupAt = 0
let cleanupRunning = false
let startupCleanupPromise: Promise<void> | null = null
/**
* Get all sessions from database
* Returns array of session keys (signal addresses)
*/
const getAllSessionKeys = async (): Promise<string[]> => {
try {
// Get all sessions from key store
// Signal addresses format: "user_domain.device"
const sessions = await keys.get('session', [])
return Object.keys(sessions)
} catch (error) {
logger.error({ error }, 'Failed to get all session keys')
return []
}
}
/**
* Parse session metadata from signal address
* Signal address format: "user_domain.device"
* Examples:
* "5511999999999_0.0" → PN primary device
* "5511999999999_0.1" → PN secondary device
* "123456789_2.0" → LID primary device
*/
const parseSessionMetadata = async (signalAddr: string): Promise<SessionMetadata | null> => {
try {
// Parse signal address: "user_domain.device"
const [userWithDomain, deviceStr] = signalAddr.split('.')
if (!userWithDomain) return null
const [user, domainStr] = userWithDomain.split('_')
if (!user) return null
const device = parseInt(deviceStr || '0', 10)
const domain = parseInt(domainStr || '0', 10)
// Determine if LID (domain 2 or 6) or PN (domain 0 or 5)
const isLID = domain === 2 || domain === 6
const isPrimary = device === 0
// Construct JID for mapping lookup
let jid: string
if (isLID) {
jid = `${user}@lid`
} else {
jid = `${user}${device > 0 ? `:${device}` : ''}@s.whatsapp.net`
}
// Check if LID has PN mapping
let hasLIDMapping: boolean | undefined
if (isLID) {
const pn = await lidMapping.getPNForLID(jid)
hasLIDMapping = !!pn
}
return {
jid,
isLID,
isPrimary,
hasLIDMapping
}
} catch (error) {
logger.warn({ error, signalAddr }, 'Failed to parse session metadata')
return null
}
}
/**
* Determine if session should be cleaned up
*/
const shouldCleanupSession = (metadata: SessionMetadata, now: number): { cleanup: boolean; reason: string } => {
// Rule 1: LID orphans (no PN mapping) after X hours
if (metadata.isLID && metadata.hasLIDMapping === false) {
const thresholdMs = config.lidOrphanHours * 60 * 60 * 1000
// Check if lastActivity is old enough
if (metadata.lastActivityMs) {
const inactiveDuration = now - metadata.lastActivityMs
if (inactiveDuration > thresholdMs) {
return {
cleanup: true,
reason: `LID orphan without PN mapping, inactive for ${Math.floor(inactiveDuration / 3600000)}h (threshold: ${config.lidOrphanHours}h)`
}
}
} else {
// No activity tracked - consider it old
return {
cleanup: true,
reason: `LID orphan without PN mapping, no activity tracked (threshold: ${config.lidOrphanHours}h)`
}
}
return { cleanup: false, reason: 'LID orphan but not inactive long enough' }
}
// Rule 2: Secondary devices inactive > X days
if (!metadata.isPrimary && metadata.lastActivityMs) {
const thresholdMs = config.secondaryDeviceInactiveDays * 24 * 60 * 60 * 1000
const inactiveDuration = now - metadata.lastActivityMs
if (inactiveDuration > thresholdMs) {
return {
cleanup: true,
reason: `Secondary device inactive for ${Math.floor(inactiveDuration / 86400000)} days (threshold: ${config.secondaryDeviceInactiveDays} days)`
}
}
return { cleanup: false, reason: `Secondary device active within ${config.secondaryDeviceInactiveDays} days` }
}
// Rule 3: Primary devices inactive > Y days
if (metadata.isPrimary && metadata.lastActivityMs) {
const thresholdMs = config.primaryDeviceInactiveDays * 24 * 60 * 60 * 1000
const inactiveDuration = now - metadata.lastActivityMs
if (inactiveDuration > thresholdMs) {
return {
cleanup: true,
reason: `Primary device inactive for ${Math.floor(inactiveDuration / 86400000)} days (threshold: ${config.primaryDeviceInactiveDays} days)`
}
}
return { cleanup: false, reason: `Primary device active within ${config.primaryDeviceInactiveDays} days` }
}
// No lastActivity tracked - don't cleanup yet (grace period)
return { cleanup: false, reason: 'No activity tracked yet' }
}
/**
* Run session cleanup
* Returns statistics about cleanup operation
*/
const runCleanup = async (): Promise<SessionCleanupStats> => {
const startTime = Date.now()
const stats: SessionCleanupStats = {
totalScanned: 0,
secondaryDevicesDeleted: 0,
primaryDevicesDeleted: 0,
lidOrphansDeleted: 0,
totalDeleted: 0,
durationMs: 0,
errors: 0
}
if (!config.enabled) {
logger.info('Session cleanup is disabled')
return stats
}
if (cleanupRunning) {
logger.warn('Session cleanup already running, skipping')
return stats
}
cleanupRunning = true
logger.info('🧹 Starting session cleanup')
try {
// Get all session keys
const sessionKeys = await getAllSessionKeys()
stats.totalScanned = sessionKeys.length
logger.info({ totalSessions: sessionKeys.length }, 'Scanning sessions for cleanup')
// Get all activity timestamps (if tracker available)
const activityMap = sessionActivityTracker
? await sessionActivityTracker.getAllActivities()
: new Map<string, number>()
logger.debug({ trackedActivities: activityMap.size }, 'Loaded session activity timestamps')
// Prepare bulk deletion
const sessionsToDelete: string[] = []
const deletionReasons: { [key: string]: string } = {}
// Process each session
for (const signalAddr of sessionKeys) {
try {
const metadata = await parseSessionMetadata(signalAddr)
if (!metadata) {
stats.errors++
continue
}
// Add lastActivity from tracker
metadata.lastActivityMs = activityMap.get(metadata.jid)
const { cleanup, reason } = shouldCleanupSession(metadata, Date.now())
if (cleanup) {
sessionsToDelete.push(signalAddr)
deletionReasons[signalAddr] = reason
// Update statistics
if (metadata.isLID && !metadata.hasLIDMapping) {
stats.lidOrphansDeleted++
} else if (!metadata.isPrimary) {
stats.secondaryDevicesDeleted++
} else {
stats.primaryDevicesDeleted++
}
logger.debug(
{
jid: metadata.jid,
signalAddr,
reason,
isLID: metadata.isLID,
isPrimary: metadata.isPrimary
},
'Session marked for deletion'
)
}
} catch (error) {
logger.warn({ error, signalAddr }, 'Error processing session for cleanup')
stats.errors++
}
}
// Bulk delete sessions
if (sessionsToDelete.length > 0) {
logger.info({ count: sessionsToDelete.length }, '🗑️ Deleting orphaned/inactive sessions')
try {
// Prepare session updates (set to null = delete)
const sessionUpdates: { [key: string]: null } = {}
sessionsToDelete.forEach(addr => {
sessionUpdates[addr] = null
})
// Single atomic transaction for all deletions
await keys.transaction(async () => {
await keys.set({ session: sessionUpdates })
}, 'session-cleanup')
stats.totalDeleted = sessionsToDelete.length
logger.info(
{
deleted: stats.totalDeleted,
lidOrphans: stats.lidOrphansDeleted,
secondaryDevices: stats.secondaryDevicesDeleted,
primaryDevices: stats.primaryDevicesDeleted,
errors: stats.errors
},
'✅ Session cleanup completed successfully'
)
} catch (error) {
logger.error({ error, count: sessionsToDelete.length }, '❌ Failed to delete sessions')
stats.errors++
}
} else {
logger.info('No sessions to cleanup')
}
lastCleanupAt = Date.now()
stats.durationMs = Date.now() - startTime
return stats
} catch (error) {
logger.error({ error }, '❌ Session cleanup failed')
stats.errors++
stats.durationMs = Date.now() - startTime
return stats
} finally {
cleanupRunning = false
}
}
/**
* Calculate milliseconds until next cleanup time
* Ensures cleanup runs at configured hour (default: 3am)
*/
const msUntilNextCleanup = (): number => {
const now = new Date()
const next = new Date()
next.setHours(config.cleanupHour, 0, 0, 0)
// If we're past cleanup hour today, schedule for tomorrow
if (now.getHours() >= config.cleanupHour) {
next.setDate(next.getDate() + 1)
}
return next.getTime() - now.getTime()
}
/**
* Start periodic session cleanup
* Runs at configured hour (default: 3am daily)
*/
const start = () => {
if (!config.enabled) {
logger.info('Session cleanup is disabled')
return
}
if (cleanupInterval) {
logger.warn('Session cleanup already started')
return
}
logger.info(
{
enabled: config.enabled,
intervalHours: config.intervalMs / (60 * 60 * 1000),
cleanupHour: config.cleanupHour,
secondaryDeviceInactiveDays: config.secondaryDeviceInactiveDays,
primaryDeviceInactiveDays: config.primaryDeviceInactiveDays,
lidOrphanHours: config.lidOrphanHours,
cleanupOnStartup: config.cleanupOnStartup,
autoCleanCorrupted: config.autoCleanCorrupted
},
'🧹 Session cleanup scheduler started'
)
// Run immediate cleanup on startup if enabled
if (config.cleanupOnStartup) {
logger.info('🚀 Running cleanup on startup...')
startupCleanupPromise = runCleanup()
.catch(err => {
logger.error({ err }, 'Cleanup on startup failed')
})
.then(() => {
startupCleanupPromise = null // Clear after completion
})
}
// Schedule first cleanup at configured hour
const msUntilFirst = msUntilNextCleanup()
logger.info(
{ msUntilFirst, nextCleanup: new Date(Date.now() + msUntilFirst).toISOString() },
'⏰ First cleanup scheduled'
)
initialTimeout = setTimeout(async () => {
initialTimeout = null // Clear reference after execution
// Wait for startup cleanup to complete (if still running)
if (startupCleanupPromise) {
logger.debug('Waiting for startup cleanup to complete before scheduled cleanup...')
await startupCleanupPromise
}
// Run first cleanup
await runCleanup()
// Schedule recurring cleanup
cleanupInterval = setInterval(async () => {
await runCleanup()
}, config.intervalMs)
}, msUntilFirst)
}
/**
* Stop periodic session cleanup
*/
const stop = () => {
if (initialTimeout) {
clearTimeout(initialTimeout)
initialTimeout = null
}
if (cleanupInterval) {
clearInterval(cleanupInterval)
cleanupInterval = null
logger.info('Session cleanup scheduler stopped')
}
}
/**
* Get cleanup statistics
*/
const getStats = () => ({
enabled: config.enabled,
lastCleanupAt,
cleanupRunning,
config
})
return {
start,
stop,
runCleanup,
getStats
}
}
/**
* Session cleanup manager type
*/
export type SessionCleanupManager = ReturnType<typeof makeSessionCleanup>
+1 -8
View File
@@ -13,14 +13,7 @@ export abstract class AbstractSocketClient extends EventEmitter {
public config: SocketConfig
) {
super()
// Set max listeners from config (default: 50)
// WARNING: 0 disables limit and allows potential memory leaks
const maxListeners = this.config.maxSocketClientListeners ?? 50
if (maxListeners === 0) {
this.config.logger?.warn('SocketClient setMaxListeners(0) allows UNLIMITED listeners - potential memory leak!')
}
this.setMaxListeners(maxListeners)
this.setMaxListeners(0)
}
abstract connect(): void
+1 -8
View File
@@ -31,14 +31,7 @@ export class WebSocketClient extends AbstractSocketClient {
agent: this.config.agent
})
// Set max listeners from config (default: 20)
// WARNING: 0 disables limit and allows potential memory leaks
const maxListeners = this.config.maxWebSocketListeners ?? 20
if (maxListeners === 0) {
this.config.logger?.warn('WebSocket setMaxListeners(0) allows UNLIMITED listeners - potential memory leak!')
}
this.socket.setMaxListeners(maxListeners)
this.socket.setMaxListeners(0)
const events = ['close', 'error', 'upgrade', 'message', 'open', 'ping', 'pong', 'unexpected-response']
+3 -15
View File
@@ -125,11 +125,7 @@ export const makeBusinessSocket = (config: SocketConfig) => {
]
})
if (!fbid) {
throw new Error('Cover photo upload failed: missing fbid')
}
return fbid
return fbid!
}
const removeCoverPhoto = async (id: string) => {
@@ -336,11 +332,7 @@ export const makeBusinessSocket = (config: SocketConfig) => {
const productCatalogEditNode = getBinaryNodeChild(result, 'product_catalog_edit')
const productNode = getBinaryNodeChild(productCatalogEditNode, 'product')
if (!productNode) {
throw new Error('Product node not found in catalog edit response')
}
return parseProductNode(productNode)
return parseProductNode(productNode!)
}
const productCreate = async (create: ProductCreate) => {
@@ -380,11 +372,7 @@ export const makeBusinessSocket = (config: SocketConfig) => {
const productCatalogAddNode = getBinaryNodeChild(result, 'product_catalog_add')
const productNode = getBinaryNodeChild(productCatalogAddNode, 'product')
if (!productNode) {
throw new Error('Product node not found in catalog add response')
}
return parseProductNode(productNode)
return parseProductNode(productNode!)
}
const productDelete = async (productIds: string[]) => {
+90 -517
View File
@@ -1,19 +1,12 @@
import NodeCache from '@cacheable/node-cache'
import { Boom } from '@hapi/boom'
import { LRUCache } from 'lru-cache'
import { proto } from '../../WAProto/index.js'
import {
DEFAULT_CACHE_MAX_KEYS,
DEFAULT_CACHE_TTLS,
HISTORY_SYNC_PAUSED_TIMEOUT_MS,
PROCESSABLE_HISTORY_TYPES
} from '../Defaults'
import { DEFAULT_CACHE_TTLS, PROCESSABLE_HISTORY_TYPES } from '../Defaults'
import type {
BotListInfo,
CacheStore,
ChatModification,
ChatMutation,
ChatUpdate,
LTHashState,
MessageUpsertType,
PresenceData,
@@ -42,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,60 +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
/**
* Server-assigned AB props that gate tctoken-related protocol behavior.
* Defaults match WA Web (safe — avoids spurious error 463 if the prop never
* arrives). Populated from `fetchProps()` on connection.
*
* - `privacyTokenOn1to1` (AB prop 10518 / `privacy_token_sending_on_all_1_on_1_messages`):
* include tctoken in 1:1 messages.
* - `profilePicPrivacyToken` (AB prop 9666 / `profile_scraping_privacy_token_in_photo_iq`):
* include tctoken in profile picture IQs.
* - `lidTrustedTokenIssueToLid` (AB prop 14303 / `lid_trusted_token_issue_to_lid`):
* issue privacy tokens to the contact's LID instead of the PN.
*/
const serverProps = {
privacyTokenOn1to1: true,
profilePicPrivacyToken: true,
lidTrustedTokenIssueToLid: false
}
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>({
@@ -161,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({
@@ -326,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']!
})
}
}
@@ -379,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
@@ -415,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
@@ -472,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: {
@@ -519,7 +381,10 @@ export const makeChatsSocket = (config: SocketConfig) => {
content: [
{
tag: 'item',
attrs: itemAttrs
attrs: {
action,
jid
}
}
]
})
@@ -600,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
)
@@ -615,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)
@@ -637,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
}
@@ -647,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()
}
})
}
@@ -731,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)
}
}
}
@@ -776,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]!)
}
}
)
@@ -799,29 +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 })
// Gate inclusion on AB prop 9666 (profile_scraping_privacy_token_in_photo_iq).
// WA Web defaults to true; if the server flips it off, we mirror that to
// avoid divergence with the spec-compliant client.
if (serverProps.profilePicPrivacyToken && isUserJid && !isSelf) {
content = await buildTcTokenFromJid({
authState,
jid: normalizedJid,
baseContent,
getLIDForPN
})
}
jid = normalizedJid
jid = jidNormalizedUser(jid)
const result = await query(
{
tag: 'iq',
@@ -831,7 +651,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
type: 'get',
xmlns: 'w:profile:picture'
},
content
content: tcTokenContent
},
timeoutMs
)
@@ -862,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',
@@ -879,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: [
{
@@ -923,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',
@@ -941,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
}
@@ -961,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'
}
@@ -981,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 } })
}
}
@@ -1014,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
@@ -1067,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 || ''
}
}
]
@@ -1114,25 +886,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
props = reduceBinaryNodeToDictionary(propsNode, 'prop')
}
// Extract protocol-relevant AB props (defaults match WA Web; see serverProps doc).
// We accept both numeric IDs and human-readable names so the parser is resilient
// to upstream renaming and to future-versioned WA Web servers.
const privacyTokenProp = props['10518'] ?? props['privacy_token_sending_on_all_1_on_1_messages']
if (privacyTokenProp !== undefined) {
serverProps.privacyTokenOn1to1 = privacyTokenProp === 'true' || privacyTokenProp === '1'
}
const profilePicProp = props['9666'] ?? props['profile_scraping_privacy_token_in_photo_iq']
if (profilePicProp !== undefined) {
serverProps.profilePicPrivacyToken = profilePicProp === 'true' || profilePicProp === '1'
}
const lidIssueProp = props['14303'] ?? props['lid_trusted_token_issue_to_lid']
if (lidIssueProp !== undefined) {
serverProps.lidTrustedTokenIssueToLid = lidIssueProp === 'true' || lidIssueProp === '1'
}
logger.debug({ serverProps }, 'fetched props')
logger.debug('fetched props')
return props
}
@@ -1325,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) {
@@ -1395,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)
@@ -1437,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')!
@@ -1480,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
@@ -1544,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)
@@ -1560,97 +1206,24 @@ 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')
}
})
return {
...sock,
serverProps,
createCallLink,
getBotListV2,
messageMutex,
+6 -16
View File
@@ -78,11 +78,11 @@ export const makeCommunitiesSocket = (config: SocketConfig) => {
}
async function parseGroupResult(node: BinaryNode) {
logger.debug({ nodeTag: node.tag }, 'parseGroupResult')
logger.info({ node }, 'parseGroupResult')
const groupNode = getBinaryNodeChild(node, 'group')
if (groupNode) {
try {
logger.debug({ groupId: groupNode.attrs?.id }, 'parsing group metadata')
logger.info({ groupNode }, 'groupNode')
const metadata = await sock.groupMetadata(`${groupNode.attrs.id}@g.us`)
return metadata ? metadata : Optional.empty()
} catch (error) {
@@ -100,13 +100,7 @@ export const makeCommunitiesSocket = (config: SocketConfig) => {
}
sock.ws.on('CB:ib,,dirty', async (node: BinaryNode) => {
const dirtyNode = getBinaryNodeChild(node, 'dirty')
if (!dirtyNode) {
logger.debug({ node: node.tag }, 'community dirty handler: no dirty node found, skipping')
return
}
const { attrs } = dirtyNode
const { attrs } = getBinaryNodeChild(node, 'dirty')!
if (attrs.type !== 'communities') {
return
}
@@ -364,7 +358,7 @@ export const makeCommunitiesSocket = (config: SocketConfig) => {
tag: 'accept',
attrs: {
code: inviteMessage.inviteCode!,
expiration: (inviteMessage.inviteExpiration ?? 0).toString(),
expiration: inviteMessage.inviteExpiration!.toString(),
admin: key.remoteJid!
}
}
@@ -438,11 +432,7 @@ export const makeCommunitiesSocket = (config: SocketConfig) => {
}
export const extractCommunityMetadata = (result: BinaryNode) => {
const community = getBinaryNodeChild(result, 'community')
if (!community) {
throw new Error('Missing community node in result')
}
const community = getBinaryNodeChild(result, 'community')!
const descChild = getBinaryNodeChild(community, 'description')
let desc: string | undefined
let descId: string | undefined
@@ -481,7 +471,7 @@ export const extractCommunityMetadata = (result: BinaryNode) => {
}
}),
ephemeralDuration: eph ? +eph : undefined,
addressingMode: getBinaryNodeChildString(community, 'addressing_mode') as GroupMetadata['addressingMode']
addressingMode: getBinaryNodeChildString(community, 'addressing_mode')! as GroupMetadata['addressingMode']
}
return metadata
}
+11 -53
View File
@@ -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
View File
@@ -1,37 +1,7 @@
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
import type { UserFacingSocketConfig, WAVersion } from '../Types'
import type { VersionCacheLogger } from '../Utils/version-cache'
import { clearVersionCache, getCachedVersion, getVersionCacheStatus, refreshVersionCache } from '../Utils/version-cache'
import type { UserFacingSocketConfig } from '../Types'
import { makeCommunitiesSocket } from './communities'
/**
* Adapts Baileys logger to VersionCacheLogger interface
*/
const createCacheLogger = (logger: any): VersionCacheLogger | undefined => {
if (!logger) return undefined
return {
info: (obj: unknown, msg?: string) => logger.info(obj, msg),
debug: (obj: unknown, msg?: string) => logger.debug(obj, msg),
warn: (obj: unknown, msg?: string) => logger.warn(obj, msg)
}
}
/**
* Compares two WhatsApp versions
* @returns true if versions are different
*/
const versionsAreDifferent = (v1: WAVersion, v2: WAVersion): boolean => {
return v1[0] !== v2[0] || v1[1] !== v2[1] || v1[2] !== v2[2]
}
/**
* Checks if a version change is critical (major or minor version changed)
*/
const isCriticalVersionChange = (oldVersion: WAVersion, newVersion: WAVersion): boolean => {
// Major version change (index 0) or minor version change (index 1)
return oldVersion[0] !== newVersion[0] || oldVersion[1] !== newVersion[1]
}
// export the last socket layer
const makeWASocket = (config: UserFacingSocketConfig) => {
const newConfig = {
@@ -42,179 +12,4 @@ const makeWASocket = (config: UserFacingSocketConfig) => {
return makeCommunitiesSocket(newConfig)
}
/**
* Creates a WhatsApp socket connection with automatic version fetching
* and periodic version checks (soft update - transparent to user).
*
* Features:
* - **Shared cache**: 150 connections = 1 request (not 150)
* - **Persistent cache**: Survives server restarts
* - Fetches latest version on connect (uses cache if valid)
* - Checks for new versions every 6 hours (configurable)
* - Updates version on next natural reconnection (transparent)
* - Emits 'version.update' event when new version is detected
*
* @example
* ```typescript
* // All connections share the same cached version
* const sock = await makeWASocketAutoVersion({
* auth: state,
* versionCheckIntervalMs: 6 * 60 * 60 * 1000 // 6 hours (default)
* })
*
* // Listen for version updates
* sock.ev.on('version.update', ({ currentVersion, newVersion, isCritical }) => {
* console.log(`New version detected: ${newVersion.join('.')}`)
* // Version will be used on next reconnection automatically
* })
* ```
*/
export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) => {
const mergedConfig = {
...DEFAULT_CONNECTION_CONFIG,
...config
}
const logger = mergedConfig.logger
const cacheLogger = createCacheLogger(logger)
const checkIntervalMs = mergedConfig.versionCheckIntervalMs
// Track version separately to avoid mutating config (Fix #7)
let trackedVersion: WAVersion = [...mergedConfig.version] as WAVersion
let versionCheckInterval: ReturnType<typeof setInterval> | null = null
let isSocketClosed = false
/**
* Cleans up the version check interval
*/
const cleanupInterval = () => {
if (versionCheckInterval) {
clearInterval(versionCheckInterval)
versionCheckInterval = null
logger?.debug('Stopped periodic version check')
}
}
// Fetch latest version using SHARED CACHE
// 150 connections starting = 1 request (deduplication)
try {
const { version, fromCache, age } = await getCachedVersion({
cacheTtlMs: checkIntervalMs,
logger: cacheLogger
})
logger?.info(
{
version,
fromCache,
ageMinutes: fromCache ? Math.round(age / 60000) : 0
},
fromCache ? 'Using cached WhatsApp Web version' : 'Fetched fresh WhatsApp Web version'
)
mergedConfig.version = version
trackedVersion = [...version] as WAVersion
} catch (error) {
logger?.warn({ error, fallbackVersion: mergedConfig.version }, 'Error fetching version, using bundled version')
}
// Create the socket
const sock = makeWASocket(mergedConfig)
// Listen for connection close to cleanup interval (Fix #1, #6)
// This handles both explicit sock.end() and internal disconnections
sock.ev.on('connection.update', update => {
if (update.connection === 'close') {
isSocketClosed = true
cleanupInterval()
} else if (update.connection === 'open') {
isSocketClosed = false
}
})
// Setup periodic version check if interval > 0
if (checkIntervalMs > 0) {
logger?.info({ intervalHours: checkIntervalMs / (60 * 60 * 1000) }, 'Starting periodic version check')
versionCheckInterval = setInterval(async () => {
// Skip if socket is closed (Fix #8 - race condition)
if (isSocketClosed) {
cleanupInterval()
return
}
try {
logger?.debug('Checking for WhatsApp Web version update...')
// Check cache status first
const cacheStatus = getVersionCacheStatus(checkIntervalMs)
// Only refresh if cache is expired (one socket refreshes, others use cache)
let newVersion: WAVersion
let fetchSuccess = true
if (cacheStatus.isExpired) {
logger?.debug('Cache expired, refreshing...')
const result = await refreshVersionCache({ logger: cacheLogger })
newVersion = result.version
fetchSuccess = result.success
// Don't update to fallback version on transient network errors
if (!fetchSuccess) {
logger?.warn({ fallbackVersion: result.version }, 'Failed to fetch latest version, keeping current version')
return // Skip version update on fetch failure
}
} else if (cacheStatus.version) {
// Cache still valid, use cached version directly (no file I/O)
newVersion = cacheStatus.version
} else {
// No cache available, skip this check
return
}
// Double-check socket is still open after async operation (Fix #8)
if (isSocketClosed) {
cleanupInterval()
return
}
if (versionsAreDifferent(trackedVersion, newVersion)) {
const isCritical = isCriticalVersionChange(trackedVersion, newVersion)
const previousVersion = trackedVersion
logger?.info(
{
currentVersion: previousVersion,
newVersion: newVersion,
isCritical
},
'New WhatsApp Web version detected! Will use on next reconnection.'
)
// Update tracked version for next reconnection (Fix #7)
trackedVersion = [...newVersion] as WAVersion
// Emit event for user to handle (only if socket still open)
if (!isSocketClosed) {
sock.ev.emit('version.update', {
currentVersion: previousVersion,
newVersion: newVersion,
isCritical
})
}
} else {
logger?.debug({ version: trackedVersion }, 'Version is up to date')
}
} catch (error) {
logger?.warn({ error }, 'Error checking for version update')
}
}, checkIntervalMs)
}
return sock
}
// Export cache utilities for manual control
export { getCachedVersion, refreshVersionCache, clearVersionCache, getVersionCacheStatus }
export default makeWASocket
+274 -1945
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11 -24
View File
@@ -7,34 +7,21 @@ import { makeGroupsSocket } from './groups'
import { executeWMexQuery as genericExecuteWMexQuery } from './mex'
const parseNewsletterCreateResponse = (response: NewsletterCreateResponse): NewsletterMetadata => {
// Validate response structure before destructuring
if (!response?.id || !response?.thread_metadata) {
throw new Error('Invalid newsletter response: missing id or thread_metadata')
}
const { id, thread_metadata: thread, viewer_metadata: viewer } = response
// Validate required thread metadata fields
if (!thread.name?.text) {
throw new Error('Invalid newsletter response: missing thread name')
}
return {
id: id,
owner: undefined,
name: thread.name.text,
creation_time: parseInt(thread.creation_time, 10) || 0,
description: thread.description?.text || '',
invite: thread.invite || '',
subscribers: parseInt(thread.subscribers_count, 10) || 0,
creation_time: parseInt(thread.creation_time, 10),
description: thread.description.text,
invite: thread.invite,
subscribers: parseInt(thread.subscribers_count, 10),
verification: thread.verification,
picture: thread.picture
? {
id: thread.picture.id || '',
directPath: thread.picture.direct_path || ''
}
: { id: '', directPath: '' },
mute_state: viewer?.mute
picture: {
id: thread.picture.id,
directPath: thread.picture.direct_path
},
mute_state: viewer.mute
}
}
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -80,8 +80,7 @@ export type SignalDataTypeMap = {
'app-state-sync-version': LTHashState
'lid-mapping': string
'device-list': string[]
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number; 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
View File
@@ -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
}
-6
View File
@@ -75,12 +75,6 @@ export type ChatUpdate = Partial<
conditional: (bufferedData: BufferedEventData) => boolean | undefined
/** last update time */
timestamp?: number
/** indicates if this chat was merged from LID to PN */
merged?: boolean
/** previous chat ID before merge (LID format) */
previousId?: string
/** timestamp when the merge occurred */
mergedAt?: number
}
>
-2
View File
@@ -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
View File
@@ -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
-4
View File
@@ -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
View File
@@ -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
+8 -8
View File
@@ -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',
-26
View File
@@ -1,26 +0,0 @@
/**
* Session cleanup configuration
*/
export interface SessionCleanupConfig {
enabled: boolean
intervalMs: number
cleanupHour: number
secondaryDeviceInactiveDays: number
primaryDeviceInactiveDays: number
lidOrphanHours: number
cleanupOnStartup: boolean
autoCleanCorrupted: boolean
}
/**
* Session cleanup statistics
*/
export interface SessionCleanupStats {
totalScanned: number
secondaryDevicesDeleted: number
primaryDevicesDeleted: number
lidOrphansDeleted: number
totalDeleted: number
durationMs: number
errors: number
}
-114
View File
@@ -1,12 +1,10 @@
import type { Agent } from 'https'
import type { URL } from 'url'
import { proto } from '../../WAProto/index.js'
import type { CircuitBreakerOptions } from '../Utils/circuit-breaker'
import type { ILogger } from '../Utils/logger'
import type { AuthenticationState, LIDMapping, SignalAuthState, TransactionCapabilityOptions } from './Auth'
import type { GroupMetadata } from './GroupMetadata'
import { type MediaConnInfo, type WAMessageKey } from './Message'
import type { SessionCleanupConfig } from './SessionCleanup'
import type { SignalRepositoryWithLIDStore } from './Signal'
export type WAVersion = [number, number, number]
@@ -50,17 +48,6 @@ export type SocketConfig = {
logger: ILogger
/** version to connect with */
version: WAVersion
/**
* Interval in milliseconds to check for new WhatsApp Web versions.
* When a new version is detected, it will be used on the next reconnection.
* Set to 0 to disable periodic checks.
*
* Note: This option is only used by `makeWASocketAutoVersion()`.
* The standard `makeWASocket()` does not perform automatic version checks.
*
* @default 21600000 (6 hours)
*/
versionCheckIntervalMs: number
/** override browser config */
browser: WABrowserDescription
/** agent used for fetch requests -- uploading/downloading media */
@@ -119,48 +106,6 @@ export type SocketConfig = {
/** Enable recent message caching for retry handling */
enableRecentMessageCache: boolean
/**
* Enable automatic recovery of Click-to-WhatsApp (CTWA) ads messages.
*
* When enabled, messages from Facebook/Instagram ads that arrive as
* "placeholder messages" (Message absent from node) will be automatically
* recovered by requesting resend from the primary phone device.
*
* This is necessary because Meta's ads endpoint doesn't encrypt messages
* for linked devices - they only arrive on the primary phone.
*
* @default true
* @see https://github.com/WhiskeySockets/Baileys/issues/1723
*/
enableCTWARecovery: boolean
/**
* Enable interactive messages (buttons, lists, templates, carousel).
* When true, injects biz/bot binary nodes required by WhatsApp.
* @default true
*/
enableInteractiveMessages: boolean
/**
* When true, clears the `routingInfo` stored in credentials before connecting.
*
* `routingInfo` is a hint that directs the socket to reconnect to the same
* WhatsApp edge server used in the previous session. After a code update or
* server-side configuration change, the old edge server may retain stale state
* (throttling, queued messages, etc.) that causes persistent slowness even with
* fresh code only solvable by re-scanning the QR code.
*
* Setting this to `true` forces WhatsApp to assign a fresh edge server on the
* next connection, equivalent to the clean state you get after a QR re-scan,
* but without invalidating the session or Signal keys.
*
* Recommended usage: enable this option in your `startSock()` call right after
* deploying a new version, then disable it on subsequent reconnections.
*
* @default true
*/
clearRoutingInfoOnStart: boolean
/**
* Returns if a jid should be ignored,
* no event for that jid will be triggered.
@@ -202,63 +147,4 @@ export type SocketConfig = {
logger: ILogger,
pnToLIDFunc?: (jids: string[]) => Promise<LIDMapping[] | undefined>
) => SignalRepositoryWithLIDStore
// === Circuit Breaker Configuration ===
/** Enable circuit breaker protection for socket operations (default: true) */
enableCircuitBreaker?: boolean
/** Circuit breaker configuration for query operations */
queryCircuitBreaker?: Partial<CircuitBreakerOptions>
/** Circuit breaker configuration for connection operations */
connectionCircuitBreaker?: Partial<CircuitBreakerOptions>
/** Circuit breaker configuration for pre-key operations */
preKeyCircuitBreaker?: Partial<CircuitBreakerOptions>
/** Circuit breaker configuration for message operations */
messageCircuitBreaker?: Partial<CircuitBreakerOptions>
// === Listener Limits (Memory Leak Prevention) ===
/**
* Max WebSocket event listeners (default: 20)
* Calculation: 8 core WS events + 10 dynamic listeners + 2 buffer slots
* WARNING: Setting to 0 disables limit and allows potential memory leaks!
*/
maxWebSocketListeners?: number
/**
* Max SocketClient event listeners (default: 50)
* Calculation: 20 core events + 20 dynamic listeners + 10 buffer slots
* WARNING: Setting to 0 disables limit and allows potential memory leaks!
*/
maxSocketClientListeners?: number
// === Unified Session Telemetry ===
/**
* Enable unified_session telemetry to reduce detection of unofficial clients.
*
* When enabled, sends time-based session identifiers that mimic official
* WhatsApp Web client behavior. This may help reduce "Your account may be
* at risk" warnings, though effectiveness is not guaranteed.
*
* Telemetry is sent at specific trigger points:
* - After successful login
* - After successful pairing
* - When sending 'available' presence
*
* Can also be controlled via environment variable:
* BAILEYS_UNIFIED_SESSION_ENABLED=true|false
*
* @default true
* @see https://github.com/tulir/whatsmeow/pull/1057
* @see https://github.com/WhiskeySockets/Baileys/pull/2294
*/
enableUnifiedSession?: boolean
/** Session cleanup configuration (optional, partial overrides merged with defaults) */
sessionCleanupConfig?: Partial<SessionCleanupConfig>
}
-6
View File
@@ -40,10 +40,4 @@ export type ConnectionState = {
* If this is false, the primary phone and other devices will receive notifs
* */
isOnline?: boolean
/**
* indicates the disconnect was caused by a session error (keys desynchronized).
* When true, the consumer should recreate the socket with makeWASocket()
* to establish a fresh session.
*/
isSessionError?: boolean
}
+1 -3
View File
@@ -10,7 +10,6 @@ export * from './Product'
export * from './Call'
export * from './Signal'
export * from './Newsletter'
export * from './SessionCleanup'
import type { AuthenticationState } from './Auth'
import type { SocketConfig } from './Socket'
@@ -36,8 +35,7 @@ export enum DisconnectReason {
restartRequired = 515,
multideviceMismatch = 411,
forbidden = 403,
unavailableService = 503,
sessionInvalidated = 516
unavailableService = 503
}
export type WAInitResponse = {
-67
View File
@@ -129,9 +129,6 @@ export const addTransactionCapability = (
// Pre-key manager for specialized operations
const preKeyManager = new PreKeyManager(state, logger)
// Destroyed flag to prevent operations after cleanup
let destroyed = false
/**
* Get or create a queue for a specific key type
*/
@@ -317,12 +314,6 @@ export const addTransactionCapability = (
try {
return await mutex.runExclusive(async () => {
// CRITICAL: Check destroyed flag INSIDE mutex to prevent race condition
// This ensures atomic check-and-execute: if we acquire mutex, resources exist
if (destroyed) {
throw new Error('Transaction capability destroyed - cannot initiate new transactions')
}
const ctx: TransactionContext = {
cache: {},
mutations: {},
@@ -348,64 +339,6 @@ export const addTransactionCapability = (
} finally {
releaseTxMutexRef(key)
}
},
/**
* Cleanup all resources (queues, managers, mutexes)
* Should be called during connection cleanup
*
* IMPORTANT BEHAVIOR:
* - Always sets destroyed=true to prevent NEW transactions
* - If mutexes are locked (active transactions), returns early WITHOUT destroying resources
* - This creates intentional temporary inconsistent state:
* * destroyed=true (new transactions rejected)
* * resources exist (active transactions complete safely)
* * resources cleaned up by GC after active transactions finish
* - If no locked mutexes, destroys resources immediately
*/
destroy: () => {
// CRITICAL: Set destroyed flag FIRST to prevent new transactions
// Note: Flag is set even if early return occurs (see doc above)
destroyed = true
logger.debug('🗑️ Cleaning up transaction capability resources')
// Count locked mutexes BEFORE destroying resources
let clearedCount = 0
let lockedCount = 0
const lockedKeys: string[] = []
txMutexes.forEach((mutex, key) => {
if (!mutex.isLocked()) {
txMutexes.delete(key)
txMutexRefCounts.delete(key)
clearedCount++
} else {
lockedCount++
lockedKeys.push(key)
}
})
// If there are locked mutexes, log error and skip resource destruction
if (lockedCount > 0) {
logger.error(
{ lockedCount, lockedKeys },
'❌ Cannot destroy resources - transactions still active! Resources will be cleaned up by GC.'
)
return
}
// Safe to destroy resources (no active transactions)
preKeyManager.destroy()
// Clear all key queues
keyQueues.forEach((queue, keyType) => {
queue.clear()
queue.pause()
logger.debug(`Queue for ${keyType} cleared and paused`)
})
keyQueues.clear()
logger.debug({ clearedCount }, 'Transaction capability cleanup completed')
}
}
}
-781
View File
@@ -1,781 +0,0 @@
/**
* Baileys Event Stream Management
*
* Provides:
* - Event buffering with backpressure
* - Event transformation and filtering
* - Priority queues for events
* - Batch processing
* - Dead letter queue for failed events
* - Event replay
* - Logging and metrics integration
*
* @module Utils/baileys-event-stream
*/
import { EventEmitter } from 'events'
import type { BaileysLogCategory } from './baileys-logger.js'
import { metrics } from './prometheus-metrics.js'
/**
* Baileys event types
*/
export type BaileysEventType =
| 'connection.update'
| 'creds.update'
| 'messaging-history.set'
| 'chats.set'
| 'contacts.set'
| 'messages.upsert'
| 'messages.update'
| 'messages.delete'
| 'messages.reaction'
| 'message-receipt.update'
| 'groups.upsert'
| 'groups.update'
| 'group-participants.update'
| 'presence.update'
| 'chats.update'
| 'chats.delete'
| 'labels.edit'
| 'labels.association'
| 'call'
| 'blocklist.set'
| 'blocklist.update'
| string // For custom events
/**
* Event priority
*/
export type EventPriority = 'critical' | 'high' | 'normal' | 'low'
/**
* Numeric priority values
*/
const PRIORITY_VALUES: Record<EventPriority, number> = {
critical: 0,
high: 1,
normal: 2,
low: 3
}
/**
* Stream event
*/
export interface StreamEvent<T = unknown> {
id: string
type: BaileysEventType
data: T
timestamp: number
priority: EventPriority
category: BaileysLogCategory
metadata?: Record<string, unknown>
retryCount?: number
originalTimestamp?: number
}
/**
* Event Stream options
*/
export interface EventStreamOptions {
/** Maximum buffer size (default: 10000) */
maxBufferSize?: number
/** Whether to apply backpressure when buffer is full */
enableBackpressure?: boolean
/** High water mark limit for backpressure */
highWaterMark?: number
/** Low water mark limit to resume */
lowWaterMark?: number
/** Batch size for processing */
batchSize?: number
/** Flush interval in ms (0 = disabled) */
flushInterval?: number
/** Maximum retries for failed events */
maxRetries?: number
/** Dead letter queue size */
deadLetterQueueSize?: number
/** Whether to collect metrics */
collectMetrics?: boolean
/** Stream name for metrics */
streamName?: string
}
/**
* Event handler
*/
export type EventHandler<T = unknown> = (event: StreamEvent<T>) => void | Promise<void>
/**
* Event filter
*/
export type EventFilter<T = unknown> = (event: StreamEvent<T>) => boolean
/**
* Event transformer
*/
export type EventTransformer<T = unknown, R = unknown> = (event: StreamEvent<T>) => StreamEvent<R>
/**
* Batch processing result
*/
export interface BatchResult {
processed: number
failed: number
duration: number
}
/**
* Stream statistics
*/
export interface EventStreamStats {
bufferSize: number
totalReceived: number
totalProcessed: number
totalFailed: number
totalDropped: number
deadLetterQueueSize: number
isBackpressured: boolean
lastEventTimestamp?: number
eventsByType: Record<string, number>
eventsByPriority: Record<EventPriority, number>
}
/**
* Event type to category mapping
*/
const EVENT_CATEGORY_MAP: Record<string, BaileysLogCategory> = {
'connection.update': 'connection',
'creds.update': 'auth',
'messaging-history.set': 'sync',
'chats.set': 'sync',
'contacts.set': 'sync',
'messages.upsert': 'message',
'messages.update': 'message',
'messages.delete': 'message',
'messages.reaction': 'message',
'message-receipt.update': 'message',
'groups.upsert': 'group',
'groups.update': 'group',
'group-participants.update': 'group',
'presence.update': 'presence',
'chats.update': 'message',
'chats.delete': 'message',
call: 'call',
'blocklist.set': 'sync',
'blocklist.update': 'sync'
}
/**
* Default priority by event type
*/
const EVENT_PRIORITY_MAP: Partial<Record<BaileysEventType, EventPriority>> = {
'connection.update': 'critical',
'creds.update': 'critical',
'messages.upsert': 'high',
'messages.update': 'high',
call: 'high',
'presence.update': 'low',
'messaging-history.set': 'normal'
}
/**
* Generate unique event ID
*/
function generateEventId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
}
/**
* Main Event Stream class
*/
export class BaileysEventStream extends EventEmitter {
private buffer: StreamEvent[] = []
private handlers: Map<BaileysEventType | '*', Set<EventHandler>> = new Map()
private filters: EventFilter[] = []
private transformers: EventTransformer[] = []
private deadLetterQueue: StreamEvent[] = []
private options: Required<EventStreamOptions>
private stats: EventStreamStats
private isProcessing = false
private flushTimer?: ReturnType<typeof setInterval>
private paused = false
constructor(options: EventStreamOptions = {}) {
super()
this.options = {
maxBufferSize: options.maxBufferSize ?? 10000,
enableBackpressure: options.enableBackpressure ?? true,
highWaterMark: options.highWaterMark ?? 8000,
lowWaterMark: options.lowWaterMark ?? 2000,
batchSize: options.batchSize ?? 100,
flushInterval: options.flushInterval ?? 0,
maxRetries: options.maxRetries ?? 3,
deadLetterQueueSize: options.deadLetterQueueSize ?? 1000,
collectMetrics: options.collectMetrics ?? true,
streamName: options.streamName ?? 'baileys'
}
this.stats = this.createInitialStats()
// Start periodic flush if configured
if (this.options.flushInterval > 0) {
this.flushTimer = setInterval(() => this.flush(), this.options.flushInterval)
}
}
private createInitialStats(): EventStreamStats {
return {
bufferSize: 0,
totalReceived: 0,
totalProcessed: 0,
totalFailed: 0,
totalDropped: 0,
deadLetterQueueSize: 0,
isBackpressured: false,
eventsByType: {},
eventsByPriority: {
critical: 0,
high: 0,
normal: 0,
low: 0
}
}
}
/**
* Add event to stream
*/
push<T>(
type: BaileysEventType,
data: T,
options?: { priority?: EventPriority; metadata?: Record<string, unknown> }
): boolean {
// Check backpressure
if (this.options.enableBackpressure && this.buffer.length >= this.options.highWaterMark) {
this.stats.isBackpressured = true
this.emit('backpressure', { bufferSize: this.buffer.length })
if (this.buffer.length >= this.options.maxBufferSize) {
this.stats.totalDropped++
this.emit('dropped', { type, reason: 'buffer_full' })
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'event_stream', code: 'dropped' })
}
return false
}
}
const event: StreamEvent<T> = {
id: generateEventId(),
type,
data,
timestamp: Date.now(),
priority: options?.priority || EVENT_PRIORITY_MAP[type] || 'normal',
category: EVENT_CATEGORY_MAP[type] || 'unknown',
metadata: options?.metadata,
retryCount: 0
}
// Apply transformers
let transformedEvent: StreamEvent = event
for (const transformer of this.transformers) {
transformedEvent = transformer(transformedEvent)
}
// Apply filters
for (const filter of this.filters) {
if (!filter(transformedEvent)) {
return false
}
}
// Add to buffer at correct position (by priority)
this.insertByPriority(transformedEvent)
// Update statistics
this.stats.totalReceived++
this.stats.bufferSize = this.buffer.length
this.stats.lastEventTimestamp = Date.now()
this.stats.eventsByType[type] = (this.stats.eventsByType[type] || 0) + 1
this.stats.eventsByPriority[event.priority]++
if (this.options.collectMetrics) {
metrics.socketEvents.inc({ event: type })
}
this.emit('event', transformedEvent)
// Process if not paused
if (!this.paused && !this.isProcessing) {
void this.processNext()
}
return true
}
/**
* Insert event in buffer by priority
*/
private insertByPriority(event: StreamEvent): void {
const eventPriorityValue = PRIORITY_VALUES[event.priority]
// Find correct position
let insertIndex = this.buffer.length
for (let i = 0; i < this.buffer.length; i++) {
const bufferEvent = this.buffer[i]
if (bufferEvent && PRIORITY_VALUES[bufferEvent.priority] > eventPriorityValue) {
insertIndex = i
break
}
}
this.buffer.splice(insertIndex, 0, event)
}
/**
* Register handler for event type
*/
on<T = unknown>(
event: BaileysEventType | '*' | 'backpressure' | 'drain' | 'dropped' | 'batch-processed' | 'retry',
handler: EventHandler<T>
): this {
// For control events (backpressure, drain, etc), use native EventEmitter
if (
event === 'backpressure' ||
event === 'drain' ||
event === 'dropped' ||
event === 'batch-processed' ||
event === 'retry'
) {
super.on(event, handler as any)
return this
}
// For Baileys events, use custom handler system
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set())
}
this.handlers.get(event)!.add(handler as EventHandler)
return this
}
/**
* Remove handler
*/
off(
event: BaileysEventType | '*' | 'backpressure' | 'drain' | 'dropped' | 'batch-processed' | 'retry',
handler: EventHandler
): this {
// For control events, use native EventEmitter
if (
event === 'backpressure' ||
event === 'drain' ||
event === 'dropped' ||
event === 'batch-processed' ||
event === 'retry'
) {
super.off(event, handler as any)
return this
}
// For Baileys events, use custom handler system
const handlers = this.handlers.get(event)
if (handlers) {
handlers.delete(handler)
}
return this
}
/**
* Register single-use handler
*/
once<T = unknown>(event: BaileysEventType, handler: EventHandler<T>): this {
const wrappedHandler: EventHandler<T> = e => {
this.off(event, wrappedHandler as EventHandler)
return handler(e)
}
return this.on(event, wrappedHandler)
}
/**
* Add filter
*/
addFilter(filter: EventFilter): this {
this.filters.push(filter)
return this
}
/**
* Remove filter
*/
removeFilter(filter: EventFilter): this {
const index = this.filters.indexOf(filter)
if (index !== -1) {
this.filters.splice(index, 1)
}
return this
}
/**
* Add transformer
*/
addTransformer(transformer: EventTransformer): this {
this.transformers.push(transformer)
return this
}
/**
* Process next events
*/
private async processNext(): Promise<void> {
if (this.isProcessing || this.paused || this.buffer.length === 0) {
return
}
this.isProcessing = true
try {
// Get batch of events
const batch = this.buffer.splice(0, this.options.batchSize)
this.stats.bufferSize = this.buffer.length
// Check if exited backpressure
if (this.stats.isBackpressured && this.buffer.length <= this.options.lowWaterMark) {
this.stats.isBackpressured = false
this.emit('drain')
}
// Process batch
const startTime = Date.now()
let processed = 0
let failed = 0
for (const event of batch) {
try {
await this.processEvent(event)
processed++
this.stats.totalProcessed++
} catch (error) {
failed++
this.stats.totalFailed++
await this.handleFailedEvent(event, error as Error)
}
}
const duration = Date.now() - startTime
this.emit('batch-processed', { processed, failed, duration } as BatchResult)
// Continue processing if there are more
if (this.buffer.length > 0) {
setImmediate(() => this.processNext())
}
} finally {
this.isProcessing = false
}
}
/**
* Process a single event
*/
private async processEvent(event: StreamEvent): Promise<void> {
// Type-specific handlers
const typeHandlers = this.handlers.get(event.type)
if (typeHandlers) {
for (const handler of typeHandlers) {
await handler(event)
}
}
// Global handlers
const globalHandlers = this.handlers.get('*')
if (globalHandlers) {
for (const handler of globalHandlers) {
await handler(event)
}
}
}
/**
* Handle failed event
*/
private async handleFailedEvent(event: StreamEvent, error: Error): Promise<void> {
event.retryCount = (event.retryCount || 0) + 1
if (event.retryCount <= this.options.maxRetries) {
// Re-add to buffer for retry
event.originalTimestamp = event.originalTimestamp || event.timestamp
event.timestamp = Date.now()
this.buffer.push(event)
this.stats.bufferSize = this.buffer.length
this.emit('retry', { event, error, attempt: event.retryCount })
} else {
// Send to dead letter queue
this.addToDeadLetterQueue(event, error)
}
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'event_stream', code: 'processing_failed' })
}
}
/**
* Add event to dead letter queue
*/
private addToDeadLetterQueue(event: StreamEvent, error: Error): void {
const dlqEvent = {
...event,
metadata: {
...event.metadata,
error: error.message,
errorStack: error.stack,
movedToDlqAt: Date.now()
}
}
this.deadLetterQueue.push(dlqEvent)
// Limit DLQ size
while (this.deadLetterQueue.length > this.options.deadLetterQueueSize) {
this.deadLetterQueue.shift()
}
this.stats.deadLetterQueueSize = this.deadLetterQueue.length
this.emit('dead-letter', dlqEvent)
}
/**
* Force flush the buffer
*/
async flush(): Promise<BatchResult> {
const startTime = Date.now()
let processed = 0
let failed = 0
while (this.buffer.length > 0 && !this.paused) {
const batch = this.buffer.splice(0, this.options.batchSize)
for (const event of batch) {
try {
await this.processEvent(event)
processed++
this.stats.totalProcessed++
} catch (error) {
failed++
this.stats.totalFailed++
await this.handleFailedEvent(event, error as Error)
}
}
}
this.stats.bufferSize = this.buffer.length
return {
processed,
failed,
duration: Date.now() - startTime
}
}
/**
* Pause processing
*/
pause(): void {
this.paused = true
this.emit('pause')
}
/**
* Resume processing
*/
resume(): void {
this.paused = false
this.emit('resume')
void this.processNext()
}
/**
* Check if paused
*/
isPaused(): boolean {
return this.paused
}
/**
* Clear the buffer
*/
clear(): void {
this.buffer = []
this.stats.bufferSize = 0
this.emit('clear')
}
/**
* Return dead letter queue events
*/
getDeadLetterQueue(): StreamEvent[] {
return [...this.deadLetterQueue]
}
/**
* Clear dead letter queue
*/
clearDeadLetterQueue(): void {
this.deadLetterQueue = []
this.stats.deadLetterQueueSize = 0
}
/**
* Replay dead letter queue events
*/
async replayDeadLetterQueue(): Promise<BatchResult> {
const events = this.deadLetterQueue.splice(0)
this.stats.deadLetterQueueSize = 0
let processed = 0
let failed = 0
const startTime = Date.now()
for (const event of events) {
// Reset retry count
event.retryCount = 0
delete event.metadata?.error
delete event.metadata?.errorStack
delete event.metadata?.movedToDlqAt
try {
await this.processEvent(event)
processed++
} catch (error) {
failed++
this.addToDeadLetterQueue(event, error as Error)
}
}
return {
processed,
failed,
duration: Date.now() - startTime
}
}
/**
* Return statistics
*/
getStats(): EventStreamStats {
return { ...this.stats }
}
/**
* Reset statistics
*/
resetStats(): void {
this.stats = this.createInitialStats()
this.stats.bufferSize = this.buffer.length
this.stats.deadLetterQueueSize = this.deadLetterQueue.length
}
/**
* Destroy and clean up resources
*/
destroy(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer)
}
this.buffer = []
this.deadLetterQueue = []
this.handlers.clear()
this.filters = []
this.transformers = []
this.removeAllListeners()
}
}
/**
* Factory to create event stream
*/
export function createEventStream(options?: EventStreamOptions): BaileysEventStream {
return new BaileysEventStream(options)
}
/**
* Pre-defined filters
*/
export const eventFilters = {
/** Filter by event type */
byType:
(...types: BaileysEventType[]): EventFilter =>
event =>
types.includes(event.type),
/** Filter by category */
byCategory:
(...categories: BaileysLogCategory[]): EventFilter =>
event =>
categories.includes(event.category),
/** Filter by minimum priority */
byMinPriority:
(minPriority: EventPriority): EventFilter =>
event =>
PRIORITY_VALUES[event.priority] <= PRIORITY_VALUES[minPriority],
/** Filter recent events (within ms) */
recentOnly:
(maxAgeMs: number): EventFilter =>
event =>
Date.now() - event.timestamp <= maxAgeMs,
/** Combine filters with AND */
and:
(...filters: EventFilter[]): EventFilter =>
event =>
filters.every(f => f(event)),
/** Combine filters with OR */
or:
(...filters: EventFilter[]): EventFilter =>
event =>
filters.some(f => f(event))
}
/**
* Pre-defined transformers
*/
export const eventTransformers = {
/** Add processing timestamp */
addProcessingTimestamp: (): EventTransformer => event => ({
...event,
metadata: {
...event.metadata,
processingTimestamp: Date.now()
}
}),
/** Add trace ID */
addTraceId:
(traceIdGenerator: () => string): EventTransformer =>
event => ({
...event,
metadata: {
...event.metadata,
traceId: traceIdGenerator()
}
}),
/** Elevate priority based on condition */
elevatepriorityIf:
(condition: (event: StreamEvent) => boolean, newPriority: EventPriority): EventTransformer =>
event =>
condition(event) ? { ...event, priority: newPriority } : event
}
export default BaileysEventStream
File diff suppressed because it is too large Load Diff
+30 -389
View File
@@ -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 (namevalue and valuename).
* We filter to only include string keys with numeric values.
*/
const BROWSER_TO_PLATFORM_ID: ReadonlyMap<string, string> = (() => {
const platformType = proto.DeviceProps?.PlatformType
if (!platformType || typeof platformType !== 'object') {
return new Map<string, string>()
}
const entries: Array<[string, string]> = []
for (const [key, value] of Object.entries(platformType)) {
if (typeof value === 'number' && typeof key === 'string' && !/^\d+$/.test(key)) {
entries.push([key.toUpperCase(), value.toString()])
}
}
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
}
-524
View File
@@ -1,524 +0,0 @@
/**
* Smart Cache System
*
* Provides:
* - In-memory cache with configurable TTL
* - Automatic and manual invalidation
* - Hit/miss metrics
* - LRU (Least Recently Used) strategy
* - Distributed cache (prepared for Redis)
* - Namespace for isolation
* - Customizable serialization
*
* @module Utils/cache-utils
*/
import { LRUCache } from 'lru-cache'
import { metrics } from './prometheus-metrics.js'
/**
* Cache configuration options
*/
export interface CacheOptions<V> {
/** Time to live in ms (default: 5 minutes) */
ttl?: number
/** Maximum cache size (default: 1000) */
maxSize?: number
/** Function to calculate item size */
sizeCalculation?: (value: V) => number
/** Whether to update TTL on access */
updateAgeOnGet?: boolean
/** Namespace for isolation */
namespace?: string
/** Callback when item expires */
onExpire?: (key: string, value: V) => void
/** Whether to collect metrics */
collectMetrics?: boolean
/** Cache name for metrics */
metricName?: string
}
/**
* Cache statistics
*/
export interface CacheStats {
hits: number
misses: number
size: number
maxSize: number
hitRate: number
}
/**
* Cache item with metadata
*/
export interface CacheItem<V> {
value: V
createdAt: number
expiresAt: number
accessCount: number
lastAccess: number
}
/**
* Cache operation result
*/
export interface CacheResult<V> {
value: V | undefined
hit: boolean
expired?: boolean
key: string
}
/**
* Main Cache class
*/
export class Cache<V = unknown> {
private cache: LRUCache<string, CacheItem<V>>
private options: Required<CacheOptions<V>>
private stats: { hits: number; misses: number }
private namespace: string
constructor(options: CacheOptions<V> = {}) {
this.options = {
ttl: options.ttl ?? 5 * 60 * 1000, // 5 minutos
maxSize: options.maxSize ?? 1000,
sizeCalculation: options.sizeCalculation ?? (() => 1),
updateAgeOnGet: options.updateAgeOnGet ?? false,
namespace: options.namespace ?? 'default',
onExpire: options.onExpire ?? (() => {}),
collectMetrics: options.collectMetrics ?? true,
metricName: options.metricName ?? 'cache'
}
this.namespace = this.options.namespace
this.stats = { hits: 0, misses: 0 }
this.cache = new LRUCache<string, CacheItem<V>>({
maxSize: this.options.maxSize,
ttl: this.options.ttl,
updateAgeOnGet: this.options.updateAgeOnGet,
sizeCalculation: item => this.options.sizeCalculation(item.value),
dispose: (value, key) => {
this.options.onExpire(key, value.value)
}
})
}
/**
* Get value from cache
*/
get(key: string): V | undefined {
const fullKey = this.getFullKey(key)
const item = this.cache.get(fullKey)
if (item) {
this.stats.hits++
item.accessCount++
item.lastAccess = Date.now()
if (this.options.collectMetrics) {
metrics.cacheHits.inc({ cache: this.options.metricName })
}
return item.value
}
this.stats.misses++
if (this.options.collectMetrics) {
metrics.cacheMisses.inc({ cache: this.options.metricName })
}
return undefined
}
/**
* Get value with detailed result
*/
getWithResult(key: string): CacheResult<V> {
const fullKey = this.getFullKey(key)
const item = this.cache.get(fullKey)
if (item) {
this.stats.hits++
item.accessCount++
item.lastAccess = Date.now()
if (this.options.collectMetrics) {
metrics.cacheHits.inc({ cache: this.options.metricName })
}
return {
value: item.value,
hit: true,
key
}
}
this.stats.misses++
if (this.options.collectMetrics) {
metrics.cacheMisses.inc({ cache: this.options.metricName })
}
return {
value: undefined,
hit: false,
key
}
}
/**
* Set value in cache
*/
set(key: string, value: V, ttl?: number): void {
const fullKey = this.getFullKey(key)
const now = Date.now()
const itemTtl = ttl ?? this.options.ttl
const item: CacheItem<V> = {
value,
createdAt: now,
expiresAt: now + itemTtl,
accessCount: 0,
lastAccess: now
}
this.cache.set(fullKey, item, { ttl: itemTtl })
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
}
}
/**
* Check if key exists
*/
has(key: string): boolean {
const fullKey = this.getFullKey(key)
return this.cache.has(fullKey)
}
/**
* Remove item from cache
*/
delete(key: string): boolean {
const fullKey = this.getFullKey(key)
const result = this.cache.delete(fullKey)
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
}
return result
}
/**
* Clear the entire cache
*/
clear(): void {
this.cache.clear()
this.stats = { hits: 0, misses: 0 }
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, 0)
}
}
/**
* Get or set value (cache-aside pattern)
*/
async getOrSet(key: string, factory: () => V | Promise<V>, ttl?: number): Promise<V> {
const existing = this.get(key)
if (existing !== undefined) {
return existing
}
const value = await factory()
this.set(key, value, ttl)
return value
}
/**
* Get or set value synchronously
*/
getOrSetSync(key: string, factory: () => V, ttl?: number): V {
const existing = this.get(key)
if (existing !== undefined) {
return existing
}
const value = factory()
this.set(key, value, ttl)
return value
}
/**
* Invalidate items by pattern
*/
invalidateByPattern(pattern: RegExp): number {
let count = 0
const prefix = `${this.namespace}:`
for (const key of this.cache.keys()) {
const shortKey = key.startsWith(prefix) ? key.slice(prefix.length) : key
if (pattern.test(shortKey)) {
this.cache.delete(key)
count++
}
}
if (this.options.collectMetrics) {
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
}
return count
}
/**
* Invalidate items by prefix
*/
invalidateByPrefix(prefix: string): number {
return this.invalidateByPattern(new RegExp(`^${prefix}`))
}
/**
* Return cache statistics
*/
getStats(): CacheStats {
const total = this.stats.hits + this.stats.misses
return {
hits: this.stats.hits,
misses: this.stats.misses,
size: this.cache.size,
maxSize: this.options.maxSize,
hitRate: total > 0 ? this.stats.hits / total : 0
}
}
/**
* Return current size
*/
get size(): number {
return this.cache.size
}
/**
* Return all keys
*/
keys(): string[] {
const prefix = `${this.namespace}:`
return Array.from(this.cache.keys()).map(k => (k.startsWith(prefix) ? k.slice(prefix.length) : k))
}
/**
* Return all values
*/
values(): V[] {
return Array.from(this.cache.values()).map(item => item.value)
}
/**
* Return all items with metadata
*/
entries(): Array<{ key: string; item: CacheItem<V> }> {
const prefix = `${this.namespace}:`
return Array.from(this.cache.entries()).map(([key, item]) => ({
key: key.startsWith(prefix) ? key.slice(prefix.length) : key,
item
}))
}
/**
* Update TTL of an item
*/
touch(key: string, ttl?: number): boolean {
const fullKey = this.getFullKey(key)
const item = this.cache.get(fullKey)
if (!item) {
return false
}
const newTtl = ttl ?? this.options.ttl
item.expiresAt = Date.now() + newTtl
this.cache.set(fullKey, item, { ttl: newTtl })
return true
}
/**
* Get expired item (if still in memory)
*/
peek(key: string): V | undefined {
const fullKey = this.getFullKey(key)
const item = this.cache.peek(fullKey)
return item?.value
}
private getFullKey(key: string): string {
return `${this.namespace}:${key}`
}
}
/**
* Factory to create typed cache
*/
export function createCache<V>(options?: CacheOptions<V>): Cache<V> {
return new Cache<V>(options)
}
/**
* Multi-level cache (L1: memory, L2: external)
*/
export class MultiLevelCache<V> {
private l1: Cache<V>
private l2?: {
get: (key: string) => Promise<V | undefined>
set: (key: string, value: V, ttl?: number) => Promise<void>
delete: (key: string) => Promise<boolean>
}
constructor(
l1Options: CacheOptions<V>,
l2?: {
get: (key: string) => Promise<V | undefined>
set: (key: string, value: V, ttl?: number) => Promise<void>
delete: (key: string) => Promise<boolean>
}
) {
this.l1 = new Cache<V>(l1Options)
this.l2 = l2
}
async get(key: string): Promise<V | undefined> {
// Try L1 first
const l1Value = this.l1.get(key)
if (l1Value !== undefined) {
return l1Value
}
// Try L2 if available
if (this.l2) {
const l2Value = await this.l2.get(key)
if (l2Value !== undefined) {
// Promote to L1
this.l1.set(key, l2Value)
return l2Value
}
}
return undefined
}
async set(key: string, value: V, ttl?: number): Promise<void> {
this.l1.set(key, value, ttl)
if (this.l2) {
await this.l2.set(key, value, ttl)
}
}
async delete(key: string): Promise<boolean> {
const l1Result = this.l1.delete(key)
let l2Result = false
if (this.l2) {
l2Result = await this.l2.delete(key)
}
return l1Result || l2Result
}
getL1(): Cache<V> {
return this.l1
}
}
/**
* Decorator to cache method result
*/
export function cached<V>(options: CacheOptions<V> & { keyGenerator?: (...args: unknown[]) => string } = {}) {
const cache = new Cache<V>(options)
const keyGenerator = options.keyGenerator ?? ((...args) => JSON.stringify(args))
return function (
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => V | Promise<V>>
) {
const originalMethod = descriptor.value
if (!originalMethod) return descriptor
descriptor.value = async function (...args: unknown[]): Promise<V> {
const key = `${propertyKey}:${keyGenerator(...args)}`
const cachedValue = cache.get(key)
if (cachedValue !== undefined) {
return cachedValue
}
const result = await originalMethod.apply(this, args)
cache.set(key, result)
return result
}
return descriptor
}
}
/**
* Wrapper for function with cache
*/
// eslint-disable-next-line space-before-function-paren
export function withCache<T extends (...args: unknown[]) => unknown>(
fn: T,
options: CacheOptions<ReturnType<T>> & { keyGenerator?: (...args: Parameters<T>) => string } = {}
): T {
const cache = new Cache<ReturnType<T>>(options)
const keyGenerator = options.keyGenerator ?? ((...args) => JSON.stringify(args))
return ((...args: Parameters<T>): ReturnType<T> => {
const key = keyGenerator(...args)
const cachedValue = cache.get(key)
if (cachedValue !== undefined) {
return cachedValue as ReturnType<T>
}
const result = fn(...args) as ReturnType<T>
if (result instanceof Promise) {
return result.then(value => {
cache.set(key, value as ReturnType<T>)
return value
}) as ReturnType<T>
}
cache.set(key, result)
return result
}) as T
}
/**
* Global singleton cache by namespace
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const globalCaches: Map<string, Cache<any>> = new Map()
export function getGlobalCache<V>(namespace: string, options?: CacheOptions<V>): Cache<V> {
if (!globalCaches.has(namespace)) {
globalCaches.set(namespace, new Cache<V>({ ...options, namespace }))
}
return globalCaches.get(namespace) as Cache<V>
}
export function clearGlobalCaches(): void {
for (const cache of globalCaches.values()) {
cache.clear()
}
globalCaches.clear()
}
export default Cache
+78 -226
View File
@@ -1,5 +1,5 @@
/* eslint-disable eqeqeq */
import { Boom } from '@hapi/boom'
import { expandAppStateKeys } from 'whatsapp-rust-bridge'
import { proto } from '../../WAProto/index.js'
import type {
BaileysEventEmitter,
@@ -26,14 +26,20 @@ import type { ILogger } from './logger'
import { LT_HASH_ANTI_TAMPERING } from './lt-hash'
import { downloadContentFromMessage } from './messages-media'
import { emitSyncActionResults, processContactAction } from './sync-action-utils'
import { expandAppStateKeys } from './wasm-bridge'
type FetchAppStateSyncKey = (keyId: string) => Promise<proto.Message.IAppStateSyncKeyData | null | undefined>
export type ChatMutationMap = { [index: string]: ChatMutation }
const mutationKeys = (keydata: Uint8Array) => {
return expandAppStateKeys(keydata)
const keys = expandAppStateKeys(keydata)
return {
indexKey: keys.indexKey,
valueEncryptionKey: keys.valueEncryptionKey,
valueMacKey: keys.valueMacKey,
snapshotMacKey: keys.snapshotMacKey,
patchMacKey: keys.patchMacKey
}
}
const generateMac = (
@@ -42,30 +48,22 @@ const generateMac = (
keyId: Uint8Array | string,
key: Uint8Array
) => {
const getKeyData = () => {
let r: number
switch (operation) {
case proto.SyncdMutation.SyncdOperation.SET:
r = 0x01
break
case proto.SyncdMutation.SyncdOperation.REMOVE:
r = 0x02
break
}
const opByte = operation === proto.SyncdMutation.SyncdOperation.SET ? 0x01 : 0x02
const keyIdBuffer = typeof keyId === 'string' ? Buffer.from(keyId, 'base64') : keyId
const keyData = new Uint8Array(1 + keyIdBuffer.length)
keyData[0] = opByte
keyData.set(keyIdBuffer, 1)
const buff = Buffer.from([r])
return Buffer.concat([buff, Buffer.from(keyId as string, 'base64')])
}
const last = new Uint8Array(8)
last[7] = keyData.length
const keyData = getKeyData()
const total = new Uint8Array(keyData.length + data.length + last.length)
total.set(keyData, 0)
total.set(data, keyData.length)
total.set(last, keyData.length + data.length)
const last = Buffer.alloc(8) // 8 bytes
last.set([keyData.length], last.length - 1)
const total = Buffer.concat([keyData, data, last])
const hmac = hmacSign(total, key, 'sha512')
return hmac.slice(0, 32)
return hmac.subarray(0, 32)
}
const to64BitNetworkOrder = (e: number) => {
@@ -76,7 +74,7 @@ const to64BitNetworkOrder = (e: number) => {
type Mac = { indexMac: Uint8Array; valueMac: Uint8Array; operation: proto.SyncdMutation.SyncdOperation }
export const makeLtHashGenerator = ({ indexValueMap, hash }: Pick<LTHashState, 'hash' | 'indexValueMap'>) => {
const makeLtHashGenerator = ({ indexValueMap, hash }: Pick<LTHashState, 'hash' | 'indexValueMap'>) => {
indexValueMap = { ...indexValueMap }
const addBuffs: Uint8Array[] = []
const subBuffs: Uint8Array[] = []
@@ -87,26 +85,23 @@ export const makeLtHashGenerator = ({ indexValueMap, hash }: Pick<LTHashState, '
const prevOp = indexValueMap[indexMacBase64]
if (operation === proto.SyncdMutation.SyncdOperation.REMOVE) {
if (!prevOp) {
// WA Web does not throw here — it logs a warning and skips the subtract.
// The missing REMOVE will cause an LTHash mismatch, which is handled
// by the MAC validation layer (snapshot recovery or retry).
return
throw new Boom('tried remove, but no previous op', { data: { indexMac, valueMac } })
}
// remove from index value mac, since this mutation is erased
delete indexValueMap[indexMacBase64]
} else {
addBuffs.push(new Uint8Array(valueMac))
addBuffs.push(valueMac)
// add this index into the history map
indexValueMap[indexMacBase64] = { valueMac }
}
if (prevOp) {
subBuffs.push(new Uint8Array(prevOp.valueMac))
subBuffs.push(prevOp.valueMac as Uint8Array)
}
},
finish: () => {
const result = LT_HASH_ANTI_TAMPERING().subtractThenAdd(new Uint8Array(hash), addBuffs, subBuffs)
const result = LT_HASH_ANTI_TAMPERING.subtractThenAdd(hash, subBuffs, addBuffs)
return {
hash: Buffer.from(result),
@@ -134,34 +129,6 @@ const generatePatchMac = (
export const newLTHashState = (): LTHashState => ({ version: 0, hash: Buffer.alloc(128), indexValueMap: {} })
export const ensureLTHashStateVersion = (state: LTHashState): LTHashState => {
if (typeof state.version !== 'number' || isNaN(state.version)) {
state.version = 0
}
return state
}
export const MAX_SYNC_ATTEMPTS = 2
/**
* Check if an error is a missing app state sync key.
* WA Web treats these as "Blocked" (waits for key arrival), not fatal.
* In Baileys we retry with a snapshot which may use a different key.
*/
export const isMissingKeyError = (error: any): boolean => {
return error?.data?.isMissingKey === true
}
/**
* Determines if an app state sync error is unrecoverable.
* TypeError indicates a WASM crash; otherwise we give up after MAX_SYNC_ATTEMPTS.
* Missing keys are NOT checked here they are handled separately as "Blocked".
*/
export const isAppStateSyncIrrecoverable = (error: any, attempts: number): boolean => {
return attempts >= MAX_SYNC_ATTEMPTS || error?.name === 'TypeError'
}
export const encodeSyncdPatch = async (
{ type, index, syncAction, apiVersion, operation }: WAPatchCreate,
myAppStateKeyId: string,
@@ -170,7 +137,7 @@ export const encodeSyncdPatch = async (
) => {
const key = !!myAppStateKeyId ? await getAppStateSyncKey(myAppStateKeyId) : undefined
if (!key) {
throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { data: { isMissingKey: true } })
throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { statusCode: 404 })
}
const encKeyId = Buffer.from(myAppStateKeyId, 'base64')
@@ -186,12 +153,7 @@ export const encodeSyncdPatch = async (
})
const encoded = proto.SyncActionData.encode(dataProto).finish()
const keyData = key.keyData
if (!keyData) {
throw new Boom('Missing keyData for encoding', { statusCode: 500 })
}
const keyValue = mutationKeys(keyData)
const keyValue = mutationKeys(key.keyData!)
const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey)
const valueMac = generateMac(operation, encValue, encKeyId, keyValue.valueMacKey)
@@ -240,6 +202,8 @@ export const decodeSyncdMutations = async (
validateMacs: boolean
) => {
const ltGenerator = makeLtHashGenerator(initialState)
const derivedKeyCache = new Map<string, ReturnType<typeof mutationKeys>>()
// indexKey used to HMAC sign record.index.blob
// valueEncryptionKey used to AES-256-CBC encrypt record.value.blob[0:-32]
// the remaining record.value.blob[0:-32] is the mac, it the HMAC sign of key.keyId + decoded proto data + length of bytes in keyId
@@ -247,35 +211,15 @@ export const decodeSyncdMutations = async (
// if it's a syncdmutation, get the operation property
// otherwise, if it's only a record -- it'll be a SET mutation
const operation = 'operation' in msgMutation ? msgMutation.operation : proto.SyncdMutation.SyncdOperation.SET
if (operation == null) {
throw new Boom('Missing operation in mutation', { statusCode: 500 })
}
const record =
'record' in msgMutation && !!msgMutation.record ? msgMutation.record : (msgMutation as proto.ISyncdRecord)
const keyIdBuf = record.keyId?.id
if (!keyIdBuf) {
throw new Boom('Missing keyId in record', { statusCode: 500 })
}
const recordBlob = record.value?.blob
if (!recordBlob) {
throw new Boom('Missing record blob', { statusCode: 500 })
}
const indexBlob = record.index?.blob
if (!indexBlob) {
throw new Boom('Missing index blob in record', { statusCode: 500 })
}
const key = await getKey(keyIdBuf)
const content = Buffer.from(recordBlob)
const encContent = content.slice(0, -32)
const ogValueMac = content.slice(-32)
const key = await getKey(record.keyId!.id!)
const content = record.value!.blob!
const encContent = content.subarray(0, -32)
const ogValueMac = content.subarray(-32)
if (validateMacs) {
const contentHmac = generateMac(operation, encContent, keyIdBuf, key.valueMacKey)
const contentHmac = generateMac(operation!, encContent, record.keyId!.id!, key.valueMacKey)
if (Buffer.compare(contentHmac, ogValueMac) !== 0) {
throw new Boom('HMAC content verification failed')
}
@@ -284,25 +228,20 @@ export const decodeSyncdMutations = async (
const result = aesDecrypt(encContent, key.valueEncryptionKey)
const syncAction = proto.SyncActionData.decode(result)
const syncActionIndex = syncAction.index
if (!syncActionIndex) {
throw new Boom('Missing index in sync action data', { statusCode: 500 })
}
if (validateMacs) {
const hmac = hmacSign(syncActionIndex, key.indexKey)
if (Buffer.compare(hmac, indexBlob) !== 0) {
const hmac = hmacSign(syncAction.index!, key.indexKey)
if (Buffer.compare(hmac, record.index!.blob!) !== 0) {
throw new Boom('HMAC index verification failed')
}
}
const indexStr = Buffer.from(syncActionIndex).toString()
const indexStr = Buffer.from(syncAction.index!).toString()
onMutation({ syncAction, index: JSON.parse(indexStr) })
ltGenerator.mix({
indexMac: indexBlob,
indexMac: record.index!.blob!,
valueMac: ogValueMac,
operation: operation
operation: operation!
})
}
@@ -310,19 +249,23 @@ export const decodeSyncdMutations = async (
async function getKey(keyId: Uint8Array) {
const base64Key = Buffer.from(keyId).toString('base64')
const cached = derivedKeyCache.get(base64Key)
if (cached) {
return cached
}
const keyEnc = await getAppStateSyncKey(base64Key)
if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, {
data: { isMissingKey: true, msgMutations }
statusCode: 404,
data: { msgMutations }
})
}
const keyData = keyEnc.keyData
if (!keyData) {
throw new Boom('Missing keyData in app state sync key', { statusCode: 500 })
}
return mutationKeys(keyData)
const keys = mutationKeys(keyEnc.keyData!)
derivedKeyCache.set(base64Key, keys)
return keys
}
}
@@ -335,66 +278,28 @@ export const decodeSyncdPatch = async (
validateMacs: boolean
) => {
if (validateMacs) {
const msgKeyId = msg.keyId?.id
if (!msgKeyId) {
throw new Boom('Missing keyId in patch message', { statusCode: 500 })
}
const base64Key = Buffer.from(msgKeyId).toString('base64')
const base64Key = Buffer.from(msg.keyId!.id!).toString('base64')
const mainKeyObj = await getAppStateSyncKey(base64Key)
if (!mainKeyObj) {
throw new Boom(`failed to find key "${base64Key}" to decode patch`, { data: { isMissingKey: true, msg } })
throw new Boom(`failed to find key "${base64Key}" to decode patch`, { statusCode: 404, data: { msg } })
}
const mainKeyData = mainKeyObj.keyData
if (!mainKeyData) {
throw new Boom('Missing keyData in main key object', { statusCode: 500 })
}
const mainKey = mutationKeys(mainKeyObj.keyData!)
const mutationmacs = msg.mutations!.map(mutation => mutation.record!.value!.blob!.slice(-32))
const mainKey = mutationKeys(mainKeyData)
const msgMutations = msg.mutations
if (!msgMutations) {
throw new Boom('Missing mutations in patch message', { statusCode: 500 })
}
const mutationmacs = msgMutations.map(mutation => {
const mutBlob = mutation.record?.value?.blob
if (!mutBlob) {
throw new Boom('Missing blob in mutation record', { statusCode: 500 })
}
return mutBlob.slice(-32)
})
const msgSnapshotMac = msg.snapshotMac
if (!msgSnapshotMac) {
throw new Boom('Missing snapshotMac in patch message', { statusCode: 500 })
}
const msgVersion = msg.version?.version
if (msgVersion == null) {
throw new Boom('Missing version in patch message', { statusCode: 500 })
}
const msgPatchMac = msg.patchMac
if (!msgPatchMac) {
throw new Boom('Missing patchMac in patch message', { statusCode: 500 })
}
const patchMac = generatePatchMac(msgSnapshotMac, mutationmacs, toNumber(msgVersion), name, mainKey.patchMacKey)
if (Buffer.compare(patchMac, msgPatchMac) !== 0) {
const patchMac = generatePatchMac(
msg.snapshotMac!,
mutationmacs,
toNumber(msg.version!.version),
name,
mainKey.patchMacKey
)
if (Buffer.compare(patchMac, msg.patchMac!) !== 0) {
throw new Boom('Invalid patch mac')
}
}
const patchMutations = msg.mutations
if (!patchMutations) {
throw new Boom('Missing mutations in patch message', { statusCode: 500 })
}
const result = await decodeSyncdMutations(patchMutations, initialState, getAppStateSyncKey, onMutation, validateMacs)
const result = await decodeSyncdMutations(msg.mutations!, initialState, getAppStateSyncKey, onMutation, validateMacs)
return result
}
@@ -436,7 +341,7 @@ export const extractSyncdPatches = async (result: BinaryNode, options: RequestIn
const syncd = proto.SyncdPatch.decode(content as Uint8Array)
if (!syncd.version) {
syncd.version = { version: +(collectionNode.attrs.version ?? 0) + 1 }
syncd.version = { version: +collectionNode.attrs.version! + 1 }
}
syncds.push(syncd)
@@ -474,25 +379,13 @@ export const decodeSyncdSnapshot = async (
validateMacs = true
) => {
const newState = newLTHashState()
const snapshotVersion = snapshot.version?.version
if (snapshotVersion == null) {
throw new Boom('Missing version in snapshot', { statusCode: 500 })
}
newState.version = toNumber(snapshotVersion)
newState.version = toNumber(snapshot.version!.version)
const mutationMap: ChatMutationMap = {}
const areMutationsRequired = typeof minimumVersionNumber === 'undefined' || newState.version > minimumVersionNumber
const snapshotRecords = snapshot.records
if (!snapshotRecords) {
throw new Boom('Missing records in snapshot', { statusCode: 500 })
}
const { hash, indexValueMap } = await decodeSyncdMutations(
snapshotRecords,
snapshot.records!,
newState,
getAppStateSyncKey,
areMutationsRequired
@@ -507,31 +400,15 @@ export const decodeSyncdSnapshot = async (
newState.indexValueMap = indexValueMap
if (validateMacs) {
const snapKeyId = snapshot.keyId?.id
if (!snapKeyId) {
throw new Boom('Missing keyId in snapshot', { statusCode: 500 })
}
const base64Key = Buffer.from(snapKeyId).toString('base64')
const base64Key = Buffer.from(snapshot.keyId!.id!).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key)
if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { data: { isMissingKey: true } })
throw new Boom(`failed to find key "${base64Key}" to decode mutation`)
}
const snapKeyData = keyEnc.keyData
if (!snapKeyData) {
throw new Boom('Missing keyData in snapshot key', { statusCode: 500 })
}
const result = mutationKeys(snapKeyData)
const result = mutationKeys(keyEnc.keyData!)
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
const snapshotMac = snapshot.mac
if (!snapshotMac) {
throw new Boom('Missing mac in snapshot', { statusCode: 500 })
}
if (Buffer.compare(snapshotMac, computedSnapshotMac) !== 0) {
if (Buffer.compare(snapshot.mac!, computedSnapshotMac) !== 0) {
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name} from snapshot`)
}
}
@@ -568,12 +445,7 @@ export const decodePatches = async (
syncd.mutations?.push(...ref.mutations)
}
const ver = version?.version
if (ver == null) {
throw new Boom('Missing version in patch', { statusCode: 500 })
}
const patchVersion = toNumber(ver)
const patchVersion = toNumber(version!.version)
newState.version = patchVersion
const shouldMutate = typeof minimumVersionNumber === 'undefined' || patchVersion > minimumVersionNumber
@@ -596,30 +468,15 @@ export const decodePatches = async (
newState.indexValueMap = decodeResult.indexValueMap
if (validateMacs) {
const patchKeyId = keyId?.id
if (!patchKeyId) {
throw new Boom('Missing keyId in patch', { statusCode: 500 })
}
const base64Key = Buffer.from(patchKeyId).toString('base64')
const base64Key = Buffer.from(keyId!.id!).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key)
if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { data: { isMissingKey: true } })
throw new Boom(`failed to find key "${base64Key}" to decode mutation`)
}
const patchKeyData = keyEnc.keyData
if (!patchKeyData) {
throw new Boom('Missing keyData in patch key', { statusCode: 500 })
}
const result = mutationKeys(patchKeyData)
const result = mutationKeys(keyEnc.keyData!)
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
if (!snapshotMac) {
throw new Boom('Missing snapshotMac in patch', { statusCode: 500 })
}
if (Buffer.compare(snapshotMac, computedSnapshotMac) !== 0) {
if (Buffer.compare(snapshotMac!, computedSnapshotMac) !== 0) {
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name}`)
}
}
@@ -767,11 +624,7 @@ export const chatModificationToAppPatch = (mod: ChatModification, jid: string) =
operation: OP.SET
}
} else if ('star' in mod) {
const key = mod.star.messages[0]
if (!key) {
throw new Boom('Missing star message key', { statusCode: 400 })
}
const key = mod.star.messages[0]!
patch = {
syncAction: {
starAction: {
@@ -1053,7 +906,7 @@ export const processSyncAction = (
ev.emit('settings.update', { setting: 'timeFormat', value: action.timeFormatAction })
} else if (action?.pnForLidChatAction) {
if (action.pnForLidChatAction.pnJid) {
ev.emit('lid-mapping.update', [{ lid: id!, pn: action.pnForLidChatAction.pnJid }])
ev.emit('lid-mapping.update', { lid: id!, pn: action.pnForLidChatAction.pnJid })
}
} else if (action?.privacySettingRelayAllCalls) {
ev.emit('settings.update', {
@@ -1083,7 +936,6 @@ export const processSyncAction = (
action.lidContactAction.firstName ||
action.lidContactAction.username ||
undefined,
username: action.lidContactAction.username || undefined,
lid: id!,
phoneNumber: undefined
}
-766
View File
@@ -1,766 +0,0 @@
/**
* Circuit Breaker Pattern Implementation
*
* Provides protection against cascading failures by monitoring operation outcomes
* and temporarily blocking requests when failure thresholds are exceeded.
*
* States:
* - CLOSED: Normal operation, requests pass through
* - OPEN: Failures exceeded threshold, requests are blocked
* - HALF_OPEN: Testing recovery, limited requests allowed
*
* @module Utils/circuit-breaker
*/
import { EventEmitter } from 'events'
import { metrics } from './prometheus-metrics.js'
/**
* Circuit breaker operational states
*/
export type CircuitState = 'closed' | 'open' | 'half-open'
/**
* Failure record with timestamp for sliding window tracking
*/
export interface FailureRecord {
timestamp: number
error: Error
}
/**
* Circuit breaker configuration options
*/
export interface CircuitBreakerOptions {
/** Unique identifier for this circuit breaker (used in metrics/logging) */
name: string
/** Number of failures within the window to trigger OPEN state (default: 5) */
failureThreshold?: number
/** Time window in ms for counting failures (default: 60000) */
failureWindow?: number
/** Number of successes required in HALF_OPEN to return to CLOSED (default: 2) */
successThreshold?: number
/** Time in ms to wait before transitioning from OPEN to HALF_OPEN (default: 30000) */
resetTimeout?: number
/** Timeout for individual operations in ms (default: 10000) */
timeout?: number
/** Minimum number of requests before circuit can trip (default: 5) */
volumeThreshold?: number
/** Predicate to determine if an error should count as a failure */
shouldCountError?: (error: Error) => boolean
/** Whether to collect Prometheus metrics (default: true) */
collectMetrics?: boolean
/** Fallback function when circuit is OPEN */
fallback?: <T>() => T | Promise<T>
/** Callback when state changes */
onStateChange?: (from: CircuitState, to: CircuitState) => void
/** Callback on failure */
onFailure?: (error: Error) => void
/** Callback on success */
onSuccess?: () => void
/** Callback when circuit opens */
onOpen?: () => void
/** Callback when circuit closes */
onClose?: () => void
/** Callback when circuit enters half-open */
onHalfOpen?: () => void
}
/**
* Circuit breaker statistics
*/
export interface CircuitBreakerStats {
state: CircuitState
failures: number
successes: number
consecutiveFailures: number
consecutiveSuccesses: number
totalCalls: number
totalFailures: number
totalSuccesses: number
totalRejected: number
failureRate: number
lastFailureTime?: number
lastSuccessTime?: number
lastStateChange?: number
isOpen: boolean
isClosed: boolean
isHalfOpen: boolean
}
/**
* Error thrown when circuit is OPEN and request is rejected
*/
export class CircuitOpenError extends Error {
constructor(
public readonly circuitName: string,
public readonly state: CircuitState
) {
super(`Circuit breaker "${circuitName}" is ${state}`)
this.name = 'CircuitOpenError'
}
}
/**
* Error thrown when operation exceeds timeout
*/
export class CircuitTimeoutError extends Error {
constructor(
public readonly circuitName: string,
public readonly timeoutMs: number
) {
super(`Circuit breaker "${circuitName}" operation timed out after ${timeoutMs}ms`)
this.name = 'CircuitTimeoutError'
}
}
/**
* Circuit Breaker implementation with sliding window failure tracking
*
* @example
* ```typescript
* const breaker = new CircuitBreaker({
* name: 'whatsapp-api',
* failureThreshold: 5,
* resetTimeout: 30000
* })
*
* try {
* const result = await breaker.execute(() => sendMessage(msg))
* } catch (error) {
* if (error instanceof CircuitOpenError) {
* // Circuit is open, use fallback
* }
* }
* ```
*/
export class CircuitBreaker extends EventEmitter {
private state: CircuitState = 'closed'
private failureRecords: FailureRecord[] = []
private consecutiveFailures = 0
private consecutiveSuccesses = 0
private totalCalls = 0
private totalFailures = 0
private totalSuccesses = 0
private totalRejected = 0
private lastFailureTime?: number
private lastSuccessTime?: number
private lastStateChange: number
private resetTimer?: ReturnType<typeof setTimeout>
private readonly options: Required<CircuitBreakerOptions>
constructor(options: CircuitBreakerOptions) {
super()
this.options = {
name: options.name,
failureThreshold: options.failureThreshold ?? 5,
failureWindow: options.failureWindow ?? 60000,
successThreshold: options.successThreshold ?? 2,
resetTimeout: options.resetTimeout ?? 30000,
timeout: options.timeout ?? 10000,
volumeThreshold: options.volumeThreshold ?? 5,
shouldCountError: options.shouldCountError ?? (() => true),
collectMetrics: options.collectMetrics ?? true,
fallback:
options.fallback ??
(() => {
throw new CircuitOpenError(this.options.name, this.state)
}),
onStateChange: options.onStateChange ?? (() => {}),
onFailure: options.onFailure ?? (() => {}),
onSuccess: options.onSuccess ?? (() => {}),
onOpen: options.onOpen ?? (() => {}),
onClose: options.onClose ?? (() => {}),
onHalfOpen: options.onHalfOpen ?? (() => {})
}
this.lastStateChange = Date.now()
}
/**
* Check if the circuit allows execution
*/
canExecute(): boolean {
if (this.state === 'closed') {
return true
}
if (this.state === 'open') {
return false
}
// HALF_OPEN: allow limited requests for testing
return true
}
/**
* Execute an async operation with circuit breaker protection
*/
async execute<T>(operation: () => T | Promise<T>): Promise<T> {
this.totalCalls++
// Check if circuit allows execution
if (this.state === 'open') {
this.totalRejected++
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'circuit_breaker', code: 'rejected' })
}
return this.options.fallback() as T
}
// Execute with timeout protection
try {
const result = await this.executeWithTimeout(operation)
this.recordSuccess()
return result
} catch (error) {
this.recordFailure(error as Error)
throw error
}
}
/**
* Execute a synchronous operation with circuit breaker protection
*/
executeSync<T>(operation: () => T): T {
this.totalCalls++
if (this.state === 'open') {
this.totalRejected++
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'circuit_breaker', code: 'rejected' })
}
return this.options.fallback() as T
}
try {
const result = operation()
this.recordSuccess()
return result
} catch (error) {
this.recordFailure(error as Error)
throw error
}
}
/**
* Execute operation with timeout wrapper
*/
private async executeWithTimeout<T>(operation: () => T | Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new CircuitTimeoutError(this.options.name, this.options.timeout))
}, this.options.timeout)
Promise.resolve(operation())
.then(result => {
clearTimeout(timer)
resolve(result)
})
.catch(error => {
clearTimeout(timer)
reject(error)
})
})
}
/**
* Record a successful operation
*/
private recordSuccess(): void {
this.totalSuccesses++
this.lastSuccessTime = Date.now()
this.consecutiveSuccesses++
this.consecutiveFailures = 0
if (this.options.collectMetrics) {
metrics.socketEvents.inc({ event: 'circuit_success' })
}
this.options.onSuccess()
this.emit('success')
// In HALF_OPEN state, check if we can close the circuit
if (this.state === 'half-open') {
if (this.consecutiveSuccesses >= this.options.successThreshold) {
this.transitionTo('closed')
}
}
}
/**
* Record a failed operation
*/
private recordFailure(error: Error): void {
// Check if this error should count as a failure
if (!this.options.shouldCountError(error)) {
return
}
const now = Date.now()
this.totalFailures++
this.lastFailureTime = now
this.consecutiveFailures++
this.consecutiveSuccesses = 0
// Add to failure records for sliding window
this.failureRecords.push({ timestamp: now, error })
// Clean old failures outside the window
this.cleanOldFailures()
if (this.options.collectMetrics) {
metrics.errors.inc({ category: 'circuit_breaker', code: 'failure' })
}
this.options.onFailure(error)
this.emit('failure', error)
// State transition logic
if (this.state === 'half-open') {
// Any failure in HALF_OPEN immediately reopens the circuit
this.transitionTo('open')
} else if (this.state === 'closed') {
// Check if we should trip the circuit
const recentFailures = this.failureRecords.length
if (this.totalCalls >= this.options.volumeThreshold && recentFailures >= this.options.failureThreshold) {
this.transitionTo('open')
}
}
}
/**
* Remove failure records outside the sliding window
*/
private cleanOldFailures(): void {
const cutoff = Date.now() - this.options.failureWindow
this.failureRecords = this.failureRecords.filter(record => record.timestamp > cutoff)
}
/**
* Transition to a new state
*/
private transitionTo(newState: CircuitState): void {
const oldState = this.state
if (oldState === newState) {
return
}
this.state = newState
this.lastStateChange = Date.now()
// Clear existing reset timer
if (this.resetTimer) {
clearTimeout(this.resetTimer)
this.resetTimer = undefined
}
// State-specific actions
switch (newState) {
case 'closed':
this.consecutiveFailures = 0
this.consecutiveSuccesses = 0
this.failureRecords = []
this.options.onClose()
this.emit('close')
break
case 'open':
this.consecutiveSuccesses = 0
this.options.onOpen()
this.emit('open')
// Record circuit breaker trip metric
if (this.options.collectMetrics) {
metrics.circuitBreakerTrips?.inc({ name: this.options.name })
}
// Schedule transition to HALF_OPEN
this.resetTimer = setTimeout(() => {
this.transitionTo('half-open')
}, this.options.resetTimeout)
break
case 'half-open':
this.consecutiveSuccesses = 0
this.consecutiveFailures = 0
this.options.onHalfOpen()
this.emit('half-open')
break
}
this.options.onStateChange(oldState, newState)
this.emit('state-change', { from: oldState, to: newState })
}
/**
* Manually trip the circuit to OPEN state
*/
trip(): void {
this.transitionTo('open')
}
/**
* Manually reset the circuit to CLOSED state
*/
reset(): void {
this.transitionTo('closed')
}
/**
* Get current circuit state
*/
getState(): CircuitState {
return this.state
}
/**
* Check if circuit is OPEN
*/
isOpen(): boolean {
return this.state === 'open'
}
/**
* Check if circuit is CLOSED
*/
isClosed(): boolean {
return this.state === 'closed'
}
/**
* Check if circuit is HALF_OPEN
*/
isHalfOpen(): boolean {
return this.state === 'half-open'
}
/**
* Get circuit breaker statistics
*/
getStats(): CircuitBreakerStats {
this.cleanOldFailures()
const failureRate = this.totalCalls > 0 ? (this.totalFailures / this.totalCalls) * 100 : 0
return {
state: this.state,
failures: this.failureRecords.length,
successes: this.consecutiveSuccesses,
consecutiveFailures: this.consecutiveFailures,
consecutiveSuccesses: this.consecutiveSuccesses,
totalCalls: this.totalCalls,
totalFailures: this.totalFailures,
totalSuccesses: this.totalSuccesses,
totalRejected: this.totalRejected,
failureRate,
lastFailureTime: this.lastFailureTime,
lastSuccessTime: this.lastSuccessTime,
lastStateChange: this.lastStateChange,
isOpen: this.isOpen(),
isClosed: this.isClosed(),
isHalfOpen: this.isHalfOpen()
}
}
/**
* Get circuit breaker name
*/
getName(): string {
return this.options.name
}
/**
* Destroy circuit breaker and clean up resources
*/
destroy(): void {
if (this.resetTimer) {
clearTimeout(this.resetTimer)
}
this.failureRecords = []
this.removeAllListeners()
}
}
/**
* Factory function to create a circuit breaker
*/
export function createCircuitBreaker(options: CircuitBreakerOptions): CircuitBreaker {
return new CircuitBreaker(options)
}
/**
* Registry for managing multiple circuit breakers
*/
export class CircuitBreakerRegistry {
private breakers: Map<string, CircuitBreaker> = new Map()
/**
* Get or create a circuit breaker by name
*/
get(name: string, options?: Omit<CircuitBreakerOptions, 'name'>): CircuitBreaker {
if (!this.breakers.has(name)) {
const breaker = new CircuitBreaker({ ...options, name })
this.breakers.set(name, breaker)
}
return this.breakers.get(name)!
}
/**
* Check if a circuit breaker exists
*/
has(name: string): boolean {
return this.breakers.has(name)
}
/**
* Remove a circuit breaker
*/
remove(name: string): boolean {
const breaker = this.breakers.get(name)
if (breaker) {
breaker.destroy()
return this.breakers.delete(name)
}
return false
}
/**
* Get all circuit breakers
*/
getAll(): Map<string, CircuitBreaker> {
return new Map(this.breakers)
}
/**
* Get statistics for all circuit breakers
*/
getAllStats(): Record<string, CircuitBreakerStats> {
const stats: Record<string, CircuitBreakerStats> = {}
for (const [name, breaker] of this.breakers) {
stats[name] = breaker.getStats()
}
return stats
}
/**
* Reset all circuit breakers to CLOSED state
*/
resetAll(): void {
for (const breaker of this.breakers.values()) {
breaker.reset()
}
}
/**
* Destroy all circuit breakers
*/
destroyAll(): void {
for (const breaker of this.breakers.values()) {
breaker.destroy()
}
this.breakers.clear()
}
}
/**
* Global circuit breaker registry instance
*/
export const globalCircuitRegistry = new CircuitBreakerRegistry()
/**
* Decorator to protect a method with circuit breaker
*
* @example
* ```typescript
* class MyService {
* @circuitBreaker({ failureThreshold: 3 })
* async fetchData() {
* return await api.getData()
* }
* }
* ```
*/
export function circuitBreaker(options: Omit<CircuitBreakerOptions, 'name'> & { name?: string } = {}) {
return function (
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => unknown>
) {
const originalMethod = descriptor.value
if (!originalMethod) return descriptor
const name = options.name || propertyKey
const breaker = globalCircuitRegistry.get(name, options)
descriptor.value = async function (...args: unknown[]): Promise<unknown> {
return breaker.execute(() => originalMethod.apply(this, args))
}
return descriptor
}
}
/**
* Wrap a function with circuit breaker protection
*
* @example
* ```typescript
* const protectedFetch = withCircuitBreaker(
* fetchData,
* { name: 'api-fetch', failureThreshold: 5 }
* )
* ```
*/
// eslint-disable-next-line space-before-function-paren
export function withCircuitBreaker<T extends (...args: unknown[]) => unknown>(
fn: T,
options: CircuitBreakerOptions
): T {
const breaker = new CircuitBreaker(options)
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
return breaker.execute(() => fn(...args)) as Promise<ReturnType<T>>
}) as unknown as T
}
/**
* Get health status of all circuit breakers
*/
export function getCircuitHealth(): {
healthy: boolean
openCircuits: string[]
stats: Record<string, CircuitBreakerStats>
} {
const stats = globalCircuitRegistry.getAllStats()
const openCircuits: string[] = []
for (const [name, stat] of Object.entries(stats)) {
if (stat.isOpen) {
openCircuits.push(name)
}
}
return {
healthy: openCircuits.length === 0,
openCircuits,
stats
}
}
/**
* Create a pre-configured circuit breaker for WhatsApp PreKey operations
*
* This circuit breaker is optimized for handling encryption/session errors
* that commonly occur with WhatsApp's Signal protocol implementation.
*
* @example
* ```typescript
* const preKeyBreaker = createPreKeyCircuitBreaker()
*
* async function sendEncryptedMessage(msg) {
* return preKeyBreaker.execute(async () => {
* return await encryptAndSend(msg)
* })
* }
* ```
*/
export function createPreKeyCircuitBreaker(customOptions?: Partial<CircuitBreakerOptions>): CircuitBreaker {
const preKeyErrorPatterns = ['prekey', 'pre-key', 'session', 'signal', 'encrypt', 'decrypt', 'cipher', 'key']
return new CircuitBreaker({
name: 'prekey-operations',
failureThreshold: 5,
failureWindow: 60000,
resetTimeout: 30000,
successThreshold: 2,
shouldCountError: (error: Error) => {
const message = error.message.toLowerCase()
return preKeyErrorPatterns.some(pattern => message.includes(pattern))
},
...customOptions
})
}
/**
* Create a circuit breaker for WebSocket connection operations
*/
export function createConnectionCircuitBreaker(customOptions?: Partial<CircuitBreakerOptions>): CircuitBreaker {
const connectionErrorPatterns = [
'econnrefused',
'econnreset',
'etimedout',
'enotfound',
'socket',
'websocket',
'connection',
'network'
]
// Normal WS lifecycle status codes that must NEVER trip the query circuit breaker.
// These are transient events that happen on every reconnect and carry no information
// about persistent server-side failures. The reconnection logic in makeSocket handles
// them independently.
const WS_LIFECYCLE_STATUS_CODES = new Set([
428, // connectionClosed
408, // connectionLost / timedOut
440, // connectionReplaced
])
return new CircuitBreaker({
name: 'connection-operations',
failureThreshold: 3,
failureWindow: 30000,
resetTimeout: 60000,
successThreshold: 1,
shouldCountError: (error: Error) => {
// CircuitTimeoutError messages embed the circuit name (e.g. "socket-query"), which
// accidentally matches the "socket" pattern below and causes a self-reinforcing loop
// where the breaker's own timeouts keep tripping the breaker. Exclude them explicitly.
if (error instanceof CircuitTimeoutError) return false
// Exclude normal WS reconnect events (Connection Closed, Connection Lost, Timed Out, etc.).
// These are not server-side failures — they happen on every restart/redeploy and should
// never cause the circuit to open. If we counted them, the 3 concurrent parallel
// post-login queries (sendPassiveIq, uploadPreKeysToServerIfRequired, digestKeyBundle) could all
// fail simultaneously when the WS drops, instantly opening the circuit and blocking
// profile-picture fetches and message delivery for the next 30 s.
const statusCode = (error as { output?: { statusCode?: number } })?.output?.statusCode
if (statusCode !== undefined && WS_LIFECYCLE_STATUS_CODES.has(statusCode)) return false
const message = error.message.toLowerCase()
const code = (error as NodeJS.ErrnoException).code?.toLowerCase() || ''
return connectionErrorPatterns.some(pattern => message.includes(pattern) || code.includes(pattern))
},
...customOptions
})
}
/**
* Create a circuit breaker for message sending operations
*/
export function createMessageCircuitBreaker(customOptions?: Partial<CircuitBreakerOptions>): CircuitBreaker {
return new CircuitBreaker({
name: 'message-operations',
failureThreshold: 5,
failureWindow: 60000,
resetTimeout: 15000,
successThreshold: 2,
timeout: 30000,
...customOptions
})
}
export default CircuitBreaker
+4 -5
View File
@@ -2,6 +2,7 @@ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes }
import * as curve from 'libsignal/src/curve'
import { KEY_BUNDLE_TYPE } from '../Defaults'
import type { KeyPair } from '../Types'
export { md5, hkdf } from 'whatsapp-rust-bridge'
// insure browser & node compatibility
const { subtle } = globalThis.crypto
@@ -83,7 +84,7 @@ export function aesDecryptCTR(ciphertext: Uint8Array, key: Uint8Array, iv: Uint8
/** decrypt AES 256 CBC; where the IV is prefixed to the buffer */
export function aesDecrypt(buffer: Uint8Array, key: Uint8Array) {
return aesDecryptWithIV(buffer.subarray(16, buffer.length), key, buffer.subarray(0, 16))
return aesDecryptWithIV(buffer.subarray(16), key, buffer.subarray(0, 16))
}
/** decrypt AES 256 CBC */
@@ -93,14 +94,14 @@ export function aesDecryptWithIV(buffer: Uint8Array, key: Uint8Array, IV: Uint8A
}
// encrypt AES 256 CBC; where a random IV is prefixed to the buffer
export function aesEncrypt(buffer: Buffer | Uint8Array, key: Uint8Array) {
export function aesEncrypt(buffer: Uint8Array, key: Uint8Array) {
const IV = randomBytes(16)
const aes = createCipheriv('aes-256-cbc', key, IV)
return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer
}
// encrypt AES 256 CBC with a given IV
export function aesEncrypWithIV(buffer: Buffer | Uint8Array, key: Uint8Array, IV: Uint8Array) {
export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
const aes = createCipheriv('aes-256-cbc', key, IV)
return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer
}
@@ -118,8 +119,6 @@ export function sha256(buffer: Buffer) {
return createHash('sha256').update(buffer).digest()
}
export { hkdf, md5 } from './wasm-bridge'
export async function derivePairingCodeKey(pairingCode: string, salt: Buffer): Promise<Buffer> {
// Convert inputs to formats Web Crypto API can work with
const encoder = new TextEncoder()
+31 -203
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+34 -96
View File
@@ -1,10 +1,7 @@
import { Boom } from '@hapi/boom'
import { createHash, randomBytes } from 'crypto'
import { proto } from '../../WAProto/index.js'
// Single source of truth for WhatsApp Web version - imported from JSON
import baileysVersionData from '../Defaults/baileys-version.json' with { type: 'json' }
const baileysVersion = baileysVersionData.version
const baileysVersion = [2, 3000, 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
-209
View File
@@ -1,209 +0,0 @@
/**
* Health Status Utilities
*
* Provides health check and status information for monitoring and k8s probes.
*
* @module Utils/health-status
*/
import { globalCircuitRegistry } from './circuit-breaker.js'
import { getVersionCacheStatus } from './version-cache.js'
/**
* Circuit breaker health information
*/
export interface CircuitBreakerHealth {
name: string
state: 'closed' | 'open' | 'half-open'
failures: number
successes: number
totalCalls: number
}
/**
* Cache health information
*/
export interface CacheHealth {
versionCache: {
hasCache: boolean
isExpired: boolean
ageMs: number | null
source: string | null
}
}
/**
* Overall health status
*/
export interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy'
timestamp: number
uptime: number
version: string
circuitBreakers: CircuitBreakerHealth[]
cache: CacheHealth
checks: {
name: string
status: 'pass' | 'warn' | 'fail'
message?: string
}[]
}
/**
* Get the current health status of the Baileys instance.
*
* Useful for:
* - Kubernetes liveness/readiness probes
* - Load balancer health checks
* - Monitoring dashboards
*
* @returns HealthStatus object with detailed status information
*
* @example
* ```typescript
* import { getHealthStatus } from '@whiskeysockets/baileys'
*
* // Simple health check endpoint
* app.get('/health', (req, res) => {
* const health = getHealthStatus()
* const statusCode = health.status === 'healthy' ? 200 :
* health.status === 'degraded' ? 200 : 503
* res.status(statusCode).json(health)
* })
*
* // Kubernetes probe
* app.get('/healthz', (req, res) => {
* const health = getHealthStatus()
* res.status(health.status !== 'unhealthy' ? 200 : 503).send(health.status)
* })
* ```
*/
export function getHealthStatus(): HealthStatus {
const checks: HealthStatus['checks'] = []
let overallStatus: HealthStatus['status'] = 'healthy'
// 1. Check version cache
const versionCacheStatus = getVersionCacheStatus()
if (!versionCacheStatus.hasCache) {
checks.push({
name: 'version_cache',
status: 'warn',
message: 'No version cache available'
})
if (overallStatus === 'healthy') overallStatus = 'degraded'
} else if (versionCacheStatus.isExpired) {
checks.push({
name: 'version_cache',
status: 'warn',
message: 'Version cache is expired'
})
if (overallStatus === 'healthy') overallStatus = 'degraded'
} else {
checks.push({
name: 'version_cache',
status: 'pass'
})
}
// 2. Check circuit breakers
const circuitBreakers: CircuitBreakerHealth[] = []
let openCircuits = 0
for (const [name, breaker] of globalCircuitRegistry.getAll()) {
const stats = breaker.getStats()
const state = breaker.getState()
circuitBreakers.push({
name,
state,
failures: stats.totalFailures,
successes: stats.totalSuccesses,
totalCalls: stats.totalCalls
})
if (state === 'open') {
openCircuits++
}
}
if (openCircuits > 0) {
checks.push({
name: 'circuit_breakers',
status: openCircuits > 2 ? 'fail' : 'warn',
message: `${openCircuits} circuit breaker(s) are open`
})
if (openCircuits > 2) {
overallStatus = 'unhealthy'
} else if (overallStatus === 'healthy') {
overallStatus = 'degraded'
}
} else {
checks.push({
name: 'circuit_breakers',
status: 'pass'
})
}
// 3. Check memory usage (warn if > 90%)
const memUsage = process.memoryUsage()
const heapUsedPercent = (memUsage.heapUsed / memUsage.heapTotal) * 100
if (heapUsedPercent > 90) {
checks.push({
name: 'memory',
status: 'warn',
message: `Heap usage is ${heapUsedPercent.toFixed(1)}%`
})
if (overallStatus === 'healthy') overallStatus = 'degraded'
} else {
checks.push({
name: 'memory',
status: 'pass'
})
}
return {
status: overallStatus,
timestamp: Date.now(),
uptime: process.uptime(),
version: process.env.npm_package_version || 'unknown',
circuitBreakers,
cache: {
versionCache: {
hasCache: versionCacheStatus.hasCache,
isExpired: versionCacheStatus.isExpired,
ageMs: versionCacheStatus.age,
source: versionCacheStatus.source
}
},
checks
}
}
/**
* Simple health check - returns true if system is healthy or degraded.
* Use this for basic liveness probes.
*
* @returns true if healthy or degraded, false if unhealthy
*/
export function isHealthy(): boolean {
const status = getHealthStatus()
return status.status !== 'unhealthy'
}
/**
* Get a simple status string for minimal health endpoints.
*
* @returns 'ok', 'degraded', or 'error'
*/
export function getSimpleHealthStatus(): 'ok' | 'degraded' | 'error' {
const status = getHealthStatus()
switch (status.status) {
case 'healthy':
return 'ok'
case 'degraded':
return 'degraded'
case 'unhealthy':
return 'error'
}
}
+47 -344
View File
@@ -1,218 +1,16 @@
import { pipeline } from 'stream/promises'
import { promisify } from 'util'
import { createInflate, inflate } from 'zlib'
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
*/
export const downloadHistory = async (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => {
const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options })
// Pipe decrypted stream directly through zlib inflate.
// Avoids allocating an intermediate buffer for the compressed payload —
// memory peaks during 50MB history syncs drop ~50% (PR upstream #2333).
const inflater = createInflate()
const chunks: Buffer[] = []
inflater.on('data', (chunk: Buffer) => chunks.push(chunk))
await pipeline(stream, inflater)
const buffer = Buffer.concat(chunks)
const syncData = proto.HistorySync.decode(buffer)
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
@@ -223,7 +21,7 @@ const extractPnFromMessages = (messages: proto.IHistorySyncMsg[]): string | unde
}
const userJid = message.userReceipt[0]?.userJid
if (userJid && isAnyPnUser(userJid)) {
if (userJid && (isPnUser(userJid) || isHostedPnUser(userJid))) {
return userJid
}
}
@@ -231,59 +29,34 @@ const extractPnFromMessages = (messages: proto.IHistorySyncMsg[]): string | unde
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 downloadHistory = async (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => {
const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options })
const bufferArray: Buffer[] = []
for await (const chunk of stream) {
bufferArray.push(chunk)
}
let buffer: Buffer = Buffer.concat(bufferArray)
// decompress buffer
buffer = await inflatePromise(buffer)
const syncData = proto.HistorySync.decode(buffer)
return syncData
}
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,64 +110,34 @@ 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]
})
}
}
chats.push(chat)
chats.push({ ...chat })
}
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
}
+16 -137
View File
@@ -1,35 +1,8 @@
/**
* Identity Change Handler
*
* Handles WhatsApp identity change notifications which occur when a contact's
* encryption keys change. This typically happens when:
* - User reinstalls WhatsApp
* - User changes devices
* - User's phone number verification expires and is re-verified
*
* This module centralizes the identity change logic to:
* - Prevent duplicate session refreshes via debouncing
* - Skip unnecessary refreshes for companion devices
* - Handle offline notification scenarios correctly
* - Provide clear result types for monitoring and debugging
*
* @module identity-change-handler
* @see https://github.com/WhiskeySockets/Baileys/issues/2132
*/
import NodeCache from '@cacheable/node-cache'
import { areJidsSameUser, type BinaryNode, getBinaryNodeChild, jidDecode } from '../WABinary'
import { isStringNullOrEmpty } from './generics'
import type { ILogger } from './logger'
// ============================================================================
// TYPES
// ============================================================================
/**
* Result type for identity change handling operations.
* Each action variant indicates a specific outcome with relevant context.
*/
export type IdentityChangeResult =
| { action: 'no_identity_node' }
| { action: 'invalid_notification' }
@@ -37,85 +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
/**
* Invoked right before `assertSessions` is called for an existing-session identity
* change. Used to kick off fire-and-forget side effects (e.g. tctoken re-issuance)
* in the same order WA Web does i.e. before the E2E session is re-established.
* Must not throw; implementations are responsible for their own error handling.
*
* Skipped when the refresh itself is skipped (no_identity_node, invalid_notification,
* skipped_companion_device, skipped_self_primary, debounced, skipped_offline).
*/
onBeforeSessionRefresh?: (jid: string) => void
}
// ============================================================================
// 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
@@ -125,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' }
@@ -133,70 +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)
// Fire-and-forget side effects (e.g. tctoken re-issuance) BEFORE the session is
// re-established. WA Web runs these in parallel with the session refresh —
// running afterwards would race with the next outbound send and risk error 463.
//
// Wrapped in try/catch so a misbehaving consumer callback cannot abort identity
// change recovery. We log and continue — assertSessions still runs so the E2E
// session always gets refreshed.
try {
ctx.onBeforeSessionRefresh?.(from)
} catch (error) {
ctx.logger.warn({ error, jid: from }, 'onBeforeSessionRefresh callback threw — continuing with session refresh')
}
// 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 }
-31
View File
@@ -9,7 +9,6 @@ export * from './noise-handler'
export * from './history'
export * from './chat-utils'
export * from './lt-hash'
export { wasmBridgeReady } from './wasm-bridge'
export * from './auth-utils'
export * from './use-multi-file-auth-state'
export * from './link-preview'
@@ -17,34 +16,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'
-374
View File
@@ -1,374 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* @fileoverview Adaptador entre diferentes sistemas de logging
* @module Utils/logger-adapter
*
* Fornece:
* - Adapter pattern para integrar diferentes loggers
* - Mapeamento de níveis de log entre sistemas
* - Transformação de formatos de log
* - Compatibilidade com Pino, Console e StructuredLogger
*/
import type P from 'pino'
import type { ILogger } from './logger.js'
import { createStructuredLogger, LOG_LEVEL_VALUES, type LogLevel, StructuredLogger } from './structured-logger.js'
/**
* Tipo de logger suportado
*/
export type LoggerType = 'pino' | 'console' | 'structured' | 'custom'
/**
* Configuração do adapter
*/
export interface LoggerAdapterConfig {
/** Tipo de logger de origem */
sourceType: LoggerType
/** Tipo de logger de destino */
targetType: LoggerType
/** Mapeamento customizado de níveis */
levelMapping?: Record<string, LogLevel>
/** Transformador de contexto */
contextTransformer?: (context: Record<string, unknown>) => Record<string, unknown>
/** Filtro de logs */
logFilter?: (level: LogLevel, message: string, data?: unknown) => boolean
}
/**
* Mapeamento padrão de níveis Pino para StructuredLogger
*/
const PINO_LEVEL_MAPPING: Record<number, LogLevel> = {
10: 'trace',
20: 'debug',
30: 'info',
40: 'warn',
50: 'error',
60: 'fatal'
}
/**
* Mapeamento reverso para Pino
*/
const STRUCTURED_TO_PINO_LEVEL: Record<LogLevel, number> = {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60,
silent: 100
}
/**
* Classe adaptadora principal
*/
export class LoggerAdapter implements ILogger {
private sourceLogger: ILogger
private targetLogger: ILogger | null = null
private config: LoggerAdapterConfig
constructor(sourceLogger: ILogger, config: Partial<LoggerAdapterConfig> = {}) {
this.sourceLogger = sourceLogger
this.config = {
sourceType: config.sourceType || 'pino',
targetType: config.targetType || 'structured',
levelMapping: config.levelMapping,
contextTransformer: config.contextTransformer,
logFilter: config.logFilter
}
}
get level(): string {
return this.sourceLogger.level
}
set level(newLevel: string) {
this.sourceLogger.level = newLevel
if (this.targetLogger) {
this.targetLogger.level = newLevel
}
}
/**
* Define o logger de destino
*/
setTargetLogger(logger: ILogger): void {
this.targetLogger = logger
}
/**
* Cria um logger filho
*/
child(obj: Record<string, unknown>): LoggerAdapter {
const transformedContext = this.config.contextTransformer ? this.config.contextTransformer(obj) : obj
const childAdapter = new LoggerAdapter(this.sourceLogger.child(transformedContext), this.config)
if (this.targetLogger) {
childAdapter.setTargetLogger(this.targetLogger.child(transformedContext))
}
return childAdapter
}
/**
* Mapeia nível de log
*/
private mapLevel(level: string | number): LogLevel {
if (typeof level === 'number') {
return PINO_LEVEL_MAPPING[level] || 'info'
}
if (this.config.levelMapping && level in this.config.levelMapping) {
return this.config.levelMapping[level] ?? 'info'
}
return (level as LogLevel) || 'info'
}
/**
* Verifica se o log deve ser processado
*/
private shouldLog(level: LogLevel, msg: string, obj?: unknown): boolean {
if (this.config.logFilter) {
return this.config.logFilter(level, msg, obj)
}
return true
}
/**
* Processa log em ambos loggers
*/
private processLog(level: LogLevel, obj: unknown, msg?: string): void {
if (!this.shouldLog(level, msg || '', obj)) {
return
}
// Log no source logger
const sourceMethod = this.sourceLogger[level as keyof ILogger]
if (typeof sourceMethod === 'function') {
;(sourceMethod as (obj: unknown, msg?: string) => void).call(this.sourceLogger, obj, msg)
}
// Log no target logger se configurado
if (this.targetLogger) {
const targetMethod = this.targetLogger[level as keyof ILogger]
if (typeof targetMethod === 'function') {
;(targetMethod as (obj: unknown, msg?: string) => void).call(this.targetLogger, obj, msg)
}
}
}
trace(obj: unknown, msg?: string): void {
this.processLog('trace', obj, msg)
}
debug(obj: unknown, msg?: string): void {
this.processLog('debug', obj, msg)
}
info(obj: unknown, msg?: string): void {
this.processLog('info', obj, msg)
}
warn(obj: unknown, msg?: string): void {
this.processLog('warn', obj, msg)
}
error(obj: unknown, msg?: string): void {
this.processLog('error', obj, msg)
}
}
/**
* Wrapper para converter Pino logger em StructuredLogger
*/
export class PinoToStructuredAdapter implements ILogger {
private pinoLogger: P.Logger
private structuredLogger: StructuredLogger
constructor(pinoLogger: P.Logger, structuredLoggerConfig?: Parameters<typeof createStructuredLogger>[0]) {
this.pinoLogger = pinoLogger
this.structuredLogger = createStructuredLogger({
level: this.mapPinoLevel(pinoLogger.level),
...structuredLoggerConfig
})
}
get level(): string {
return this.pinoLogger.level
}
set level(newLevel: string) {
this.pinoLogger.level = newLevel
this.structuredLogger.level = newLevel
}
private mapPinoLevel(pinoLevel: string): LogLevel {
const levelMap: Record<string, LogLevel> = {
trace: 'trace',
debug: 'debug',
info: 'info',
warn: 'warn',
error: 'error',
fatal: 'fatal',
silent: 'silent'
}
return levelMap[pinoLevel] || 'info'
}
child(obj: Record<string, unknown>): PinoToStructuredAdapter {
const adapter = new PinoToStructuredAdapter(this.pinoLogger.child(obj))
return adapter
}
trace(obj: unknown, msg?: string): void {
this.pinoLogger.trace(obj as object, msg)
this.structuredLogger.trace(obj, msg)
}
debug(obj: unknown, msg?: string): void {
this.pinoLogger.debug(obj as object, msg)
this.structuredLogger.debug(obj, msg)
}
info(obj: unknown, msg?: string): void {
this.pinoLogger.info(obj as object, msg)
this.structuredLogger.info(obj, msg)
}
warn(obj: unknown, msg?: string): void {
this.pinoLogger.warn(obj as object, msg)
this.structuredLogger.warn(obj, msg)
}
error(obj: unknown, msg?: string): void {
this.pinoLogger.error(obj as object, msg)
this.structuredLogger.error(obj, msg)
}
/**
* Obtém métricas do structured logger
*/
getMetrics() {
return this.structuredLogger.getMetrics()
}
}
/**
* Factory para criar adapter baseado no tipo de logger
*/
export function createLoggerAdapter(logger: ILogger, config?: Partial<LoggerAdapterConfig>): LoggerAdapter {
return new LoggerAdapter(logger, config)
}
/**
* Converte qualquer logger para a interface ILogger
*/
export function normalizeLogger(logger: unknown): ILogger {
if (isILogger(logger)) {
return logger
}
// Se for um objeto com métodos de log
if (typeof logger === 'object' && logger !== null) {
const logObj = logger as Record<string, unknown>
return {
level: (logObj.level as string) || 'info',
child: (obj: Record<string, unknown>) => {
if (typeof logObj.child === 'function') {
return normalizeLogger((logObj.child as (obj: Record<string, unknown>) => unknown)(obj))
}
return normalizeLogger(logger)
},
trace: createLogMethod(logObj, 'trace'),
debug: createLogMethod(logObj, 'debug'),
info: createLogMethod(logObj, 'info'),
warn: createLogMethod(logObj, 'warn'),
error: createLogMethod(logObj, 'error')
}
}
// Fallback: console logger
return createConsoleLogger()
}
/**
* Verifica se objeto implementa ILogger
*/
export function isILogger(obj: unknown): obj is ILogger {
if (typeof obj !== 'object' || obj === null) {
return false
}
const logger = obj as Record<string, unknown>
return (
typeof logger.child === 'function' &&
typeof logger.trace === 'function' &&
typeof logger.debug === 'function' &&
typeof logger.info === 'function' &&
typeof logger.warn === 'function' &&
typeof logger.error === 'function'
)
}
/**
* Cria método de log genérico
*/
function createLogMethod(logger: Record<string, unknown>, level: string): (obj: unknown, msg?: string) => void {
return (obj: unknown, msg?: string) => {
if (typeof logger[level] === 'function') {
;(logger[level] as (obj: unknown, msg?: string) => void)(obj, msg)
} else {
// Fallback to console methods
const consoleMethod = (console as unknown as Record<string, ((...args: unknown[]) => void) | undefined>)[level]
if (typeof consoleMethod === 'function') {
consoleMethod(obj, msg)
}
}
}
}
/**
* Cria um logger baseado em console
*/
export function createConsoleLogger(prefix?: string): ILogger {
const formatMessage = (level: string, obj: unknown, msg?: string): string => {
const timestamp = new Date().toISOString()
const prefixStr = prefix ? `[${prefix}]` : ''
const message = msg || (typeof obj === 'string' ? obj : '')
const data = typeof obj === 'object' ? JSON.stringify(obj) : ''
return `${timestamp} ${prefixStr}[${level.toUpperCase()}] ${message} ${data}`.trim()
}
return {
level: 'info',
child(obj: Record<string, unknown>): ILogger {
const childPrefix = prefix ? `${prefix}:${Object.values(obj)[0]}` : String(Object.values(obj)[0])
return createConsoleLogger(childPrefix)
},
trace(obj: unknown, msg?: string): void {
console.debug(formatMessage('trace', obj, msg))
},
debug(obj: unknown, msg?: string): void {
console.debug(formatMessage('debug', obj, msg))
},
info(obj: unknown, msg?: string): void {
console.info(formatMessage('info', obj, msg))
},
warn(obj: unknown, msg?: string): void {
console.warn(formatMessage('warn', obj, msg))
},
error(obj: unknown, msg?: string): void {
console.error(formatMessage('error', obj, msg))
}
}
}
export default LoggerAdapter
+2 -143
View File
@@ -1,22 +1,4 @@
/**
* Configurable Logger for Baileys
*
* Supports environment variables:
* - BAILEYS_LOG: Enable/disable logging (default: true)
* - BAILEYS_LOG_LEVEL: Log level - trace/debug/info/warn/error/fatal/silent (default: info)
* - USE_STRUCTURED_LOGS: Use structured logger with advanced features (default: false)
* - LOG_FORMAT: Output format - 'json' or 'pretty' (default: json)
* - LOGGER_INFO: Enable info level (default: true)
* - LOGGER_WARN: Enable warn level (default: true)
* - LOGGER_ERROR: Enable error level (default: true)
*
* @module Utils/logger
*/
import { createRequire } from 'module'
import P, { type Logger as PinoLogger } from 'pino'
const require = createRequire(import.meta.url)
import P from 'pino'
export interface ILogger {
level: string
@@ -28,127 +10,4 @@ export interface ILogger {
error(obj: unknown, msg?: string): void
}
/**
* Logger configuration from environment variables
*/
interface LoggerConfig {
enabled: boolean
level: string
format: 'json' | 'pretty'
useStructuredLogs: boolean
levelFilters: {
info: boolean
warn: boolean
error: boolean
}
}
/**
* Load logger configuration from environment
*/
function loadLoggerConfig(): LoggerConfig {
return {
enabled: process.env.BAILEYS_LOG !== 'false',
level: process.env.BAILEYS_LOG_LEVEL || 'info',
format: (process.env.LOG_FORMAT as 'json' | 'pretty') || 'json',
useStructuredLogs: process.env.USE_STRUCTURED_LOGS === 'true',
levelFilters: {
info: process.env.LOGGER_INFO !== 'false',
warn: process.env.LOGGER_WARN !== 'false',
error: process.env.LOGGER_ERROR !== 'false'
}
}
}
function canUsePrettyTransport(): boolean {
try {
require.resolve('pino-pretty')
return true
} catch {
return false
}
}
/**
* Create a filtered logger that respects LOGGER_INFO/WARN/ERROR settings
*/
function createFilteredLogger(baseLogger: PinoLogger, config: LoggerConfig): ILogger {
const noop = () => {}
return {
get level() {
return baseLogger.level
},
set level(newLevel: string) {
baseLogger.level = newLevel
},
child(obj: Record<string, unknown>): ILogger {
return createFilteredLogger(baseLogger.child(obj), config)
},
trace: baseLogger.trace.bind(baseLogger),
debug: baseLogger.debug.bind(baseLogger),
info: config.levelFilters.info ? baseLogger.info.bind(baseLogger) : noop,
warn: config.levelFilters.warn ? baseLogger.warn.bind(baseLogger) : noop,
error: config.levelFilters.error ? baseLogger.error.bind(baseLogger) : noop
}
}
/**
* Create a silent logger when logging is disabled
*/
function createSilentLogger(): ILogger {
const noop = () => {}
const silentLogger: ILogger = {
level: 'silent',
child: () => silentLogger,
trace: noop,
debug: noop,
info: noop,
warn: noop,
error: noop
}
return silentLogger
}
/**
* Create the configured logger instance
*/
function createLogger(): ILogger {
const config = loadLoggerConfig()
// If logging is disabled, return silent logger
if (!config.enabled) {
return createSilentLogger()
}
// Create pino options
const pinoOptions: P.LoggerOptions = {
level: config.level,
timestamp: () => `,"time":"${new Date().toJSON()}"`
}
// Add pretty printing when explicitly set and available
if (config.format === 'pretty' && canUsePrettyTransport()) {
pinoOptions.transport = {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname'
}
}
}
const baseLogger = P(pinoOptions)
return createFilteredLogger(baseLogger, config)
}
// Export the configured logger instance
const logger = createLogger()
export default logger
// Export config loader for testing/inspection
export { loadLoggerConfig, type LoggerConfig }
export default P({ timestamp: () => `,"time":"${new Date().toJSON()}"` })
+7 -2
View File
@@ -1,3 +1,8 @@
import { getLTHashAntiTampering } from './wasm-bridge'
import { LTHashAntiTampering } from 'whatsapp-rust-bridge'
export { getLTHashAntiTampering as LT_HASH_ANTI_TAMPERING }
/**
* LT Hash is a summation based hash algorithm that maintains the integrity of a piece of data
* over a series of mutations. You can add/remove mutations and it'll return a hash equal to
* if the same series of mutations was made sequentially.
*/
export const LT_HASH_ANTI_TAMPERING = new LTHashAntiTampering()
+38 -155
View File
@@ -1,7 +1,6 @@
import { LRUCache } from 'lru-cache'
import type { proto } from '../../WAProto/index.js'
import type { ILogger } from './logger'
import { metrics } from './prometheus-metrics.js'
/** Number of sent messages to cache in memory for handling retry receipts */
const RECENT_MESSAGES_SIZE = 512
@@ -11,63 +10,6 @@ const MESSAGE_KEY_SEPARATOR = '\u0000'
/** Timeout for session recreation - 1 hour */
const RECREATE_SESSION_TIMEOUT = 60 * 60 * 1000 // 1 hour in milliseconds
const PHONE_REQUEST_DELAY = 3000
/**
* Retry reason codes from WhatsApp protocol
* These map to the error codes sent in retry receipts
*
* @see https://github.com/WhiskeySockets/Baileys/pull/2307
*/
export enum RetryReason {
/** Unknown or unspecified error */
UnknownError = 0,
/** No Signal session exists for recipient */
SignalErrorNoSession = 1,
/** Invalid key format or corrupted key */
SignalErrorInvalidKey = 2,
/** Invalid pre-key ID (key not found) */
SignalErrorInvalidKeyId = 3,
/** Invalid message - MAC verification failed */
SignalErrorInvalidMessage = 4,
/** Invalid signature on message or key */
SignalErrorInvalidSignature = 5,
/** Message from the future (timestamp issue) */
SignalErrorFutureMessage = 6,
/** Explicit MAC verification failure */
SignalErrorBadMac = 7,
/** Session is corrupted or invalid state */
SignalErrorInvalidSession = 8,
/** Invalid message key (decryption key issue) */
SignalErrorInvalidMsgKey = 9,
/** Bad broadcast ephemeral setting */
BadBroadcastEphemeralSetting = 10,
/** Unknown companion device without pre-key */
UnknownCompanionNoPrekey = 11,
/** ADV (Announcement Delivery Verification) failure */
AdvFailure = 12,
/** Status revoke was delayed */
StatusRevokeDelay = 13
}
/**
* MAC error codes that indicate identity key mismatch
* These errors occur when the sender's identity key has changed (e.g., reinstalled WhatsApp)
* and require immediate session recreation without waiting for the normal timeout
*/
export const MAC_ERROR_CODES = new Set<RetryReason>([
RetryReason.SignalErrorInvalidMessage,
RetryReason.SignalErrorBadMac
])
/**
* Session-related error codes that may require session recreation
*/
export const SESSION_ERROR_CODES = new Set<RetryReason>([
RetryReason.SignalErrorNoSession,
RetryReason.SignalErrorInvalidSession,
RetryReason.SignalErrorInvalidKey,
RetryReason.SignalErrorInvalidKeyId
])
export interface RecentMessageKey {
to: string
id: string
@@ -97,6 +39,29 @@ export interface RetryStatistics {
phoneRequests: number
}
// Retry reason codes matching WhatsApp Web's Signal error codes.
export enum RetryReason {
UnknownError = 0,
SignalErrorNoSession = 1,
SignalErrorInvalidKey = 2,
SignalErrorInvalidKeyId = 3,
/** MAC verification failed - most common cause of decryption failures */
SignalErrorInvalidMessage = 4,
SignalErrorInvalidSignature = 5,
SignalErrorFutureMessage = 6,
/** Explicit MAC failure - session is definitely out of sync */
SignalErrorBadMac = 7,
SignalErrorInvalidSession = 8,
SignalErrorInvalidMsgKey = 9,
BadBroadcastEphemeralSetting = 10,
UnknownCompanionNoPrekey = 11,
AdvFailure = 12,
StatusRevokeDelay = 13
}
/** Error codes that indicate a MAC failure and require immediate session recreation */
const MAC_ERROR_CODES = new Set([RetryReason.SignalErrorInvalidMessage, RetryReason.SignalErrorBadMac])
export class MessageRetryManager {
private recentMessagesMap = new LRUCache<string, RecentMessage>({
max: RECENT_MESSAGES_SIZE,
@@ -156,44 +121,17 @@ export class MessageRetryManager {
}
/**
* Get a recent message from the cache.
*
* First attempts an exact `to+id` key lookup. If that misses which happens when
* the retry receipt arrives from a device-specific JID (e.g. `55123:82@s.whatsapp.net`)
* while the message was stored under the normalised base JID (`55123@s.whatsapp.net`),
* or when the JID domain flipped between LID and PN falls back to the `messageKeyIndex`
* which maps bare message IDs to stored keys regardless of the `to` format.
* Get a recent message from the cache
*/
getRecentMessage(to: string, id: string): RecentMessage | undefined {
const key: RecentMessageKey = { to, id }
const keyStr = this.keyToString(key)
const exact = this.recentMessagesMap.get(keyStr)
if (exact) return exact
// Fallback: look up by message ID only to handle JID format mismatches
// (device suffix present/absent, LID vs PN, etc.)
const indexedKeyStr = this.messageKeyIndex.get(id)
if (indexedKeyStr) {
const message = this.recentMessagesMap.get(indexedKeyStr)
if (!message) {
// The LRU cache evicted this entry; clean up the stale index reference
// to prevent repeated futile lookups on subsequent retry receipts.
this.messageKeyIndex.delete(id)
}
return message
}
return undefined
return this.recentMessagesMap.get(keyStr)
}
/**
* Check if a session should be recreated based on retry count, history, and error code
*
* @param jid - The JID of the recipient
* @param hasSession - Whether a Signal session exists for this JID
* @param errorCode - Optional error code from the retry receipt (indicates type of failure)
* @returns Object with reason string and boolean indicating if session should be recreated
* Check if a session should be recreated based on retry count, history, and error code.
* MAC errors (codes 4 and 7) trigger immediate session recreation regardless of timeout.
*/
shouldRecreateSession(
jid: string,
@@ -204,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
*/
+31 -83
View File
@@ -1,5 +1,5 @@
import { Boom } from '@hapi/boom'
import { execFile } from 'child_process'
import { exec } from 'child_process'
import * as Crypto from 'crypto'
import { once } from 'events'
import { createReadStream, createWriteStream, promises as fs, WriteStream } from 'fs'
@@ -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))
@@ -618,7 +597,7 @@ export const downloadEncryptedContent = async (
const output = new Transform({
transform(chunk, _, callback) {
let data = remainingBytes.length ? Buffer.concat([remainingBytes, chunk]) : chunk
let data = Buffer.concat([remainingBytes, chunk])
const decryptLength = toSmallestChunkSize(data.length)
remainingBytes = data.slice(decryptLength)
@@ -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
View File
File diff suppressed because it is too large Load Diff
+12 -28
View File
@@ -64,9 +64,9 @@ export const makeNoiseHandler = ({
const data = Buffer.from(NOISE_MODE)
let hash = data.byteLength === 32 ? data : sha256(data)
let salt: Buffer = hash
let encKey: Buffer = hash
let decKey: Buffer = hash
let salt: Uint8Array = hash
let encKey: Uint8Array = hash
let decKey: Uint8Array = hash
let counter = 0
let sentIntro = false
@@ -123,9 +123,9 @@ export const makeNoiseHandler = ({
const mixIntoKey = (data: Uint8Array) => {
const [write, read] = localHKDF(data)
salt = Buffer.from(write)
encKey = Buffer.from(read)
decKey = Buffer.from(read)
salt = write
encKey = read
decKey = read
counter = 0
}
@@ -180,25 +180,13 @@ export const makeNoiseHandler = ({
mixIntoKey,
finishInit,
processHandshake: ({ serverHello }: proto.HandshakeMessage, noiseKey: KeyPair) => {
if (!serverHello?.ephemeral) {
throw new Boom('Missing server hello ephemeral', { statusCode: 500 })
}
authenticate(serverHello!.ephemeral!)
mixIntoKey(Curve.sharedKey(privateKey, serverHello!.ephemeral!))
if (!serverHello?.static) {
throw new Boom('Missing server hello static', { statusCode: 500 })
}
if (!serverHello?.payload) {
throw new Boom('Missing server hello payload', { statusCode: 500 })
}
authenticate(serverHello.ephemeral)
mixIntoKey(Curve.sharedKey(privateKey, serverHello.ephemeral))
const decStaticContent = decrypt(serverHello.static)
const decStaticContent = decrypt(serverHello!.static!)
mixIntoKey(Curve.sharedKey(privateKey, decStaticContent))
const certDecoded = decrypt(serverHello.payload)
const certDecoded = decrypt(serverHello!.payload!)
const { intermediate: certIntermediate, leaf } = proto.CertChain.decode(certDecoded)
// leaf
@@ -214,11 +202,7 @@ export const makeNoiseHandler = ({
const { issuerSerial } = details
if (!details.key) {
throw new Boom('Missing certificate key', { statusCode: 500 })
}
const verify = Curve.verify(details.key, leaf.details, leaf.signature)
const verify = Curve.verify(details.key!, leaf.details, leaf.signature)
const verifyIntermediate = Curve.verify(
WA_CERT_DETAILS.PUBLIC_KEY,
@@ -239,7 +223,7 @@ export const makeNoiseHandler = ({
}
const keyEnc = encrypt(noiseKey.public)
mixIntoKey(Curve.sharedKey(noiseKey.private, serverHello.ephemeral))
mixIntoKey(Curve.sharedKey(noiseKey.private, serverHello!.ephemeral!))
return keyEnc
},
-59
View File
@@ -8,34 +8,11 @@ import type { ILogger } from './logger'
export class PreKeyManager {
private readonly queues = new Map<string, PQueue>()
/**
* Destroyed flag - protected by atomic check-and-set in destroy()
*
* THREAD SAFETY: Prevents operations from executing after destroy() is called.
* All public methods check this flag before proceeding.
*
* CRITICAL: Prevents race conditions where:
* - Operations add tasks to queues after they've been cleared/paused
* - New queues are created after destroy() has cleaned them up
* - Tasks execute on destroyed resources
*/
private destroyed = false
constructor(
private readonly store: SignalKeyStore,
private readonly logger: ILogger
) {}
/**
* Check if manager has been destroyed
* @throws Error if manager has been destroyed
*/
private checkDestroyed(): void {
if (this.destroyed) {
throw new Error('PreKeyManager has been destroyed - cannot perform operations')
}
}
/**
* Get or create a queue for a specific key type
*/
@@ -57,9 +34,6 @@ export class PreKeyManager {
mutations: SignalDataSet,
isInTransaction: boolean
): Promise<void> {
// PROTECTION: Check destroyed flag before processing
this.checkDestroyed()
const keyData = data[keyType]
if (!keyData) return
@@ -131,9 +105,6 @@ export class PreKeyManager {
* Validate and process pre-key deletions outside transactions
*/
async validateDeletions(data: SignalDataSet, keyType: keyof SignalDataTypeMap): Promise<void> {
// PROTECTION: Check destroyed flag before processing
this.checkDestroyed()
const keyData = data[keyType]
if (!keyData) return
@@ -152,34 +123,4 @@ export class PreKeyManager {
}
})
}
/**
* Cleanup all queues and resources
* Should be called during connection cleanup to prevent memory leaks
*/
destroy(): void {
// PROTECTION: Atomic check-and-set to prevent race conditions
// Flag is set IMMEDIATELY after check, BEFORE any operations
// This prevents:
// 1. Multiple calls to destroy() (reentrancy guard)
// 2. Operations from executing after destroy() starts
// 3. New queues from being created after cleanup
if (this.destroyed) {
this.logger.debug('PreKeyManager already destroyed')
return
}
this.destroyed = true // ← Set IMMEDIATELY to close race window
this.logger.debug('🗑️ Destroying PreKeyManager')
this.queues.forEach((queue, keyType) => {
queue.clear()
queue.pause()
this.logger.debug(`Queue for ${keyType} cleared and paused`)
})
this.queues.clear()
this.logger.debug('PreKeyManager destroyed - all queues cleaned up')
}
}
+53 -492
View File
@@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import Long from 'long'
import { proto } from '../../WAProto/index.js'
import type {
AuthenticationCreds,
@@ -10,7 +8,6 @@ import type {
GroupParticipant,
LIDMapping,
ParticipantAction,
PlaceholderMessageData,
RequestJoinAction,
RequestJoinMethod,
SignalKeyStoreWithTransaction,
@@ -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,8 +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'
import { buildMergedTcTokenIndexWrite } from './tc-token-utils'
type ProcessMessageContext = {
shouldProcessHistoryMsg: boolean
@@ -63,163 +55,6 @@ const REAL_MSG_STUB_TYPES = new Set([
const REAL_MSG_REQ_ME_STUB_TYPES = new Set([WAMessageStubType.GROUP_PARTICIPANT_ADD])
/**
* Extract tctoken / tcTokenTimestamp / tcTokenSenderTimestamp from history-sync chats
* and persist them to the `tctoken` store. Mirrors WA Web's `bulkCreateOrMerge` pass
* over the chat table during history sync.
*
* Why this matters: when a user logs in on a new device, the multi-device history sync
* is the only way that device learns about tctokens issued/received on the original
* device. Without this pass, the new device sends 1:1 messages with no tctoken until
* the contact triggers a fresh notification which surfaces as error 463 in production.
*
* Monotonicity: we only overwrite an existing entry if the incoming timestamp is
* STRICTLY newer (`incoming > existing`). Equal timestamps are skipped to avoid
* reverting senderTimestamp / realIssueTimestamp set by other layers (e.g. a
* reissue that fired between history-sync chunks).
*
* Index hygiene: every JID we write here is added to the persistent prune index
* (TC_TOKEN_INDEX_KEY) via buildMergedTcTokenIndexWrite, so the 24h prune sweep in
* messages-recv picks them up across sessions.
*/
/**
* Single-concurrency queue for `storeTcTokensFromHistorySync` calls.
*
* Why: the function does read-then-write merges (`keyStore.get('tctoken', ...)`
* compute `keyStore.set(...)`) which are NOT atomic at the store level. If two
* history-sync chunks invoke this concurrently (common during reconnect / QR
* scan), an older chunk that started first can `keyStore.set` AFTER a newer
* chunk, overwriting the newer entry and worse, the merged `__index` write
* can drop JIDs the other chunk just added. Result: stale tcTokens / repeat 463
* sends until the next opportunistic refetch.
*
* Serialising via a chained Promise keeps the runs ordered while still freeing
* the calling `processMessage` to emit `messaging-history.set` immediately
* (the chain is fire-and-forget at the call site). Errors don't break the chain
* each `catch` resets it to `Promise.resolve()` so a single failure can't
* stall future runs.
*
* The chain is module-scoped (one per Node process). Multiple Baileys instances
* sharing this module will serialise across instances too, but their writes
* target different keyStores so there's no correctness gain only a tiny loss
* of inter-instance parallelism for tcToken syncs, which is acceptable given
* how rarely this runs vs. how rare cross-instance contention is.
*/
let historyTcTokenChain: Promise<void> = Promise.resolve()
function scheduleHistoryTcTokenSync(
chats: Chat[],
signalRepository: SignalRepositoryWithLIDStore,
keyStore: SignalKeyStoreWithTransaction,
logger?: ILogger
): void {
historyTcTokenChain = historyTcTokenChain
.catch(() => {
/* swallow prior error so chain stays alive */
})
.then(() => storeTcTokensFromHistorySync(chats, signalRepository, keyStore, logger))
.catch(err => {
logger?.warn({ err }, 'background tctoken history-sync persistence failed')
})
}
async function storeTcTokensFromHistorySync(
chats: Chat[],
signalRepository: SignalRepositoryWithLIDStore,
keyStore: SignalKeyStoreWithTransaction,
logger?: ILogger
) {
// Cheap filter first — most chats in a sync chunk don't carry tcToken at all,
// and we want to avoid spinning up promises for them.
const tokenChats = chats.filter(chat => {
const ts = chat.tcTokenTimestamp ? toNumber(chat.tcTokenTimestamp) : 0
return !!chat.tcToken?.length && ts > 0
})
if (!tokenChats.length) {
return
}
// Pre-normalize so the rest of the pipeline is a synchronous join.
const normalized = tokenChats.map(chat => ({
chat,
ts: toNumber(chat.tcTokenTimestamp!),
jid: jidNormalizedUser(chat.id!)
}))
// BATCHED LID resolution. The previous shape called getLIDForPN once per
// chat (sequential await inside a for-of), which became the bottleneck
// during heavy history sync — every cold-cache hit was a DB round-trip,
// stalling messaging-history.set and spilling into the event-buffer.
// `getLIDsForPNs` resolves a deduped list in ONE batched query (and shares
// USync retry across PNs that miss cache), turning O(N) round-trips into 1.
//
// LID inputs (and `@hosted.lid`) skip the lookup entirely — they're already
// the storage form. Failures degrade gracefully: a missing mapping just
// stores under the original jid, matching `resolveTcTokenJid`'s null branch.
const pnsToResolve = [...new Set(normalized.filter(({ jid }) => !isLidUser(jid)).map(({ jid }) => jid))]
const pnToLid = new Map<string, string>()
if (pnsToResolve.length) {
try {
const mappings = await signalRepository.lidMapping.getLIDsForPNs(pnsToResolve)
// Flat loop (continue-on-skip) keeps max nesting depth at 4 for lint.
for (const { pn, lid } of mappings ?? []) {
if (!pn || !lid) continue
pnToLid.set(jidNormalizedUser(pn), lid)
}
} catch (err) {
// Per-chat fallback below (storageJid := jid). Don't abort the chunk —
// CodeRabbit noted that all-or-nothing rejection here would drop every
// tctoken in the batch AND prevent messaging-history.set from firing.
logger?.warn({ err }, 'storeTcTokensFromHistorySync: getLIDsForPNs batch failed; falling back to per-chat jid')
}
}
const candidates = normalized.map(({ chat, ts, jid }) => ({
storageJid: pnToLid.get(jid) ?? jid,
token: Buffer.from(chat.tcToken!),
ts,
senderTs: chat.tcTokenSenderTimestamp ? toNumber(chat.tcTokenSenderTimestamp) : undefined
}))
const jids = candidates.map(c => c.storageJid)
const existing = await keyStore.get('tctoken', jids)
const entries: Record<string, { token: Buffer; timestamp?: string; senderTimestamp?: number }> = {}
for (const c of candidates) {
// Same-batch dedup: when two chats resolve to the same storageJid (e.g. PN+LID
// aliases collapsing through resolveTcTokenJid, or duplicate chunks across
// retries), prefer the value already written by an earlier iteration so a
// lower-ts entry can't overwrite a higher-ts one captured from `existing`.
const existingEntry = entries[c.storageJid] ?? existing[c.storageJid]
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
// Strict > guard: equal timestamps are skipped so we never clobber
// senderTimestamp written by other layers (issuance after send, etc).
if (existingTs > 0 && existingTs >= c.ts) {
continue
}
entries[c.storageJid] = {
...existingEntry,
token: c.token,
timestamp: String(c.ts),
...(c.senderTs !== undefined ? { senderTimestamp: c.senderTs } : {})
}
}
if (Object.keys(entries).length) {
logger?.debug({ count: Object.keys(entries).length }, 'storing tctokens from history sync')
try {
// Include updated __index so cross-session pruning picks these JIDs up.
const indexWrite = await buildMergedTcTokenIndexWrite(keyStore, Object.keys(entries))
await keyStore.set({ tctoken: { ...entries, ...indexWrite } })
} catch (err) {
logger?.warn({ err }, 'failed to store tctokens from history sync')
}
}
}
/** Cleans a received message to further processing */
export const cleanMessage = (message: WAMessage, meId: string, meLid: string) => {
// ensure remoteJid and participant doesn't have device or agent in it
@@ -244,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) {
@@ -274,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 LIDPN 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 &&
@@ -449,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) {
@@ -483,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) {
@@ -509,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)) }
@@ -543,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
@@ -574,93 +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
//
// MUST run BEFORE storeTcTokensFromHistorySync — otherwise resolveTcTokenJid()
// can't resolve PN→LID for fresh-device chats (mapping cache is empty), tokens
// get persisted under PN keys, and the send path (which resolves to LID first)
// misses them — exactly the error 463 scenario this whole change is meant to
// prevent. Catches Codex P1 review on PR #386.
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'))
}
// Persist tctokens carried by history-sync chats in BACKGROUND, serialised.
//
// Originally awaited (PR #386) to avoid 463 on first multi-device send, but in
// production this drained the event buffer per-chunk and added visible delivery
// latency (especially after restart / QR scan when many chunks arrived at once).
//
// `scheduleHistoryTcTokenSync` enqueues onto a single-concurrency promise chain
// (see definition above) — chunks persist sequentially in the order they were
// emitted, preserving timestamp monotonicity AND keeping the `__index` write
// safe from concurrent merge clobbers. The call returns immediately so the
// `messaging-history.set` emit is not blocked.
//
// TRADE-OFF: a listener that fires an outbound send IMMEDIATELY after the emit
// may race the still-pending persistence and get a 463 on that specific send.
// The existing 463 handler in messages-recv.ts triggers a getPrivacyTokens()
// refetch that auto-recovers within seconds. Net result is much better UX than
// per-chunk stalls.
//
// DO NOT add `await` back here without re-evaluating production latency, AND
// DO NOT call storeTcTokensFromHistorySync directly — it must go through the
// chain to preserve write ordering across overlapping chunks.
scheduleHistoryTcTokenSync(data.chats, signalRepository, keyStore, logger)
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
}
@@ -679,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 }
}
@@ -692,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
@@ -836,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')
@@ -858,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
@@ -871,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)
@@ -893,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
)
@@ -922,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]
}
@@ -950,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 }
])
}
@@ -971,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,
@@ -979,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
-700
View File
@@ -1,700 +0,0 @@
/**
* Smart Retry Logic
*
* Provides:
* - Exponential backoff
* - Jitter to avoid thundering herd
* - Configurable max attempts
* - Customizable retry predicates
* - Circuit breaker integration
* - Event hooks
* - Cancellation support
*
* @module Utils/retry-utils
*/
import { EventEmitter } from 'events'
import type { CircuitBreaker } from './circuit-breaker.js'
import { metrics } from './prometheus-metrics.js'
/**
* Retry configuration with custom progressive backoff
* Fixed delay steps in milliseconds: 1s 2s 5s 10s 20s
* Exported for external use (e.g., custom retry logic)
*/
export const RETRY_BACKOFF_DELAYS = [1000, 2000, 5000, 10000, 20000] as const
/**
* Jitter factor for retry delays (0.15 = ±15% randomization)
* Helps prevent thundering herd problem
*/
export const RETRY_JITTER_FACTOR = 0.15 as const
/**
* Backoff strategies
*/
export type BackoffStrategy = 'exponential' | 'linear' | 'constant' | 'fibonacci' | 'stepped'
/**
* Retry configuration options
*/
export interface RetryOptions {
/** Maximum number of attempts (default: 3) */
maxAttempts?: number
/** Base delay in ms (default: 1000) */
baseDelay?: number
/** Maximum delay in ms (default: 30000) */
maxDelay?: number
/** Backoff strategy (default: exponential) */
backoffStrategy?: BackoffStrategy
/** Multiplier for exponential backoff (default: 2) */
backoffMultiplier?: number
/** Jitter percentage (0-1, default: 0.1) */
jitter?: number
/** Function to determine if should retry */
shouldRetry?: (error: Error, attempt: number) => boolean | Promise<boolean>
/** Timeout per attempt in ms */
timeout?: number
/** Operation name for metrics */
operationName?: string
/** Collect metrics */
collectMetrics?: boolean
/** Circuit breaker for integration */
circuitBreaker?: CircuitBreaker
/** Callback before each retry */
onRetry?: (error: Error, attempt: number, delay: number) => void | Promise<void>
/** Callback on success */
onSuccess?: (result: unknown, attempt: number) => void
/** Callback on final failure */
onFailure?: (error: Error, attempts: number) => void
/** Signal for cancellation */
abortSignal?: AbortSignal
}
/**
* Result of operation with retry
*/
export interface RetryResult<T> {
success: boolean
result?: T
error?: Error
attempts: number
totalDuration: number
lastAttemptDuration: number
}
/**
* Retry context
*/
export interface RetryContext {
attempt: number
maxAttempts: number
lastError?: Error
startTime: number
aborted: boolean
}
/**
* Retry exhausted error
*/
export class RetryExhaustedError extends Error {
constructor(
public readonly originalError: Error,
public readonly attempts: number,
public readonly operationName?: string
) {
super(
`Retry exhausted after ${attempts} attempts${operationName ? ` for "${operationName}"` : ''}: ${originalError.message}`
)
this.name = 'RetryExhaustedError'
}
}
/**
* Abort error
*/
export class RetryAbortedError extends Error {
constructor(public readonly attempt: number) {
super(`Retry aborted at attempt ${attempt}`)
this.name = 'RetryAbortedError'
}
}
/**
* Calculate delay based on strategy
*/
export function calculateDelay(
attempt: number,
baseDelay: number,
maxDelay: number,
strategy: BackoffStrategy,
multiplier: number,
jitter: number
): number {
// Normalize attempt to ensure valid calculation (must be >= 1)
const normalizedAttempt = Math.max(attempt, 1)
let delay: number
switch (strategy) {
case 'exponential':
delay = baseDelay * Math.pow(multiplier, normalizedAttempt - 1)
break
case 'linear':
delay = baseDelay * normalizedAttempt
break
case 'constant':
delay = baseDelay
break
case 'fibonacci': {
const fib = fibonacciNumber(normalizedAttempt)
delay = baseDelay * fib
break
}
case 'stepped': {
// Uses pre-defined delay array directly (ignores baseDelay/multiplier)
// Falls back to last delay if attempt exceeds array length
const index = Math.min(normalizedAttempt - 1, RETRY_BACKOFF_DELAYS.length - 1)
delay = RETRY_BACKOFF_DELAYS[index] ?? RETRY_BACKOFF_DELAYS[0] ?? baseDelay
break
}
default:
delay = baseDelay
}
// Apply jitter BEFORE capping to maxDelay
if (jitter > 0) {
const jitterAmount = delay * jitter
delay = delay + (Math.random() * 2 - 1) * jitterAmount
}
// Apply max delay cap AFTER jitter to ensure we never exceed maxDelay
delay = Math.min(delay, maxDelay)
return Math.max(0, Math.round(delay))
}
/**
* Calculate Fibonacci number
*/
function fibonacciNumber(n: number): number {
if (n <= 1) return 1
let a = 1,
b = 1
for (let i = 2; i < n; i++) {
const c = a + b
a = b
b = c
}
return b
}
/**
* Sleep with abort support
*/
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
let abortHandler: (() => void) | undefined
const timer = setTimeout(() => {
// Cleanup abort listener on normal completion to prevent memory leak
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler)
}
resolve()
}, ms)
if (signal) {
if (signal.aborted) {
clearTimeout(timer)
reject(new RetryAbortedError(0))
return
}
abortHandler = () => {
clearTimeout(timer)
reject(new RetryAbortedError(0))
}
signal.addEventListener('abort', abortHandler, { once: true })
}
})
}
/**
* Execute operation with timeout
*/
async function executeWithTimeout<T>(operation: () => Promise<T>, timeout: number, signal?: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
let completed = false
const timer = setTimeout(() => {
if (!completed) {
completed = true
reject(new Error(`Operation timed out after ${timeout}ms`))
}
}, timeout)
if (signal?.aborted) {
clearTimeout(timer)
reject(new RetryAbortedError(0))
return
}
operation()
.then(result => {
if (!completed) {
completed = true
clearTimeout(timer)
resolve(result)
}
})
.catch(error => {
if (!completed) {
completed = true
clearTimeout(timer)
reject(error)
}
})
})
}
/**
* Main retry function
*/
export async function retry<T>(
operation: (context: RetryContext) => T | Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const config = {
maxAttempts: options.maxAttempts ?? 3,
baseDelay: options.baseDelay ?? 1000,
maxDelay: options.maxDelay ?? 30000,
backoffStrategy: options.backoffStrategy ?? 'exponential',
backoffMultiplier: options.backoffMultiplier ?? 2,
jitter: options.jitter ?? 0.1,
shouldRetry: options.shouldRetry ?? (() => true),
timeout: options.timeout,
operationName: options.operationName ?? 'operation',
collectMetrics: options.collectMetrics ?? true,
circuitBreaker: options.circuitBreaker,
onRetry: options.onRetry ?? (() => {}),
onSuccess: options.onSuccess ?? (() => {}),
onFailure: options.onFailure ?? (() => {}),
abortSignal: options.abortSignal
}
const context: RetryContext = {
attempt: 0,
maxAttempts: config.maxAttempts,
startTime: Date.now(),
aborted: false
}
let lastError: Error | undefined
// Check initial abort
if (config.abortSignal?.aborted) {
throw new RetryAbortedError(0)
}
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
context.attempt = attempt
// Check abort
if (config.abortSignal?.aborted) {
context.aborted = true
throw new RetryAbortedError(attempt)
}
// Check circuit breaker
if (config.circuitBreaker?.isOpen()) {
throw new Error(`Circuit breaker "${config.circuitBreaker.getName()}" is open`)
}
try {
// Execute operation
let result: T
if (config.timeout) {
result = await executeWithTimeout(() => Promise.resolve(operation(context)), config.timeout, config.abortSignal)
} else {
result = await operation(context)
}
// Success - only count as retry success if this wasn't the first attempt
if (config.collectMetrics && attempt > 1) {
// This was a successful retry (not first attempt)
metrics.retries.inc({ operation: config.operationName })
}
config.onSuccess(result, attempt)
return result
} catch (error) {
lastError = error as Error
context.lastError = lastError
// Check if should retry
const shouldRetry = await config.shouldRetry(lastError, attempt)
if (!shouldRetry || attempt >= config.maxAttempts) {
// Final failure - use dedicated retry exhausted metric
if (config.collectMetrics) {
metrics.retryExhausted.inc({ operation: config.operationName })
}
config.onFailure(lastError, attempt)
throw new RetryExhaustedError(lastError, attempt, config.operationName)
}
// Calculate delay
const delay = calculateDelay(
attempt,
config.baseDelay,
config.maxDelay,
config.backoffStrategy,
config.backoffMultiplier,
config.jitter
)
// Retry callback
await config.onRetry(lastError, attempt, delay)
if (config.collectMetrics) {
metrics.retryLatency.observe({ operation: config.operationName }, delay)
}
// Wait for delay
await sleep(delay, config.abortSignal)
}
}
// Should never reach here, but TypeScript needs this
throw new RetryExhaustedError(lastError || new Error('Unknown error'), config.maxAttempts, config.operationName)
}
/**
* Retry with detailed result
*/
export async function retryWithResult<T>(
operation: (context: RetryContext) => T | Promise<T>,
options: RetryOptions = {}
): Promise<RetryResult<T>> {
const startTime = Date.now()
let attempts = 0
let lastAttemptStart = startTime
try {
const result = await retry(context => {
attempts = context.attempt
lastAttemptStart = Date.now()
return operation(context)
}, options)
return {
success: true,
result,
attempts,
totalDuration: Date.now() - startTime,
lastAttemptDuration: Date.now() - lastAttemptStart
}
} catch (error) {
return {
success: false,
error: error as Error,
attempts,
totalDuration: Date.now() - startTime,
lastAttemptDuration: Date.now() - lastAttemptStart
}
}
}
/**
* Factory to create configured retry function
*/
export function createRetrier(defaultOptions: RetryOptions = {}) {
return <T>(operation: (context: RetryContext) => T | Promise<T>, options?: RetryOptions): Promise<T> => {
return retry(operation, { ...defaultOptions, ...options })
}
}
/**
* Decorator to add retry to method
*/
export function withRetry(options: RetryOptions = {}) {
return function (
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => unknown>
) {
const originalMethod = descriptor.value
if (!originalMethod) return descriptor
descriptor.value = async function (...args: unknown[]): Promise<unknown> {
return retry(() => originalMethod.apply(this, args), {
...options,
operationName: options.operationName || propertyKey
})
}
return descriptor
}
}
/**
* Wrapper for function with retry
*/
// eslint-disable-next-line space-before-function-paren
export function retryable<T extends (...args: unknown[]) => unknown>(
fn: T,
options: RetryOptions = {}
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
return async (...args: Parameters<T>): Promise<ReturnType<T>> => {
return retry(() => fn(...args), options) as Promise<ReturnType<T>>
}
}
/**
* Class to manage retries with state
*/
export class RetryManager extends EventEmitter {
private activeRetries: Map<string, { cancel: () => void; context: RetryContext }> = new Map()
private defaultOptions: RetryOptions
constructor(defaultOptions: RetryOptions = {}) {
super()
this.defaultOptions = defaultOptions
}
/**
* Execute operation with retry
*/
async execute<T>(
id: string,
operation: (context: RetryContext) => T | Promise<T>,
options?: RetryOptions
): Promise<T> {
// Cancel previous retry with same ID
this.cancel(id)
const abortController = new AbortController()
const mergedOptions = { ...this.defaultOptions, ...options, abortSignal: abortController.signal }
const retryPromise = retry(context => {
this.activeRetries.set(id, {
cancel: () => abortController.abort(),
context
})
this.emit('attempt', { id, attempt: context.attempt })
return operation(context)
}, mergedOptions)
try {
const result = await retryPromise
this.emit('success', { id })
return result
} catch (error) {
this.emit('failure', { id, error })
throw error
} finally {
this.activeRetries.delete(id)
}
}
/**
* Cancel in-progress retry
*/
cancel(id: string): boolean {
const active = this.activeRetries.get(id)
if (active) {
active.cancel()
this.activeRetries.delete(id)
this.emit('cancelled', { id })
return true
}
return false
}
/**
* Cancel all retries
*/
cancelAll(): void {
for (const [id, active] of this.activeRetries) {
active.cancel()
this.emit('cancelled', { id })
}
this.activeRetries.clear()
}
/**
* Check if there is an active retry
*/
isActive(id: string): boolean {
return this.activeRetries.has(id)
}
/**
* Return active retry context
*/
getContext(id: string): RetryContext | undefined {
return this.activeRetries.get(id)?.context
}
/**
* Return active retry IDs
*/
getActiveIds(): string[] {
return Array.from(this.activeRetries.keys())
}
}
/**
* Common predicates for shouldRetry
*/
export const retryPredicates = {
/** Always retry (up to max attempts) */
always: () => true,
/** Never retry */
never: () => false,
/** Retry only on network errors */
onNetworkError: (error: Error) => {
const networkErrors = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN']
return networkErrors.some(code => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
},
/** Retry only on specific errors */
onErrorCodes:
(codes: string[]) =>
(error: Error): boolean => {
return codes.some(code => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
},
/** Retry except on specific errors */
exceptErrorCodes:
(codes: string[]) =>
(error: Error): boolean => {
return !codes.some(code => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
},
/** Retry on HTTP 5xx errors or timeout */
onServerError: (error: Error) => {
const message = error.message.toLowerCase()
return (
message.includes('500') ||
message.includes('502') ||
message.includes('503') ||
message.includes('504') ||
message.includes('timeout')
)
},
/** Combine multiple predicates with OR */
or:
(...predicates: Array<(error: Error, attempt: number) => boolean>) =>
(error: Error, attempt: number): boolean => {
return predicates.some(p => p(error, attempt))
},
/** Combine multiple predicates with AND */
and:
(...predicates: Array<(error: Error, attempt: number) => boolean>) =>
(error: Error, attempt: number): boolean => {
return predicates.every(p => p(error, attempt))
}
}
/**
* Pre-defined retry configurations
*/
export const retryConfigs = {
/** Aggressive retry (many attempts, short delays) */
aggressive: {
maxAttempts: 10,
baseDelay: 100,
maxDelay: 5000,
backoffStrategy: 'exponential' as const,
jitter: 0.2
},
/** Conservative retry (few attempts, long delays) */
conservative: {
maxAttempts: 3,
baseDelay: 2000,
maxDelay: 60000,
backoffStrategy: 'exponential' as const,
jitter: 0.1
},
/** Fast retry (for short operations) */
fast: {
maxAttempts: 5,
baseDelay: 50,
maxDelay: 1000,
backoffStrategy: 'linear' as const,
jitter: 0.05
},
/** Retry for network operations */
network: {
maxAttempts: 5,
baseDelay: 1000,
maxDelay: 30000,
backoffStrategy: 'exponential' as const,
jitter: 0.1,
shouldRetry: retryPredicates.onNetworkError
},
/**
* RSocket-style retry with stepped delays
* Uses fixed delay array: 1s, 2s, 5s, 10s, 20s (with ±15% jitter)
*
* NOTE: Values are hardcoded instead of referencing RETRY_BACKOFF_DELAYS/RETRY_JITTER_FACTOR
* to prevent "Cannot access before initialization" errors in ESM environments.
* This occurs when modules are loaded in specific orders due to indirect circular imports
* (e.g., via prometheus-metrics.ts -> circuit-breaker.ts chain).
* Keep these values in sync with the constants above (lines 25, 31).
*/
rsocket: {
maxAttempts: 5, // = RETRY_BACKOFF_DELAYS.length
baseDelay: 1000, // = RETRY_BACKOFF_DELAYS[0]
maxDelay: 20000, // = RETRY_BACKOFF_DELAYS[4]
backoffStrategy: 'stepped' as const,
jitter: 0.15 // = RETRY_JITTER_FACTOR
}
}
/**
* Get retry delay with jitter applied
* Uses RETRY_BACKOFF_DELAYS and RETRY_JITTER_FACTOR defined locally
*
* @param attempt - Current attempt number (1-based)
* @returns Delay in ms with jitter applied
*/
export function getRetryDelayWithJitter(attempt: number): number {
const index = Math.min(Math.max(attempt - 1, 0), RETRY_BACKOFF_DELAYS.length - 1)
const baseDelay = RETRY_BACKOFF_DELAYS[index] ?? RETRY_BACKOFF_DELAYS[0] ?? 1000
const jitterRange = baseDelay * RETRY_JITTER_FACTOR
const jitter = (Math.random() * 2 - 1) * jitterRange // ±15%
return Math.round(baseDelay + jitter)
}
/**
* Get all retry delays with jitter for planning
* @returns Array of delays with jitter applied
*/
export function getAllRetryDelaysWithJitter(): number[] {
return RETRY_BACKOFF_DELAYS.map((_, i) => getRetryDelayWithJitter(i + 1))
}
export default retry
-880
View File
@@ -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 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')
})
}
-943
View File
@@ -1,943 +0,0 @@
/**
* Structured Logging System for InfiniteAPI
*
* Enterprise-grade features:
* - Environment variable configuration (BAILEYS_LOG_*)
* - Configurable log levels (trace, debug, info, warn, error, fatal)
* - JSON formatting for log analysis
* - Hierarchical context with child loggers
* - External system integration via hooks with circuit breaker
* - Logging metrics with Prometheus integration
* - Sensitive data sanitization
* - Log buffering for batch writes
* - Rate limiting to prevent flooding
* - Async logging queue for non-blocking operations
* - Proper resource cleanup (destroy)
*
* @module Utils/structured-logger
*/
import type { ILogger } from './logger.js'
// ============================================================================
// CONFIGURATION
// ============================================================================
/**
* Available log levels (ordered by severity)
*/
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent'
/**
* Numeric values for each log level
*/
export const LOG_LEVEL_VALUES: Record<LogLevel, number> = {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60,
silent: 100
}
/**
* Structured logger configuration
*/
export interface StructuredLoggerConfig {
/** Minimum log level to record */
level: LogLevel
/** Service/component name */
name?: string
/** Additional context to include in all logs */
context?: Record<string, unknown>
/** Format as JSON (true) or human-readable text (false) */
jsonFormat?: boolean
/** Fields to sanitize (passwords, tokens, etc.) */
redactFields?: string[]
/** Hook for sending logs to external systems */
externalHook?: (entry: LogEntry) => void | Promise<void>
/** Include stack trace in errors */
includeStackTrace?: boolean
/** Timezone for timestamps (default: UTC) */
timezone?: string
/** Enable log buffering for batch writes */
enableBuffering?: boolean
/** Buffer flush interval in ms (default: 1000) */
bufferFlushIntervalMs?: number
/** Maximum buffer size before auto-flush (default: 100) */
maxBufferSize?: number
/** Enable rate limiting (default: false) */
enableRateLimiting?: boolean
/** Max logs per second (default: 1000) */
maxLogsPerSecond?: number
/** Enable async logging queue (default: false) */
enableAsyncQueue?: boolean
/** Circuit breaker failure threshold for external hooks (default: 5) */
circuitBreakerThreshold?: number
/** Circuit breaker reset timeout in ms (default: 30000) */
circuitBreakerResetMs?: number
/** Enable Prometheus metrics integration (default: false) */
enableMetrics?: boolean
}
/**
* Internal resolved configuration with required fields
* externalHook remains optional since it may not be provided
*/
type ResolvedLoggerConfig = Omit<Required<StructuredLoggerConfig>, 'externalHook'> & {
externalHook?: (entry: LogEntry) => void | Promise<void>
}
/**
* Load configuration from environment variables
*/
export function loadLoggerConfig(): Partial<StructuredLoggerConfig> {
const level = process.env.BAILEYS_LOG_LEVEL as LogLevel | undefined
return {
level: level && level in LOG_LEVEL_VALUES ? level : undefined,
name: process.env.BAILEYS_LOG_NAME,
jsonFormat: process.env.BAILEYS_LOG_JSON === 'true' || process.env.NODE_ENV === 'production',
enableBuffering: process.env.BAILEYS_LOG_BUFFERING === 'true',
bufferFlushIntervalMs: process.env.BAILEYS_LOG_BUFFER_FLUSH_MS
? parseInt(process.env.BAILEYS_LOG_BUFFER_FLUSH_MS, 10)
: undefined,
maxBufferSize: process.env.BAILEYS_LOG_MAX_BUFFER_SIZE
? parseInt(process.env.BAILEYS_LOG_MAX_BUFFER_SIZE, 10)
: undefined,
enableRateLimiting: process.env.BAILEYS_LOG_RATE_LIMIT === 'true',
maxLogsPerSecond: process.env.BAILEYS_LOG_MAX_PER_SECOND
? parseInt(process.env.BAILEYS_LOG_MAX_PER_SECOND, 10)
: undefined,
enableAsyncQueue: process.env.BAILEYS_LOG_ASYNC === 'true',
enableMetrics: process.env.BAILEYS_LOG_METRICS === 'true',
includeStackTrace: process.env.BAILEYS_LOG_STACK_TRACE !== 'false'
}
}
/**
* Structured log entry
*/
export interface LogEntry {
/** ISO 8601 timestamp */
timestamp: string
/** Log level */
level: LogLevel
/** Numeric level value */
levelValue: number
/** Main message */
message: string
/** Logger/component name */
name?: string
/** Additional context */
context?: Record<string, unknown>
/** Logged object data */
data?: Record<string, unknown>
/** Stack trace (for errors) */
stack?: string
/** Correlation ID for tracing */
correlationId?: string
/** Operation duration in ms (if applicable) */
durationMs?: number
}
/**
* Logger metrics
*/
export interface LoggerMetrics {
totalLogs: number
logsByLevel: Record<LogLevel, number>
errorsCount: number
lastLogTimestamp?: string
/** Logs dropped due to rate limiting */
droppedLogs: number
/** Buffer flushes performed */
bufferFlushes: number
/** External hook failures */
hookFailures: number
/** Circuit breaker trips */
circuitBreakerTrips: number
/** Average log processing time in ms */
avgProcessingTimeMs: number
}
/**
* Logger statistics for monitoring
*/
export interface LoggerStatistics extends LoggerMetrics {
/** Buffer current size */
bufferSize: number
/** Rate limiter tokens available */
rateLimiterTokens: number
/** Circuit breaker state */
circuitBreakerState: 'closed' | 'open' | 'half-open'
/** Queue size (if async enabled) */
queueSize: number
/** Created timestamp */
createdAt: number
/** Uptime in ms */
uptimeMs: number
}
/**
* Default fields to sanitize
*/
const DEFAULT_REDACT_FIELDS = [
'password',
'passwd',
'secret',
'token',
'accessToken',
'refreshToken',
'apiKey',
'api_key',
'authorization',
'auth',
'credentials',
'privateKey',
'private_key'
]
// ============================================================================
// RATE LIMITER
// ============================================================================
/**
* Token bucket rate limiter
*/
class RateLimiter {
private tokens: number
private lastRefill: number
private readonly maxTokens: number
private readonly refillRate: number // tokens per ms
constructor(maxPerSecond: number) {
this.maxTokens = maxPerSecond
this.tokens = maxPerSecond
this.refillRate = maxPerSecond / 1000
this.lastRefill = Date.now()
}
tryAcquire(): boolean {
this.refill()
if (this.tokens >= 1) {
this.tokens--
return true
}
return false
}
private refill(): void {
const now = Date.now()
const elapsed = now - this.lastRefill
const tokensToAdd = elapsed * this.refillRate
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd)
this.lastRefill = now
}
getTokens(): number {
this.refill()
return Math.floor(this.tokens)
}
}
// ============================================================================
// CIRCUIT BREAKER
// ============================================================================
/**
* Circuit breaker for external hook protection
*/
class CircuitBreaker {
private failures = 0
private lastFailure = 0
private state: 'closed' | 'open' | 'half-open' = 'closed'
constructor(
private readonly threshold: number,
private readonly resetTimeoutMs: number
) {}
async execute<T>(operation: () => Promise<T>): Promise<T | null> {
if (this.state === 'open') {
if (Date.now() - this.lastFailure > this.resetTimeoutMs) {
this.state = 'half-open'
} else {
return null // Circuit is open, skip
}
}
try {
const result = await operation()
this.onSuccess()
return result
} catch (error) {
this.onFailure()
throw error
}
}
private onSuccess(): void {
this.failures = 0
this.state = 'closed'
}
private onFailure(): void {
this.failures++
this.lastFailure = Date.now()
if (this.failures >= this.threshold) {
this.state = 'open'
}
}
getState(): 'closed' | 'open' | 'half-open' {
// Check if should transition from open to half-open
if (this.state === 'open' && Date.now() - this.lastFailure > this.resetTimeoutMs) {
this.state = 'half-open'
}
return this.state
}
getFailures(): number {
return this.failures
}
}
// ============================================================================
// ASYNC LOG QUEUE
// ============================================================================
/**
* Async log processing queue
*/
class AsyncLogQueue {
private queue: Array<() => void> = []
private processing = false
private destroyed = false
enqueue(task: () => void): void {
if (this.destroyed) return
this.queue.push(task)
void this.processNext()
}
private async processNext(): Promise<void> {
if (this.processing || this.queue.length === 0 || this.destroyed) return
this.processing = true
while (this.queue.length > 0 && !this.destroyed) {
const task = this.queue.shift()
if (task) {
try {
task()
} catch {
// Silently ignore errors
}
}
// Yield to event loop periodically
if (this.queue.length > 0 && this.queue.length % 10 === 0) {
await new Promise(resolve => setImmediate(resolve))
}
}
this.processing = false
}
getSize(): number {
return this.queue.length
}
destroy(): void {
this.destroyed = true
this.queue = []
}
}
// ============================================================================
// MAIN CLASS
// ============================================================================
/**
* Structured Logger main class
*
* Enterprise-grade features:
* - Environment variable configuration
* - Log buffering for batch writes
* - Rate limiting to prevent flooding
* - Async logging queue
* - Circuit breaker for external hooks
* - Prometheus metrics integration
*
* @example
* ```typescript
* const logger = createStructuredLogger({
* level: 'info',
* name: 'my-service',
* jsonFormat: true,
* enableBuffering: true,
* enableRateLimiting: true
* })
*
* logger.info({ userId: '123' }, 'User logged in')
* logger.error(new Error('Connection failed'))
*
* // Cleanup when done
* logger.destroy()
* ```
*/
export class StructuredLogger implements ILogger {
private config: ResolvedLoggerConfig
private metrics: LoggerMetrics
private childContext: Record<string, unknown> = {}
private destroyed = false
private createdAt: number
// Buffer for batch writes
private buffer: LogEntry[] = []
private bufferFlushTimer: NodeJS.Timeout | null = null
// Rate limiter
private rateLimiter: RateLimiter | null = null
// Circuit breaker for external hook
private circuitBreaker: CircuitBreaker | null = null
// Async queue
private asyncQueue: AsyncLogQueue | null = null
// Performance tracking
private totalProcessingTime = 0
private processedLogs = 0
// Metrics module (lazy loaded)
// NOTE: Currently no metrics are recorded to this module - it's loaded but unused
private metricsModule: typeof import('./prometheus-metrics') | null = null
constructor(config: StructuredLoggerConfig) {
const envConfig = loadLoggerConfig()
this.createdAt = Date.now()
this.config = {
level: config.level ?? envConfig.level ?? 'info',
name: config.name ?? envConfig.name ?? 'app',
context: config.context || {},
jsonFormat: config.jsonFormat ?? envConfig.jsonFormat ?? true,
redactFields: [...DEFAULT_REDACT_FIELDS, ...(config.redactFields || [])],
externalHook: config.externalHook,
includeStackTrace: config.includeStackTrace ?? envConfig.includeStackTrace ?? true,
timezone: config.timezone || 'UTC',
enableBuffering: config.enableBuffering ?? envConfig.enableBuffering ?? false,
bufferFlushIntervalMs: config.bufferFlushIntervalMs ?? envConfig.bufferFlushIntervalMs ?? 1000,
maxBufferSize: config.maxBufferSize ?? envConfig.maxBufferSize ?? 100,
enableRateLimiting: config.enableRateLimiting ?? envConfig.enableRateLimiting ?? false,
maxLogsPerSecond: config.maxLogsPerSecond ?? envConfig.maxLogsPerSecond ?? 1000,
enableAsyncQueue: config.enableAsyncQueue ?? envConfig.enableAsyncQueue ?? false,
circuitBreakerThreshold: config.circuitBreakerThreshold ?? 5,
circuitBreakerResetMs: config.circuitBreakerResetMs ?? 30000,
enableMetrics: config.enableMetrics ?? envConfig.enableMetrics ?? false
}
this.metrics = {
totalLogs: 0,
logsByLevel: {
trace: 0,
debug: 0,
info: 0,
warn: 0,
error: 0,
fatal: 0,
silent: 0
},
errorsCount: 0,
droppedLogs: 0,
bufferFlushes: 0,
hookFailures: 0,
circuitBreakerTrips: 0,
avgProcessingTimeMs: 0
}
// Initialize rate limiter
if (this.config.enableRateLimiting) {
this.rateLimiter = new RateLimiter(this.config.maxLogsPerSecond)
}
// Initialize circuit breaker for external hook
if (this.config.externalHook) {
this.circuitBreaker = new CircuitBreaker(this.config.circuitBreakerThreshold, this.config.circuitBreakerResetMs)
}
// Initialize async queue
if (this.config.enableAsyncQueue) {
this.asyncQueue = new AsyncLogQueue()
}
// Initialize buffer flush timer
if (this.config.enableBuffering) {
this.bufferFlushTimer = setInterval(() => {
this.flushBuffer()
}, this.config.bufferFlushIntervalMs)
}
// Load metrics module if enabled (currently unused but loaded for future use)
if (this.config.enableMetrics) {
import('./prometheus-metrics')
.then(m => {
this.metricsModule = m
this.debug('📊 Prometheus metrics loaded for logger')
})
.catch(() => {
// Metrics module not available - silent fail
})
}
}
/**
* Get current log level (ILogger compatibility)
*/
get level(): string {
return this.config.level
}
/**
* Set log level
*/
set level(newLevel: string) {
if (newLevel in LOG_LEVEL_VALUES) {
this.config.level = newLevel as LogLevel
}
}
/**
* Check if logger has been destroyed
*/
isDestroyed(): boolean {
return this.destroyed
}
/**
* Create a child logger with additional context
*/
child(obj: Record<string, unknown>): StructuredLogger {
const childLogger = new StructuredLogger({
...this.config,
context: { ...this.config.context, ...this.childContext, ...obj }
})
childLogger.childContext = { ...this.childContext, ...obj }
return childLogger
}
/**
* Check if log level is enabled
*/
isLevelEnabled(level: LogLevel): boolean {
return LOG_LEVEL_VALUES[level] >= LOG_LEVEL_VALUES[this.config.level]
}
/**
* Main logging method
*/
private log(level: LogLevel, obj: unknown, msg?: string): void {
if (this.destroyed || !this.isLevelEnabled(level)) {
return
}
const startTime = Date.now()
// Rate limiting check
if (this.rateLimiter && !this.rateLimiter.tryAcquire()) {
this.metrics.droppedLogs++
return
}
const entry = this.createLogEntry(level, obj, msg)
// Update metrics
this.updateMetrics(level)
// Process log (sync or async)
const processLog = () => {
// Buffered output
if (this.config.enableBuffering) {
this.buffer.push(entry)
if (this.buffer.length >= this.config.maxBufferSize) {
this.flushBuffer()
}
} else {
this.output(entry)
}
// External hook with circuit breaker
const externalHook = this.config.externalHook
if (externalHook && this.circuitBreaker) {
this.circuitBreaker
.execute(async () => {
await Promise.resolve(externalHook(entry))
})
.catch(() => {
this.metrics.hookFailures++
if (this.circuitBreaker?.getState() === 'open') {
this.metrics.circuitBreakerTrips++
}
})
}
// Track processing time
this.totalProcessingTime += Date.now() - startTime
this.processedLogs++
this.metrics.avgProcessingTimeMs = this.totalProcessingTime / this.processedLogs
}
// Async or sync processing
if (this.asyncQueue) {
this.asyncQueue.enqueue(processLog)
} else {
processLog()
}
}
/**
* Flush buffered logs
*/
flushBuffer(): void {
if (this.buffer.length === 0) return
const entries = this.buffer
this.buffer = []
this.metrics.bufferFlushes++
for (const entry of entries) {
this.output(entry)
}
}
/**
* Create a structured log entry
*/
private createLogEntry(level: LogLevel, obj: unknown, msg?: string): LogEntry {
const timestamp = new Date().toISOString()
let message = msg || ''
let data: Record<string, unknown> | undefined
let stack: string | undefined
// Process object
if (obj instanceof Error) {
message = message || obj.message
if (this.config.includeStackTrace && obj.stack) {
stack = obj.stack
}
data = {
errorName: obj.name,
errorMessage: obj.message,
...(obj as unknown as Record<string, unknown>)
}
} else if (typeof obj === 'object' && obj !== null) {
data = this.sanitize(obj as Record<string, unknown>)
if (!message && 'msg' in (obj as Record<string, unknown>)) {
message = String((obj as Record<string, unknown>).msg)
}
} else if (typeof obj === 'string') {
message = message || obj
}
// Extract correlationId and durationMs if present
const correlationId = data?.correlationId as string | undefined
const durationMs = data?.durationMs as number | undefined
return {
timestamp,
level,
levelValue: LOG_LEVEL_VALUES[level],
message,
name: this.config.name,
context: Object.keys(this.config.context).length > 0 ? this.config.context : undefined,
data,
stack,
correlationId,
durationMs
}
}
/**
* Sanitize sensitive data
*/
private sanitize(obj: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const lowerKey = key.toLowerCase()
if (this.config.redactFields.some(field => lowerKey.includes(field.toLowerCase()))) {
sanitized[key] = '[REDACTED]'
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
sanitized[key] = this.sanitize(value as Record<string, unknown>)
} else {
sanitized[key] = value
}
}
return sanitized
}
/**
* Update internal metrics
*/
private updateMetrics(level: LogLevel): void {
this.metrics.totalLogs++
this.metrics.logsByLevel[level]++
this.metrics.lastLogTimestamp = new Date().toISOString()
if (level === 'error' || level === 'fatal') {
this.metrics.errorsCount++
}
}
/**
* Output log entry
*/
private output(entry: LogEntry): void {
const output = this.config.jsonFormat ? JSON.stringify(entry) : this.formatText(entry)
switch (entry.level) {
case 'trace':
case 'debug':
console.debug(output)
break
case 'info':
console.info(output)
break
case 'warn':
console.warn(output)
break
case 'error':
case 'fatal':
console.error(output)
break
}
}
/**
* Format log as human-readable text
*/
private formatText(entry: LogEntry): string {
const parts = [
`[${entry.timestamp}]`,
`[${entry.level.toUpperCase()}]`,
entry.name ? `[${entry.name}]` : '',
entry.correlationId ? `[${entry.correlationId}]` : '',
entry.message,
entry.durationMs !== undefined ? `(${entry.durationMs}ms)` : ''
]
let text = parts.filter(Boolean).join(' ')
if (entry.data && Object.keys(entry.data).length > 0) {
text += ` | ${JSON.stringify(entry.data)}`
}
if (entry.stack) {
text += `\n${entry.stack}`
}
return text
}
// Convenience methods for each log level
trace(obj: unknown, msg?: string): void {
this.log('trace', obj, msg)
}
debug(obj: unknown, msg?: string): void {
this.log('debug', obj, msg)
}
info(obj: unknown, msg?: string): void {
this.log('info', obj, msg)
}
warn(obj: unknown, msg?: string): void {
this.log('warn', obj, msg)
}
error(obj: unknown, msg?: string): void {
this.log('error', obj, msg)
}
fatal(obj: unknown, msg?: string): void {
this.log('fatal', obj, msg)
}
/**
* Log with temporary context
*/
withContext(context: Record<string, unknown>): StructuredLogger {
return this.child(context)
}
/**
* Log with correlation ID
*/
withCorrelationId(correlationId: string): StructuredLogger {
return this.child({ correlationId })
}
/**
* Log operation with duration tracking
*/
logOperation<T>(operationName: string, operation: () => T | Promise<T>, level: LogLevel = 'info'): T | Promise<T> {
const startTime = Date.now()
const contextLogger = this.child({ operation: operationName })
contextLogger.log(level, { event: 'operation_start' }, `Starting ${operationName}`)
const handleResult = (result: T): T => {
const durationMs = Date.now() - startTime
contextLogger.log(level, { event: 'operation_complete', durationMs }, `Completed ${operationName}`)
return result
}
const handleError = (error: Error): never => {
const durationMs = Date.now() - startTime
contextLogger.error({ event: 'operation_error', durationMs, error }, `Failed ${operationName}`)
throw error
}
try {
const result = operation()
if (result instanceof Promise) {
return result.then(handleResult).catch(handleError)
}
return handleResult(result)
} catch (error) {
return handleError(error as Error)
}
}
/**
* Get logger metrics
*/
getMetrics(): LoggerMetrics {
return { ...this.metrics }
}
/**
* Get comprehensive statistics
*/
getStatistics(): LoggerStatistics {
return {
...this.metrics,
bufferSize: this.buffer.length,
rateLimiterTokens: this.rateLimiter?.getTokens() ?? 0,
circuitBreakerState: this.circuitBreaker?.getState() ?? 'closed',
queueSize: this.asyncQueue?.getSize() ?? 0,
createdAt: this.createdAt,
uptimeMs: Date.now() - this.createdAt
}
}
/**
* Reset metrics
*/
resetMetrics(): void {
this.metrics = {
totalLogs: 0,
logsByLevel: {
trace: 0,
debug: 0,
info: 0,
warn: 0,
error: 0,
fatal: 0,
silent: 0
},
errorsCount: 0,
droppedLogs: 0,
bufferFlushes: 0,
hookFailures: 0,
circuitBreakerTrips: 0,
avgProcessingTimeMs: 0
}
this.totalProcessingTime = 0
this.processedLogs = 0
}
/**
* Destroy the logger and clean up resources
* CRITICAL: Call this when done to prevent memory leaks
*/
destroy(): void {
if (this.destroyed) return
this.destroyed = true
// Flush remaining buffer
this.flushBuffer()
// Clear buffer flush timer
if (this.bufferFlushTimer) {
clearInterval(this.bufferFlushTimer)
this.bufferFlushTimer = null
}
// Destroy async queue
if (this.asyncQueue) {
this.asyncQueue.destroy()
this.asyncQueue = null
}
// Clear references
this.rateLimiter = null
this.circuitBreaker = null
this.metricsModule = null
}
}
/**
* Factory to create structured logger
*/
export function createStructuredLogger(config: Partial<StructuredLoggerConfig> = {}): StructuredLogger {
return new StructuredLogger({
level: config.level || 'info',
...config
})
}
/**
* Default singleton logger
*/
let defaultLogger: StructuredLogger | null = null
export function getDefaultLogger(): StructuredLogger {
if (!defaultLogger) {
defaultLogger = createStructuredLogger({
level: 'info',
name: 'baileys',
jsonFormat: process.env.NODE_ENV === 'production'
})
}
return defaultLogger
}
export function setDefaultLogger(logger: StructuredLogger): void {
defaultLogger = logger
}
/**
* Utility to measure execution time
*/
export function createTimer(): { elapsed: () => number; elapsedMs: () => string } {
const start = process.hrtime.bigint()
return {
elapsed: () => Number(process.hrtime.bigint() - start) / 1_000_000,
elapsedMs: () => `${(Number(process.hrtime.bigint() - start) / 1_000_000).toFixed(2)}ms`
}
}
export default StructuredLogger
+1 -2
View File
@@ -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 -266
View File
@@ -1,178 +1,5 @@
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { BinaryNode } from '../WABinary'
import {
getBinaryNodeChild,
getBinaryNodeChildren,
isAnyLidUser,
isAnyPnUser,
isHostedLidUser,
isHostedPnUser,
isJidMetaAI,
isLidUser,
isPnUser,
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
/**
* Sentinel key under the `tctoken` store holding a JSON array of tracked storage JIDs
* for cross-session pruning. Mirrors WA Web's CLEAN_TC_TOKENS index lookup.
*
* Exported so other modules (messages-recv, messages-send, process-message) reference
* the same constant. The fork previously inlined this string in messages-recv.ts;
* keep the value identical (`'__index'`) for backward compatibility with persisted state.
*/
export const TC_TOKEN_INDEX_KEY = '__index'
// Phone-number pattern matching WABinary's isJidBot, applied against the user part so
// the check is invariant to @c.us ↔ @s.whatsapp.net normalization.
const BOT_PHONE_REGEX = /^1313555\d{4}$|^131655500\d{2}$/
/**
* Mirrors WA Web's `Wid.isRegularUser()` (user ¬PSA ¬Bot). Used to gate tctoken
* storage against malformed notifications WA Web filters server-side but we
* defend here for parity with `WAWebSetTcTokenChatAction.handleIncomingTcToken`.
* Works for both pre- and post-normalized JIDs (`@c.us` vs `@s.whatsapp.net`).
*/
export function isRegularUser(jid: string | undefined): boolean {
if (!jid) return false
const user = jid.split('@')[0] ?? ''
if (user === '0') return false // PSA
if (BOT_PHONE_REGEX.test(user)) return false // Bot by phone pattern
if (isJidMetaAI(jid)) return false // MetaAI (@bot server)
return !!(isPnUser(jid) || isLidUser(jid) || isHostedPnUser(jid) || isHostedLidUser(jid) || jid.endsWith('@c.us'))
}
/** Read the persisted tctoken JID index and return its entries (never contains the sentinel key itself). */
export async function readTcTokenIndex(keys: SignalKeyStoreWithTransaction): Promise<string[]> {
const data = await keys.get('tctoken', [TC_TOKEN_INDEX_KEY])
const entry = data[TC_TOKEN_INDEX_KEY]
if (!entry?.token?.length) return []
try {
const parsed = JSON.parse(Buffer.from(entry.token).toString())
if (!Array.isArray(parsed)) return []
return parsed.filter((j): j is string => typeof j === 'string' && j.length > 0 && j !== TC_TOKEN_INDEX_KEY)
} catch {
return []
}
}
/**
* Build a SignalDataSet fragment that writes the merged index (persisted added)
* under the sentinel key. Lets callers update the index without clobbering writes
* made by other layers (history sync, concurrent sessions on the same store).
*/
export async function buildMergedTcTokenIndexWrite(
keys: SignalKeyStoreWithTransaction,
addedJids: Iterable<string>
): Promise<{ [TC_TOKEN_INDEX_KEY]: { token: Buffer } }> {
const persisted = await readTcTokenIndex(keys)
const merged = new Set(persisted)
for (const jid of addedJids) {
if (jid && jid !== TC_TOKEN_INDEX_KEY) merged.add(jid)
}
return {
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify([...merged])) }
}
}
/**
* 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
}
/**
* Resolve target JID for issuing privacy token based on AB prop 14303
* (`lid_trusted_token_issue_to_lid`). When the prop is on, issuance goes to
* the LID; when off, it goes to the PN. Returns the original JID if no
* mapping is found in either direction.
*
* Normalizes the JID upfront and uses the `isAny*` helpers so callers can pass
* `@c.us`, `@s.whatsapp.net`, `@hosted`, `@hosted.lid`, `@lid` or device-specific
* forms `LIDMappingStore.getLIDForPN` early-returns unless `isAnyPnUser`,
* so unnormalized inputs would silently bypass routing.
*
* Reference: WAWebTrustedContactsManager.issuePrivacyTokens
*/
export async function resolveIssuanceJid(
jid: string,
issueToLid: boolean,
getLIDForPN: (pn: string) => Promise<string | null>,
getPNForLID?: (lid: string) => Promise<string | null>
): Promise<string> {
const normalized = jidNormalizedUser(jid)
if (issueToLid) {
if (isAnyLidUser(normalized)) return normalized
if (!isAnyPnUser(normalized)) return normalized
const lid = await getLIDForPN(normalized)
return lid ?? normalized
}
if (!isAnyLidUser(normalized)) return normalized
if (getPNForLID) {
const pn = await getPNForLID(normalized)
return pn ?? normalized
}
return normalized
}
type TcTokenParams = {
jid: string
@@ -180,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',
@@ -215,82 +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
}
// In notifications, tokenNode.attrs.jid is OUR own device JID, not the sender's.
// Prefer fallbackJid (resolved from notification's `from` / `sender_lid`) so the
// token is stored under the peer's JID, never under self.
const rawJid = jidNormalizedUser(fallbackJid || tokenNode.attrs.jid)
// Defense against malformed notifications (PSA WID '0', bots, MetaAI). WA Web
// filters these server-side; we mirror Wid.isRegularUser() locally.
if (!isRegularUser(rawJid)) continue
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)
}
}
-655
View File
@@ -1,655 +0,0 @@
/**
* Request Tracing Context
*
* Provides:
* - Unique trace ID generation
* - Context propagation between operations
* - Correlation IDs for request tracking
* - Performance timing
* - Span tracking for nested operations
* - Baggage for contextual data
*
* @module Utils/trace-context
*/
import { AsyncLocalStorage } from 'async_hooks'
import { randomBytes } from 'crypto'
/**
* Trace identifiers
*/
export interface TraceIds {
/** Unique trace ID (16 bytes hex) */
traceId: string
/** Current span ID (8 bytes hex) */
spanId: string
/** Parent span ID (optional) */
parentSpanId?: string
/** Correlation ID for logging */
correlationId: string
}
/**
* Baggage data (propagated context)
*/
export type Baggage = Record<string, string | number | boolean>
/**
* Span status
*/
export type SpanStatus = 'unset' | 'ok' | 'error'
/**
* Span represents a unit of work
*/
export interface Span {
/** Operation name */
name: string
/** Trace identifiers */
traceIds: TraceIds
/** Start timestamp (ms) */
startTime: number
/** End timestamp (ms) */
endTime?: number
/** Duration in ms */
duration?: number
/** Span status */
status: SpanStatus
/** Span attributes */
attributes: Record<string, unknown>
/** Events occurred during the span */
events: SpanEvent[]
/** Whether the span has ended */
ended: boolean
}
/**
* Event within a span
*/
export interface SpanEvent {
/** Event name */
name: string
/** Event timestamp */
timestamp: number
/** Event attributes */
attributes?: Record<string, unknown>
}
/**
* Complete trace context
*/
export interface TraceContext {
/** Trace identifiers */
traceIds: TraceIds
/** Baggage (propagated data) */
baggage: Baggage
/** Current span */
currentSpan?: Span
/** Span stack (for nested spans) */
spanStack: Span[]
/** Context creation timestamp */
createdAt: number
/** Additional metadata */
metadata: Record<string, unknown>
}
/**
* Options for creating a new context
*/
export interface CreateContextOptions {
/** Existing trace ID (for propagation) */
traceId?: string
/** Parent span ID */
parentSpanId?: string
/** Existing correlation ID */
correlationId?: string
/** Initial baggage */
baggage?: Baggage
/** Initial metadata */
metadata?: Record<string, unknown>
}
/**
* Options for creating a span
*/
export interface CreateSpanOptions {
/** Span name */
name: string
/** Initial attributes */
attributes?: Record<string, unknown>
/** Whether to be a child of the current span */
asChild?: boolean
}
/**
* Async storage for trace context
*/
const traceStorage = new AsyncLocalStorage<TraceContext>()
/**
* Generate a random hexadecimal ID
*/
function generateId(bytes: number): string {
return randomBytes(bytes).toString('hex')
}
/**
* Generate a trace ID (16 bytes = 32 chars hex)
*/
export function generateTraceId(): string {
return generateId(16)
}
/**
* Generate a span ID (8 bytes = 16 chars hex)
*/
export function generateSpanId(): string {
return generateId(8)
}
/**
* Generate a more readable correlation ID
*/
export function generateCorrelationId(): string {
const timestamp = Date.now().toString(36)
const random = generateId(4)
return `${timestamp}-${random}`
}
/**
* Create a new trace context
*/
export function createTraceContext(options: CreateContextOptions = {}): TraceContext {
const traceId = options.traceId || generateTraceId()
const spanId = generateSpanId()
const correlationId = options.correlationId || generateCorrelationId()
return {
traceIds: {
traceId,
spanId,
parentSpanId: options.parentSpanId,
correlationId
},
baggage: options.baggage || {},
spanStack: [],
createdAt: Date.now(),
metadata: options.metadata || {}
}
}
/**
* Get the current trace context
*/
export function getCurrentContext(): TraceContext | undefined {
return traceStorage.getStore()
}
/**
* Get the current trace context or create a new one
*/
export function getOrCreateContext(): TraceContext {
const existing = getCurrentContext()
if (existing) {
return existing
}
return createTraceContext()
}
/**
* Execute function with trace context
*/
export function runWithContext<T>(context: TraceContext, fn: () => T): T {
return traceStorage.run(context, fn)
}
/**
* Execute function with new trace context
*/
export function runWithNewContext<T>(options: CreateContextOptions, fn: () => T): T {
const context = createTraceContext(options)
return runWithContext(context, fn)
}
/**
* Execute async function with trace context
*/
export async function runWithContextAsync<T>(context: TraceContext, fn: () => Promise<T>): Promise<T> {
return traceStorage.run(context, fn)
}
/**
* Create a new span
*/
export function createSpan(options: CreateSpanOptions): Span {
const context = getCurrentContext()
const parentSpan = context?.currentSpan
const span: Span = {
name: options.name,
traceIds: {
traceId: context?.traceIds.traceId || generateTraceId(),
spanId: generateSpanId(),
parentSpanId: options.asChild && parentSpan ? parentSpan.traceIds.spanId : undefined,
correlationId: context?.traceIds.correlationId || generateCorrelationId()
},
startTime: Date.now(),
status: 'unset',
attributes: options.attributes || {},
events: [],
ended: false
}
return span
}
/**
* Start a span in the current context
*/
export function startSpan(options: CreateSpanOptions): Span {
const context = getOrCreateContext()
const span = createSpan({ ...options, asChild: true })
// Push current span to stack and set new one as current
if (context.currentSpan) {
context.spanStack.push(context.currentSpan)
}
context.currentSpan = span
return span
}
/**
* End a span
*/
export function endSpan(span: Span, status?: SpanStatus): void {
if (span.ended) {
return
}
span.endTime = Date.now()
span.duration = span.endTime - span.startTime
span.status = status || 'ok'
span.ended = true
// Pop span from stack in context
const context = getCurrentContext()
if (context?.currentSpan === span) {
context.currentSpan = context.spanStack.pop()
}
}
/**
* Add event to a span
*/
export function addSpanEvent(span: Span, name: string, attributes?: Record<string, unknown>): void {
if (span.ended) {
return
}
span.events.push({
name,
timestamp: Date.now(),
attributes
})
}
/**
* Set attributes on a span
*/
export function setSpanAttributes(span: Span, attributes: Record<string, unknown>): void {
if (span.ended) {
return
}
Object.assign(span.attributes, attributes)
}
/**
* Mark span as error
*/
export function setSpanError(span: Span, error: Error): void {
if (span.ended) {
return
}
span.status = 'error'
span.attributes.error = true
span.attributes.errorMessage = error.message
span.attributes.errorName = error.name
if (error.stack) {
span.attributes.errorStack = error.stack
}
addSpanEvent(span, 'exception', {
'exception.type': error.name,
'exception.message': error.message
})
}
/**
* Decorator for automatic function tracing
*/
export function traced(name?: string) {
return function <T extends (...args: unknown[]) => unknown>(
_target: unknown,
propertyKey: string,
descriptor: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T> {
const originalMethod = descriptor.value
if (!originalMethod) {
return descriptor
}
const spanName = name || propertyKey
descriptor.value = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
const span = startSpan({ name: spanName })
try {
const result = originalMethod.apply(this, args) as ReturnType<T>
if (result instanceof Promise) {
return result
.then(value => {
endSpan(span, 'ok')
return value
})
.catch(error => {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}) as ReturnType<T>
}
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
} as T
return descriptor
}
}
/**
* Wrapper for tracing a function
*/
// eslint-disable-next-line space-before-function-paren
export function traceFunction<T extends (...args: unknown[]) => unknown>(name: string, fn: T): T {
return function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
const span = startSpan({ name })
try {
const result = fn.apply(this, args) as ReturnType<T>
if (result instanceof Promise) {
return result
.then(value => {
endSpan(span, 'ok')
return value
})
.catch(error => {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}) as ReturnType<T>
}
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
} as T
}
/**
* Execute operation with automatic span
*/
export async function withSpan<T>(
name: string,
operation: (span: Span) => Promise<T>,
attributes?: Record<string, unknown>
): Promise<T> {
const span = startSpan({ name, attributes })
try {
const result = await operation(span)
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
}
/**
* Execute sync operation with automatic span
*/
export function withSpanSync<T>(name: string, operation: (span: Span) => T, attributes?: Record<string, unknown>): T {
const span = startSpan({ name, attributes })
try {
const result = operation(span)
endSpan(span, 'ok')
return result
} catch (error) {
setSpanError(span, error as Error)
endSpan(span, 'error')
throw error
}
}
// === Baggage Management ===
/**
* Set item in baggage
*/
export function setBaggage(key: string, value: string | number | boolean): void {
const context = getCurrentContext()
if (context) {
context.baggage[key] = value
}
}
/**
* Get item from baggage
*/
export function getBaggage(key: string): string | number | boolean | undefined {
const context = getCurrentContext()
return context?.baggage[key]
}
/**
* Get all baggage
*/
export function getAllBaggage(): Baggage {
const context = getCurrentContext()
return context?.baggage || {}
}
/**
* Remove item from baggage
*/
export function removeBaggage(key: string): void {
const context = getCurrentContext()
if (context) {
delete context.baggage[key]
}
}
// === Header Utilities ===
/**
* Standard headers for trace propagation
*/
export const TRACE_HEADERS = {
TRACE_ID: 'x-trace-id',
SPAN_ID: 'x-span-id',
PARENT_SPAN_ID: 'x-parent-span-id',
CORRELATION_ID: 'x-correlation-id',
BAGGAGE: 'baggage'
} as const
/**
* Inject context into HTTP headers
*/
export function injectTraceHeaders(headers: Record<string, string>): Record<string, string> {
const context = getCurrentContext()
if (!context) {
return headers
}
const result = { ...headers }
result[TRACE_HEADERS.TRACE_ID] = context.traceIds.traceId
result[TRACE_HEADERS.SPAN_ID] = context.traceIds.spanId
result[TRACE_HEADERS.CORRELATION_ID] = context.traceIds.correlationId
if (context.traceIds.parentSpanId) {
result[TRACE_HEADERS.PARENT_SPAN_ID] = context.traceIds.parentSpanId
}
// Baggage as key=value list
if (Object.keys(context.baggage).length > 0) {
result[TRACE_HEADERS.BAGGAGE] = Object.entries(context.baggage)
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
.join(',')
}
return result
}
/**
* Extract context from HTTP headers
*/
export function extractTraceHeaders(headers: Record<string, string | undefined>): CreateContextOptions {
const options: CreateContextOptions = {}
if (headers[TRACE_HEADERS.TRACE_ID]) {
options.traceId = headers[TRACE_HEADERS.TRACE_ID]
}
if (headers[TRACE_HEADERS.PARENT_SPAN_ID]) {
options.parentSpanId = headers[TRACE_HEADERS.PARENT_SPAN_ID]
}
if (headers[TRACE_HEADERS.CORRELATION_ID]) {
options.correlationId = headers[TRACE_HEADERS.CORRELATION_ID]
}
// Parse baggage
const baggageHeader = headers[TRACE_HEADERS.BAGGAGE]
if (baggageHeader) {
options.baggage = {}
const pairs = baggageHeader.split(',')
for (const pair of pairs) {
const parts = pair.split('=')
const key = parts[0]
const value = parts[1]
if (key && value) {
options.baggage[key.trim()] = decodeURIComponent(value.trim())
}
}
}
return options
}
/**
* Export trace context for serialization
*/
export function exportContext(context: TraceContext): string {
return JSON.stringify({
traceIds: context.traceIds,
baggage: context.baggage,
metadata: context.metadata
})
}
/**
* Import trace context from serialized string
*/
export function importContext(serialized: string): CreateContextOptions {
try {
const data = JSON.parse(serialized)
return {
traceId: data.traceIds?.traceId,
parentSpanId: data.traceIds?.spanId,
correlationId: data.traceIds?.correlationId,
baggage: data.baggage,
metadata: data.metadata
}
} catch {
return {}
}
}
// === Timer Utilities ===
/**
* High precision timer
*/
export interface PrecisionTimer {
/** Return elapsed time in milliseconds */
elapsed(): number
/** Return formatted elapsed time */
elapsedFormatted(): string
/** Stop the timer and return duration */
stop(): number
}
/**
* Create a high precision timer
*/
export function createPrecisionTimer(): PrecisionTimer {
const start = process.hrtime.bigint()
let stopped = false
let finalDuration = 0
return {
elapsed(): number {
if (stopped) return finalDuration
return Number(process.hrtime.bigint() - start) / 1_000_000
},
elapsedFormatted(): string {
const ms = this.elapsed()
if (ms < 1) return `${(ms * 1000).toFixed(2)}µs`
if (ms < 1000) return `${ms.toFixed(2)}ms`
return `${(ms / 1000).toFixed(2)}s`
},
stop(): number {
if (!stopped) {
finalDuration = Number(process.hrtime.bigint() - start) / 1_000_000
stopped = true
}
return finalDuration
}
}
}
export default {
createTraceContext,
getCurrentContext,
getOrCreateContext,
runWithContext,
runWithNewContext,
createSpan,
startSpan,
endSpan,
withSpan,
withSpanSync,
injectTraceHeaders,
extractTraceHeaders,
createPrecisionTimer
}
-414
View File
@@ -1,414 +0,0 @@
/**
* Unified Session Telemetry Implementation
*
* This module implements WhatsApp's unified_session telemetry feature to reduce
* detection of unofficial clients. The implementation is inspired by:
* - whatsmeow PR #1057 (Go implementation)
* - Baileys PR #2294 (TypeScript implementation)
*
* The unified_session is a time-based identifier that mimics the behavior of
* official WhatsApp Web clients, potentially reducing account restriction warnings.
*
* @module Utils/unified-session
* @see https://github.com/tulir/whatsmeow/pull/1057
* @see https://github.com/WhiskeySockets/Baileys/pull/2294
*/
import type { BinaryNode } from '../WABinary/types.js'
import { CircuitBreaker, CircuitOpenError, createConnectionCircuitBreaker } from './circuit-breaker.js'
import type { ILogger } from './logger.js'
import { metrics } from './prometheus-metrics.js'
// ============================================
// Time Constants (Enterprise-grade)
// ============================================
/**
* Time constants in milliseconds for unified session calculations.
* Defined locally to avoid circular dependency issues with Defaults/index.ts
*/
const TimeMs = {
/** One second in milliseconds */
Second: 1_000,
/** One minute in milliseconds */
Minute: 60_000,
/** One hour in milliseconds */
Hour: 3_600_000,
/** One day in milliseconds */
Day: 86_400_000,
/** One week in milliseconds */
Week: 604_800_000
} as const
/**
* Offset for session ID calculation (3 days in ms).
* This matches the WhatsApp Web official client behavior.
*/
const SESSION_OFFSET_MS = 3 * TimeMs.Day
// ============================================
// Types
// ============================================
/**
* Unified Session Manager options
*/
export interface UnifiedSessionOptions {
/** Whether unified session telemetry is enabled */
enabled?: boolean
/** Logger instance for debugging */
logger?: ILogger
/** Enable circuit breaker protection for send operations */
enableCircuitBreaker?: boolean
/** Function to send binary nodes to WhatsApp */
sendNode?: (node: BinaryNode) => Promise<void>
}
/**
* Unified Session state
*/
export interface UnifiedSessionState {
/** Server time offset in milliseconds (server time - local time) */
serverTimeOffset: number
/** Last time unified_session was sent (Unix timestamp ms) */
lastSentTime: number
/** Total number of unified_session messages sent */
sendCount: number
/** Whether the session manager is initialized */
isInitialized: boolean
}
/**
* Trigger types for unified session sending
*/
export type UnifiedSessionTrigger = 'login' | 'pairing' | 'presence' | 'manual'
// ============================================
// Core Implementation
// ============================================
/**
* Unified Session Manager
*
* Manages the unified_session telemetry feature with:
* - Server time synchronization
* - Rate limiting (prevents spam)
* - Circuit breaker protection
* - Prometheus metrics integration
* - Structured logging
*
* @example
* ```typescript
* const sessionManager = new UnifiedSessionManager({
* enabled: true,
* logger: myLogger,
* sendNode: (node) => sock.sendNode(node)
* })
*
* // Update server time offset when receiving server timestamp
* sessionManager.updateServerTimeOffset(serverTimeAttr)
*
* // Send unified_session on login
* await sessionManager.send('login')
* ```
*/
export class UnifiedSessionManager {
private state: UnifiedSessionState = {
serverTimeOffset: 0,
lastSentTime: 0,
sendCount: 0,
isInitialized: false
}
private readonly options: Required<UnifiedSessionOptions>
private circuitBreaker: CircuitBreaker | null = null
/** Minimum interval between unified_session sends (1 minute) */
private static readonly MIN_SEND_INTERVAL_MS = TimeMs.Minute
constructor(options: UnifiedSessionOptions = {}) {
this.options = {
enabled: options.enabled ?? true,
logger: options.logger ?? (console as unknown as ILogger),
enableCircuitBreaker: options.enableCircuitBreaker ?? true,
sendNode:
options.sendNode ??
(async () => {
throw new Error('sendNode function not configured')
})
}
// Initialize circuit breaker if enabled
if (this.options.enableCircuitBreaker) {
this.circuitBreaker = createConnectionCircuitBreaker({
name: 'unified-session',
failureThreshold: 3,
failureWindow: 60000,
resetTimeout: 30000,
successThreshold: 1,
timeout: 10000,
onStateChange: (from, to) => {
this.options.logger.debug?.({ from, to }, 'Unified session circuit breaker state changed')
},
onOpen: () => {
this.options.logger.warn?.('Unified session circuit breaker OPENED')
metrics.circuitBreakerTrips?.inc({ name: 'unified-session' })
}
})
}
this.state.isInitialized = true
this.options.logger.debug?.('UnifiedSessionManager initialized')
}
/**
* Update the server time offset from a received server timestamp.
*
* WhatsApp includes a 't' attribute in some nodes containing the server's
* Unix timestamp (in seconds). We use this to calculate the offset between
* server time and local time.
*
* @param serverTime - Server timestamp (seconds) from node.attrs.t
*/
updateServerTimeOffset(serverTime: string | number | undefined): void {
if (serverTime === undefined || serverTime === null) {
return
}
const serverTimeNum = typeof serverTime === 'string' ? parseInt(serverTime, 10) : serverTime
if (isNaN(serverTimeNum) || serverTimeNum <= 0) {
return
}
// Server time is in seconds, convert to ms
const serverTimeMs = serverTimeNum * 1000
const localTimeMs = Date.now()
const newOffset = serverTimeMs - localTimeMs
// Reject outliers: if the new offset differs from the current stable
// offset by more than 30 seconds, this timestamp is likely stale
// (e.g. replayed device notification with old 't' value).
// The first sample (offset === 0) is always accepted.
const MAX_DRIFT_MS = 30_000
if (this.state.serverTimeOffset !== 0 && Math.abs(newOffset - this.state.serverTimeOffset) > MAX_DRIFT_MS) {
return
}
// Only update if the offset changed significantly (>1 second)
if (Math.abs(newOffset - this.state.serverTimeOffset) > 1000) {
this.state.serverTimeOffset = newOffset
this.options.logger.trace?.({ newOffset, serverTime: serverTimeNum }, 'Server time offset updated')
// Record metric
metrics.socketEvents?.inc({ event: 'server_time_sync' })
}
}
/**
* Calculate the unified session ID.
*
* The algorithm matches WhatsApp Web's official implementation:
* - Takes current time adjusted by server offset
* - Adds 3-day offset
* - Modulo 7 days (one week cycle)
* - Returns as string
*
* @returns The unified session ID as a string
*/
getSessionId(): string {
const adjustedTime = Date.now() + this.state.serverTimeOffset
const sessionId = (adjustedTime + SESSION_OFFSET_MS) % TimeMs.Week
return String(Math.floor(sessionId))
}
/**
* Check if enough time has passed since the last send.
* Prevents spamming the server with unified_session messages.
*/
private canSend(): boolean {
const timeSinceLastSend = Date.now() - this.state.lastSentTime
return timeSinceLastSend >= UnifiedSessionManager.MIN_SEND_INTERVAL_MS
}
/**
* Send the unified_session telemetry to WhatsApp.
*
* This should be called at specific trigger points:
* - After successful login (CB:success)
* - After successful pairing (CB:iq,,pair-success)
* - When sending 'available' presence
*
* @param trigger - What triggered this send (for logging/metrics)
* @returns Promise that resolves when sent, or void if skipped
*/
async send(trigger: UnifiedSessionTrigger = 'manual'): Promise<void> {
// Check if enabled
if (!this.options.enabled) {
this.options.logger.trace?.('Unified session is disabled, skipping')
return
}
// Rate limiting (except for login which always sends)
if (trigger !== 'login' && !this.canSend()) {
this.options.logger.trace?.(
{ trigger, lastSent: this.state.lastSentTime },
'Unified session rate limited, skipping'
)
return
}
const sessionId = this.getSessionId()
const startTime = Date.now()
const node: BinaryNode = {
tag: 'ib',
attrs: {},
content: [
{
tag: 'unified_session',
attrs: { id: sessionId }
}
]
}
const sendOperation = async (): Promise<void> => {
await this.options.sendNode(node)
// Update state on success
this.state.lastSentTime = Date.now()
this.state.sendCount++
const latency = Date.now() - startTime
this.options.logger.debug?.(
{ sessionId, trigger, latency, sendCount: this.state.sendCount },
'Unified session telemetry sent'
)
// Record metrics
metrics.socketEvents?.inc({ event: 'unified_session_sent' })
}
try {
// Execute with circuit breaker if available
if (this.circuitBreaker) {
await this.circuitBreaker.execute(sendOperation)
} else {
await sendOperation()
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
if (error instanceof CircuitOpenError) {
this.options.logger.warn?.({ trigger, circuitState: error.state }, 'Unified session blocked by circuit breaker')
} else {
this.options.logger.warn?.({ trigger, error: errorMessage }, 'Failed to send unified session telemetry')
}
// Record failure metric
metrics.errors?.inc({ category: 'unified_session', code: 'send_failed' })
// Don't rethrow - unified_session is non-critical
}
}
/**
* Get the current state for debugging/monitoring.
*/
getState(): Readonly<UnifiedSessionState> {
return { ...this.state }
}
/**
* Reset the manager state (useful for testing or reconnection).
*/
reset(): void {
this.state = {
serverTimeOffset: 0,
lastSentTime: 0,
sendCount: 0,
isInitialized: true
}
this.circuitBreaker?.reset()
this.options.logger.debug?.('UnifiedSessionManager reset')
}
/**
* Destroy the manager and clean up resources.
*/
destroy(): void {
this.circuitBreaker?.destroy()
this.state.isInitialized = false
this.options.logger.debug?.('UnifiedSessionManager destroyed')
}
}
// ============================================
// Factory Function
// ============================================
/**
* Create a new UnifiedSessionManager instance.
*
* @param options - Configuration options
* @returns A new UnifiedSessionManager instance
*
* @example
* ```typescript
* const sessionManager = createUnifiedSessionManager({
* enabled: config.enableUnifiedSession,
* logger: config.logger,
* sendNode: sock.sendNode
* })
* ```
*/
export function createUnifiedSessionManager(options: UnifiedSessionOptions = {}): UnifiedSessionManager {
return new UnifiedSessionManager(options)
}
// ============================================
// Utility Functions
// ============================================
/**
* Extract server time from a binary node's attributes.
* WhatsApp includes 't' attribute in various nodes.
*
* @param node - Binary node with potential time attribute
* @returns Server time in seconds, or undefined if not present
*/
export function extractServerTime(node: BinaryNode): number | undefined {
const timeAttr = node.attrs?.t
if (timeAttr === undefined || timeAttr === null) {
return undefined
}
const parsed =
typeof timeAttr === 'string' ? parseInt(timeAttr, 10) : typeof timeAttr === 'number' ? timeAttr : undefined
if (parsed === undefined || isNaN(parsed) || parsed <= 0) {
return undefined
}
return parsed
}
/**
* Check if unified_session should be enabled based on environment.
* Can be controlled via environment variable for testing.
*
* @returns true if unified_session should be enabled
*/
export function shouldEnableUnifiedSession(): boolean {
const envValue = process.env.BAILEYS_UNIFIED_SESSION_ENABLED
if (envValue !== undefined) {
return envValue.toLowerCase() === 'true' || envValue === '1'
}
// Default: enabled
return true
}
export default UnifiedSessionManager
+11 -6
View File
@@ -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
-276
View File
@@ -1,276 +0,0 @@
import { promises as fs } from 'fs'
import { join } from 'path'
import type { WAVersion } from '../Types'
import { fetchLatestWaWebVersion } from './generics'
/**
* Version cache entry stored in file
*/
interface VersionCacheEntry {
version: WAVersion
fetchedAt: number // timestamp in ms
source: 'online' | 'fallback'
}
/**
* Logger interface for version cache operations
*/
export interface VersionCacheLogger {
info: (obj: unknown, msg?: string) => void
debug: (obj: unknown, msg?: string) => void
warn: (obj: unknown, msg?: string) => void
}
/**
* In-memory cache to avoid file reads on every connection
*/
let memoryCache: VersionCacheEntry | null = null
/**
* Promise to prevent concurrent fetches (deduplication)
*/
let fetchInProgress: Promise<VersionCacheEntry> | null = null
/**
* Default cache TTL: 6 hours
*/
const DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1000
/**
* Default cache file path.
*
* NOTE: Uses process.cwd() which may not be writable in some environments
* (containers, serverless, etc). In such cases, specify a custom `cacheFilePath`
* in the config pointing to a writable directory like `/tmp`.
*/
const DEFAULT_CACHE_FILE = join(process.cwd(), '.baileys-version-cache.json')
/**
* Configuration for version cache
*/
export interface VersionCacheConfig {
/** Cache TTL in milliseconds (default: 6 hours) */
cacheTtlMs?: number
/**
* Path to cache file (default: .baileys-version-cache.json in cwd)
*
* NOTE: If running in a container or serverless environment where cwd
* is not writable, specify a writable path like '/tmp/.baileys-version-cache.json'
*/
cacheFilePath?: string
/** Logger instance */
logger?: VersionCacheLogger
}
/**
* Result from refreshVersionCache with success status
*/
export interface RefreshVersionResult {
version: WAVersion
success: boolean
source: 'online' | 'fallback'
}
/**
* Reads the cache from file
*/
async function readCacheFile(filePath: string): Promise<VersionCacheEntry | null> {
try {
const data = await fs.readFile(filePath, 'utf-8')
return JSON.parse(data) as VersionCacheEntry
} catch {
return null
}
}
/**
* Writes the cache to file
*/
async function writeCacheFile(filePath: string, entry: VersionCacheEntry, logger?: VersionCacheLogger): Promise<void> {
try {
await fs.writeFile(filePath, JSON.stringify(entry, null, 2), 'utf-8')
} catch (error) {
// Log write errors for debugging - cache is optional but failures should be visible
logger?.warn({ error, filePath }, 'Failed to write version cache file')
}
}
/**
* Checks if cache entry is still valid
*/
function isCacheValid(entry: VersionCacheEntry | null, ttlMs: number): boolean {
if (!entry) return false
const age = Date.now() - entry.fetchedAt
return age < ttlMs
}
/**
* Fetches version with deduplication (prevents 150 parallel requests)
*/
async function fetchVersionOnce(cacheFilePath: string, logger?: VersionCacheLogger): Promise<VersionCacheEntry> {
logger?.info({}, 'Fetching WhatsApp Web version (shared for all connections)...')
const result = await fetchLatestWaWebVersion()
const entry: VersionCacheEntry = {
version: result.version,
fetchedAt: Date.now(),
source: result.isLatest ? 'online' : 'fallback'
}
// Update memory cache
memoryCache = entry
// Persist to file (async, don't wait)
writeCacheFile(cacheFilePath, entry, logger).catch(() => {})
logger?.info({ version: entry.version, source: entry.source }, 'Version fetched and cached')
return entry
}
/**
* Gets the cached WhatsApp version, fetching if necessary.
*
* Features:
* - File-based persistence (survives restarts)
* - In-memory cache (fast access)
* - Request deduplication (150 connections = 1 request)
* - Configurable TTL
*
* @example
* ```typescript
* // All 150 connections share the same cached version
* const { version } = await getCachedVersion()
* const sock = makeWASocket({ version, auth })
* ```
*/
export async function getCachedVersion(
config: VersionCacheConfig = {}
): Promise<{ version: WAVersion; fromCache: boolean; age: number; source: 'online' | 'fallback' | 'memory' | 'file' }> {
const { cacheTtlMs = DEFAULT_CACHE_TTL_MS, cacheFilePath = DEFAULT_CACHE_FILE, logger } = config
// 1. Check memory cache first (fastest)
if (isCacheValid(memoryCache, cacheTtlMs) && memoryCache) {
const age = Date.now() - memoryCache.fetchedAt
logger?.debug({ age: Math.round(age / 1000) + 's' }, 'Using memory cached version')
return { version: memoryCache.version, fromCache: true, age, source: 'memory' }
}
// 2. Check file cache (survives restarts)
const fileCache = await readCacheFile(cacheFilePath)
if (isCacheValid(fileCache, cacheTtlMs) && fileCache) {
memoryCache = fileCache // Update memory cache
const age = Date.now() - fileCache.fetchedAt
logger?.debug({ age: Math.round(age / 1000) + 's' }, 'Using file cached version')
return { version: fileCache.version, fromCache: true, age, source: 'file' }
}
// 3. Need to fetch - but deduplicate concurrent requests
// If 150 connections start at once, only 1 fetch happens
if (!fetchInProgress) {
fetchInProgress = fetchVersionOnce(cacheFilePath, logger).finally(() => {
fetchInProgress = null
})
}
const entry = await fetchInProgress
return { version: entry.version, fromCache: false, age: 0, source: entry.source }
}
/**
* Clears the version cache (memory and file).
* Also cancels any in-progress fetch to prevent it from restoring the cache.
*/
export async function clearVersionCache(cacheFilePath: string = DEFAULT_CACHE_FILE): Promise<void> {
// Wait for any in-progress fetch to complete before clearing
// This prevents the fetch from restoring the cache after we clear it
if (fetchInProgress) {
try {
await fetchInProgress
} catch {
// Ignore fetch errors during clear
}
}
memoryCache = null
fetchInProgress = null
try {
await fs.unlink(cacheFilePath)
} catch {
// Ignore if file doesn't exist
}
}
/**
* Forces a refresh of the cached version.
* Returns success status to indicate if online fetch succeeded or fell back to bundled version.
*
* @returns Object with version, success status, and source
*/
export async function refreshVersionCache(config: VersionCacheConfig = {}): Promise<RefreshVersionResult> {
const { cacheFilePath = DEFAULT_CACHE_FILE, logger } = config
// Wait for any existing fetch to complete first (deduplication)
if (fetchInProgress) {
try {
const existing = await fetchInProgress
// If there's already a fresh fetch in progress, return its result
return {
version: existing.version,
success: existing.source === 'online',
source: existing.source
}
} catch {
// Ignore and proceed with new fetch
}
}
// Clear existing cache
memoryCache = null
// Fetch fresh
const entry = await fetchVersionOnce(cacheFilePath, logger)
return {
version: entry.version,
success: entry.source === 'online',
source: entry.source
}
}
/**
* Gets cache status information
*/
export function getVersionCacheStatus(cacheTtlMs: number = DEFAULT_CACHE_TTL_MS): {
hasCache: boolean
version: WAVersion | null
age: number | null
isExpired: boolean
expiresIn: number | null
source: 'online' | 'fallback' | null
} {
if (!memoryCache) {
return {
hasCache: false,
version: null,
age: null,
isExpired: true,
expiresIn: null,
source: null
}
}
const age = Date.now() - memoryCache.fetchedAt
const isExpired = age >= cacheTtlMs
return {
hasCache: true,
version: memoryCache.version,
age,
isExpired,
expiresIn: isExpired ? 0 : cacheTtlMs - age,
source: memoryCache.source
}
}
-40
View File
@@ -1,40 +0,0 @@
type WasmBridgeModule = typeof import('whatsapp-rust-bridge')
let _bridge: WasmBridgeModule | undefined
// Start loading eagerly using .then() instead of top-level await.
// This prevents the whatsapp-rust-bridge top-level await from propagating
// through the ESM graph, which would break CJS require() consumers.
const _bridgeReady = import('whatsapp-rust-bridge').then(m => {
_bridge = m
return m
})
export { _bridgeReady as wasmBridgeReady }
function getBridge(): WasmBridgeModule {
if (!_bridge) {
throw new Error(
'whatsapp-rust-bridge not yet loaded. ' + 'Ensure async operations have started before calling crypto functions.'
)
}
return _bridge
}
export const hkdf: WasmBridgeModule['hkdf'] = (...args) => getBridge().hkdf(...args)
export const md5: WasmBridgeModule['md5'] = (...args) => getBridge().md5(...args)
export const expandAppStateKeys: WasmBridgeModule['expandAppStateKeys'] = (...args) =>
getBridge().expandAppStateKeys(...args)
let _ltHash: InstanceType<WasmBridgeModule['LTHashAntiTampering']> | undefined
export function getLTHashAntiTampering(): InstanceType<WasmBridgeModule['LTHashAntiTampering']> {
if (!_ltHash) {
_ltHash = new (getBridge().LTHashAntiTampering)()
}
return _ltHash
}
+4 -13
View File
@@ -55,12 +55,12 @@ export const jidEncode = (user: string | number | null, server: JidServer, devic
export const jidDecode = (jid: string | undefined): FullJid | undefined => {
// todo: investigate how to implement hosted ids in this case
const sepIdx = typeof jid === 'string' ? jid.indexOf('@') : -1
if (sepIdx < 0 || !jid) {
if (sepIdx < 0) {
return undefined
}
const server = jid.slice(sepIdx + 1)
const userCombined = jid.slice(0, sepIdx)
const server = jid!.slice(sepIdx + 1)
const userCombined = jid!.slice(0, sepIdx)
const [userAgent, device] = userCombined.split(':')
const [user, agent] = userAgent!.split('_')
@@ -105,10 +105,6 @@ export const isJidNewsletter = (jid: string | undefined) => jid?.endsWith('@news
export const isHostedPnUser = (jid: string | undefined) => jid?.endsWith('@hosted')
/** is the jid a hosted LID */
export const isHostedLidUser = (jid: string | undefined) => jid?.endsWith('@hosted.lid')
/** is the jid any LID (regular or hosted) */
export const isAnyLidUser = (jid: string | undefined) => !!(isLidUser(jid) || isHostedLidUser(jid))
/** is the jid any PN user (regular or hosted) */
export const isAnyPnUser = (jid: string | undefined) => !!(isPnUser(jid) || isHostedPnUser(jid))
const botRegexp = /^1313555\d{4}$|^131655500\d{2}$/
@@ -127,11 +123,6 @@ export const jidNormalizedUser = (jid: string | undefined) => {
export const transferDevice = (fromJid: string, toJid: string) => {
const fromDecoded = jidDecode(fromJid)
const deviceId = fromDecoded?.device || 0
const toDecoded = jidDecode(toJid)
if (!toDecoded) {
throw new Error(`transferDevice: failed to decode toJid "${toJid}"`)
}
const { server, user } = toDecoded
const { server, user } = jidDecode(toJid)!
return jidEncode(user, server, deviceId)
}
+1 -6
View File
@@ -78,12 +78,7 @@ function encodeEvents(binaryInfo: BinaryInfo) {
}
const fieldFlag = extended ? FLAG_EVENT : FLAG_FIELD | FLAG_EXTENDED
// eslint-disable-next-line eqeqeq
if (id == null) {
continue
}
binaryInfo.buffer.push(serializeData(id, value, fieldFlag))
binaryInfo.buffer.push(serializeData(id!, value, fieldFlag))
}
}
}
+3 -29
View File
@@ -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
}
}
-1
View File
@@ -2,4 +2,3 @@ export * from './USyncDeviceProtocol'
export * from './USyncContactProtocol'
export * from './USyncStatusProtocol'
export * from './USyncDisappearingModeProtocol'
export * from './USyncUsernameProtocol'
+10 -23
View File
@@ -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
}
}
-12
View File
@@ -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
-255
View File
@@ -44,259 +44,4 @@ describe('LIDMappingStore', () => {
expect(result).toBeNull()
})
})
describe('getLIDsForPNs', () => {
it('should resolve multiple PNs in a single batch', async () => {
const pnOne = '11111@s.whatsapp.net'
const pnTwo = '22222:5@s.whatsapp.net'
// @ts-ignore
mockKeys.get.mockResolvedValue({ '11111': 'aaaaa', '22222': 'bbbbb' } as SignalDataTypeMap['lid-mapping'])
const result = await lidMappingStore.getLIDsForPNs([pnOne, pnTwo])
expect(result).toEqual(
expect.arrayContaining([
{ pn: pnOne, lid: 'aaaaa@lid' },
{ pn: pnTwo, lid: 'bbbbb:5@lid' }
])
)
})
})
describe('getPNsForLIDs', () => {
it('should resolve multiple LIDs in a single batch', async () => {
const lidOne = '33333@lid'
const lidTwo = '44444:99@hosted.lid'
// @ts-ignore
mockKeys.get.mockResolvedValue({
'33333_reverse': '77777',
'44444_reverse': '88888'
} as SignalDataTypeMap['lid-mapping'])
const result = await lidMappingStore.getPNsForLIDs([lidOne, lidTwo])
expect(result).toEqual(
expect.arrayContaining([
{ lid: lidOne, pn: '77777@s.whatsapp.net' },
{ lid: lidTwo, pn: '88888:99@hosted' }
])
)
})
})
// ========================================================================
// M3: REQUEST COALESCING TESTS
// ========================================================================
describe('Request Coalescing (M3)', () => {
it('should deduplicate concurrent getLIDForPN calls for same PN', async () => {
const pn = '12345@s.whatsapp.net'
const lidUser = 'aaaaa'
// @ts-ignore - Mock DB lookup to return mapping
mockKeys.get.mockResolvedValue({ '12345': lidUser } as SignalDataTypeMap['lid-mapping'])
// Make 10 concurrent calls for the same PN
const promises = Array(10)
.fill(null)
.map(() => lidMappingStore.getLIDForPN(pn))
// All should resolve to same result
const results = await Promise.all(promises)
expect(results.every(r => r === `${lidUser}@lid`)).toBe(true)
// But DB should only be queried ONCE (not 10 times)
// The batch method getLIDsForPNs calls keys.get once
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
it('should deduplicate concurrent getPNForLID calls for same LID', async () => {
const lid = '54321@lid'
const pnUser = 'bbbbb'
// @ts-ignore - Mock DB lookup to return reverse mapping
mockKeys.get.mockResolvedValue({ '54321_reverse': pnUser } as SignalDataTypeMap['lid-mapping'])
// Make 10 concurrent calls for the same LID
const promises = Array(10)
.fill(null)
.map(() => lidMappingStore.getPNForLID(lid))
// All should resolve to same result
const results = await Promise.all(promises)
expect(results.every(r => r === `${pnUser}@s.whatsapp.net`)).toBe(true)
// But DB should only be queried ONCE (not 10 times)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
it('should NOT coalesce calls for different PNs', async () => {
const pn1 = '11111@s.whatsapp.net'
const pn2 = '22222@s.whatsapp.net'
// @ts-ignore - Mock to return different mappings
mockKeys.get.mockResolvedValue({ '11111': 'aaaaa', '22222': 'bbbbb' } as SignalDataTypeMap['lid-mapping'])
// Concurrent calls for DIFFERENT PNs
const [result1, result2] = await Promise.all([lidMappingStore.getLIDForPN(pn1), lidMappingStore.getLIDForPN(pn2)])
expect(result1).toBe('aaaaa@lid')
expect(result2).toBe('bbbbb@lid')
// Should make 2 separate DB calls (no coalescing)
expect(mockKeys.get).toHaveBeenCalledTimes(2)
})
})
// ========================================================================
// V4: DESTROYED FLAG & OPERATION COUNTER TESTS
// ========================================================================
describe('Destroyed Flag Protection (V4, M2)', () => {
it('should reject operations after destroy()', async () => {
lidMappingStore.destroy()
// All operations should throw after destroy
await expect(lidMappingStore.getLIDForPN('12345@s.whatsapp.net')).rejects.toThrow(
'LIDMappingStore has been destroyed'
)
await expect(lidMappingStore.getPNForLID('54321@lid')).rejects.toThrow('LIDMappingStore has been destroyed')
await expect(lidMappingStore.storeLIDPNMappings([{ lid: 'a@lid', pn: 'b@s.whatsapp.net' }])).rejects.toThrow(
'LIDMappingStore has been destroyed'
)
})
it('should allow destroy() to be called multiple times safely', () => {
// First destroy
lidMappingStore.destroy()
// Second destroy should not throw (reentrancy guard)
expect(() => lidMappingStore.destroy()).not.toThrow()
// Third destroy should also be safe
expect(() => lidMappingStore.destroy()).not.toThrow()
})
it('should complete active operations before destroying resources (graceful degradation)', async () => {
const pn = '12345@s.whatsapp.net'
// Mock slow DB operation (simulates long-running operation)
let operationStarted = false
let operationCompleted = false
// @ts-ignore
mockKeys.get.mockImplementation(async () => {
operationStarted = true
await new Promise(resolve => setTimeout(resolve, 100)) // 100ms delay
operationCompleted = true
return { '12345': 'aaaaa' } as unknown as SignalDataTypeMap['lid-mapping']
})
// Start operation
const operationPromise = lidMappingStore.getLIDForPN(pn)
// Wait a bit to ensure operation has started
await new Promise(resolve => setTimeout(resolve, 10))
expect(operationStarted).toBe(true)
expect(operationCompleted).toBe(false)
// Call destroy while operation is in progress
lidMappingStore.destroy()
// Operation should still complete successfully (graceful degradation)
const result = await operationPromise
expect(result).toBe('aaaaa@lid')
expect(operationCompleted).toBe(true)
// But new operations should be rejected
await expect(lidMappingStore.getLIDForPN(pn)).rejects.toThrow('LIDMappingStore has been destroyed')
})
})
// ========================================================================
// CACHE & OPTIMIZATION TESTS
// ========================================================================
describe('Cache Behavior', () => {
it('should use cache for subsequent lookups (no DB hit)', async () => {
const pn = '12345@s.whatsapp.net'
const lidUser = 'aaaaa'
// @ts-ignore - First lookup hits DB
mockKeys.get.mockResolvedValue({ '12345': lidUser } as SignalDataTypeMap['lid-mapping'])
// First lookup - cache miss, DB hit
const result1 = await lidMappingStore.getLIDForPN(pn)
expect(result1).toBe(`${lidUser}@lid`)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
// Second lookup - cache hit, no DB call
const result2 = await lidMappingStore.getLIDForPN(pn)
expect(result2).toBe(`${lidUser}@lid`)
// DB should still only have been called once (cache hit)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
it('should clear cache on destroy()', async () => {
const pn = '12345@s.whatsapp.net'
// @ts-ignore
mockKeys.get.mockResolvedValue({ '12345': 'aaaaa' } as SignalDataTypeMap['lid-mapping'])
// Populate cache
await lidMappingStore.getLIDForPN(pn)
// Destroy should clear cache
lidMappingStore.destroy()
// Create new store
const newStore = new LIDMappingStore(mockKeys, logger, mockPnToLIDFunc)
// New lookup should hit DB again (cache was cleared)
jest.clearAllMocks() // Reset call count
await newStore.getLIDForPN(pn)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
})
// ========================================================================
// EDGE CASES & ERROR HANDLING
// ========================================================================
describe('Edge Cases', () => {
it('should handle invalid JIDs gracefully', async () => {
const result1 = await lidMappingStore.getLIDForPN('invalid')
expect(result1).toBeNull()
const result2 = await lidMappingStore.getPNForLID('invalid')
expect(result2).toBeNull()
})
it('should handle empty results from DB', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({} as SignalDataTypeMap['lid-mapping'])
const result = await lidMappingStore.getLIDForPN('12345@s.whatsapp.net')
expect(result).toBeNull()
})
it('should handle batch operations with mixed valid/invalid JIDs', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({ '12345': 'aaaaa' } as SignalDataTypeMap['lid-mapping'])
const result = await lidMappingStore.getLIDsForPNs([
'12345@s.whatsapp.net', // Valid
'invalid', // Invalid
'67890@s.whatsapp.net' // Valid but not in DB
])
// Should only return valid results
expect(result).toEqual([{ pn: '12345@s.whatsapp.net', lid: 'aaaaa@lid' }])
})
})
})
@@ -1,543 +0,0 @@
import { jest } from '@jest/globals'
import P from 'pino'
import type { SessionActivityMetadata } from '../../Signal/session-activity-tracker'
import { makeSessionActivityTracker } from '../../Signal/session-activity-tracker'
import type { SignalKeyStoreWithTransaction } from '../../Types'
const mockKeys: jest.Mocked<SignalKeyStoreWithTransaction> = {
get: jest.fn() as any,
set: jest.fn() as any,
transaction: jest.fn(async (work: () => any) => await work()) as any,
isInTransaction: jest.fn() as any
}
const logger = P({ level: 'silent' })
describe('SessionActivityTracker', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
})
describe('recordActivity', () => {
it('should record activity in memory cache', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
tracker.recordActivity(jid)
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(1)
expect(stats.cacheSize).toBe(1)
})
it('should update existing activity timestamp', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
tracker.recordActivity(jid)
jest.advanceTimersByTime(5000) // 5 seconds later
tracker.recordActivity(jid)
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(2)
expect(stats.cacheSize).toBe(1) // Still one unique JID
})
it('should not record activity when disabled', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(0)
expect(stats.cacheSize).toBe(0)
})
it('should handle multiple JIDs', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.recordActivity('5511888888888@s.whatsapp.net')
tracker.recordActivity('123456789@lid')
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(3)
expect(stats.cacheSize).toBe(3)
})
})
describe('getLastActivity', () => {
it('should return activity from cache (cache hit)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const beforeTime = Date.now()
tracker.recordActivity(jid)
const lastActivity = await tracker.getLastActivity(jid)
expect(lastActivity).toBeGreaterThanOrEqual(beforeTime)
expect(mockKeys.get).not.toHaveBeenCalled() // Cache hit, no DB call
})
it('should fallback to disk when not in cache (cache miss)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const diskTimestamp = Date.now() - 10000
// @ts-ignore
mockKeys.get.mockResolvedValue({
'session-activity:5511999999999@s.whatsapp.net': {
lastActivityAt: diskTimestamp,
createdAt: diskTimestamp
} as SessionActivityMetadata
})
const lastActivity = await tracker.getLastActivity(jid)
expect(lastActivity).toBe(diskTimestamp)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
it('should return undefined when activity not found', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// @ts-ignore
mockKeys.get.mockResolvedValue({})
const lastActivity = await tracker.getLastActivity('nonexistent@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
it('should return undefined when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const lastActivity = await tracker.getLastActivity('5511999999999@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
it('should handle disk read errors gracefully', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// @ts-ignore
mockKeys.get.mockRejectedValue(new Error('Disk read error'))
const lastActivity = await tracker.getLastActivity('5511999999999@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
})
describe('getAllActivities', () => {
it('should return all activities from disk and cache', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const now = Date.now()
// Simulate disk data
// @ts-ignore
mockKeys.get.mockResolvedValue({
'session-activity:5511999999999@s.whatsapp.net': {
lastActivityAt: now - 10000,
createdAt: now - 20000
} as SessionActivityMetadata,
'session-activity:5511888888888@s.whatsapp.net': {
lastActivityAt: now - 5000,
createdAt: now - 15000
} as SessionActivityMetadata
})
// Add new activity to cache
tracker.recordActivity('123456789@lid')
const activities = await tracker.getAllActivities()
expect(activities.size).toBe(3)
expect(activities.has('5511999999999@s.whatsapp.net')).toBe(true)
expect(activities.has('5511888888888@s.whatsapp.net')).toBe(true)
expect(activities.has('123456789@lid')).toBe(true)
})
it('should prioritize cache over disk (cache is more recent)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const diskTimestamp = Date.now() - 10000
// Simulate old disk data
// @ts-ignore
mockKeys.get.mockResolvedValue({
[`session-activity:${jid}`]: {
lastActivityAt: diskTimestamp,
createdAt: diskTimestamp
} as SessionActivityMetadata
})
// Record new activity in cache
const beforeCache = Date.now()
tracker.recordActivity(jid)
const activities = await tracker.getAllActivities()
const cacheTimestamp = activities.get(jid)
expect(cacheTimestamp).toBeGreaterThanOrEqual(beforeCache)
expect(cacheTimestamp).not.toBe(diskTimestamp) // Cache wins
})
it('should return empty map when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const activities = await tracker.getAllActivities()
expect(activities.size).toBe(0)
})
it('should handle disk read errors gracefully', async () => {
// Use real timers for this test
jest.useRealTimers()
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Simulate disk error
// @ts-ignore
mockKeys.get.mockRejectedValue(new Error('Disk read error'))
// getAllActivities should return empty map (logs warning but doesn't throw)
const activities = await tracker.getAllActivities()
expect(activities).toBeInstanceOf(Map)
expect(activities.size).toBe(0) // Current implementation doesn't return cache on disk error
// Restore fake timers
jest.useFakeTimers()
})
})
describe('flush', () => {
it('should flush cache to disk in batch', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid1 = '5511999999999@s.whatsapp.net'
const jid2 = '5511888888888@s.whatsapp.net'
tracker.recordActivity(jid1)
tracker.recordActivity(jid2)
await tracker.flush()
// Should call transaction once for batch write
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalledTimes(1)
// Verify batch update structure
const setCall = mockKeys.set.mock.calls[0]?.[0]
// @ts-ignore
expect(setCall['session-activity']).toBeDefined()
// @ts-ignore
expect(setCall['session-activity'][`session-activity:${jid1}`]).toBeDefined()
// @ts-ignore
expect(setCall['session-activity'][`session-activity:${jid2}`]).toBeDefined()
})
it('should clear cache after successful flush', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
await tracker.flush()
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(0) // Cache cleared
expect(stats.totalFlushes).toBe(1)
})
it('should not flush when cache is empty', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
await tracker.flush()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should not flush when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
await tracker.flush()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should keep cache on flush failure (retry on next flush)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Simulate flush error
mockKeys.transaction.mockRejectedValueOnce(new Error('Flush failed'))
await tracker.flush()
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(1) // Cache NOT cleared
expect(stats.totalFlushes).toBe(0) // Flush failed
})
it('should update statistics after successful flush', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
const beforeFlush = Date.now()
await tracker.flush()
const afterFlush = Date.now()
const stats = tracker.getStats()
expect(stats.totalFlushes).toBe(1)
expect(stats.lastFlushAt).toBeGreaterThanOrEqual(beforeFlush)
expect(stats.lastFlushAt).toBeLessThanOrEqual(afterFlush)
expect(stats.lastFlushDuration).toBeGreaterThanOrEqual(0)
})
})
describe('start/stop lifecycle', () => {
it('should start periodic flush', async () => {
// Use real timers for this test
jest.useRealTimers()
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record activity so there's something to flush
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.start()
// Wait for initial flush to complete
await new Promise(resolve => setTimeout(resolve, 50))
// Should attempt initial flush
expect(mockKeys.transaction).toHaveBeenCalled()
// Cleanup
await tracker.stop()
// Restore fake timers
jest.useFakeTimers()
})
it('should flush periodically after start', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Advance time to trigger flush
jest.advanceTimersByTime(60000)
await Promise.resolve() // Let flush promise resolve
expect(mockKeys.transaction).toHaveBeenCalled()
})
it('should not start when disabled', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should not start twice (guard against multiple start calls)', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Second start should be ignored
tracker.start()
// Should not trigger another initial flush
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should stop periodic flush and flush pending data', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Stop should flush pending data
await tracker.stop()
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalled()
})
it('should stop periodic flush without error when no pending data', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
await tracker.stop()
// No flush needed (no pending data)
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
})
describe('getStats', () => {
it('should return current statistics', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.recordActivity('5511888888888@s.whatsapp.net')
const stats = tracker.getStats()
expect(stats.enabled).toBe(true)
expect(stats.totalUpdates).toBe(2)
expect(stats.cacheSize).toBe(2)
expect(stats.totalFlushes).toBe(0) // No flush yet
expect(stats.lastFlushAt).toBe(0)
expect(stats.lastFlushDuration).toBe(0)
})
it('should show disabled status', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const stats = tracker.getStats()
expect(stats.enabled).toBe(false)
})
})
describe('Performance', () => {
it('should handle high-volume activity recording (1000 messages)', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const startTime = Date.now()
// Simulate 1000 messages
for (let i = 0; i < 1000; i++) {
tracker.recordActivity(`5511${i.toString().padStart(9, '0')}@s.whatsapp.net`)
}
const duration = Date.now() - startTime
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(1000)
expect(stats.cacheSize).toBe(1000)
// Should be very fast (<100ms for 1000 updates)
expect(duration).toBeLessThan(100)
})
it('should batch flush multiple activities in single transaction', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record 100 activities
for (let i = 0; i < 100; i++) {
tracker.recordActivity(`5511${i.toString().padStart(9, '0')}@s.whatsapp.net`)
}
await tracker.flush()
// Should use single transaction for all 100
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalledTimes(1)
const stats = tracker.getStats()
expect(stats.totalFlushes).toBe(1)
})
})
describe('Edge Cases', () => {
it('should handle JID with special characters', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const specialJids = [
'5511999999999:1@s.whatsapp.net', // Device ID
'123456789@lid', // LID
'5511999999999:99@hosted', // Hosted device
'123456789:5@hosted.lid' // Hosted LID
]
specialJids.forEach(jid => tracker.recordActivity(jid))
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(4)
})
it('should handle rapid updates to same JID', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
// Rapid updates (10 in quick succession)
for (let i = 0; i < 10; i++) {
tracker.recordActivity(jid)
}
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(10)
expect(stats.cacheSize).toBe(1) // Still one JID
})
it('should handle flush during active recording', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Flush clears the cache
await tracker.flush()
// Record new activity after flush
tracker.recordActivity('5511888888888@s.whatsapp.net')
// New activity should be in cache
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(1) // Only the new one
})
})
})

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