diff --git a/.github/workflows/fork-tests.yml b/.github/workflows/fork-tests.yml new file mode 100644 index 0000000000..f4be16c016 --- /dev/null +++ b/.github/workflows/fork-tests.yml @@ -0,0 +1,81 @@ +# The fork's tests. Every commit on a fork/ branch runs them, so a test: +# commit shows its red run and the fix: commit after it shows green (FORK.md). +name: fork tests + +on: + push: + branches: ['fork/**'] + pull_request: + schedule: + # Weekly, against the newest Baileys release, so a new version is a + # diff to read before anyone bumps the pin. + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: typecheck and tests (pinned Baileys) + runs-on: ubuntu-latest + # A few tests need what only a real database does (foreign keys, cascades): + # they create and drop their own database on this server (test/helpers/real-postgres.ts). + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10 + env: + FORK_TEST_PG_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run db:generate + env: + DATABASE_PROVIDER: postgresql + - run: npx tsc --noEmit + - name: tests + run: | + expected=$(node -p "require('./package.json').dependencies.baileys") + BAILEYS_EXPECT="$expected" npx vitest run + + baileys-latest: + name: tests against the newest Baileys + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + # A few tests need what only a real database does (foreign keys, cascades): + # they create and drop their own database on this server (test/helpers/real-postgres.ts). + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10 + env: + FORK_TEST_PG_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm install --no-save baileys@latest + - run: npm run db:generate + env: + DATABASE_PROVIDER: postgresql + - name: tests + run: | + installed=$(node -p "require('baileys/package.json').version") + echo "Testing baileys $installed" + BAILEYS_EXPECT="$installed" npx vitest run diff --git a/.gitignore b/.gitignore index 768d8afa41..1f86abc52a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,13 +34,16 @@ lerna-debug.log* # Project related /instances/* !/instances/.gitkeep -/test/ + /src/env.yml /store *.env /temp/* +# Raw live-check recordings (docs/LIVE-CHECKS.md): never committed +/live-records/ + .DS_Store *.DS_Store .tool-versions diff --git a/AGENTS.md b/AGENTS.md index 143e6c748e..0750c9147f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,67 @@ +# Working in this fork (read first) + +This is an unofficial fork of Evolution API 2.3.7 (`FORK.md`). +These rules are this fork's own standard. They are not Evolution's, and they +do not go into pull requests offered to evolution-foundation/evolution-api. + +## Test first, always: red, then green + +1. Write the test that reproduces the bug or specifies the behaviour, and run + it against the code as it is. It must FAIL, for the reason the bug gives. + A test that passes before the fix proves nothing: rewrite it. +2. Commit the test alone: `test: `. CI runs on that commit, + and its red run is the evidence. +3. Make the smallest change that turns it green, with the rest of the suite + still green, and commit it: `fix: ...` or `feat: ...`. Push the two commits + one at a time, so CI records each. + +Never change a test to make it pass, and never skip or delete a failing test +without saying why in the commit. + +## How the tests run + +- `npm test` runs vitest over `test/**/*.test.ts`. The tests run Evolution's + TypeScript source, not the bundle, against the Baileys that `package.json` + pins (`BAILEYS_DIR` points them at another build). +- A fixture is a WhatsApp-side input (a `HistorySync` proto, an app-state + action, an encrypted patch), turned into events by the Baileys version under + test. Hand-written events are allowed only where Baileys has no builder. +- `test/helpers/baileys-service.ts` builds the real `BaileysStartupService` + with an in-memory Prisma and the real Baileys event buffer; `deliver()` + sends a batch the way the socket does (buffered). +- Every test runs under a configuration profile (`test/helpers/profiles.ts`). + The default is `minimal`, a production configuration that stores only the instance; a test + of storage uses `stored`. A behaviour that depends on a flag is tested + under both. +- Assert what a consumer observes: the webhook payload, the stored row, the + socket call, the HTTP answer. Assert exact fields, not substrings. +- No test touches WhatsApp, a real account or the network beyond localhost. + +## Recordings and fixtures (live checks) + +A live check records a real session (`LIVE_RECORD_DIR`, `docs/LIVE-CHECKS.md`). +Raw recordings hold real people's numbers, names and messages. Any agent or +person working here follows these rules, with no exception: + +- Never commit, stage or copy anything from `LIVE_RECORD_DIR` (or + `live-records/`). Only the scrubber's output (`scripts/live-scrub.ts`) goes + into `test/fixtures/live/`. +- Before committing a fixture, run `npx tsx scripts/live-guard.ts` and read + `scrub-report.json` (`"leakGate": "pass"`, counts that fit the check). +- Open and skim every new fixture file yourself. The guard cannot recognise a + name or a message text; you can. +- If anything looks like a phone number, a user part of an `@lid` or + `@s.whatsapp.net` address that is not a scrubber fake, a name, a message + text, a signed media URL (`mmg.whatsapp.net`, `oh=` / `oe=` parameters), an + email, a token or a key: stop. Fix the scrubber and scrub again. Never edit + a fixture by hand. +- Never paste a raw recording, or any part of one, into a commit, an issue, a + pull request or a chat. +- A fixture's replay test must fail when the behaviour it covers is broken: + run it once against the code before the fix and say so in the commit. + +--- + # Evolution API - AI Agent Guidelines This document provides comprehensive guidelines for AI agents (Claude, GPT, Cursor, etc.) working with the Evolution API codebase. diff --git a/CLAUDE.md b/CLAUDE.md index 7c0e87a040..9e073c2181 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,5 @@ +**This is a fork: read the first section of `AGENTS.md` (test first, red then green) before changing anything.** + # CLAUDE.md This file provides comprehensive guidance to Claude AI when working with the Evolution API codebase. diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000000..b6dc0c5830 --- /dev/null +++ b/FORK.md @@ -0,0 +1,84 @@ +# This fork + +An unofficial fork of [Evolution API](https://github.com/evolution-foundation/evolution-api) +2.3.7. It is not endorsed by Evolution Foundation, and +the image it builds is not an official Evolution API build: it carries its own +name of its own, not Evolution's. + +## Why it exists + +Evolution API has no test suite. Every bug below shipped because nothing could +catch it, and a consumer that needed a fix could only patch the minified bundle +with string replacements. This fork shows the other way: the same code, a test +harness that runs Evolution's own TypeScript source against the real Baileys, +and every change made test first. + +## The rule: red, then green + +Every change in this fork is two commits, in this order: + +1. `test: ...`, a test that reproduces the bug and **fails** on the code as it + is. CI runs on that commit, so its red run is the proof that the test sees + the bug. +2. `fix: ...` (or `feat:`), the smallest change that makes that test pass, + with the rest of the suite still green. + +A fix without a failing test first is not merged here. See `AGENTS.md`. + +## Base + +- Source: the `2.3.7` tag (`cd800f29`). Upstream `main` has the same code. + Upstream `develop` (2.4.0) is not used: it requires licence activation + against Evolution Foundation's server and breaks `POST /instance/create`. +- Baileys pinned to `7.0.0-rc14` (2.3.7 ships rc.9, which is inside the range + of CVE-2026-48063). + +## Changes from 2.3.7 + +Each line is a pair of commits: the test that failed on the code before the +fix, then the fix. For most, that code is 2.3.7 itself; several need what the +fork added first (the test harness, the Baileys 7.0.0-rc14 pin), and a red commit +that only fails to import what its fix adds proves nothing about behaviour. +`git log 2.3.7..` is the source of truth, and `git diff 2.3.7 --stat` lists +every file modified from the original. Upstream issues and pull requests are +named where one exists. + +**Contacts, names and groups** +- App-state sync keys are reloaded with `fromObject` in all three auth stores, so saved names, labels, mutes and archives keep syncing after a restart (#2576, #2384; fixes also offered in #2685 and #2610). +- Every @lid to phone mapping Baileys learns is forwarded on `contacts.upsert`, captured before Baileys' event buffer drops it. +- Every `contacts.upsert` item for a contact says whether its name is the one the owner saved (`saved`), and only when that is certain. The @lid mapping items above carry `pushName: null` and no `saved`: a consumer that replaces a whole contact with an item, rather than merging its fields, loses the name. +- Group updates reach subscriptions stored as `GROUP_UPDATE`, in all seven transports and in the global configurations (#2652). +- Group metadata is filled from `groups.update` instead of queried again for every group on every listing. +- `chats.update`, `chats.set` and `chats.upsert` items carry the chat's archive, pin and mute state (`archived`, `pinned`, `muteEndTime`) when Baileys has it, and omit a field it does not have rather than guess. + +**Messages and privacy** +- A `getMessage` miss or failed lookup answers nothing, so Baileys no longer relays an empty message on a retry and uses it up (#2705, and #2706 for groups; fixes also offered in #2728 and #2623). +- The fork's own log lines at `LOG_LEVEL=ERROR,WARN`, and Baileys' error logs, carry bounded fields, not message text, phone numbers, JIDs or push names; an exception's message is kept only scrubbed of URLs, JIDs, quoted parts and phone numbers (also as a person writes them). This is not a guarantee for every line: inherited code still logs some raw errors (integrations such as S3, Chatwoot and the chatbots are not covered by the tests), and a name inside an exception's unquoted words is not recognisable. +- The `messages.upsert` webhook key of a DM WhatsApp addresses by @lid shows the phone JID as `remoteJid`, as 2.3.7 does, but keeps the original @lid in `remoteJidAlt` (2.3.7 copied the phone there too and lost it), with `addressingMode: 'pn'`: upstream develop's swap. +- A media re-upload request names the message by the address the phone stores it under: the @lid from `remoteJidAlt` or `participantAlt` when the key shows the phone, or, for a key stored from 2.3.7 (the phone twice, `addressingMode: 'lid'`), the @lid Baileys' LID mapping has for the phone. The phone refuses a request that names an @lid chat by its phone JID. A key with the phone and its @lid beside it cannot say which of the two the phone keeps (a DM WhatsApp addresses by phone looks the same), so a refusal under the @lid is asked again once under the key as given. A request the phone does not answer in time ends Baileys' own wait for it, so no listener is left and a late answer changes nothing. +- A media download records whether it asked the phone to re-upload an expired file and how that ended (a bounded log line, and `reupload` on the download's error), and when the phone refused, why (`reason=` on the log line, `reuploadReason` on the error and the HTTP answer: the phone's result such as `NOT_FOUND`, `error_`, `missing_ciphertext`, or `no_answer`). Baileys 7.0.0-rc14 never asks on a CDN 404 or 410 (its check misses the status), so Evolution asks the phone itself, once per download, for at most 60s. A CDN 403 counts as expired only when the link that actually failed (the one on Baileys' error, which is the directPath when the message has one, else the url) carries exactly one `oe` query parameter, in hex unix seconds, that has passed by the local clock. This is a conservative heuristic: on 84 history-sync attachments, 403 answered 34 of 34 expired links and 0 of 50 valid ones, where a valid link to a dropped file answered 404 or 410. +- A media download turns the media key back into bytes (from a base64 string, or the object JSON makes of a Uint8Array or a Buffer) before it asks the phone to re-upload, since Baileys 7.0.0-rc14 derives the re-upload's key from the value as given and then cannot decrypt the phone's answer (Baileys #2729 proposed the same fix there). +- `POST /chat/getBase64FromMediaMessage` takes an optional `reupload` (boolean, default true). With `false` the phone is never asked to re-upload, and a file gone from the CDN (404, 410, or 403 on an expired link) fails at once with a 400 that says so; the HTTP error carries `reupload` for any failed download. +- A failed media download never answers with the media link. The error's own text (Baileys' "Failed to fetch stream from" and the signed CDN link, whose `oh`, `oe` and `_nc_*` parameters let anyone holding it fetch the file, and whose directPath alone names it) used to be the message of the HTTP answer, of what the error handler posts to the errors webhook, and of the S3 upload's error log. The answer now says what failed, never where: `The media could not be downloaded (HTTP )`, or the kind of error and its network code when there is no status, with `reupload` and `reuploadReason` as before; a text Evolution threw itself (it names no link) stands. + +**Proxy** +- Media downloads, media uploads and the WhatsApp Web version fetch leave through the instance's proxy (uploads failed outright on a proxied instance). +- A connect waits for the instance's proxy and stored settings, so it never starts from the server's own address or with default settings. Reloading the proxy keeps the one in force until the new one is read; a proxyscrape list that cannot be fetched fails the connect (and it is retried), and a proxyscrape download with no socket exit to share fails, rather than either going out directly. Tested with a local HTTP and SOCKS5 proxy; other transports a deployment adds are not covered. + +**Sessions and connections** +- A database error never replaces a linked session's credentials with fresh ones, and a failed settings read no longer drops an event batch. +- A logout or delete while the socket is down keeps the session until WhatsApp is told, so the device leaves the phone's Linked devices; it answers `202 PENDING` meanwhile and forwards nothing (#2520, #2508 in part). The pending logout is recorded on the instance's row (`disconnectionObject.logoutPending`) as well as in a file next to the session keys, so a restart that lost the instances volume still finishes it; when it cannot be recorded, the logout or delete answers 500 instead of 202. A logout is finished only on WhatsApp's word: its answer to remove-companion-device, or its refusal of the device on the next connection; the socket's own close after sending the request (all Baileys' `logout()` waits for) is not one. Concurrent logouts and deletes share one run and answer with its outcome. A deleted instance keeps its database row until then (Session and Proxy cascade from it), out of the API, its name taken and its token cleared. +- Reconnects back off from 1s to 60s instead of spinning, the version fetch times out after 10s, and an instance never runs two sockets: a reload after a profile or privacy change joins a connect under way, and once an instance is removed nothing builds a socket for it, runs a batch it had queued, or forwards anything (#2134, #2184, #2430; ideas from #2732). A connect that fails at boot keeps the instance in the API and is retried on the same backoff. +- A creds write that fails is logged and tried again until it lands, and stored creds that cannot be parsed fail the connect instead of being replaced by fresh ones. +- Session files on disk are replaced whole, never written in place: each signal key file of the Prisma auth store (keys live under INSTANCE_DIR next to creds in the database) and the pending-logout marker go to a temp file in the same directory, are flushed, renamed over the target and the directory flushed. A process killed mid-write, a full disk or two writes of one key at once used to leave a torn or empty file, and a key file that does not parse reads as no key; tested by SIGKILLing a writer at random moments (half the files torn or empty before, none after). A failed write keeps the previous file and rejects; writes of one file run in call order; a temp file a killed writer left is removed when the store next opens. The provider-files store writes on its own server and Redis holds no files, so neither is covered. +- Profile pictures are looked up once per contact per hour, at most four at a time, and history never waits on them (#1883). The picture update a history batch sends carries each contact's newest name, and a lookup that finishes after WhatsApp said the picture changed or was removed is dropped. +- One pairing code per connect attempt, and a fresh QR budget per attempt (#2100, #2696). + +**Live checks** +- A live check against a real phone can be recorded (`LIVE_RECORD_DIR`; the QR, its image and pairing codes are never written), scrubbed into a fixture, and replayed through the real event buffer and service in a test. The scrubber keeps a value as written only under a field it lists with a value that field is known to take, and stops on a field it does not know; its leak gate searches the output for every raw value that is not structure; a guard scans every committed fixture. None of them recognises a name the scrubber mistook for structure, so a person still reads every new fixture. A replay compares the webhooks' content, not their order or the pictures, and runs on events already decoded: it shows what Evolution does with what WhatsApp sent, not the encryption or the timing around it. The protocol, the check catalogue and the results log are in `docs/LIVE-CHECKS.md`. + +## Licence + +Evolution API is licensed under the Apache License 2.0 with additional +conditions (`LICENSE`), which this fork keeps unchanged. `NOTICE` carries +Evolution Foundation's attribution and states the modifications. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000000..0f3727630b --- /dev/null +++ b/NOTICE @@ -0,0 +1,27 @@ +Evolution API +Copyright 2026 Evolution Foundation + +This product includes software developed by Evolution Foundation +(https://evolutionfoundation.com.br). + +Trademark notice +"Evolution Foundation", "Evolution" and "Evolution API" are trademarks of +Evolution Foundation. The Evolution API logo, wordmark, and visual identity +are governed by Evolution Foundation's Trademark and Brand Assets Policy +(https://github.com/evolution-foundation/evolution-api/blob/main/TRADEMARKS.md). +This fork does not change the Evolution API user interface or its brand assets. + +Third-party attributions + +This product includes software derived from CodeChat WhatsApp API +(https://github.com/code-chat-br/whatsapp-api), originally licensed under MIT. +The CodeChat project implemented the Baileys library +(https://github.com/WhiskeySockets/Baileys), which Evolution API also uses for +its WhatsApp Web integration. + +Modifications + +This is an unofficial fork of Evolution API 2.3.7, maintained by Almog Cohen +(https://github.com/AlmogCohen/evolution-api). It is not endorsed +by Evolution Foundation. The changes are described in FORK.md, and each one is +a separate commit with the test that proves it. diff --git a/README.md b/README.md index eb7e638c16..7d02d4534c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> **Unofficial fork** of Evolution API 2.3.7, made test first. Not endorsed by Evolution Foundation. See [FORK.md](FORK.md) for what changed and why. +

Evolution Api

diff --git a/docs/LIVE-CHECKS.md b/docs/LIVE-CHECKS.md new file mode 100644 index 0000000000..4cb37ffc63 --- /dev/null +++ b/docs/LIVE-CHECKS.md @@ -0,0 +1,226 @@ +# Live checks + +The unit and harness tests run Evolution's source against the real Baileys, but +the input is ours. A live check runs the fork against a real phone and records +what WhatsApp actually sent, so the behaviour can be replayed in a test from then +on. This file is the protocol, the catalogue of checks, and the log of results. + +## The chain + +1. **Record.** The fork records a session when `LIVE_RECORD_DIR` is set + (`src/utils/live-record/recorder.ts`), and does nothing otherwise. Per + instance and session start it writes: + - `events.ndjson`: every Baileys event (name, payload, a sequence number, + the socket it came from, whether the event buffer held it) and every batch + the buffer delivered. This is the input tape. + - `webhooks.ndjson`: every payload Evolution sent (`sendDataWebhook`). This is + the golden output tape. + - `manifest.json`: versions and conditions (below). No number, JID, name or + content. + - `owner.json`: the linked account, for the scrubber only. It is never copied + into a fixture. + + Values are written in a tagged codec (`src/utils/live-record/codec.ts`) so a + replay rebuilds identical ones: `$bytes` (Buffer or Uint8Array), `$long`, + `$u` (undefined), `$proto` (the protobuf class), `$err`, `$date`. The auth + creds are redacted when recorded, and so is whatever links a device, on both + tapes: the QR payload (`$qr`) in `connection.update`, and the QR payload, its + image and the pairing code (`$qr`, `$pairingCode`) in `qrcode.updated`. +2. **Scrub.** `scripts/live-scrub.ts` turns a raw session into + `test/fixtures/live/-/`, then runs a leak gate that + takes every value of the raw tapes that is not structure and searches the + output for it, and writes nothing if one survives. +3. **Replay.** `test/helpers/live-replay.ts` feeds the fixture's events through + Baileys' real event buffer (buffered where they were) into the real + `BaileysStartupService`, and `compareGolden` compares what Evolution sends + with `webhooks.ndjson`. + +### What the manifest records + +| Field | Source | +| --- | --- | +| `forkCommit` | `/evolution/FORK_SHA` (the working directory's `FORK_SHA` file), else `git describe`, else the `FORK_SHA` variable | +| `baileysVersion` | the installed `baileys/package.json` | +| `nodeVersion` | the running Node | +| `waWebVersion` | the version the socket was built with | +| `phonePlatform` | `creds.platform`, which WhatsApp reports at pairing (`smba`, `smbi`, `android`, `iphone`...). Known after the connection opens; kept in the creds, so also known after a restart | +| `accountType` | derived from the platform: `smb*` is WhatsApp Business. Null when the platform is unknown | +| `linkMethod` | `qr`, `code` (a pairing code was asked for) or `existing-session` (the creds were already paired) | +| `proxy` | used or not, and the protocol. Never the host or the credentials | +| `sockets`, `startedAt`, `openedAt`, `endedAt` | the session's own bookkeeping | + +The operator adds by hand, when scrubbing: **phone model, OS version, WhatsApp +app version, country code** (the code only, never a number). Nothing on the +socket reveals them. Record them every time: a result without them cannot be +compared with the next one. + +## Protocol + +**Who and what phone.** A maintainer, on a test account on a phone kept for it, +linked to nothing else that matters. Use a real account only when the check needs +a real history, and then only one whose contacts know it is used for testing. +Write down the phone model, the OS version and the WhatsApp app version before +you start. + +**Run the rig with recording on.** Build the fork image and keep the raw +recordings outside the repository (or in `live-records/`, which is gitignored): + +```bash +docker build -t evolution-fork:$(git rev-parse --short=8 HEAD) . +mkdir -p ~/live-records && chmod 700 ~/live-records +docker run --rm -p 127.0.0.1:8080:8080 --env-file .env \ + -e FORK_SHA=$(git rev-parse --short=8 HEAD) \ + -e LIVE_RECORD_DIR=/evolution/live-records \ + -v ~/live-records:/evolution/live-records \ + evolution-fork:$(git rev-parse --short=8 HEAD) +``` + +Or from the source, which reads the commit from git: + +```bash +LIVE_RECORD_DIR=~/live-records npm run dev:server +``` + +Each process start is a new session directory, +`~/live-records///`. Run one check per session where you can: +restart the container between checks. + +**Scrub.** + +```bash +npx tsx scripts/live-scrub.ts ~/live-records// \ + --phone-model "Pixel 8" --os-version "Android 15" --wa-version 2.25.27.78 --country-code 972 +npx tsx scripts/live-guard.ts +``` + +The scrubber prints the fixture directory and a report of counts. It exits 1 +and writes nothing when it meets a string under a field it does not know (the +error names the field's path, never the value), or when its leak gate finds an +original in the output: say what the field is in `test/tools/live-scrub.ts` or +`test/tools/live-fields.ts`, never in the fixture, and scrub again. Delete the +raw session once the fixture is committed. + +The leak gate does not ask the scrubber what it replaced. It reads the raw tapes +itself and searches every value and key of the output for each string of 4+ +characters that is not structure, the user part of every address, every run of +7+ digits that is not an epoch, every decimal but the tape's clock, and every +byte string. + +What the scrubber does: one person keeps one fake index across their phone JID, +@lid and device suffix (the owner is index 0, `972500000000`); names keep +equality (a saved name and a profile name stay different, the same name stays +the same); message ids keep their first two characters and length (Evolution +reads the device from them); bytes keep their length and type, with random +content, so a replay test must never depend on real crypto; text (a stub +parameter included) becomes lorem of the same length; a username becomes a fake +name; URLs become `https://example.invalid/`; a location, and any decimal but +the tape's clock, becomes `0.`. A string is kept as written only under a +field `test/tools/live-fields.ts` lists, and only with a value that field is +known to take (event names, Baileys' and Evolution's enums), never because it +looks like an identifier. + +**Add the fixture and its replay test.** Put the test next to the behaviour it +covers, and assert the exact thing the check is about, then the whole output: + +```ts +import { compareGolden, loadFixture, replayFixture } from '../helpers/live-replay'; + +const FIXTURE = 'test/fixtures/live/2026-10-01-archive-toggle'; + +it('archiving on the phone reaches chats.update with archived: true', async () => { + const { webhooks } = await replayFixture(FIXTURE); + const updates = webhooks.filter((w) => w.event === 'chats.update').flatMap((w) => w.data); + expect(updates).toContainEqual(expect.objectContaining({ remoteJid: '972500000001@s.whatsapp.net', archived: true })); + expect(compareGolden(webhooks, loadFixture(FIXTURE).webhooks, { events: ['chats.update'] })).toEqual([]); +}); +``` + +Socket queries (profile pictures, group metadata, LID lookups) were not +recorded: they answer as the harness does, and `replayFixture(dir, { client })` +takes answers a test needs. Fields Evolution fills from the clock, the database +or those queries are left out of the comparison (`VOLATILE` in +`live-replay.ts`). + +**The rule: a fixture test must fail when the behaviour is broken.** A fixture +recorded on fixed code carries the fix in its golden output. Prove the test sees +it once: run it against the code before the fix (check out the parent of the +fix, or revert it locally) and see it fail for the reason the check is about. +Say so in the commit. A fixture test that passes either way proves nothing. + +## Before you commit a recording (checklist) + +Nothing personal may reach the repository. Before any commit or push that +touches `test/fixtures/live/`: + +- [ ] Nothing from `LIVE_RECORD_DIR` (or `live-records/`) is staged. Only the + scrubber's output is committed; `owner.json` never is. +- [ ] `npx tsx scripts/live-guard.ts` says clean, and `scrub-report.json` says + `"leakGate": "pass"` with counts that make sense for the check. +- [ ] You opened every new fixture file and skimmed it yourself. +- [ ] Nothing in it looks like a phone number, the user part of an + `@lid` / `@s.whatsapp.net` address that is not a fake (`972500......`, + `100000000......`, `120363............`), a name, a message text, a signed + media URL (`mmg.whatsapp.net`, `oh=` / `oe=` parameters), an email, a + token or a key. +- [ ] If anything does: stop, fix the scrubber, and scrub again. Never edit a + fixture by hand. +- [ ] No raw recording is pasted into a commit message, an issue, a pull + request or a chat. + +`test/live/fixture-guard.test.ts` runs the same guard over +`test/fixtures/live/` in CI on every commit, and lint-staged runs it on staged +fixture files. In a tape it accepts only the scrubber's fakes and the values +`live-fields.ts` lists by field, and flags any other string and any decimal +number. It still cannot tell a name the scrubber mistook for structure from +structure, which is why the skim is not optional. + +## Check catalogue + +Each check says what it proves and the steps. "Fixture" means the session is +worth scrubbing into a replay test. + +| Check id | Proves | Steps | Fixture | +| --- | --- | --- | --- | +| `stock-to-fork-switch` | a session linked on the stock image keeps working on the fork | link on the stock 2.3.7 image; stop it; start the fork on the same volume and database; send and receive one message | no | +| `app-state-after-restart` | saved names, labels, mutes and archives keep syncing after a restart | link; wait for history; restart the container; on the phone rename a contact and archive a chat; watch `contacts.upsert` and `chats.update` | yes | +| `saved-vs-profile-names` | `saved` is true only for the name the owner saved | save contact A under a name that differs from A's profile name; leave B unsaved; both send a message | yes | +| `archive-toggle` | archive and unarchive on the phone reach `chats.update` with `archived` | archive a chat on the phone, wait, unarchive it | yes | +| `group-rename-participants` | group updates and participant changes reach their webhooks | create a group with two test numbers; rename it; add, promote, demote and remove a participant | yes | +| `live-lid-message-key` | a DM WhatsApp addresses by @lid shows the phone as `remoteJid` and keeps the @lid in `remoteJidAlt` | from a number that is not a saved contact, send a message to the account | yes | +| `history-lid-keys` | history arrives with the original @lid keys | link a fresh device on an account with @lid chats; wait for history to finish | yes (history is large: scrub a short account) | +| `pairing-code-over-45s` | one pairing code per connect attempt, still valid after the 45s QR window | connect with a number; wait more than 45s before typing the code on the phone | no | +| `logout-reaches-phone` | a logout takes the device off the phone's Linked devices | log out through the API; check Linked devices on the phone | no | +| `logout-while-offline` | a logout with the socket down is delivered when it reconnects | cut the container's network; log out through the API (expect `202 PENDING`); restore the network; check Linked devices | no | +| `reconnect-backoff-one-socket` | reconnects back off 1s to 60s, never give up, and one socket at a time | cut the container's network for 3 minutes; read the reconnect log lines; restore; count sockets in the manifest and the log | yes (the close and reopen) | +| `proxy-per-number` | each instance leaves through its own proxy (socket, version fetch, media) | two instances behind two proxies; check each proxy's log for its instance's traffic and none of the other's | no | +| `clean-logs` | no message text, number, JID or push name in logs at `LOG_LEVEL=ERROR,WARN` | run a session with restarts, reconnects and group listings; count digit runs of 8+, addresses and media URLs in the log | no | +| `bounded-queries` | profile pictures and group metadata are queried within their bounds | on an account in several groups, link and list groups; count the queries per contact and group | yes | +| `media-reupload-expired` | a download asks the phone to re-upload an expired file, and a 403 counts as expired only when the link has expired | download media 30 to 180 days old through `getBase64FromMediaMessage`; compare SHA-256 with the phone's copy | no | + +## Results log + +Outcomes are counts. "Not recorded" means the operator did not write it down; +it is not a guess. + +| Date | Check id | Fork commit | Baileys | WA Web | Phone (platform / model / app) | Outcome | +| --- | --- | --- | --- | --- | --- | --- | +| 2026-09-27 | `stock-to-fork-switch` | not recorded | 7.0.0-rc14 | not recorded | not recorded / not recorded / not recorded | the session survived the switch from the stock image to the fork | +| 2026-09-27 | `logout-reaches-phone` | not recorded | 7.0.0-rc14 | not recorded | not recorded / not recorded / not recorded | 2 of 2 logouts reached WhatsApp; the device left Linked devices both times | +| 2026-09-27 | `history-lid-keys` | not recorded | 7.0.0-rc14 | not recorded | not recorded / not recorded / not recorded | history arrived with the original @lid keys | +| 2026-09-27 | `media-reupload-expired` (403 rule) | bb269777 | 7.0.0-rc14 | not recorded | not recorded / not recorded / not recorded | 84-file sample: a 403 answered 34 of 34 expired links and 0 of 50 valid ones | +| 2026-09-27 | `media-reupload-expired` | 3fc63a62 | 7.0.0-rc14 | not recorded | not recorded / not recorded / not recorded | 7 of 8 expired files recovered (30 to 180 days old), SHA-256 verified; 1 refused `NOT_FOUND` | +| 2026-09-27 | `clean-logs` | 3fc63a62 | 7.0.0-rc14 | not recorded | business, platform not recorded / not recorded / not recorded; linked earlier by QR; no proxy | at `LOG_LEVEL=ERROR,WARN`, `LOG_BAILEYS=error`: 48 lines across a restart, 2 sessions reconnecting and 2 group listings held 0 digit runs of 8+, 0 WhatsApp addresses, 0 media URLs. 1 finding: an earlier ERROR-level media download failure printed the signed media URL (fixed separately). At full verbosity (INFO to WEBHOOKS, Baileys debug) addresses appear by design | +| 2026-09-27 | `reconnect-backoff-one-socket` | 3fc63a62 | 7.0.0-rc14 | not recorded | business, platform not recorded / not recorded / not recorded; linked earlier by QR; no proxy | network cut for 180s: attempts 1, 2, 4, 8, 16, 32, 60s apart (status 408), capped at 60s, never gave up; reopened on the first attempt after the network returned. 1 finding: the failure line logged `[object Object]` (fixed separately). Sockets not counted | +| 2026-09-27 | `bounded-queries` | 3fc63a62 | 7.0.0-rc14 | not recorded | business, platform not recorded / not recorded / not recorded | not measurable: the account is in no groups. Moved to a later session | +| 2026-09-27 | `app-state-after-restart` | bc522750 | 7.0.0-rc14 | 2.3000.1048596303 | smbi (WhatsApp Business) / iPhone 16, iOS 18.6 / WhatsApp Business 25.24 | PASS: after a restart, a contact renamed on the phone reached `contacts.upsert` with `saved: true` and a mapping item (`lid`, `phoneNumber`), then `contacts.update`. Replayed from `2026-09-27-rig-session` in `test/live/app-state-rename.test.ts` | +| 2026-09-27 | `archive-toggle` | bc522750 | 7.0.0-rc14 | 2.3000.1048596303 | smbi (WhatsApp Business) / iPhone 16, iOS 18.6 / WhatsApp Business 25.24 | PASS: archive, then unarchive, reached `chats.update` with `archived: true` (with `pinned: null`), then `archived: false`. Replayed in `test/live/archive-toggle.test.ts` | +| 2026-09-27 | `group-rename-participants` | bc522750 | 7.0.0-rc14 | 2.3000.1048596303 | smbi (WhatsApp Business) / iPhone 16, iOS 18.6 / WhatsApp Business 25.24 | PASS: the rename reached `groups.update` with the new subject. PASS: a member removed and added back reached `group-participants.update` with `participantsData` holding the @lid as `jid` and the phone JID as `phoneNumber`. Promote and demote not run. Replayed in `test/live/group-rename-participants.test.ts` | +| 2026-09-27 | `live-lid-message-key` | bc522750 | 7.0.0-rc14 | 2.3000.1048596303 | smbi (WhatsApp Business) / iPhone 16, iOS 18.6 / WhatsApp Business 25.24 | PASS: a text and an image in a DM addressed by @lid reached `messages.upsert` with the phone JID as `remoteJid`, the @lid kept in `remoteJidAlt`, `addressingMode: 'pn'`. Replayed in `test/live/live-lid-message-key.test.ts` | +| 2026-09-27 | `clean-logs` | bc522750 | 7.0.0-rc14 | 2.3000.1048596303 | smbi (WhatsApp Business) / iPhone 16, iOS 18.6 / WhatsApp Business 25.24 | PASS: production-level logs over the session, 49 lines: 0 digit runs of 8+, 0 WhatsApp addresses, 0 media URLs | +| 2026-09-29 | `logout-reaches-phone` | 95c844b3 | 7.0.0-rc14 | not recorded | smbi (WhatsApp Business) / iPhone 16 / not recorded | PASS in part: a logout through the API with the socket open was not left pending, and WhatsApp closed the session with 401 (logged out) in the same second. Linked devices on the phone was not checked this time | +| 2026-09-29 | `pairing-code-over-45s` | 95c844b3 | 7.0.0-rc14 | not recorded | smbi (WhatsApp Business) / iPhone 16 / not recorded | PARTIAL, not the check itself: linked by pairing code 33s after the code was requested, the code typed within seconds, so the 45s window was not tested. One phone notification per code request, although `qrcode.updated` carried the code five times in an earlier attempt: consistent with one code per connect attempt. That earlier attempt failed on the phone ("Couldn't link device") and its window closed with 401 after 216s; cause not found (logs at WARN, no recorder). The phone's prompt named the device `Chrome (Mac OS)`, Baileys' default, since number mode sends no configured browser (a custom label there is rejected by WhatsApp: WhiskeySockets/Baileys#2560) | + +The phone platform and model were not recorded for the earlier runs: the +recorder did not exist yet, and the operator did not write them down. The rig +session (the rows at fork bc522750) ran on a different phone, recorded, and one +fixture (`test/fixtures/live/2026-09-27-rig-session`) covers its four checks. diff --git a/package-lock.json b/package-lock.json index c45e8fef38..506d676f9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "amqplib": "^0.10.5", "audio-decode": "^2.2.3", "axios": "^1.7.9", - "baileys": "7.0.0-rc.9", + "baileys": "7.0.0-rc14", "class-validator": "^0.14.1", "compression": "^1.7.5", "cors": "^2.8.5", @@ -97,7 +97,8 @@ "prettier": "^3.4.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.20.5", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^4.1.11" } }, "node_modules/@adiwajshing/keyed-db": { @@ -829,9 +830,9 @@ } }, "node_modules/@borewit/text-codec": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.0.tgz", - "integrity": "sha512-X999CKBxGwX8wW+4gFibsbiNdwqmdQEXmUejIWaIqdrHBgS5ARIOOeyiQbHjP9G58xVEPcuvP6VwwH3A0OFTOA==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", "license": "MIT", "funding": { "type": "github", @@ -2372,17 +2373,17 @@ } }, "node_modules/@jimp/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.0.tgz", - "integrity": "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz", + "integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==", "license": "MIT", "dependencies": { - "@jimp/file-ops": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/file-ops": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", - "file-type": "^16.0.0", + "file-type": "^21.3.3", "mime": "3" }, "engines": { @@ -2402,14 +2403,14 @@ } }, "node_modules/@jimp/diff": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.0.tgz", - "integrity": "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz", + "integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==", "license": "MIT", "dependencies": { - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "pixelmatch": "^5.3.0" }, "engines": { @@ -2417,23 +2418,23 @@ } }, "node_modules/@jimp/file-ops": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.0.tgz", - "integrity": "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz", + "integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@jimp/js-bmp": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.0.tgz", - "integrity": "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz", + "integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "bmp-ts": "^1.0.9" }, "engines": { @@ -2441,13 +2442,13 @@ } }, "node_modules/@jimp/js-gif": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.0.tgz", - "integrity": "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz", + "integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "gifwrap": "^0.10.1", "omggif": "^1.0.10" }, @@ -2456,13 +2457,13 @@ } }, "node_modules/@jimp/js-jpeg": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.0.tgz", - "integrity": "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz", + "integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "jpeg-js": "^0.4.4" }, "engines": { @@ -2470,13 +2471,13 @@ } }, "node_modules/@jimp/js-png": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.0.tgz", - "integrity": "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz", + "integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "pngjs": "^7.0.0" }, "engines": { @@ -2484,13 +2485,13 @@ } }, "node_modules/@jimp/js-tiff": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.0.tgz", - "integrity": "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz", + "integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "utif2": "^4.1.0" }, "engines": { @@ -2498,13 +2499,13 @@ } }, "node_modules/@jimp/plugin-blit": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.0.tgz", - "integrity": "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz", + "integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2512,25 +2513,25 @@ } }, "node_modules/@jimp/plugin-blur": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.0.tgz", - "integrity": "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz", + "integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/utils": "1.6.0" + "@jimp/core": "1.6.1", + "@jimp/utils": "1.6.1" }, "engines": { "node": ">=18" } }, "node_modules/@jimp/plugin-circle": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.0.tgz", - "integrity": "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz", + "integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2538,14 +2539,14 @@ } }, "node_modules/@jimp/plugin-color": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.0.tgz", - "integrity": "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz", + "integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "tinycolor2": "^1.6.0", "zod": "^3.23.8" }, @@ -2554,16 +2555,16 @@ } }, "node_modules/@jimp/plugin-contain": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.0.tgz", - "integrity": "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz", + "integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-blit": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2571,15 +2572,15 @@ } }, "node_modules/@jimp/plugin-cover": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.0.tgz", - "integrity": "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz", + "integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-crop": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2587,14 +2588,14 @@ } }, "node_modules/@jimp/plugin-crop": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.0.tgz", - "integrity": "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz", + "integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2602,13 +2603,13 @@ } }, "node_modules/@jimp/plugin-displace": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.0.tgz", - "integrity": "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz", + "integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2616,25 +2617,25 @@ } }, "node_modules/@jimp/plugin-dither": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.0.tgz", - "integrity": "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz", + "integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0" + "@jimp/types": "1.6.1" }, "engines": { "node": ">=18" } }, "node_modules/@jimp/plugin-fisheye": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.0.tgz", - "integrity": "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz", + "integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2642,12 +2643,12 @@ } }, "node_modules/@jimp/plugin-flip": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.0.tgz", - "integrity": "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz", + "integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2655,20 +2656,20 @@ } }, "node_modules/@jimp/plugin-hash": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.0.tgz", - "integrity": "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q==", - "license": "MIT", - "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/js-bmp": "1.6.0", - "@jimp/js-jpeg": "1.6.0", - "@jimp/js-png": "1.6.0", - "@jimp/js-tiff": "1.6.0", - "@jimp/plugin-color": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz", + "integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "any-base": "^1.1.0" }, "engines": { @@ -2676,12 +2677,12 @@ } }, "node_modules/@jimp/plugin-mask": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.0.tgz", - "integrity": "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz", + "integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2689,16 +2690,16 @@ } }, "node_modules/@jimp/plugin-print": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.0.tgz", - "integrity": "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz", + "integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/js-jpeg": "1.6.0", - "@jimp/js-png": "1.6.0", - "@jimp/plugin-blit": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/types": "1.6.1", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", @@ -2710,9 +2711,9 @@ } }, "node_modules/@jimp/plugin-quantize": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.0.tgz", - "integrity": "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz", + "integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==", "license": "MIT", "dependencies": { "image-q": "^4.0.0", @@ -2723,13 +2724,13 @@ } }, "node_modules/@jimp/plugin-resize": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz", - "integrity": "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz", + "integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2737,16 +2738,16 @@ } }, "node_modules/@jimp/plugin-rotate": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.0.tgz", - "integrity": "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz", + "integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-crop": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2754,16 +2755,16 @@ } }, "node_modules/@jimp/plugin-threshold": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.0.tgz", - "integrity": "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz", + "integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==", "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-color": "1.6.0", - "@jimp/plugin-hash": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -2771,9 +2772,9 @@ } }, "node_modules/@jimp/types": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.0.tgz", - "integrity": "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz", + "integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==", "license": "MIT", "dependencies": { "zod": "^3.23.8" @@ -2783,12 +2784,12 @@ } }, "node_modules/@jimp/utils": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.0.tgz", - "integrity": "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==", "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "tinycolor2": "^1.6.0" }, "engines": { @@ -3414,6 +3415,16 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -3578,25 +3589,24 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -3605,12 +3615,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -3624,9 +3628,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, "node_modules/@redis/bloom": { @@ -3688,6 +3692,286 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", @@ -4687,9 +4971,9 @@ "license": "MIT" }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, "node_modules/@thi.ng/bitstream": { @@ -4758,34 +5042,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/@tokenizer/inflate/node_modules/@borewit/text-codec": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", - "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@tokenizer/inflate/node_modules/token-types": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", - "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.1.0", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -4803,6 +5059,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/compression": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", @@ -4842,6 +5109,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4895,12 +5169,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" - }, "node_modules/@types/mime": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-4.0.0.tgz", @@ -5314,6 +5582,119 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wasm-audio-decoders/common": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/@wasm-audio-decoders/common/-/common-9.0.7.tgz", @@ -5698,6 +6079,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", @@ -5814,21 +6205,22 @@ } }, "node_modules/baileys": { - "version": "7.0.0-rc.9", - "resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc.9.tgz", - "integrity": "sha512-Txd2dZ9MHbojvsHckeuCnAKPO/bQjKxua/0tQSJwOKXffK5vpS82k4eA/Nb46K0cK0Bx+fyY0zhnQHYMBriQcw==", + "version": "7.0.0-rc14", + "resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc14.tgz", + "integrity": "sha512-pewtrljhWx5JTUBvvkXZz1fL3JPiwzjBsnhx/DWf2LWBx1cZNH7J/sF22xDehba5mySWUQU4a1Pwt7lWARUxaA==", "hasInstallScript": true, "license": "MIT", "dependencies": { "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", - "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", + "libsignal": "^6.0.0", "lru-cache": "^11.1.0", - "music-metadata": "^11.7.0", + "music-metadata": "^11.12.3", "p-queue": "^9.0.0", "pino": "^9.6", - "protobufjs": "^7.2.4", + "protobufjs": "^7.5.6", + "whatsapp-rust-bridge": "0.5.4", "ws": "^8.13.0" }, "engines": { @@ -5836,7 +6228,7 @@ }, "peerDependencies": { "audio-decode": "^2.1.3", - "jimp": "^1.6.0", + "jimp": "^1.6.1", "link-preview-js": "^3.0.0", "sharp": "*" }, @@ -5877,6 +6269,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -6221,6 +6614,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -6712,6 +7115,13 @@ "node": ">=16" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -7572,6 +7982,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -8296,6 +8713,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -8336,15 +8763,6 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/exif-parser": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", @@ -8363,6 +8781,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -8629,17 +9057,18 @@ } }, "node_modules/file-type": { - "version": "16.5.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", - "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", "license": "MIT", "dependencies": { - "readable-web-to-node-stream": "^3.0.0", - "strtok3": "^6.2.4", - "token-types": "^4.1.1" + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sindresorhus/file-type?sponsor=1" @@ -9090,7 +9519,7 @@ "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -10351,38 +10780,38 @@ "license": "ISC" }, "node_modules/jimp": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz", - "integrity": "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==", - "license": "MIT", - "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/diff": "1.6.0", - "@jimp/js-bmp": "1.6.0", - "@jimp/js-gif": "1.6.0", - "@jimp/js-jpeg": "1.6.0", - "@jimp/js-png": "1.6.0", - "@jimp/js-tiff": "1.6.0", - "@jimp/plugin-blit": "1.6.0", - "@jimp/plugin-blur": "1.6.0", - "@jimp/plugin-circle": "1.6.0", - "@jimp/plugin-color": "1.6.0", - "@jimp/plugin-contain": "1.6.0", - "@jimp/plugin-cover": "1.6.0", - "@jimp/plugin-crop": "1.6.0", - "@jimp/plugin-displace": "1.6.0", - "@jimp/plugin-dither": "1.6.0", - "@jimp/plugin-fisheye": "1.6.0", - "@jimp/plugin-flip": "1.6.0", - "@jimp/plugin-hash": "1.6.0", - "@jimp/plugin-mask": "1.6.0", - "@jimp/plugin-print": "1.6.0", - "@jimp/plugin-quantize": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/plugin-rotate": "1.6.0", - "@jimp/plugin-threshold": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0" + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz", + "integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/diff": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-gif": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-blur": "1.6.1", + "@jimp/plugin-circle": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-contain": "1.6.1", + "@jimp/plugin-cover": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-displace": "1.6.1", + "@jimp/plugin-dither": "1.6.1", + "@jimp/plugin-fisheye": "1.6.1", + "@jimp/plugin-flip": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/plugin-mask": "1.6.1", + "@jimp/plugin-print": "1.6.1", + "@jimp/plugin-quantize": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/plugin-rotate": "1.6.1", + "@jimp/plugin-threshold": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1" }, "engines": { "node": ">=18" @@ -10610,51 +11039,286 @@ "license": "MIT" }, "node_modules/libsignal": { - "name": "@whiskeysockets/libsignal-node", - "version": "2.0.1", - "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/libsignal/-/libsignal-6.0.0.tgz", + "integrity": "sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA==", "license": "GPL-3.0", "dependencies": { "curve25519-js": "^0.0.4", - "protobufjs": "6.8.8" + "protobufjs": "^7.5.5" } }, - "node_modules/libsignal/node_modules/@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", - "license": "MIT" + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/libsignal/node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "license": "Apache-2.0" + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/libsignal/node_modules/protobufjs": { - "version": "6.8.8", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz", - "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "@types/node": "^10.1.0", - "long": "^4.0.0" + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" }, - "bin": { - "pbjs": "bin/pbjs", - "pbts": "bin/pbts" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lilconfig": { @@ -11232,12 +11896,16 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz", + "integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mediainfo.js": { @@ -11656,9 +12324,9 @@ } }, "node_modules/music-metadata": { - "version": "11.10.3", - "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.10.3.tgz", - "integrity": "sha512-j0g/x4cNNZW6I5gdcPAY+GFkJY9WHTpkFDMBJKQLxJQyvSfQbXm57fTE3haGFFuOzCgtsTd4Plwc49Sn9RacDQ==", + "version": "11.16.1", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.16.1.tgz", + "integrity": "sha512-uR/mHK6eyfl8h3jVCobF5b38Mfg1IDJMiBxIVbmm5F+G1OXEFcUpQHveO84tJBAqvY93GCRY2R4kDyx7s06Rug==", "funding": [ { "type": "github", @@ -11671,80 +12339,32 @@ ], "license": "MIT", "dependencies": { - "@borewit/text-codec": "^0.2.0", + "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", - "content-type": "^1.0.5", + "content-type": "^2.1.0", "debug": "^4.4.3", - "file-type": "^21.1.1", - "media-typer": "^1.1.0", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.5.0" + "file-type": "^21.3.4", + "media-typer": "^2.0.0", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0", + "win-guid": "^0.2.1" }, "engines": { "node": ">=18" } }, - "node_modules/music-metadata/node_modules/file-type": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.1.1.tgz", - "integrity": "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg==", - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, - "node_modules/music-metadata/node_modules/strtok3": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", - "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "node_modules/music-metadata/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0" - }, "engines": { "node": ">=18" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/music-metadata/node_modules/token-types": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", - "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.1.0", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/music-metadata/node_modules/token-types/node_modules/@borewit/text-codec": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", - "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mute-stream": { @@ -11778,6 +12398,25 @@ "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" } }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/nats": { "version": "2.29.3", "resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz", @@ -12042,6 +12681,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ogg-opus-decoder": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/ogg-opus-decoder/-/ogg-opus-decoder-1.7.3.tgz", @@ -12576,19 +13229,6 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, - "node_modules/peek-readable": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", - "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -12812,6 +13452,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -12957,15 +13626,6 @@ } } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -12983,24 +13643,23 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -13411,62 +14070,6 @@ "node": ">= 6" } }, - "node_modules/readable-web-to-node-stream": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", - "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", - "license": "MIT", - "dependencies": { - "readable-stream": "^4.7.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/readable-web-to-node-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -13642,7 +14245,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true, + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -13697,6 +14300,40 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, "node_modules/rollup": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", @@ -14168,6 +14805,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -14176,9 +14820,9 @@ "license": "ISC" }, "node_modules/simple-xml-to-json": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.3.tgz", - "integrity": "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz", + "integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==", "license": "MIT", "engines": { "node": ">=20.12.2" @@ -14412,6 +15056,16 @@ "node": ">= 12" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split-on-first": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", @@ -14430,6 +15084,13 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -14439,6 +15100,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -14640,16 +15308,15 @@ "license": "MIT" }, "node_modules/strtok3": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", - "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", "license": "MIT", "dependencies": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^4.1.0" + "@tokenizer/token": "^0.3.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "type": "github", @@ -14818,6 +15485,13 @@ "readable-stream": "3" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -14834,13 +15508,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -14867,9 +15541,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -14878,6 +15552,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -14914,16 +15598,17 @@ } }, "node_modules/token-types": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", - "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", "license": "MIT", "dependencies": { + "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" }, "engines": { - "node": ">=10" + "node": ">=14.16" }, "funding": { "type": "github", @@ -15057,7 +15742,7 @@ "version": "4.20.6", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.25.0", @@ -15519,7 +16204,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -15705,7 +16390,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -15907,6 +16592,200 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -15944,6 +16823,12 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatsapp-rust-bridge": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.5.4.tgz", + "integrity": "sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A==", + "license": "MIT" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -16064,6 +16949,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", + "license": "MIT" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -16220,7 +17128,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 56e32fcc8a..d8cf259a1a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "tsx ./src/main.ts", "start:prod": "node dist/main", "dev:server": "tsx watch ./src/main.ts", - "test": "tsx watch ./test/all.test.ts", + "test": "vitest run", "lint": "eslint --fix --ext .ts src", "lint:check": "eslint --ext .ts src", "commit": "cz", @@ -20,7 +20,8 @@ "db:studio": "node runWithProvider.js \"npx prisma studio --schema ./prisma/DATABASE_PROVIDER-schema.prisma\"", "db:migrate:dev": "node runWithProvider.js \"rm -rf ./prisma/migrations && cp -r ./prisma/DATABASE_PROVIDER-migrations ./prisma/migrations && npx prisma migrate dev --schema ./prisma/DATABASE_PROVIDER-schema.prisma && cp -r ./prisma/migrations/* ./prisma/DATABASE_PROVIDER-migrations\"", "db:migrate:dev:win": "node runWithProvider.js \"xcopy /E /I prisma\\DATABASE_PROVIDER-migrations prisma\\migrations && npx prisma migrate dev --schema prisma\\DATABASE_PROVIDER-schema.prisma\"", - "prepare": "husky" + "prepare": "husky", + "test:watch": "vitest" }, "repository": { "type": "git", @@ -57,6 +58,9 @@ ], "src/**/*.ts": [ "sh -c 'tsc --noEmit'" + ], + "test/fixtures/live/**": [ + "tsx scripts/live-guard.ts" ] }, "config": { @@ -77,7 +81,7 @@ "amqplib": "^0.10.5", "audio-decode": "^2.2.3", "axios": "^1.7.9", - "baileys": "7.0.0-rc.9", + "baileys": "7.0.0-rc14", "class-validator": "^0.14.1", "compression": "^1.7.5", "cors": "^2.8.5", @@ -87,10 +91,10 @@ "eventemitter2": "^6.4.9", "express": "^4.21.2", "express-async-errors": "^3.1.1", + "fetch-socks": "^1.3.2", "fluent-ffmpeg": "^2.1.3", "form-data": "^4.0.1", "https-proxy-agent": "^7.0.6", - "fetch-socks": "^1.3.2", "i18next": "^23.7.19", "jimp": "^1.6.0", "json-schema": "^0.4.0", @@ -153,6 +157,7 @@ "prettier": "^3.4.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.20.5", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^4.1.11" } } diff --git a/scripts/live-guard.ts b/scripts/live-guard.ts new file mode 100644 index 0000000000..413fcd47b5 --- /dev/null +++ b/scripts/live-guard.ts @@ -0,0 +1,16 @@ +// Scan live-check fixtures for personal data (test/tools/fixture-guard.ts). +// +// npx tsx scripts/live-guard.ts [files or directories...] (default: test/fixtures/live) +// +// Prints the file, line, masked path and kind of each finding, never the value, +// and exits 1 when there is one. lint-staged runs it on staged fixture files. +import { formatFindings, scanFixtures } from '../test/tools/fixture-guard'; + +const paths = process.argv.slice(2); +const findings = scanFixtures(...(paths.length ? paths : ['test/fixtures/live'])); +if (findings.length) { + console.error(`live fixture guard: ${findings.length} finding(s). Fix the scrubber and re-scrub; never edit a fixture by hand.`); + console.error(formatFindings(findings)); + process.exit(1); +} +console.log('live fixture guard: clean'); diff --git a/scripts/live-scrub.ts b/scripts/live-scrub.ts new file mode 100644 index 0000000000..3fc5d7463a --- /dev/null +++ b/scripts/live-scrub.ts @@ -0,0 +1,42 @@ +// Scrub a raw live-check recording into a committable fixture (docs/LIVE-CHECKS.md). +// +// npx tsx scripts/live-scrub.ts [--date YYYY-MM-DD] +// [--phone-model "Pixel 8"] [--os-version "Android 15"] [--wa-version 2.25.27.78] +// [--country-code 972] [--out test/fixtures/live] +// +// Exits 1 and writes nothing when the leak gate finds an original in the output. +import { scrubSession } from '../test/tools/live-scrub'; + +const args = process.argv.slice(2); +const flags: Record = {}; +const positional: string[] = []; +for (let i = 0; i < args.length; i++) { + if (args[i].startsWith('--')) flags[args[i].slice(2)] = args[++i]; + else positional.push(args[i]); +} +const [rawDir, checkId] = positional; +if (!rawDir || !checkId) { + console.error( + 'usage: npx tsx scripts/live-scrub.ts [--date YYYY-MM-DD] [--phone-model ...] [--os-version ...] [--wa-version ...] [--country-code ...] [--out dir]', + ); + process.exit(2); +} + +try { + const { dir, report } = scrubSession(rawDir, { + checkId, + date: flags.date, + outRoot: flags.out, + operator: { + phoneModel: flags['phone-model'], + osVersion: flags['os-version'], + whatsappAppVersion: flags['wa-version'], + countryCode: flags['country-code'], + }, + }); + console.log(`fixture written: ${dir}`); + console.log(JSON.stringify(report, null, 2)); +} catch (error) { + console.error(error?.message ?? error); + process.exit(1); +} diff --git a/src/api/controllers/instance.controller.ts b/src/api/controllers/instance.controller.ts index 6a69106881..70252d2ce5 100644 --- a/src/api/controllers/instance.controller.ts +++ b/src/api/controllers/instance.controller.ts @@ -17,6 +17,18 @@ import { v4 } from 'uuid'; import { ProxyController } from './proxy.controller'; +// A logout that could not reach WhatsApp yet: answered 202, and connectionState carries logoutPending: true until it has. +const LOGOUT_PENDING = { + status: 'PENDING', + error: false, + response: { message: 'Logout pending: WhatsApp will be told when the connection returns' }, +}; +const DELETE_PENDING = { + status: 'PENDING', + error: false, + response: { message: 'Instance deleted; its logout will reach WhatsApp when the connection returns' }, +}; + export class InstanceController { constructor( private readonly waMonitor: WAMonitoringService, @@ -315,6 +327,11 @@ export class InstanceController { throw new BadRequestException('The "' + instanceName + '" instance does not exist'); } + // A pending logout owns the connection: answer with that instead of starting a new one. + if (instance.logoutPending) { + return await this.connectionState({ instanceName }); + } + if (state == 'open') { return await this.connectionState({ instanceName }); } @@ -391,10 +408,13 @@ export class InstanceController { } public async connectionState({ instanceName }: InstanceDto) { + // A deleted instance whose logout is still on its way to WhatsApp answers here (and only here) until it is. + const instance = this.waMonitor.waInstances[instanceName] ?? this.waMonitor.finishingLogouts?.[instanceName]; return { instance: { instanceName: instanceName, - state: this.waMonitor.waInstances[instanceName]?.connectionStatus?.state, + state: instance?.connectionStatus?.state, + ...(instance?.logoutPending ? { logoutPending: true } : {}), }, }; } @@ -435,13 +455,26 @@ export class InstanceController { public async logout({ instanceName }: InstanceDto) { const { instance } = await this.connectionState({ instanceName }); + const waInstance = this.waMonitor.waInstances[instanceName]; - if (instance.state === 'close') { + // Already pending: answered 202 only once it is recorded (logoutInstance tries again if it was not). + if (waInstance?.logoutPending) { + try { + await waInstance.logoutInstance(); + } catch (error) { + throw new InternalServerErrorException(error.toString()); + } + return LOGOUT_PENDING; + } + + // "close" is also an instance whose socket dropped and whose linked session is still stored + // (a reconnect waiting): that one is logged out, or its reconnect brings the session back. + if (instance.state === 'close' && !(await waInstance?.hasSessionToLogOut?.())) { throw new BadRequestException('The "' + instanceName + '" instance is not connected'); } try { - await this.waMonitor.waInstances[instanceName]?.logoutInstance(); + if ((await waInstance?.logoutInstance()) === 'pending') return LOGOUT_PENDING; return { status: 'SUCCESS', error: false, response: { message: 'Instance logged out' } }; } catch (error) { @@ -455,8 +488,34 @@ export class InstanceController { const waInstances = this.waMonitor.waInstances[instanceName]; if (this.configService.get('CHATWOOT').ENABLED) waInstances?.clearCacheChatwoot(); - if (instance.state === 'connecting' || instance.state === 'open') { - await this.logout({ instanceName }); + let pending = !!waInstances?.logoutPending; + if ( + !pending && + (instance.state === 'connecting' || instance.state === 'open' || (await waInstances?.hasSessionToLogOut?.())) + ) { + try { + pending = (await this.logout({ instanceName }))?.status === 'PENDING'; + } catch (error) { + // Pending but not recorded: deleting now would wipe the creds the logout needs. + if (waInstances?.logoutPending) throw error; + // A failed logout must not stop the delete. The remove.instance emit + // below is the only path that purges the in-memory entry and runs + // cleaningUp() and cleaningStoreData(), which wipe the session again. + // Without this catch, the stale entry persists until the entire + // process restarts. + this.logger.warn({ + message: 'logout failed during deleteInstance, proceeding with cleanup', + instanceName, + error, + }); + } + } + + // The logout has not reached WhatsApp: the instance leaves the API now, keeping only what the + // logout needs, and finishes it in the background (WAMonitoringService.deleteKeepingLogout). + if (pending) { + await this.waMonitor.deleteKeepingLogout(instanceName); + return DELETE_PENDING; } try { @@ -471,6 +530,11 @@ export class InstanceController { this.eventEmitter.emit('remove.instance', instanceName, 'inner'); return { status: 'SUCCESS', error: false, response: { message: 'Instance deleted' } }; } catch (error) { + if (error instanceof InternalServerErrorException) throw error; + // A pending logout that could not be recorded as deleted: the instance stays, and so does its logout. + if (this.waMonitor.waInstances[instanceName]?.logoutPending) { + throw new InternalServerErrorException(error.toString()); + } throw new BadRequestException(error.toString()); } } diff --git a/src/api/dto/chat.dto.ts b/src/api/dto/chat.dto.ts index b11f32b054..bc5558523c 100644 --- a/src/api/dto/chat.dto.ts +++ b/src/api/dto/chat.dto.ts @@ -20,6 +20,8 @@ export class OnWhatsAppDto { export class getBase64FromMediaMessageDto { message: proto.WebMessageInfo; convertToMp4?: boolean; + /** false: never ask the phone to re-upload a file that has expired on the CDN (default true). */ + reupload?: boolean; } export class WhatsAppNumberDto { diff --git a/src/api/guards/instance.guard.ts b/src/api/guards/instance.guard.ts index e692f3622e..bfa87ac33c 100644 --- a/src/api/guards/instance.guard.ts +++ b/src/api/guards/instance.guard.ts @@ -5,6 +5,8 @@ import { BadRequestException, ForbiddenException, InternalServerErrorException, import { NextFunction, Request, Response } from 'express'; async function getInstance(instanceName: string) { + // A deleted instance keeps its row while its logout is pending, but is gone from the API. + if (waMonitor.finishingLogouts?.[instanceName]) return false; try { const cacheConf = configService.get('CACHE'); @@ -32,7 +34,11 @@ export async function instanceExistsGuard(req: Request, _: Response, next: NextF throw new BadRequestException('"instanceName" not provided.'); } - if (!(await getInstance(param.instanceName))) { + // A deleted instance whose logout has not reached WhatsApp yet answers connectionState, nothing else. + const finishingLogout = + req.originalUrl.includes('/instance/connectionState/') && !!waMonitor.finishingLogouts?.[param.instanceName]; + + if (!finishingLogout && !(await getInstance(param.instanceName))) { throw new NotFoundException(`The "${param.instanceName}" instance does not exist`); } @@ -42,7 +48,8 @@ export async function instanceExistsGuard(req: Request, _: Response, next: NextF export async function instanceLoggedGuard(req: Request, _: Response, next: NextFunction) { if (req.originalUrl.includes('/instance/create')) { const instance = req.body as InstanceDto; - if (await getInstance(instance.instanceName)) { + // The name of a deleted instance stays taken until its logout has reached WhatsApp. + if ((await getInstance(instance.instanceName)) || waMonitor.finishingLogouts?.[instance.instanceName]) { throw new ForbiddenException(`This name "${instance.instanceName}" is already in use.`); } diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc1..54329dc753 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -75,15 +75,21 @@ import { QrCode, S3, } from '@config/env.config'; +import { INSTANCE_DIR } from '@config/path.config'; import { BadRequestException, InternalServerErrorException, NotFoundException } from '@exceptions'; import ffmpegPath from '@ffmpeg-installer/ffmpeg'; import { Boom } from '@hapi/boom'; import { createId as cuid } from '@paralleldrive/cuid2'; -import { Instance, Message } from '@prisma/client'; +import { Instance, Message, Prisma } from '@prisma/client'; +import { chatState } from '@utils/chat-state'; import { createJid } from '@utils/createJid'; import { fetchLatestWaWebVersion } from '@utils/fetchLatestWaWebVersion'; +import { LiveRecorder } from '@utils/live-record/recorder'; +import { errorFields, jidKind, makeBaileysLogger } from '@utils/log-privacy'; +import { readLogoutMarker, writeLogoutMarker } from '@utils/logout-marker'; import { makeProxyAgent, makeProxyAgentUndici } from '@utils/makeProxyAgent'; import { getOnWhatsappCache, saveOnWhatsappCache } from '@utils/onWhatsappCache'; +import { QueryLimiter } from '@utils/queryLimiter'; import { status } from '@utils/renderStatus'; import { sendTelemetry } from '@utils/sendTelemetry'; import useMultiFileAuthStatePrisma from '@utils/use-multi-file-auth-state-prisma'; @@ -110,9 +116,11 @@ import makeWASocket, { getContentType, getDevice, GroupMetadata, + GroupParticipant, isJidBroadcast, isJidGroup, isJidNewsletter, + isLidUser, isPnUser, jidNormalizedUser, makeCacheableSignalKeyStore, @@ -123,6 +131,7 @@ import makeWASocket, { prepareWAMessageMedia, Product, proto, + S_WHATSAPP_NET, UserFacingSocketConfig, WABrowserDescription, WAMediaUpload, @@ -139,13 +148,13 @@ import { createHash } from 'crypto'; import EventEmitter2 from 'eventemitter2'; import ffmpeg from 'fluent-ffmpeg'; import FormData from 'form-data'; +import { rmSync } from 'fs'; import Long from 'long'; import mimeTypes from 'mime-types'; import NodeCache from 'node-cache'; import cron from 'node-cron'; import { release } from 'os'; import { join } from 'path'; -import P from 'pino'; import qrcode, { QRCodeToDataURLOptions } from 'qrcode'; import qrcodeTerminal from 'qrcode-terminal'; import sharp from 'sharp'; @@ -224,6 +233,139 @@ async function getVideoDuration(input: Buffer | string | Readable): Promise { + if (typeof value === 'string') return Buffer.from(value.replace('data:;base64,', ''), 'base64'); + if (value instanceof Uint8Array) return Buffer.from(value); + if (value?.type === 'Buffer' && Array.isArray(value.data)) return Buffer.from(value.data); + return Buffer.from(Object.values(value) as number[]); +}; + +/** Whether a media download asked the phone to re-upload an expired file, and how that ended. */ +type MediaReupload = 'not_requested' | 'ok' | 'failed'; + +/** The HTTP status a Baileys media error carries (a Boom's output.statusCode), or 'none'. */ +const httpStatus = (error: any) => error?.output?.statusCode ?? error?.status ?? 'none'; + +/** What kind of error was thrown, never what it says: a thrown string is 'string'. */ +const errorName = (error: any): string => + typeof error === 'string' ? 'string' : typeof error?.name === 'string' ? error.name.slice(0, 40) : 'unknown'; + +/** + * Why the phone refused a re-upload, from the error Baileys' updateMediaMessage threw: + * the phone's MediaRetryNotification result (NOT_FOUND, DECRYPTION_ERROR, GENERAL_ERROR), + * error_ for an answer, missing_ciphertext for an answer with neither, + * no_answer when it did not answer in time, else unknown. Never content or a JID. + */ +const reuploadRefusal = (error: any): string => { + if (error?.name === 'ReuploadTimeoutError') return 'no_answer'; + const result = error?.data?.result; + if (typeof result === 'number') return proto.MediaRetryNotification.ResultType[result] ?? `result_${result}`; + const code = String(error?.data?.code ?? ''); + if (/^\d{1,6}$/.test(code)) return `error_${code}`; + if (error?.message === 'Failed to re-upload media (missing ciphertext)') return 'missing_ciphertext'; + return 'unknown'; +}; + +/** + * What a failed media download answers with, instead of the error's own text. Baileys' + * download error says "Failed to fetch stream from " and carries the link (Boom + * data.url); a WhatsApp media link is signed per message (oh, oe, the _nc_ parameters), so + * whoever holds it can fetch the file, and even its directPath alone names the file. So the + * answer says what failed and never where: the CDN's HTTP status, else the kind of error and + * its network code. A text Evolution threw itself (it names no link) stands, as does the + * message of an answer already built here (the MP4 conversion's 400). + */ +const mediaDownloadFailure = (error: any): string => { + if (typeof error === 'string') return error; + if (!(error instanceof Error) && Array.isArray(error?.message) && typeof error.message[0] === 'string') { + return error.message[0]; + } + const status = httpStatus(error); + if (typeof status === 'number') return `The media could not be downloaded (HTTP ${status})`; + const code = error?.cause?.code ?? error?.code; + const network = typeof code === 'string' && /^[A-Z][A-Z0-9_]{1,40}$/.test(code) ? `, ${code}` : ''; + return `The media could not be downloaded (${errorName(error)}${network})`; +}; + +/** + * When a WhatsApp media link stops working, in ms: its `oe` query parameter (hex unix + * seconds). Read with URLSearchParams, so the name is case-sensitive, the value is + * percent-decoded and a fragment never counts. Undefined unless the query carries exactly + * one `oe` and it is plain hex. + */ +const mediaLinkExpiry = (link: string): number | undefined => { + let values: string[]; + try { + // A directPath has no host; the base only makes it parseable. + values = new URL(link, 'https://mmg.whatsapp.net').searchParams.getAll('oe'); + } catch { + return undefined; + } + const [oe] = values; + if (values.length !== 1 || !/^[0-9a-f]+$/i.test(oe)) return undefined; + const expiry = parseInt(oe, 16) * 1000; + return Number.isSafeInteger(expiry) ? expiry : undefined; +}; + +/** + * A CDN answer that means the file has expired there, and only the phone still has it: + * 404 or 410, or 403 when the link that actually failed carries an `oe` that has passed + * by the local clock. That link is the url on Baileys' error (Boom data.url), else the + * one Baileys downloads: the directPath when there is one, else the url. + * + * The 403 rule is a conservative heuristic, not a documented contract. Measured on + * WhatsApp's media CDN (2026-09-27, 84 history-sync attachments): 403 on 34 of 34 links + * whose `oe` had passed and on 0 of 50 valid ones, where a valid link to a dropped file + * answered 404 or 410. A 403 without that evidence is not treated as an expiry, as a + * policy; a local clock that is off moves the line. + */ +const isExpiredMedia = (error: any, media: { url?: string | null; directPath?: string | null } | undefined) => { + const status = httpStatus(error); + if (status === 404 || status === 410) return true; + if (status !== 403) return false; + const requested = error?.data?.url; + const link = + typeof requested === 'string' || requested instanceof URL ? requested.toString() : media?.directPath || media?.url; + const expiry = link ? mediaLinkExpiry(link) : undefined; + return expiry !== undefined && expiry <= Date.now(); +}; + +/** + * How long the phone gets to answer a re-upload request. Baileys' updateMediaMessage + * waits for the answer with no timeout of its own, so this is its default query + * timeout (defaultQueryTimeoutMs). + */ +export const MEDIA_REUPLOAD_TIMEOUT_MS = 60_000; + +/** A socket build stopped because the instance was shut down meanwhile. */ +class ConnectAborted extends Error { + constructor() { + super('The instance was shut down while its connection was being built'); + this.name = 'ConnectAborted'; + } +} + +/** The pending logout recordPending wrote on an instance's row (disconnectionObject), if there is one. */ +const pendingOnRow = (value: unknown): { deleted: boolean } | undefined => { + let object: any = value; + if (typeof object === 'string') { + try { + object = JSON.parse(object); + } catch { + return undefined; + } + } + const pending = object?.logoutPending; + return pending && typeof pending === 'object' ? { deleted: !!pending.deleted } : undefined; +}; + export class BaileysStartupService extends ChannelStartupService { private messageProcessor = new BaileysMessageProcessor(); @@ -249,27 +391,398 @@ export class BaileysStartupService extends ChannelStartupService { private readonly msgRetryCounterCache: CacheStore = new NodeCache(); private readonly userDevicesCache: CacheStore = new NodeCache({ stdTTL: 300000, useClones: false }); private endSession = false; + // A reconnectable close is retried after 1s, 2s, 4s... doubling to one a minute, for as long as + // it takes: an instance that gave up would sit disconnected on valid credentials. An open resets it. + private static readonly RECONNECT_FIRST_DELAY_MS = 1_000; + private static readonly RECONNECT_MAX_DELAY_MS = 60_000; + // How long WhatsApp gets to answer a remove-companion-device before the next connection is asked + // instead; under logoutInstance's own 10s, so a logout that goes unanswered answers 202. + private static readonly REMOVE_ANSWER_TIMEOUT_MS = 8_000; + private reconnectAttempts = 0; + private reconnectTimer: NodeJS.Timeout | null = null; + // The connect under way, so a second one joins it instead of building a second socket. + private connecting: { number: string | null; socket: Promise } | null = null; + // Stops Evolution listening to the current socket; called before that socket is ended. + private detachClient?: () => void; + // A logout under way. Until it is done the instance forwards and stores nothing, and it connects + // only to tell WhatsApp. `marked`: it could not reach WhatsApp, so it is pending, recorded on the + // instance's row (`recorded`, what a 202 promises: it survives a restart and the loss of the + // instances volume) and in a marker file (utils/logout-marker.ts). `deleted`: the instance has + // left the API. + private logout: { + marked: boolean; + deleted: boolean; + recorded?: boolean; + settle: (outcome: 'done' | 'pending') => void; + } | null = null; private logBaileys = this.configService.get('LOG').BAILEYS; private eventProcessingQueue: Promise = Promise.resolve(); + // Records this session's events and webhooks for a live check; undefined unless LIVE_RECORD_DIR is set. + private liveRecorder?: LiveRecorder; + // The dispatcher media downloads go through: the instance's proxy, the same exit as the socket. + private mediaProxy?: { key: string; dispatcher: ReturnType }; // Cache TTL constants (in seconds) private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing private readonly UPDATE_CACHE_TTL_SECONDS = 30 * 60; // 30 minutes - avoid duplicate status updates + // Profile pictures seen by the event handlers: one IQ per jid per hour, a few + // in flight at once. Every contacts.upsert, contacts.update and inbound message + // used to ask WhatsApp again, which at link time is thousands of IQs in a + // burst. WhatsApp's picture notification (contacts.update imgUrl) refreshes it. + private readonly PICTURE_TTL_MS = 60 * 60 * 1000; + private readonly pictureCache = new Map(); + private readonly pictureLookups = new Map }>(); + // Lookups the event handlers start on their own (pictures, group metadata refreshes): four at a time. + private readonly backgroundQueries = new QueryLimiter(4); + private readonly groupRefreshes = new Map>(); + public stateConnection: wa.StateConnection = { state: 'close' }; public phoneNumber: string; + // The socket a pairing code was requested on. Baileys' requestPairingCode makes + // a new code and pushes a notification to the phone, so it is asked once per + // socket, not on every QR refresh (which would kill the code being typed). + private pairingCodeSocket?: WASocket; + public get connectionStatus() { return this.stateConnection; } - public async logoutInstance() { + /** + * Log the device out on WhatsApp's side (remove-companion-device, which takes it off the person's + * Linked devices), then wipe the session. That needs an open socket and the credentials, so when + * the socket is down or waiting to reconnect the credentials are kept and the logout is pending + * until the connection returns: 'pending'. A session that is not linked has nothing to tell + * WhatsApp and is wiped at once. + */ + public async logoutInstance(): Promise<'done' | 'pending'> { + // One logout at a time: a second call (another client, a delete) waits for the one under way and + // answers with its outcome. Installed before any wait, so two calls can never both start one. + this.logoutRun ??= this.runLogout().finally(() => (this.logoutRun = null)); + const result = await this.logoutRun; + // A 202 promises the logout survives a restart: when that cannot be recorded, the caller hears + // it, and every call tries again. + if (result === 'pending') await this.recordPending(); + return result; + } + + private logoutRun: Promise<'done' | 'pending'> | null = null; + + private async runLogout(): Promise<'done' | 'pending'> { + // A connect under way finishes first, so the socket logged out is the one it builds. + await this.connecting?.socket.catch(() => undefined); + if (this.logout?.marked) return 'pending'; + + const linked = await this.hasLinkedSession(); this.messageProcessor.onDestroy(); - await this.client?.logout('Log out instance: ' + this.instanceName); + this.pictureCache.clear(); + const socketClosed = this.stateConnection.state === 'close'; + const outcome = new Promise<'done' | 'pending'>( + (settle) => (this.logout = { marked: false, deleted: false, settle }), + ); - this.client?.ws?.close(); + if (linked) { + try { + if (this.stateConnection.state !== 'open') throw new Error('the connection is not open'); + // Confirmed: the socket ends with loggedOut, which finishes it. Not: it ends with a code that + // reconnects, and the logout is pending until the next connection says (logoutUpdate). + await this.removeFromWhatsApp(); + } catch (error) { + this.logger.warn(`Logout could not reach WhatsApp (${error?.message}): pending until the connection returns`); + await this.markLogoutPending(); + } + } else { + this.stopReconnecting(); + try { + // Nothing to tell WhatsApp: Baileys only ends the socket, with loggedOut. + await this.client?.logout('Log out instance: ' + this.instanceName); + } catch { + // No socket to end. + } + // A socket that had already closed announces nothing when ended. + if (socketClosed) await this.finishLogout(); + } + + // The loggedOut close normally follows at once; if it never comes, keep the session (pending). + let timer: NodeJS.Timeout; + const late = new Promise<'late'>((r) => (timer = setTimeout(() => r('late'), 10_000))); + const result = await Promise.race([outcome, late]); + clearTimeout(timer); + if (result === 'late') { + await this.markLogoutPending(); + return 'pending'; + } + return result; + } + + /** Whether a logout is pending (it could not reach WhatsApp yet). */ + public get logoutPending() { + return !!this.logout?.marked; + } + + /** + * The instance was deleted while its logout is pending: it finishes out of the API, then removes + * the rest. Throws, changing nothing, when that cannot be recorded on its row. + */ + public async markLogoutDeleted() { + if (!this.logout) return; + this.logout.deleted = true; + this.logout.recorded = false; + try { + await this.recordPending(); + } catch (error) { + this.logout.deleted = false; + this.logout.recorded = false; + throw error; + } + await this.writeMarker(); + } + /** + * A logout that was pending when the process stopped: from the marker file, else from the row + * (the instances volume can be lost; the row and the creds are in the database). + */ + public async pendingLogout(): Promise<{ deleted: boolean } | undefined> { + const marker = readLogoutMarker(this.instanceId); + if (marker) return { deleted: !!marker.deleted }; + try { + const row = await this.prismaRepository.instance.findUnique({ where: { id: this.instanceId } }); + return pendingOnRow(row?.disconnectionObject); + } catch (error) { + this.logger.error({ message: 'Could not read whether a logout is pending', error: errorFields(error) }); + return undefined; + } + } + + /** On boot: an instance with a pending logout connects only to finish it. Returns whether it had one. */ + public async resumePendingLogout(pending?: { deleted: boolean }): Promise { + pending ??= await this.pendingLogout(); + if (!pending) return false; + this.logout = { marked: true, deleted: pending.deleted, recorded: true, settle: () => undefined }; + this.logger.info(`Resuming a pending logout for instance "${this.instance.name}"`); + try { + await this.connect(this.phoneNumber); + } catch (error) { + this.logger.error({ message: 'Connect for a pending logout failed', error: errorFields(error?.cause ?? error) }); + this.scheduleReconnect(); + } + return true; + } + + /** Whether logging out an instance that is not connected has anything to do. */ + public async hasSessionToLogOut(): Promise { + if (this.reconnectTimer || this.connecting) return true; + return this.hasLinkedSession(); + } + + /** + * The instance is removed from the API: no reconnect, its socket let go of without its close being + * handled, and no socket built after this, including one being built now (createClient checks). + */ + public shutdown() { + this.shutDown = true; + this.stopReconnecting(); + this.retireClient(); + } + + private shutDown = false; + + /** A socket build stops at its next step once the instance is shut down. */ + private stillWanted() { + if (this.shutDown) throw new ConnectAborted(); + } + + /** Whether the stored session is a linked device (creds carry `me`), i.e. whether WhatsApp has something to remove. */ + private async hasLinkedSession(): Promise { + const cache = this.configService.get('CACHE'); + const provider = this.configService.get('PROVIDER'); + const db = this.configService.get('DATABASE'); + if (provider?.ENABLED || (cache?.REDIS.ENABLED && cache?.REDIS.SAVE_INSTANCES) || !db.SAVE_DATA.INSTANCE) { + return !!this.instance.authState?.state?.creds?.me?.id; + } + const row = await this.prismaRepository.session.findFirst({ where: { sessionId: this.instanceId } }); + let creds: any = row?.creds; + while (typeof creds === 'string') creds = JSON.parse(creds); + return !!creds?.me?.id; + } + + private async writeMarker() { + try { + await writeLogoutMarker(this.instanceId, { + instanceName: this.instance.name, + deleted: !!this.logout?.deleted, + since: new Date().toISOString(), + }); + } catch (error) { + // Still pending in this process; only a restart before it is delivered would lose it. + this.logger.error({ message: 'Could not write the logout marker', error: error?.toString() }); + } + } + + /** + * Record the pending logout on the instance's row, where the boot finds it even without the + * marker file, and where the creds it needs are. Throws when it cannot: a 202 must not promise + * what a restart would forget. + */ + private async recordPending() { + const logout = this.logout; + if (!logout?.marked || logout.recorded) return; + await this.prismaRepository.instance.update({ + where: { id: this.instanceId }, + data: { + connectionStatus: 'close', + disconnectionAt: new Date(), + disconnectionObject: { logoutPending: { deleted: logout.deleted, since: new Date().toISOString() } }, + }, + }); + logout.recorded = true; + } + + /** The logout could not reach WhatsApp: keep the session, mark it pending, and reconnect to deliver it. */ + private async markLogoutPending() { + if (!this.logout || this.logout.marked) return; + this.logout.marked = true; + await this.writeMarker(); + // Still pending in this process if it fails; logoutInstance tries again and tells the caller. + await this.recordPending().catch((error) => + this.logger.error({ message: 'Could not record the pending logout on the row', error: errorFields(error) }), + ); + this.logout.settle('pending'); + if (!this.reconnectTimer && !this.connecting && this.stateConnection.state === 'close') this.scheduleReconnect(); + } + + /** A connection.update while a logout is under way: deliver it on open, finish on loggedOut, else reconnect. */ + private async logoutUpdate({ qr, connection, lastDisconnect }: Partial, from?: WASocket) { + if (from && from !== this.client) return; + const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; + if (connection) this.stateConnection = { state: connection, statusReason: statusCode ?? 200 }; + + // A QR: WhatsApp does not know this device, so there is nothing left to remove. + if (qr) return this.finishLogout(); + + if (connection === 'open') { + this.reconnectAttempts = 0; + try { + await this.removeFromWhatsApp(); + } catch (error) { + // The socket dropped before the logout went out: its close reconnects. + this.logger.warn(`Pending logout not sent (${error?.message}), trying again on the next connection`); + } + return; + } + + if (connection === 'close') { + // loggedOut: WhatsApp confirmed the removal (removeFromWhatsApp then ends the socket so), or + // refused the device on connecting (it is already removed). The other final codes leave + // nothing to log out either. + if ([DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406].includes(statusCode)) { + return this.finishLogout(); + } + if (!this.logout.marked) return this.markLogoutPending(); + this.scheduleReconnect(statusCode); + } + } + + /** + * Ask WhatsApp to remove this device (remove-companion-device) and wait for its answer. Baileys' + * logout() writes the same request but waits for nothing and then ends the socket itself with + * loggedOut, a close that says nothing about whether WhatsApp got it. Here the socket ends with + * loggedOut only once WhatsApp answered; otherwise (no answer in time, an error answer, the + * connection failing) it ends with a code that reconnects, and the next connection says: WhatsApp + * refusing the device (401) finishes the logout, an open asks again. Throws when the request could + * not be written at all. + */ + private async removeFromWhatsApp() { + const client = this.client; + const jid = this.instance.authState?.state?.creds?.me?.id ?? client.user?.id; + let confirmed = false; + try { + const answer: any = await client.query( + { + tag: 'iq', + attrs: { to: S_WHATSAPP_NET, type: 'set', xmlns: 'md' }, + content: [{ tag: 'remove-companion-device', attrs: { jid, reason: 'user_initiated' } }], + }, + BaileysStartupService.REMOVE_ANSWER_TIMEOUT_MS, + ); + confirmed = answer?.attrs?.type === 'result'; + } catch (error) { + this.logger.warn(`WhatsApp did not confirm the logout (${errorName(error)}): checking on the next connection`); + } + if (client !== this.client) return; + if (confirmed) { + client.end(new Boom('Intentional Logout', { statusCode: DisconnectReason.loggedOut })); + } else { + client.end(new Boom('Logout not confirmed', { statusCode: DisconnectReason.connectionClosed })); + } + } + + /** WhatsApp has been told (or has nothing to remove): wipe the session and the marker, then close as a logout does. */ + private async finishLogout() { + const logout = this.logout; + if (!logout) return; + this.stopReconnecting(); + try { + await this.removeSession(); + // Not pending any more: the boot must never resume a logout on a device linked again later. + if (!logout.deleted) { + await this.prismaRepository.instance.updateMany({ + where: { id: this.instanceId }, + data: { disconnectionObject: Prisma.DbNull }, + }); + } + if (logout.deleted) { + // Out of the API already: remove what was kept for the logout, the row last (it cascades to + // the rest). Before the marker goes, so a failure here is finished again on the next try. + await this.prismaRepository.proxy.deleteMany({ where: { instanceId: this.instanceId } }); + await this.prismaRepository.instance.deleteMany({ where: { id: this.instanceId } }); + } + } catch (error) { + // The device is off WhatsApp; the next connection answers loggedOut, which finishes it again. + this.logger.error({ message: 'Could not wipe the session after the logout', error: error?.toString() }); + if (!logout.marked) await this.markLogoutPending(); + else this.scheduleReconnect(); + return; + } + rmSync(join(INSTANCE_DIR, this.instanceId), { recursive: true, force: true }); + this.logout = null; + + if (logout.deleted) { + // Announce nothing. + this.shutdown(); + this.stateConnection = { state: 'close', statusReason: DisconnectReason.loggedOut }; + this.eventEmitter.emit('logout.finished', this); + } else { + // The same close, webhooks and cleanup as a logout that reached WhatsApp at once. + await this.connectionUpdate({ + connection: 'close', + lastDisconnect: { + error: new Boom('Intentional Logout', { statusCode: DisconnectReason.loggedOut }), + date: new Date(), + }, + }); + } + logout.settle('done'); + } + + // While a logout is under way, and once the instance is shut down (removed), it forwards nothing: + // work that finishes late (a queued batch, a picture lookup, an open's handler) included. The + // one exception is the removal's own announcement, which the monitor sends after the shutdown. + public async sendDataWebhook( + event: Events, + data: T, + local = true, + integration?: string[], + extra?: Record, + ) { + if (this.logout || (this.shutDown && event !== Events.REMOVE_INSTANCE)) return; + this.liveRecorder?.webhook(event, data, extra); + return super.sendDataWebhook(event, data, local, integration, extra); + } + + private async removeSession() { const db = this.configService.get('DATABASE'); const cache = this.configService.get('CACHE'); const provider = this.configService.get('PROVIDER'); @@ -331,7 +844,10 @@ export class BaileysStartupService extends ChannelStartupService { }; } - private async connectionUpdate({ qr, connection, lastDisconnect }: Partial) { + private async connectionUpdate({ qr, connection, lastDisconnect }: Partial, from?: WASocket) { + // A replaced socket speaks for nobody: its close must not reconnect, its QR must not show. + if (from && from !== this.client) return; + if (qr) { if (this.instance.qrcode.count === this.configService.get('QRCODE').LIMIT) { this.sendDataWebhook(Events.QRCODE_UPDATED, { @@ -373,8 +889,16 @@ export class BaileysStartupService extends ChannelStartupService { }; if (this.phoneNumber) { - await delay(1000); - this.instance.qrcode.pairingCode = await this.client.requestPairingCode(this.phoneNumber); + if (this.pairingCodeSocket !== this.client) { + const socket = (this.pairingCodeSocket = this.client); + try { + await delay(1000); + this.instance.qrcode.pairingCode = await socket.requestPairingCode(this.phoneNumber); + } catch (error) { + if (this.pairingCodeSocket === socket) this.pairingCodeSocket = undefined; + throw error; + } + } } else { this.instance.qrcode.pairingCode = null; } @@ -428,8 +952,10 @@ export class BaileysStartupService extends ChannelStartupService { const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; const shouldReconnect = !codesToNotReconnect.includes(statusCode); if (shouldReconnect) { - await this.connectToWhatsapp(this.phoneNumber); + // Baileys' own reconnect (QR refs ended, a dropped socket) is the same attempt: the QR budget carries over. + this.scheduleReconnect(statusCode); } else { + this.stopReconnecting(); this.sendDataWebhook(Events.STATUS_INSTANCE, { instance: this.instance.name, status: 'closed', @@ -458,13 +984,14 @@ export class BaileysStartupService extends ChannelStartupService { this.eventEmitter.emit('logout.instance', this.instance.name, 'inner'); this.client?.ws?.close(); - this.client.end(new Error('Close connection')); + this.client?.end(new Error('Close connection')); this.sendDataWebhook(Events.CONNECTION_UPDATE, { instance: this.instance.name, ...this.stateConnection }); } } if (connection === 'open') { + this.reconnectAttempts = 0; this.instance.wuid = this.client.user.id.replace(/:\d+/, ''); try { const profilePic = await this.profilePicture(this.instance.wuid); @@ -529,6 +1056,12 @@ export class BaileysStartupService extends ChannelStartupService { AND "key"->>'id' = ${key.id} `) as proto.IWebMessageInfo[]; + // Not stored: answer undefined. Baileys calls this to answer a retry request + // and relays any truthy answer; only a falsy one means "not available". + if (!webMessageInfo?.length) { + return undefined; + } + if (full) { return webMessageInfo[0]; } @@ -549,7 +1082,9 @@ export class BaileysStartupService extends ChannelStartupService { return webMessageInfo[0].message; } catch { - return { conversation: '' }; + // A failed lookup is a miss too: any truthy answer here is relayed as the message. + this.logger.warn('getMessage: the message lookup failed, answering nothing'); + return undefined; } } @@ -574,7 +1109,10 @@ export class BaileysStartupService extends ChannelStartupService { } private async createClient(number?: string): Promise { - this.instance.authState = await this.defineAuthState(); + this.stillWanted(); + const authState = await this.defineAuthState(); + this.stillWanted(); + this.instance.authState = authState; const session = this.configService.get('CONFIG_SESSION_PHONE'); @@ -591,58 +1129,68 @@ export class BaileysStartupService extends ChannelStartupService { this.logger.info(`Browser: ${browser}`); } - const baileysVersion = await fetchLatestWaWebVersion({}); - const version = baileysVersion.version; - const log = `Baileys version: ${version.join('.')}`; - - this.logger.info(log); - - this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`); - let options; if (this.localProxy?.enabled) { this.logger.info('Proxy enabled: ' + this.localProxy?.host); + let proxy: Parameters[0]; + if (this.localProxy?.host?.includes('proxyscrape')) { + // No list, no exit: the connect fails (and is retried) rather than leave from the server's address. + let proxyUrls: string[]; try { const response = await axios.get(this.localProxy?.host); - const text = response.data; - const proxyUrls = text.split('\r\n'); - const rand = Math.floor(Math.random() * Math.floor(proxyUrls.length)); - const proxyUrl = 'http://' + proxyUrls[rand]; - options = { agent: makeProxyAgent(proxyUrl), fetchAgent: makeProxyAgentUndici(proxyUrl) }; - } catch { - this.localProxy.enabled = false; + proxyUrls = String(response.data ?? '') + .split('\r\n') + .filter((line) => line.trim()); + } catch (error) { + throw new Error( + `The proxy list could not be fetched (${errorName(error)}): not connecting without the proxy`, + ); } + if (!proxyUrls.length) throw new Error('The proxy list is empty: not connecting without the proxy'); + proxy = 'http://' + proxyUrls[Math.floor(Math.random() * proxyUrls.length)]; } else { - options = { - agent: makeProxyAgent({ - host: this.localProxy.host, - port: this.localProxy.port, - protocol: this.localProxy.protocol, - username: this.localProxy.username, - password: this.localProxy.password, - }), - fetchAgent: makeProxyAgentUndici({ - host: this.localProxy.host, - port: this.localProxy.port, - protocol: this.localProxy.protocol, - username: this.localProxy.username, - password: this.localProxy.password, - }), + proxy = { + host: this.localProxy.host, + port: this.localProxy.port, + protocol: this.localProxy.protocol, + username: this.localProxy.username, + password: this.localProxy.password, }; } + + if (proxy) { + // Baileys uploads with http.request under Node, which takes an http agent + // (fetchAgent), not an undici dispatcher. Downloads use fetch, which takes + // only a dispatcher: see mediaDownloadOptions. All three share one exit. + options = { agent: makeProxyAgent(proxy), fetchAgent: makeProxyAgent(proxy) }; + this.mediaProxy = { key: this.proxyKey(), dispatcher: makeProxyAgentUndici(proxy) }; + } } + // The version request precedes every connect, so it leaves through the same exit as the socket. + const baileysVersion = await fetchLatestWaWebVersion( + options ? { httpsAgent: options.fetchAgent, proxy: false } : {}, + options ? ({ dispatcher: this.mediaProxy.dispatcher } as RequestInit) : {}, + ); + this.stillWanted(); + const version = baileysVersion.version; + const log = `Baileys version: ${version.join('.')}`; + + this.logger.info(log); + + this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`); + const socketConfig: UserFacingSocketConfig = { ...options, version, - logger: P({ level: this.logBaileys }), + logger: makeBaileysLogger(this.logBaileys), printQRInTerminal: false, auth: { creds: this.instance.authState.state.creds, - keys: makeCacheableSignalKeyStore(this.instance.authState.state.keys, P({ level: 'error' }) as any), + keys: makeCacheableSignalKeyStore(this.instance.authState.state.keys, makeBaileysLogger('error') as any), }, msgRetryCounterCache: this.msgRetryCounterCache, generateHighQualityLinkPreview: true, @@ -695,37 +1243,109 @@ export class BaileysStartupService extends ChannelStartupService { this.endSession = false; + this.stillWanted(); + this.retireClient(); this.client = makeWASocket(socketConfig); + // Any reconnect still waiting was for the socket just replaced. + this.stopReconnecting(); + + this.liveRecorder ??= LiveRecorder.start(this.instance.name); + const creds = this.instance.authState.state.creds; + this.liveRecorder?.attach(this.client, { + waWebVersion: version.join('.'), + // creds.account is set once a pairing succeeded. + linkMethod: creds?.account ? 'existing-session' : this.phoneNumber ? 'code' : 'qr', + proxyProtocol: options ? (this.localProxy?.protocol ?? 'http') : null, + creds: () => this.instance.authState?.state?.creds, + }); if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) { useVoiceCallsBaileys(this.localSettings.wavoipToken, this.client, this.connectionStatus.state as any, true); } - this.eventHandler(); + const client = this.client; + const stopProcessing = this.eventHandler(); - this.client.ws.on('CB:call', (packet) => { - console.log('CB:call', packet); + const onCall = (packet) => { + this.logger.verbose(`CB:call id=${packet?.attrs?.id ?? ''}`); const payload = { event: 'CB:call', packet: packet }; this.sendDataWebhook(Events.CALL, payload, true, ['websocket']); - }); - - this.client.ws.on('CB:ack,class:call', (packet) => { - console.log('CB:ack,class:call', packet); + }; + const onCallAck = (packet) => { + this.logger.verbose(`CB:ack,class:call id=${packet?.attrs?.id ?? ''}`); const payload = { event: 'CB:ack,class:call', packet: packet }; this.sendDataWebhook(Events.CALL, payload, true, ['websocket']); - }); + }; + client.ws.on('CB:call', onCall); + client.ws.on('CB:ack,class:call', onCallAck); + + this.detachClient = () => { + stopProcessing(); + client.ws.off('CB:call', onCall); + client.ws.off('CB:ack,class:call', onCallAck); + }; this.phoneNumber = number; return this.client; } + /** + * Let go of the current socket before another is built: stop listening to it first, because + * ending a live Baileys socket announces a close, which would otherwise reconnect and end the + * new one in turn. + */ + private retireClient() { + const previous = this.client; + this.detachClient?.(); + this.detachClient = undefined; + if (!previous) return; + try { + previous.end(new Error('Replaced by a new connection')); + } catch (error) { + this.logger.warn({ message: 'Could not end the replaced socket', error: error?.toString() }); + } + } + + /** A new connect attempt: a fresh QR budget, and no QR or pairing code left from an earlier attempt. */ public async connectToWhatsapp(number?: string): Promise { + if (this.logout) throw new BadRequestException('A logout is pending: the instance connects only to deliver it'); + this.instance.qrcode = { count: 0 }; + return await this.connect(number); + } + + /** + * One connect at a time, for connectToWhatsapp and the reconnect alike. A connect that arrives + * while one is under way (/instance/connect polled during a reconnect) joins it; one for a + * different number runs after it. The socket it builds takes the place of a reconnect still + * waiting on its backoff (createClient drops that); if it fails before building one, the waiting + * reconnect still happens. + */ + private async connect(number?: string): Promise { + this.stillWanted(); + const inFlight = this.connecting; + if (inFlight && inFlight.number === (number ?? null)) return inFlight.socket; + + const socket = (inFlight ? inFlight.socket.catch(() => undefined) : Promise.resolve()).then(() => + this.openConnection(number), + ); + const entry = { number: number ?? null, socket }; + this.connecting = entry; + try { + return await socket; + } finally { + if (this.connecting === entry) this.connecting = null; + } + } + + private async openConnection(number?: string): Promise { try { this.loadChatwoot(); - this.loadSettings(); + // The socket takes syncFullHistory, groupsIgnore, readStatus and alwaysOnline as config: read them first. + await this.loadSettings(); this.loadWebhook(); - this.loadProxy(); + // The socket, the version fetch and media all take their exit from localProxy: read it before connecting. + await this.loadProxy(); // Remontar o messageProcessor para garantir que estĂĄ funcionando apĂłs reconexĂŁo this.messageProcessor.mount({ @@ -734,16 +1354,91 @@ export class BaileysStartupService extends ChannelStartupService { return await this.createClient(number); } catch (error) { - this.logger.error(error); - throw new InternalServerErrorException(error?.toString()); + // Shut down while it was being built: nothing failed, nothing to retry. + if (error instanceof ConnectAborted) throw error; + this.logger.error({ message: 'Connect failed', error: errorFields(error) }); + // The same 500, still carrying what failed (not enumerable, so not in an HTTP answer): a reconnect logs it. + try { + new InternalServerErrorException(error?.toString()); + } catch (serverError) { + throw Object.defineProperty(serverError, 'cause', { value: error, enumerable: false }); + } + } + } + + private scheduleReconnect(statusCode?: number) { + this.stopReconnecting(); + if (this.shutDown) return; + const delay = Math.min( + BaileysStartupService.RECONNECT_FIRST_DELAY_MS * 2 ** Math.min(this.reconnectAttempts, 16), + BaileysStartupService.RECONNECT_MAX_DELAY_MS, + ); + this.reconnectAttempts++; + this.logger.info(`Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts}, status code ${statusCode})`); + this.reconnectTimer = setTimeout(async () => { + this.reconnectTimer = null; + try { + await this.connect(this.phoneNumber); + } catch (error) { + // No socket was built, so no close will come to retry it: schedule the next attempt here. + this.logger.error({ message: 'Reconnect attempt failed', error: errorFields(error?.cause ?? error) }); + this.scheduleReconnect(statusCode); + } + }, delay); + } + + private credsRetry: NodeJS.Timeout | null = null; + private credsRetryAttempts = 0; + + /** + * Save the creds a creds.update changed. A failed save is tried again (1s, 2s, 4s... up to 30s) + * with the creds as they are by then, until one lands: the creds live in memory meanwhile, and a + * restart before it would open the old ones. + */ + private saveCreds() { + const authState = this.instance.authState; + if (!authState?.saveCreds) return; + Promise.resolve() + .then(() => authState.saveCreds()) + .then(() => { + this.credsRetryAttempts = 0; + }) + .catch((error) => { + this.logger.error({ message: 'Could not save the creds, trying again', error: errorFields(error) }); + if (this.credsRetry || this.shutDown || authState !== this.instance.authState) return; + const delay = Math.min(1_000 * 2 ** this.credsRetryAttempts++, 30_000); + this.credsRetry = setTimeout(() => { + this.credsRetry = null; + this.saveCreds(); + }, delay); + }); + } + + /** A connect failed before it built a socket (so no close will retry it): try again after the backoff. */ + public retryConnect() { + if (!this.reconnectTimer && !this.connecting) this.scheduleReconnect(); + } + + /** Drop a reconnect that is still waiting: the instance is being logged out or deleted. */ + public stopReconnecting() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; } } + /** + * A new socket after a profile or privacy change, through the same one-at-a-time connect as any + * other: it joins a connect under way. Nothing for an instance shut down, or one whose pending + * logout owns the connection. + */ public async reloadConnection(): Promise { + if (this.shutDown || this.logout) return this.client; try { - return await this.createClient(this.phoneNumber); + return await this.connect(this.phoneNumber); } catch (error) { - this.logger.error(error); + if (error instanceof ConnectAborted) return this.client; + this.logger.error({ message: 'Reload connection failed', error: errorFields(error?.cause ?? error) }); throw new InternalServerErrorException(error?.toString()); } } @@ -766,7 +1461,11 @@ export class BaileysStartupService extends ChannelStartupService { unreadMessages: chat.unreadCount !== undefined ? chat.unreadCount : 0, })); - this.sendDataWebhook(Events.CHATS_UPSERT, chatsToInsert); + const stateOf = new Map(chats.map((chat) => [chat.id, chatState(chat)])); + this.sendDataWebhook( + Events.CHATS_UPSERT, + chatsToInsert.map((chat) => ({ ...chat, ...stateOf.get(chat.remoteJid) })), + ); if (chatsToInsert.length > 0) { if (this.configService.get('DATABASE').SAVE_DATA.CHATS) @@ -782,7 +1481,7 @@ export class BaileysStartupService extends ChannelStartupService { >[], ) => { const chatsRaw = chats.map((chat) => { - return { remoteJid: chat.id, instanceId: this.instanceId }; + return { remoteJid: chat.id, instanceId: this.instanceId, ...chatState(chat) }; }); this.sendDataWebhook(Events.CHATS_UPDATE, chatsRaw); @@ -806,7 +1505,12 @@ export class BaileysStartupService extends ChannelStartupService { }; private readonly contactHandle = { - 'contacts.upsert': async (contacts: Contact[]) => { + // `saved` on each contacts.upsert item says the name is certainly the one the + // owner saved in their address book. In Baileys only an app-state contact + // action emits contacts.upsert, with name = fullName || firstName || username, + // so a name equal to the username is a handle, not a saved name. Callers that + // cannot be sure (history) pass saved: false. + 'contacts.upsert': async (contacts: (Contact & { saved?: boolean })[]) => { try { const contactsRaw: any = contacts.map((contact) => ({ remoteJid: contact.id, @@ -816,7 +1520,13 @@ export class BaileysStartupService extends ChannelStartupService { })); if (contactsRaw.length > 0) { - this.sendDataWebhook(Events.CONTACTS_UPSERT, contactsRaw); + this.sendDataWebhook( + Events.CONTACTS_UPSERT, + contactsRaw.map((raw, i) => ({ + ...raw, + saved: contacts[i].saved ?? (!!contacts[i].name && contacts[i].name !== contacts[i].username), + })), + ); if (this.configService.get('DATABASE').SAVE_DATA.CONTACTS) await this.prismaRepository.contact.createMany({ data: contactsRaw, skipDuplicates: true }); @@ -843,51 +1553,8 @@ export class BaileysStartupService extends ChannelStartupService { ); } - const updatedContacts = await Promise.all( - contacts.map(async (contact) => ({ - remoteJid: contact.id, - pushName: contact?.name || contact?.verifiedName || contact.id.split('@')[0], - profilePicUrl: (await this.profilePicture(contact.id)).profilePictureUrl, - instanceId: this.instanceId, - })), - ); - - if (updatedContacts.length > 0) { - const usersContacts = updatedContacts.filter((c) => c.remoteJid.includes('@s.whatsapp')); - if (usersContacts) { - await saveOnWhatsappCache(usersContacts.map((c) => ({ remoteJid: c.remoteJid }))); - } - - this.sendDataWebhook(Events.CONTACTS_UPDATE, updatedContacts); - await Promise.all( - updatedContacts.map(async (contact) => { - if (this.configService.get('DATABASE').SAVE_DATA.CONTACTS) { - await this.prismaRepository.contact.updateMany({ - where: { remoteJid: contact.remoteJid, instanceId: this.instanceId }, - data: { profilePicUrl: contact.profilePicUrl }, - }); - } - - if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { - const instance = { instanceName: this.instance.name, instanceId: this.instance.id }; - - const findParticipant = await this.chatwootService.findContact( - instance, - contact.remoteJid.split('@')[0], - ); - - if (!findParticipant) { - return; - } - - this.chatwootService.updateContact(instance, findParticipant.id, { - name: contact.pushName, - avatar_url: contact.profilePicUrl, - }); - } - }), - ); - } + // Pictures follow on contacts.update when their lookups finish: history waits for none of them. + void this.contactPictures(contacts); } catch (error) { console.error(error); this.logger.error(`Error: ${error.message}`); @@ -898,10 +1565,17 @@ export class BaileysStartupService extends ChannelStartupService { const contactsRaw: { remoteJid: string; pushName?: string; profilePicUrl?: string; instanceId: string }[] = []; for await (const contact of contacts) { this.logger.debug(`Updating contact: ${JSON.stringify(contact, null, 2)}`); + // imgUrl is set only by WhatsApp's picture notification: 'changed' or 'removed'. + if (contact.imgUrl === 'changed' || contact.imgUrl === 'removed') this.invalidatePicture(createJid(contact.id)); + const renamed = contact?.name ?? contact?.verifiedName; + const awaiting = this.namesAwaitingPicture.get(contact.id); + if (renamed && awaiting) awaiting.name = renamed; + if (contact.imgUrl === 'removed') this.pictureCache.set(createJid(contact.id), { url: null, at: Date.now() }); contactsRaw.push({ remoteJid: contact.id, pushName: contact?.name ?? contact?.verifiedName, - profilePicUrl: (await this.profilePicture(contact.id)).profilePictureUrl, + profilePicUrl: (await this.cachedProfilePicture(contact.id, { fresh: contact.imgUrl === 'changed' })) + .profilePictureUrl, instanceId: this.instanceId, }); } @@ -923,6 +1597,57 @@ export class BaileysStartupService extends ChannelStartupService { }, }; + /** + * Tell consumers which phone number a private @lid belongs to: one CONTACTS_UPSERT + * item per pair, { remoteJid: , pushName: null, lid, phoneNumber: }. + */ + private lidMappingHandle(mappings: { lid?: string; pn?: string }[]) { + const seen = new Set(); + const contacts = []; + for (const m of mappings ?? []) { + let [lid, pn] = [m?.lid, m?.pn]; + if (isLidUser(pn) && isPnUser(lid)) [lid, pn] = [pn, lid]; + if (!isLidUser(lid) || !isPnUser(pn)) continue; + [lid, pn] = [jidNormalizedUser(lid), jidNormalizedUser(pn)]; + if (!lid || !pn || seen.has(`${lid}|${pn}`)) continue; + seen.add(`${lid}|${pn}`); + contacts.push({ remoteJid: pn, pushName: null, lid, phoneNumber: pn, instanceId: this.instanceId }); + } + if (contacts.length) { + this.sendDataWebhook(Events.CONTACTS_UPSERT, contacts); + } + } + + /** + * Baileys hands over the mappings it learns from a history sync as `lidPnMappings` + * on messaging-history.set, but history is processed inside a buffered function and + * the event buffer drops that field when it consolidates the batch (Baileys + * lib/Utils/event-buffer.js, consolidateEvents). So read it where Baileys emits it, + * before the buffer does. Every mapping in the batch is there, whether it came from + * phoneNumberToLidMappings or from a conversation's pnJid or lidJid, and nothing is + * looked up, so no network query can follow. + */ + private tapHistoryLidMappings() { + const client = this.client; + const ev = client.ev as any; + if (ev.__lidPnMappingsTap) return; + const emit = ev.emit.bind(ev); + ev.emit = (event: string, data: any) => { + if (event === 'messaging-history.set' && data?.lidPnMappings?.length) { + const mappings = [...data.lidPnMappings]; + this.eventProcessingQueue = this.eventProcessingQueue.then(() => { + try { + if (!this.endSession && !this.logout && client === this.client) this.lidMappingHandle(mappings); + } catch (error) { + this.logger.error(error); + } + }); + } + return emit(event, data); + }; + ev.__lidPnMappingsTap = true; + } + private readonly messageHandle = { 'messaging-history.set': async ({ messages, @@ -941,7 +1666,7 @@ export class BaileysStartupService extends ChannelStartupService { }) => { try { if (syncType === proto.HistorySync.HistorySyncType.ON_DEMAND) { - console.log('received on-demand history sync, messages=', messages); + this.logger.info(`received on-demand history sync, messages=${messages.length}`); } console.log( `recv ${chats.length} chats, ${contacts.length} contacts, ${messages.length} msgs (is latest: ${isLatest}, progress: ${progress}%), type: ${syncType}`, @@ -975,6 +1700,7 @@ export class BaileysStartupService extends ChannelStartupService { } const chatsRaw: { remoteJid: string; instanceId: string; name?: string }[] = []; + const chatItems: Record[] = []; const chatsRepository = new Set( (await this.prismaRepository.chat.findMany({ where: { instanceId: this.instanceId } })).map( (chat) => chat.remoteJid, @@ -987,9 +1713,10 @@ export class BaileysStartupService extends ChannelStartupService { } chatsRaw.push({ remoteJid: chat.id, instanceId: this.instanceId, name: chat.name }); + chatItems.push({ ...chatsRaw[chatsRaw.length - 1], ...chatState(chat) }); } - this.sendDataWebhook(Events.CHATS_SET, chatsRaw); + this.sendDataWebhook(Events.CHATS_SET, chatItems); if (this.configService.get('DATABASE').SAVE_DATA.HISTORIC) { await this.prismaRepository.chat.createMany({ data: chatsRaw, skipDuplicates: true }); @@ -1068,7 +1795,10 @@ export class BaileysStartupService extends ChannelStartupService { } await this.contactHandle['contacts.upsert']( - contacts.filter((c) => !!c.notify || !!c.name).map((c) => ({ id: c.id, name: c.name ?? c.notify })), + contacts + .filter((c) => !!c.notify || !!c.name) + // A history name is displayName || name || username, and a push name is the profile name. + .map((c) => ({ id: c.id, name: c.name ?? c.notify, saved: false })), ); contacts = undefined; @@ -1085,21 +1815,20 @@ export class BaileysStartupService extends ChannelStartupService { ) => { try { for (const received of messages) { - if ( - received?.messageStubParameters?.some?.((param) => - [ - 'No matching sessions found for message', - 'Bad MAC', - 'failed to decrypt message', - 'SessionError', - 'Invalid PreKey ID', - 'No session record', - 'No session found to decrypt message', - 'Message absent from node', - ].some((err) => param?.includes?.(err)), - ) - ) { - this.logger.warn(`Message ignored with messageStubParameters: ${JSON.stringify(received, null, 2)}`); + const decryptFailure = [ + 'No matching sessions found for message', + 'Bad MAC', + 'failed to decrypt message', + 'SessionError', + 'Invalid PreKey ID', + 'No session record', + 'No session found to decrypt message', + 'Message absent from node', + ].find((err) => received?.messageStubParameters?.some?.((param) => param?.includes?.(err))); + if (decryptFailure) { + this.logger.warn( + `Message ignored with messageStubParameters: id=${received.key?.id}, chat=${jidKind(received.key?.remoteJid)}, reason=${decryptFailure}`, + ); continue; } if (received.message?.conversation || received.message?.extendedTextMessage?.text) { @@ -1110,7 +1839,7 @@ export class BaileysStartupService extends ChannelStartupService { console.log('requested placeholder resync, id=', messageId); } else if (requestId) { - console.log('Message received from phone, id=', requestId, received); + this.logger.info(`Message received from phone, id=${requestId}, message id=${received.key?.id}`); } if (text == 'onDemandHistSync') { @@ -1196,7 +1925,9 @@ export class BaileysStartupService extends ChannelStartupService { data: { name: received.pushName }, }); } catch { - console.log(`Chat insert record ignored: ${received.key.remoteJid} - ${this.instanceId}`); + this.logger.warn( + `Chat insert record ignored: ${jidKind(received.key.remoteJid)} chat - ${this.instanceId}`, + ); } } } @@ -1447,8 +2178,8 @@ export class BaileysStartupService extends ChannelStartupService { const buffer = await downloadMediaMessage( { key: received.key, message: received?.message }, 'buffer', - {}, - { logger: P({ level: 'error' }) as any, reuploadRequest: this.client.updateMediaMessage }, + this.mediaDownloadOptions(), + { logger: makeBaileysLogger('error') as any, reuploadRequest: this.client.updateMediaMessage }, ); if (buffer) { @@ -1458,8 +2189,8 @@ export class BaileysStartupService extends ChannelStartupService { const buffer = await downloadMediaMessage( { key: received.key, message: received?.message }, 'buffer', - {}, - { logger: P({ level: 'error' }) as any, reuploadRequest: this.client.updateMediaMessage }, + this.mediaDownloadOptions(), + { logger: makeBaileysLogger('error') as any, reuploadRequest: this.client.updateMediaMessage }, ); if (buffer) { @@ -1467,7 +2198,13 @@ export class BaileysStartupService extends ChannelStartupService { } } } catch (error) { - this.logger.error(['Error converting media to base64', error?.message]); + // A failed download's error names the signed media link: bounded, scrubbed fields only. + this.logger.error({ + message: 'Error converting media to base64', + messageId: received.key?.id, + chatType: jidKind(received.key?.remoteJid), + error: errorFields(error), + }); } } } @@ -1475,11 +2212,15 @@ export class BaileysStartupService extends ChannelStartupService { this.logger.verbose(messageRaw); sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`); + // The webhook shows the phone JID as remoteJid, and keeps the @lid WhatsApp stores + // the message under in remoteJidAlt (upstream develop's swap), so a consumer can + // still name the message the way the phone does (a media re-upload request). if (messageRaw.key.remoteJid?.includes('@lid') && messageRaw.key.remoteJidAlt) { + const lid = messageRaw.key.remoteJid; messageRaw.key.remoteJid = messageRaw.key.remoteJidAlt; + messageRaw.key.remoteJidAlt = lid; + messageRaw.key.addressingMode = 'pn'; } - console.log(messageRaw); - this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw); await chatbotController.emit({ @@ -1501,7 +2242,7 @@ export class BaileysStartupService extends ChannelStartupService { } = { remoteJid: received.key.remoteJid, pushName: received.key.fromMe ? '' : received.key.fromMe == null ? '' : received.pushName, - profilePicUrl: (await this.profilePicture(received.key.remoteJid)).profilePictureUrl, + profilePicUrl: (await this.cachedProfilePicture(received.key.remoteJid)).profilePictureUrl, instanceId: this.instanceId, }; @@ -1541,7 +2282,8 @@ export class BaileysStartupService extends ChannelStartupService { continue; } - this.sendDataWebhook(Events.CONTACTS_UPSERT, contactRaw); + // A message's pushName is the sender's own profile name, never a saved one. + this.sendDataWebhook(Events.CONTACTS_UPSERT, { ...contactRaw, saved: false }); if (this.configService.get('DATABASE').SAVE_DATA.CONTACTS) await this.prismaRepository.contact.upsert({ @@ -1570,7 +2312,7 @@ export class BaileysStartupService extends ChannelStartupService { const cached = await this.baileysCache.get(updateKey); const secondsSinceEpoch = Math.floor(Date.now() / 1000); - console.log('CACHE:', { cached, updateKey, messageTimestamp: update.messageTimestamp, secondsSinceEpoch }); + this.logger.verbose({ cached, updateKey, messageTimestamp: update.messageTimestamp, secondsSinceEpoch }); if ( (update.messageTimestamp && update.messageTimestamp === cached) || @@ -1646,7 +2388,7 @@ export class BaileysStartupService extends ChannelStartupService { findMessage = messages[0] || null; if (!findMessage?.id) { - this.logger.warn(`Original message not found for update. Skipping. Key: ${JSON.stringify(key)}`); + this.logger.warn(`Original message not found for update. Skipping. Message id: ${key.id}`); continue; } message.messageId = findMessage.id; @@ -1719,7 +2461,9 @@ export class BaileysStartupService extends ChannelStartupService { try { await this.prismaRepository.chat.update({ where: { id: existingChat.id }, data: chatToInsert }); } catch { - console.log(`Chat insert record ignored: ${chatToInsert.remoteJid} - ${chatToInsert.instanceId}`); + this.logger.warn( + `Chat insert record ignored: ${jidKind(chatToInsert.remoteJid)} chat - ${chatToInsert.instanceId}`, + ); } } } @@ -1739,27 +2483,31 @@ export class BaileysStartupService extends ChannelStartupService { this.sendDataWebhook(Events.GROUPS_UPDATE, groupMetadataUpdate); groupMetadataUpdate.forEach((group) => { - if (isJidGroup(group.id)) { - this.updateGroupMetadataCache(group.id); + if (!isJidGroup(group.id)) return; + // A listing (groupFetchAllParticipating) carries each group's full metadata: keep it as it is. + // A change (subject, settings) carries only what changed: ask for the group once. + if (Array.isArray(group.participants)) { + this.keepGroupMetadata(group.id, group as GroupMetadata); + } else { + this.refreshGroupMetadata(group.id); } }); }, 'group-participants.update': async (participantsUpdate: { id: string; - participants: string[]; + participants: (GroupParticipant | string)[]; action: ParticipantAction; }) => { // ENHANCEMENT: Adds participantsData field while maintaining backward compatibility - // MAINTAINS: participants: string[] (original JID strings) - // ADDS: participantsData: { jid: string, phoneNumber: string, name?: string, imgUrl?: string }[] + // MAINTAINS: participants, exactly as Baileys emitted it + // ADDS: participantsData: { jid: string, phoneNumber?: string, name?: string, imgUrl?: string }[] // This enables LID to phoneNumber conversion without breaking existing webhook consumers - - // Helper to normalize participantId as phone number - const normalizePhoneNumber = (id: string | null | undefined): string => { - // Remove @lid, @s.whatsapp.net suffixes and extract just the number part - return String(id || '').split('@')[0]; - }; + // + // Baileys 7 emits each participant as a GroupParticipant object ({ id, phoneNumber?, ... }); + // Baileys 6 emitted jid strings. phoneNumber is a phone jid (@s.whatsapp.net): the + // participant's own, else the group metadata's, else the LID mapping store's, else none. + const phoneJid = (jid: string | null | undefined) => (isPnUser(jid) ? jidNormalizedUser(jid) : undefined); try { // Usa o mesmo mĂ©todo que o endpoint /group/participants @@ -1771,28 +2519,34 @@ export class BaileysStartupService extends ChannelStartupService { } // Filtra apenas os participantes que estĂŁo no evento - const resolvedParticipants = participantsUpdate.participants.map((participantId) => { - const participantData = groupParticipants.participants.find((p) => p.id === participantId); - - let phoneNumber: string; - if (participantData?.phoneNumber) { - phoneNumber = participantData.phoneNumber; - } else { - phoneNumber = normalizePhoneNumber(participantId); - } + const resolvedParticipants = await Promise.all( + participantsUpdate.participants.map(async (participant) => { + const jid = typeof participant === 'string' ? participant : participant?.id; + const participantData = groupParticipants.participants.find((p) => p.id === jid); + + let phoneNumber = + phoneJid(typeof participant === 'string' ? undefined : participant?.phoneNumber) ?? + phoneJid(participantData?.phoneNumber) ?? + phoneJid(jid); + if (!phoneNumber && isLidUser(jid)) { + phoneNumber = phoneJid( + await this.client.signalRepository?.lidMapping?.getPNForLID(jid).catch((): undefined => undefined), + ); + } - return { - jid: participantId, - phoneNumber, - name: participantData?.name, - imgUrl: participantData?.imgUrl, - }; - }); + return { + jid, + phoneNumber, + name: participantData?.name, + imgUrl: participantData?.imgUrl, + }; + }), + ); // MantĂ©m formato original + adiciona dados resolvidos const enhancedParticipantsUpdate = { ...participantsUpdate, - participants: participantsUpdate.participants, // MantĂ©m array original de strings + participants: participantsUpdate.participants, // MantĂ©m o array original, como o Baileys emitiu // Adiciona dados resolvidos em campo separado participantsData: resolvedParticipants, }; @@ -1800,13 +2554,13 @@ export class BaileysStartupService extends ChannelStartupService { this.sendDataWebhook(Events.GROUP_PARTICIPANTS_UPDATE, enhancedParticipantsUpdate); } catch (error) { this.logger.error( - `Failed to resolve participant data for GROUP_PARTICIPANTS_UPDATE webhook: ${error.message} | Group: ${participantsUpdate.id} | Participants: ${participantsUpdate.participants.length}`, + `Failed to resolve participant data for GROUP_PARTICIPANTS_UPDATE webhook: ${error.message} | Participants: ${participantsUpdate.participants.length}`, ); // Fallback - envia sem conversĂŁo this.sendDataWebhook(Events.GROUP_PARTICIPANTS_UPDATE, participantsUpdate); } - this.updateGroupMetadataCache(participantsUpdate.id); + this.refreshGroupMetadata(participantsUpdate.id); }, }; @@ -1872,13 +2626,35 @@ export class BaileysStartupService extends ChannelStartupService { }, }; - private eventHandler() { - this.client.ev.process(async (events) => { + /** Returns the function that stops processing this socket's events. */ + private eventHandler(): () => void { + const client = this.client; + this.tapHistoryLidMappings(); + + return client.ev.process(async (events) => { + // Events a replaced socket still emits do not drive the instance. + if (client !== this.client) return; this.eventProcessingQueue = this.eventProcessingQueue.then(async () => { + // Checked again when the batch runs, which can be long after it was queued: an instance shut + // down meanwhile (removed) handles nothing more. A batch of a socket replaced meanwhile by a + // reconnect of the same session still runs: its messages were delivered and acknowledged to + // WhatsApp, which will not send them again. + if (this.shutDown) return; try { + // A logout under way: nothing is forwarded or stored; the connection only delivers the logout. + if (this.logout) { + if (events['creds.update']) this.saveCreds(); + if (events['connection.update']) await this.logoutUpdate(events['connection.update'], client); + return; + } if (!this.endSession) { const database = this.configService.get('DATABASE'); - const settings = await this.findSettings(); + // A failed read must not drop the batch (messages, creds, connection updates with it): + // fall back to the settings loaded at connect and kept by setSettings. + const settings = await this.findSettings().catch((error) => { + this.logger.warn(`Settings read failed, using the last known settings: ${error?.message ?? error}`); + return { ...this.localSettings }; + }); if (events.call) { const call = events.call[0]; @@ -1893,18 +2669,24 @@ export class BaileysStartupService extends ChannelStartupService { } const msg = await this.client.sendMessage(call.from, { text: settings.msgCall }); - this.client.ev.emit('messages.upsert', { messages: [msg], type: 'notify' }); + const upsert = () => this.client.ev.emit('messages.upsert', { messages: [msg], type: 'notify' }); + if (this.liveRecorder) this.liveRecorder.fromApp(upsert); + else upsert(); } this.sendDataWebhook(Events.CALL, call); } if (events['connection.update']) { - this.connectionUpdate(events['connection.update']); + this.connectionUpdate(events['connection.update'], client); } if (events['creds.update']) { - this.instance.authState.saveCreds(); + this.saveCreds(); + } + + if (events['lid-mapping.update']) { + this.lidMappingHandle([events['lid-mapping.update']]); } if (events['messaging-history.set']) { @@ -2042,16 +2824,149 @@ export class BaileysStartupService extends ChannelStartupService { ); } + // The newest name of each contact whose picture contactPictures is still looking up: a rename + // that arrives meanwhile (contacts.update) is what that update must carry, not the batch's name. + private readonly namesAwaitingPicture = new Map(); + + /** + * Look up the pictures of contacts from contacts.upsert (history, address book) and send them on + * contacts.update, with each contact's newest name (a rename that arrived while the lookups ran + * wins over the batch's). A contact whose picture WhatsApp said changed or was removed while its + * lookup ran is left out: that notification's own update is the newer one. + */ + private async contactPictures(contacts: Contact[]) { + const names = contacts.map((contact) => { + const name = contact?.name || contact?.verifiedName || contact.id.split('@')[0]; + const entry = this.namesAwaitingPicture.get(contact.id) ?? { name, refs: 0 }; + entry.name = name; + entry.refs++; + this.namesAwaitingPicture.set(contact.id, entry); + return entry; + }); + try { + const looked = await Promise.all( + contacts.map(async (contact) => { + const version = this.pictureVersion(createJid(contact.id)); + const { profilePictureUrl } = await this.cachedProfilePicture(contact.id, { bulk: true }); + return { contact, version, profilePictureUrl }; + }), + ); + const updatedContacts = looked + .filter(({ contact, version }) => this.pictureVersion(createJid(contact.id)) === version) + .map(({ contact, profilePictureUrl }) => ({ + remoteJid: contact.id, + pushName: this.namesAwaitingPicture.get(contact.id)?.name, + profilePicUrl: profilePictureUrl, + instanceId: this.instanceId, + })); + + if (updatedContacts.length > 0) { + const usersContacts = updatedContacts.filter((c) => c.remoteJid.includes('@s.whatsapp')); + if (usersContacts) { + await saveOnWhatsappCache(usersContacts.map((c) => ({ remoteJid: c.remoteJid }))); + } + + this.sendDataWebhook(Events.CONTACTS_UPDATE, updatedContacts); + await Promise.all( + updatedContacts.map(async (contact) => { + if (this.configService.get('DATABASE').SAVE_DATA.CONTACTS) { + await this.prismaRepository.contact.updateMany({ + where: { remoteJid: contact.remoteJid, instanceId: this.instanceId }, + data: { profilePicUrl: contact.profilePicUrl }, + }); + } + + if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { + const instance = { instanceName: this.instance.name, instanceId: this.instance.id }; + + const findParticipant = await this.chatwootService.findContact(instance, contact.remoteJid.split('@')[0]); + + if (!findParticipant) { + return; + } + + this.chatwootService.updateContact(instance, findParticipant.id, { + name: contact.pushName, + avatar_url: contact.profilePicUrl, + }); + } + }), + ); + } + } catch (error) { + this.logger.error(`Error: ${error.message}`); + } finally { + for (const [i, contact] of contacts.entries()) { + const entry = names[i]; + if (--entry.refs <= 0 && this.namesAwaitingPicture.get(contact.id) === entry) { + this.namesAwaitingPicture.delete(contact.id); + } + } + } + } + + /** + * Asks WhatsApp now (an explicit request), and keeps the answer for the event handlers, unless + * WhatsApp said the picture changed or was removed while it was asking: that is newer. + */ public async profilePicture(number: string) { const jid = createJid(number); + const version = this.pictureVersion(jid); + let profilePictureUrl: string | null; try { - const profilePictureUrl = await this.client.profilePictureUrl(jid, 'image'); - - return { wuid: jid, profilePictureUrl }; + profilePictureUrl = await this.client.profilePictureUrl(jid, 'image'); } catch { - return { wuid: jid, profilePictureUrl: null }; + profilePictureUrl = null; } + + if (this.pictureVersion(jid) === version) this.pictureCache.set(jid, { url: profilePictureUrl, at: Date.now() }); + return { wuid: jid, profilePictureUrl }; + } + + /** Bumped by WhatsApp's picture notification, so a lookup started before it cannot overwrite it. */ + private readonly pictureVersions = new Map(); + private pictureVersion(jid: string) { + return this.pictureVersions.get(jid) ?? 0; + } + private invalidatePicture(jid: string) { + this.pictureVersions.set(jid, this.pictureVersion(jid) + 1); + this.pictureCache.delete(jid); + this.pictureLookups.delete(jid); + } + + /** + * The picture the event handlers report: kept for PICTURE_TTL_MS, one lookup per jid at a time. + * Bulk lookups (history, address book) queue behind each other; a live event's lookup never joins one. + */ + private async cachedProfilePicture(number: string, opts: { fresh?: boolean; bulk?: boolean } = {}) { + const jid = createJid(number); + const bulk = !!opts.bulk; + const kept = () => { + const k = this.pictureCache.get(jid); + return !opts.fresh && k && Date.now() - k.at < this.PICTURE_TTL_MS ? k : undefined; + }; + + if (kept()) return { wuid: jid, profilePictureUrl: kept().url }; + + const pending = opts.fresh ? undefined : this.pictureLookups.get(jid); + if (pending && (bulk || !pending.bulk)) return { wuid: jid, profilePictureUrl: await pending.lookup }; + + const lookup = this.backgroundQueries + // A queued lookup may find the picture already kept by the time its turn comes. + .run( + async () => { + const k = kept(); + return k ? k.url : (await this.profilePicture(jid)).profilePictureUrl; + }, + { bulk }, + ) + .finally(() => { + if (this.pictureLookups.get(jid)?.lookup === lookup) this.pictureLookups.delete(jid); + }); + this.pictureLookups.set(jid, { bulk, lookup }); + + return { wuid: jid, profilePictureUrl: await lookup }; } public async getStatus(number: string) { @@ -2515,8 +3430,8 @@ export class BaileysStartupService extends ChannelStartupService { const buffer = await downloadMediaMessage( { key: messageRaw.key, message: messageRaw?.message }, 'buffer', - {}, - { logger: P({ level: 'error' }) as any, reuploadRequest: this.client.updateMediaMessage }, + this.mediaDownloadOptions(), + { logger: makeBaileysLogger('error') as any, reuploadRequest: this.client.updateMediaMessage }, ); if (buffer) { @@ -2526,8 +3441,8 @@ export class BaileysStartupService extends ChannelStartupService { const buffer = await downloadMediaMessage( { key: messageRaw.key, message: messageRaw?.message }, 'buffer', - {}, - { logger: P({ level: 'error' }) as any, reuploadRequest: this.client.updateMediaMessage }, + this.mediaDownloadOptions(), + { logger: makeBaileysLogger('error') as any, reuploadRequest: this.client.updateMediaMessage }, ); if (buffer) { @@ -2535,7 +3450,13 @@ export class BaileysStartupService extends ChannelStartupService { } } } catch (error) { - this.logger.error(['Error converting media to base64', error?.message]); + // A failed download's error names the signed media link: bounded, scrubbed fields only. + this.logger.error({ + message: 'Error converting media to base64', + messageId: messageRaw.key?.id, + chatType: jidKind(messageRaw.key?.remoteJid), + error: errorFields(error), + }); } } } @@ -3834,7 +4755,99 @@ export class BaileysStartupService extends ChannelStartupService { return map[mediaType] || null; } + private proxyKey() { + const { protocol, host, port, username, password } = this.localProxy; + return JSON.stringify([protocol, host, port, username, password]); + } + + /** + * Options for Baileys' media downloads. Baileys downloads with fetch, which + * ignores the socket's `agent`/`fetchAgent` and routes only through a + * `dispatcher`, so without this the media leaves from the server's own IP + * while the messages leave through the instance's proxy. + */ + private mediaDownloadOptions() { + if (!this.localProxy?.enabled || !this.localProxy.host) return {}; + const key = this.proxyKey(); + if (this.mediaProxy?.key !== key) { + // A proxyscrape host is a list the socket picked one exit from; only the socket's own dispatcher + // is that exit. Without one, the download fails rather than leave from the server's address. + if (this.localProxy.host.includes('proxyscrape')) { + if (this.mediaProxy) return { options: { dispatcher: this.mediaProxy.dispatcher } as RequestInit }; + throw new BadRequestException('No proxy exit for this download yet: connect the instance first'); + } + this.mediaProxy = { + key, + dispatcher: makeProxyAgentUndici({ + host: this.localProxy.host, + port: this.localProxy.port, + protocol: this.localProxy.protocol, + username: this.localProxy.username, + password: this.localProxy.password, + }), + }; + } + return { options: { dispatcher: this.mediaProxy.dispatcher } as RequestInit }; + } + + /** + * End Baileys' wait for the phone's answer to a re-upload request (updateMediaMessage waits on + * messages.media-update with no timeout): answer it with an error for that message, on the socket + * that asked. Marked as Evolution's own event for a live recording. + */ + private abandonReupload(client: WASocket, key: WAMessageKey) { + const emit = () => + client?.ev?.emit('messages.media-update', [ + { key, error: new Boom('Media re-upload abandoned: no answer in time', { statusCode: 408 }) }, + ]); + try { + if (this.liveRecorder) this.liveRecorder.fromApp(emit); + else emit(); + } catch (error) { + this.logger.warn({ message: 'Could not end the re-upload wait', error: errorFields(error) }); + } + } + + /** + * The key the phone stores a message under, which is how a request about the message + * (a media re-upload) must name it: the phone refuses one that names a DM it keeps + * under an @lid by the phone JID. The messages.upsert webhook shows such a DM under + * the phone JID with the @lid in remoteJidAlt; a key from Evolution 2.3.7 has the phone + * twice and addressingMode 'lid', and only Baileys' LID mapping still knows the @lid. + * A group key names its sender the same way, in participant / participantAlt. + */ + private async originalMessageKey(key: WAMessageKey): Promise { + if (!key) return key; + const original = { ...key }; + if (!isLidUser(key.remoteJid) && isLidUser(key.remoteJidAlt)) { + original.remoteJid = key.remoteJidAlt; + original.remoteJidAlt = key.remoteJid; + original.addressingMode = 'lid'; + } else if (key.addressingMode === 'lid' && isPnUser(key.remoteJid)) { + let lid: string | null = null; + try { + lid = await this.client.signalRepository?.lidMapping?.getLIDForPN(key.remoteJid); + } catch { + // No mapping: the key is asked for as it was given. + } + if (isLidUser(lid)) { + original.remoteJid = lid; + original.remoteJidAlt = key.remoteJid; + } + } + if (!isLidUser(key.participant) && isLidUser(key.participantAlt)) { + original.participant = key.participantAlt; + original.participantAlt = key.participant; + original.addressingMode = 'lid'; + } + return original; + } + public async getBase64FromMediaMessage(data: getBase64FromMediaMessageDto, getBuffer = false) { + // Set once a download is attempted: whether the phone was asked to re-upload an expired file. + let reupload: MediaReupload | undefined; + // Set when the re-upload failed: why (reuploadRefusal). + let reuploadReason: string | undefined; try { const m = data?.message; const convertToMp4 = data?.convertToMp4 ?? false; @@ -3889,20 +4902,102 @@ export class BaileysStartupService extends ChannelStartupService { } } - if (typeof mediaMessage['mediaKey'] === 'object') { - msg.message[mediaType].mediaKey = Uint8Array.from(Object.values(mediaMessage['mediaKey'])); + if (mediaMessage['mediaKey'] != null) { + msg.message[mediaType].mediaKey = mediaKeyBytes(mediaMessage['mediaKey']); } let buffer: Buffer; + const media = `message=${msg?.key?.id}, chat=${jidKind(msg?.key?.remoteJid)}`; + // reupload: false downloads only what is still on WhatsApp's servers. + const askPhone = data?.reupload !== false; + reupload = 'not_requested'; + // Asks the phone for a new copy, for a bounded time. Called at most once per download. + const reuploadRequest = async (message: WAMessage): Promise => { + this.logger.warn(`media download: ${media}, outcome=reupload_requested`); + let timer: NodeJS.Timeout; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error(`the phone did not answer the re-upload request in ${MEDIA_REUPLOAD_TIMEOUT_MS}ms`); + reject(Object.assign(error, { name: 'ReuploadTimeoutError' })); + }, MEDIA_REUPLOAD_TIMEOUT_MS); + }); + const client = this.client; + let abandoned = false; + let asking: Promise; + try { + // The key's original form first; when the key cannot say which address the phone keeps the + // message under (the phone with its @lid beside it), the key as given if the phone refuses. + const ask = async () => { + const original = await this.originalMessageKey(message.key); + const candidates = [original]; + if (JSON.stringify(original) !== JSON.stringify(message.key)) candidates.push(message.key); + for (const [i, key] of candidates.entries()) { + if (abandoned) throw new Error('re-upload abandoned'); + try { + return await client.updateMediaMessage({ ...message, key }); + } catch (error) { + const refused = typeof error?.data?.result === 'number'; + if (!refused || i === candidates.length - 1) throw error; + this.logger.warn( + `media download: ${media}, outcome=reupload_refused, reason=${reuploadRefusal(error)}, trying the other address`, + ); + } + } + }; + asking = ask(); + const updated = await Promise.race([asking, timeout]); + reupload = 'ok'; + this.logger.warn(`media download: ${media}, outcome=reupload_ok`); + return updated; + } catch (error) { + if (error?.name === 'ReuploadTimeoutError') { + // Baileys waits for the answer with no timeout of its own (bindWaitForEvent): end that + // wait, so its listeners go and an answer arriving later changes nothing. + abandoned = true; + asking?.catch(() => undefined); + this.abandonReupload(client, message.key); + } + reupload = 'failed'; + reuploadReason = reuploadRefusal(error); + this.logger.warn( + `media download: ${media}, outcome=reupload_failed, error=${error?.name ?? 'unknown'}, status=${httpStatus(error)}, reason=${reuploadReason}`, + ); + throw error; + } finally { + clearTimeout(timer); + } + }; + const target: WAMessage = { key: msg?.key, message: msg?.message }; + // The links as first downloaded, for a 403 whose error does not name the one that failed. + const link = { url: msg.message[mediaType]?.url, directPath: msg.message[mediaType]?.directPath }; + // No reuploadRequest for Baileys: Evolution asks the phone itself, below. Baileys + // means to ask on a 404 or 410, but 7.0.0-rc14 checks error.status + // (lib/Utils/messages.js:836) while its CDN fetch sets only output.statusCode + // (lib/Utils/messages-media.js:304), so it never does. Handing it the request as + // well would let a Baileys that does ask make a second one. + const download = (message: WAMessage) => + downloadMediaMessage(message, 'buffer', this.mediaDownloadOptions()) as Promise; try { - buffer = await downloadMediaMessage( - { key: msg?.key, message: msg?.message }, - 'buffer', - {}, - { logger: P({ level: 'error' }) as any, reuploadRequest: this.client.updateMediaMessage }, - ); - } catch { + try { + buffer = await download(target); + } catch (error) { + if (!askPhone || !isExpiredMedia(error, link)) throw error; + let refreshed: WAMessage; + try { + refreshed = await reuploadRequest(target); + } catch { + // The download's own error stands; the log already says how the re-upload ended. + throw error; + } + buffer = await download(refreshed); + } + } catch (error) { + const status = httpStatus(error); + this.logger.error(`media download: ${media}, outcome=download_failed, status=${status}, reupload=${reupload}`); + if (!askPhone && isExpiredMedia(error, link)) { + throw `The media is no longer on WhatsApp's servers (HTTP ${status}), and no re-upload from the phone was attempted (reupload: false)`; + } this.logger.error('Download Media failed, trying to retry in 5 seconds...'); await new Promise((resolve) => setTimeout(resolve, 5000)); const mediaType = Object.keys(msg.message).find((key) => key.endsWith('Message')); @@ -3916,7 +5011,7 @@ export class BaileysStartupService extends ChannelStartupService { url: `https://mmg.whatsapp.net${msg?.message?.[mediaType]?.directPath}`, }, await this.mapMediaType(mediaType), - {}, + this.mediaDownloadOptions(), ); const chunks = []; for await (const chunk of media) { @@ -3925,7 +5020,10 @@ export class BaileysStartupService extends ChannelStartupService { buffer = Buffer.concat(chunks); this.logger.info('Download Media with downloadContentFromMessage was successful!'); } catch (fallbackErr) { - this.logger.error('Download Media with downloadContentFromMessage also failed!'); + // Its error carries the signed media URL (message and data.url): name and status only. + this.logger.error( + `media fallback: ${media}, outcome=failed, error=${errorName(fallbackErr)}, status=${httpStatus(fallbackErr)}`, + ); throw fallbackErr; } } @@ -3972,9 +5070,18 @@ export class BaileysStartupService extends ChannelStartupService { buffer: getBuffer ? buffer : null, }; } catch (error) { - this.logger.error('Error processing media message:'); - this.logger.error(error); - throw new BadRequestException(error.toString()); + const key = data?.message?.key; + this.logger.error( + `media processing failed: message=${key?.id}, chat=${jidKind(key?.remoteJid)}, error=${errorName(error)}, status=${httpStatus(error)}`, + ); + if (reupload === undefined) throw new BadRequestException(error.toString()); + // The same 400, plus whether the phone was asked to re-upload the file, and why it refused. + // Never the error's own text, which names the signed media link (mediaDownloadFailure). + try { + new BadRequestException(mediaDownloadFailure(error)); + } catch (badRequest) { + throw { ...badRequest, reupload, ...(reuploadReason && { reuploadReason }) }; + } } } @@ -4284,16 +5391,32 @@ export class BaileysStartupService extends ChannelStartupService { } // Group + private async keepGroupMetadata(groupJid: string, meta: GroupMetadata) { + const cacheConf = this.configService.get('CACHE'); + + if ((cacheConf?.REDIS?.ENABLED && cacheConf?.REDIS?.URI !== '') || cacheConf?.LOCAL?.ENABLED) { + this.logger.verbose(`Updating cache for group: ${groupJid}`); + await groupMetadataCache.set(groupJid, { timestamp: Date.now(), data: meta }); + } + } + + /** Refetch a group's metadata in the background: one query per group at a time, a few groups at once. */ + private refreshGroupMetadata(groupJid: string) { + let refresh = this.groupRefreshes.get(groupJid); + if (!refresh) { + refresh = this.backgroundQueries + .run(() => this.updateGroupMetadataCache(groupJid)) + .finally(() => this.groupRefreshes.delete(groupJid)); + this.groupRefreshes.set(groupJid, refresh); + } + return refresh; + } + private async updateGroupMetadataCache(groupJid: string) { try { const meta = await this.client.groupMetadata(groupJid); - const cacheConf = this.configService.get('CACHE'); - - if ((cacheConf?.REDIS?.ENABLED && cacheConf?.REDIS?.URI !== '') || cacheConf?.LOCAL?.ENABLED) { - this.logger.verbose(`Updating cache for group: ${groupJid}`); - await groupMetadataCache.set(groupJid, { timestamp: Date.now(), data: meta }); - } + await this.keepGroupMetadata(groupJid, meta); return meta; } catch (error) { @@ -4309,7 +5432,7 @@ export class BaileysStartupService extends ChannelStartupService { if ((cacheConf?.REDIS?.ENABLED && cacheConf?.REDIS?.URI !== '') || cacheConf?.LOCAL?.ENABLED) { if (await groupMetadataCache?.has(groupJid)) { - console.log(`Cache request for group: ${groupJid}`); + this.logger.verbose('Cache request for group: found'); const meta = await groupMetadataCache.get(groupJid); if (Date.now() - meta.timestamp > 3600000) { @@ -4319,7 +5442,7 @@ export class BaileysStartupService extends ChannelStartupService { return meta.data; } - console.log(`Cache request for group: ${groupJid} - not found`); + this.logger.verbose('Cache request for group: not found'); return await this.updateGroupMetadataCache(groupJid); } @@ -4450,7 +5573,7 @@ export class BaileysStartupService extends ChannelStartupService { let groups = []; for (const group of fetch) { - const picture = await this.profilePicture(group.id); + const picture = await this.cachedProfilePicture(group.id); const result = { id: group.id, @@ -4860,7 +5983,7 @@ export class BaileysStartupService extends ChannelStartupService { } public async baileysSendNode(stanza: any) { - console.log('stanza', JSON.stringify(stanza)); + this.logger.verbose(`stanza ${stanza?.tag ?? ''}`); const response = await this.client.sendNode(stanza); return response; diff --git a/src/api/integrations/event/event.controller.ts b/src/api/integrations/event/event.controller.ts index 39b52184bf..a714686a48 100644 --- a/src/api/integrations/event/event.controller.ts +++ b/src/api/integrations/event/event.controller.ts @@ -17,6 +17,20 @@ export type EmitData = { extra?: Record; }; +// Group updates have two spellings. Baileys emits groups.update, GROUPS_UPDATE once +// upper-cased, but every //set schema (EventController.events) and every +// global env config except SQS's stores it as GROUP_UPDATE. Both are accepted and +// neither is renamed, so a subscription stored under either name keeps working. +const EVENT_ALIASES: Record = { GROUPS_UPDATE: ['GROUPS_UPDATE', 'GROUP_UPDATE'] }; + +/** Whether `subscribed` (an instance's stored event names, or a global config's flags) includes the event `we`. */ +export function isSubscribed(subscribed: unknown, we: string): boolean { + const names = EVENT_ALIASES[we] ?? [we]; + if (Array.isArray(subscribed)) return names.some((name) => subscribed.includes(name)); + if (subscribed && typeof subscribed === 'object') return names.some((name) => !!subscribed[name]); + return false; +} + export interface EventControllerInterface { set(instanceName: string, data: any): Promise; get(instanceName: string): Promise; diff --git a/src/api/integrations/event/kafka/kafka.controller.ts b/src/api/integrations/event/kafka/kafka.controller.ts index 543c759ad5..7df6076d87 100644 --- a/src/api/integrations/event/kafka/kafka.controller.ts +++ b/src/api/integrations/event/kafka/kafka.controller.ts @@ -4,7 +4,7 @@ import { configService, Kafka, Log } from '@config/env.config'; import { Logger } from '@config/logger.config'; import { Consumer, ConsumerConfig, Kafka as KafkaJS, KafkaConfig, Producer, ProducerConfig } from 'kafkajs'; -import { EmitData, EventController, EventControllerInterface } from '../event.controller'; +import { EmitData, EventController, EventControllerInterface, isSubscribed } from '../event.controller'; export class KafkaController extends EventController implements EventControllerInterface { private kafkaClient: KafkaJS | null = null; @@ -299,7 +299,7 @@ export class KafkaController extends EventController implements EventControllerI const messageValue = JSON.stringify(message); // Instance-specific events - if (instanceKafka?.enabled && this.producer && Array.isArray(kafkaLocal) && kafkaLocal.includes(we)) { + if (instanceKafka?.enabled && this.producer && isSubscribed(kafkaLocal, we)) { const topicName = this.getTopicName(event, false, instanceName); let retry = 0; @@ -345,7 +345,7 @@ export class KafkaController extends EventController implements EventControllerI } // Global events - if (kafkaGlobal && kafkaEvents[we] && this.producer) { + if (kafkaGlobal && isSubscribed(kafkaEvents, we) && this.producer) { const topicName = this.getTopicName(event, true); let retry = 0; diff --git a/src/api/integrations/event/nats/nats.controller.ts b/src/api/integrations/event/nats/nats.controller.ts index 1ff4fbae89..5b0e759233 100644 --- a/src/api/integrations/event/nats/nats.controller.ts +++ b/src/api/integrations/event/nats/nats.controller.ts @@ -4,7 +4,7 @@ import { configService, Log, Nats } from '@config/env.config'; import { Logger } from '@config/logger.config'; import { connect, NatsConnection, StringCodec } from 'nats'; -import { EmitData, EventController, EventControllerInterface } from '../event.controller'; +import { EmitData, EventController, EventControllerInterface, isSubscribed } from '../event.controller'; export class NatsController extends EventController implements EventControllerInterface { public natsClient: NatsConnection | null = null; @@ -78,7 +78,7 @@ export class NatsController extends EventController implements EventControllerIn // InstĂąncia especĂ­fica if (instanceNats?.enabled) { - if (Array.isArray(natsLocal) && natsLocal.includes(we)) { + if (isSubscribed(natsLocal, we)) { const subject = `${instanceName}.${event.toLowerCase()}`; try { @@ -98,7 +98,7 @@ export class NatsController extends EventController implements EventControllerIn } // Global - if (natsGlobal && natsEvents[we]) { + if (natsGlobal && isSubscribed(natsEvents, we)) { try { const subject = prefixKey ? `${prefixKey}.${event.toLowerCase()}` : event.toLowerCase(); diff --git a/src/api/integrations/event/pusher/pusher.controller.ts b/src/api/integrations/event/pusher/pusher.controller.ts index 045f7cc4f7..bbb2d23e4b 100644 --- a/src/api/integrations/event/pusher/pusher.controller.ts +++ b/src/api/integrations/event/pusher/pusher.controller.ts @@ -6,7 +6,7 @@ import { configService, Log, Pusher as ConfigPusher } from '@config/env.config'; import { Logger } from '@config/logger.config'; import Pusher from 'pusher'; -import { EmitData, EventController, EventControllerInterface } from '../event.controller'; +import { EmitData, EventController, EventControllerInterface, isSubscribed } from '../event.controller'; export class PusherController extends EventController implements EventControllerInterface { private readonly logger = new Logger('PusherController'); private pusherClients: { [instanceName: string]: Pusher } = {}; @@ -162,7 +162,7 @@ export class PusherController extends EventController implements EventController } if (local && instance && instance.enabled) { const pusherLocalEvents = instance.events; - if (Array.isArray(pusherLocalEvents) && pusherLocalEvents.includes(we)) { + if (isSubscribed(pusherLocalEvents, we)) { if (enabledLog) { this.logger.log({ local: `${origin}.sendData-Pusher`, @@ -188,7 +188,7 @@ export class PusherController extends EventController implements EventController } if (this.pusherConfig.GLOBAL?.ENABLED) { const globalEvents = this.pusherConfig.EVENTS; - if (globalEvents[we]) { + if (isSubscribed(globalEvents, we)) { if (enabledLog) { this.logger.log({ local: `${origin}.sendData-Pusher-Global`, diff --git a/src/api/integrations/event/rabbitmq/rabbitmq.controller.ts b/src/api/integrations/event/rabbitmq/rabbitmq.controller.ts index b4625508be..5b070ec1a8 100644 --- a/src/api/integrations/event/rabbitmq/rabbitmq.controller.ts +++ b/src/api/integrations/event/rabbitmq/rabbitmq.controller.ts @@ -4,7 +4,7 @@ import { configService, Log, Rabbitmq } from '@config/env.config'; import { Logger } from '@config/logger.config'; import * as amqp from 'amqplib/callback_api'; -import { EmitData, EventController, EventControllerInterface } from '../event.controller'; +import { EmitData, EventController, EventControllerInterface, isSubscribed } from '../event.controller'; export class RabbitmqController extends EventController implements EventControllerInterface { public amqpChannel: amqp.Channel | null = null; @@ -245,7 +245,7 @@ export class RabbitmqController extends EventController implements EventControll }; if (instanceRabbitmq?.enabled && this.amqpChannel) { - if (Array.isArray(rabbitmqLocal) && rabbitmqLocal.includes(we)) { + if (isSubscribed(rabbitmqLocal, we)) { const exchangeName = instanceName ?? rabbitmqExchangeName; let retry = 0; @@ -298,7 +298,7 @@ export class RabbitmqController extends EventController implements EventControll } } - if (rabbitmqGlobal && rabbitmqEvents[we] && this.amqpChannel) { + if (rabbitmqGlobal && isSubscribed(rabbitmqEvents, we) && this.amqpChannel) { const exchangeName = rabbitmqExchangeName; let retry = 0; diff --git a/src/api/integrations/event/sqs/sqs.controller.ts b/src/api/integrations/event/sqs/sqs.controller.ts index 2b0398ef21..d3788aebde 100644 --- a/src/api/integrations/event/sqs/sqs.controller.ts +++ b/src/api/integrations/event/sqs/sqs.controller.ts @@ -5,7 +5,7 @@ import { CreateQueueCommand, DeleteQueueCommand, ListQueuesCommand, SQS } from ' import { configService, HttpServer, Log, S3, Sqs } from '@config/env.config'; import { Logger } from '@config/logger.config'; -import { EmitData, EventController, EventControllerInterface } from '../event.controller'; +import { EmitData, EventController, EventControllerInterface, isSubscribed } from '../event.controller'; import { EventDto } from '../event.dto'; export class SqsController extends EventController implements EventControllerInterface { @@ -119,7 +119,7 @@ export class SqsController extends EventController implements EventControllerInt } } - if (Array.isArray(sqsEvents) && sqsEvents.includes(we)) { + if (isSubscribed(sqsEvents, we)) { const prefixName = sqsConfig.GLOBAL_ENABLED ? sqsConfig.GLOBAL_PREFIX_NAME : instanceName; const eventFormatted = sqsConfig.GLOBAL_ENABLED && sqsConfig.GLOBAL_FORCE_SINGLE_QUEUE diff --git a/src/api/integrations/event/webhook/webhook.controller.ts b/src/api/integrations/event/webhook/webhook.controller.ts index 7f1dd8dc0e..44956936c9 100644 --- a/src/api/integrations/event/webhook/webhook.controller.ts +++ b/src/api/integrations/event/webhook/webhook.controller.ts @@ -8,7 +8,7 @@ import { Logger } from '@config/logger.config'; import axios, { AxiosInstance } from 'axios'; import * as jwt from 'jsonwebtoken'; -import { EmitData, EventController, EventControllerInterface } from '../event.controller'; +import { EmitData, EventController, EventControllerInterface, isSubscribed } from '../event.controller'; export class WebhookController extends EventController implements EventControllerInterface { private readonly logger = new Logger('WebhookController'); @@ -103,7 +103,7 @@ export class WebhookController extends EventController implements EventControlle }; if (local && instance?.enabled) { - if (Array.isArray(webhookLocal) && webhookLocal.includes(we)) { + if (isSubscribed(webhookLocal, we)) { let baseURL: string; if (instance?.webhookByEvents) { @@ -150,7 +150,7 @@ export class WebhookController extends EventController implements EventControlle } if (webhookConfig.GLOBAL?.ENABLED) { - if (webhookConfig.EVENTS[we]) { + if (isSubscribed(webhookConfig.EVENTS, we)) { let globalURL = webhookConfig.GLOBAL.URL; if (webhookConfig.GLOBAL.WEBHOOK_BY_EVENTS) { diff --git a/src/api/integrations/event/websocket/websocket.controller.ts b/src/api/integrations/event/websocket/websocket.controller.ts index 3c763f08d6..595aaa991c 100644 --- a/src/api/integrations/event/websocket/websocket.controller.ts +++ b/src/api/integrations/event/websocket/websocket.controller.ts @@ -5,7 +5,7 @@ import { Logger } from '@config/logger.config'; import { Server } from 'http'; import { Server as SocketIO } from 'socket.io'; -import { EmitData, EventController, EventControllerInterface } from '../event.controller'; +import { EmitData, EventController, EventControllerInterface, isSubscribed } from '../event.controller'; export class WebsocketController extends EventController implements EventControllerInterface { private io: SocketIO; @@ -156,7 +156,7 @@ export class WebsocketController extends EventController implements EventControl return; } - if (Array.isArray(instance?.events) && instance?.events.includes(configEv)) { + if (isSubscribed(instance?.events, configEv)) { this.socket.of(`/${instanceName}`).emit(event, message); if (logEnabled) { diff --git a/src/api/routes/chat.router.ts b/src/api/routes/chat.router.ts index 158947ed22..da13dff7fc 100644 --- a/src/api/routes/chat.router.ts +++ b/src/api/routes/chat.router.ts @@ -24,6 +24,7 @@ import { blockUserSchema, contactValidateSchema, deleteMessageSchema, + getBase64FromMediaMessageSchema, markChatUnreadSchema, messageUpSchema, messageValidateSchema, @@ -113,7 +114,7 @@ export class ChatRouter extends RouterBroker { .post(this.routerPath('getBase64FromMediaMessage'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, - schema: null, + schema: getBase64FromMediaMessageSchema, ClassRef: getBase64FromMediaMessageDto, execute: (instance, data) => chatController.getBase64FromMediaMessage(instance, data), }); diff --git a/src/api/routes/index.router.ts b/src/api/routes/index.router.ts index 45c43fca5b..f39576fb61 100644 --- a/src/api/routes/index.router.ts +++ b/src/api/routes/index.router.ts @@ -28,6 +28,7 @@ import { ViewsRouter } from './view.router'; enum HttpStatus { OK = 200, CREATED = 201, + ACCEPTED = 202, NOT_FOUND = 404, FORBIDDEN = 403, BAD_REQUEST = 400, diff --git a/src/api/routes/instance.router.ts b/src/api/routes/instance.router.ts index 3559893e68..1cf61f05dc 100644 --- a/src/api/routes/instance.router.ts +++ b/src/api/routes/instance.router.ts @@ -84,7 +84,8 @@ export class InstanceRouter extends RouterBroker { execute: (instance) => instanceController.logout(instance), }); - return res.status(HttpStatus.OK).json(response); + // A logout still on its way to WhatsApp is accepted, not done. + return res.status(response?.status === 'PENDING' ? HttpStatus.ACCEPTED : HttpStatus.OK).json(response); }) .delete(this.routerPath('delete'), ...guards, async (req, res) => { const response = await this.dataValidate({ @@ -94,7 +95,8 @@ export class InstanceRouter extends RouterBroker { execute: (instance) => instanceController.deleteInstance(instance), }); - return res.status(HttpStatus.OK).json(response); + // A logout still on its way to WhatsApp is accepted, not done. + return res.status(response?.status === 'PENDING' ? HttpStatus.ACCEPTED : HttpStatus.OK).json(response); }); } diff --git a/src/api/services/channel.service.ts b/src/api/services/channel.service.ts index 56bec08021..2cca82660d 100644 --- a/src/api/services/channel.service.ts +++ b/src/api/services/channel.service.ts @@ -362,17 +362,22 @@ export class ChannelStartupService { } } + /** + * Read the instance's proxy (the global PROXY_* configuration, then its Proxy row) and put it in + * force at once. The one in force stays until the new one is read: a media download while this + * waits for the database must not leave directly, and a failed read (thrown) changes nothing. + */ public async loadProxy() { - this.localProxy.enabled = false; + const next: Partial = { enabled: false }; const proxyConfig = this.configService.get('PROXY'); if (proxyConfig.HOST) { - this.localProxy.enabled = true; - this.localProxy.host = proxyConfig.HOST; - this.localProxy.port = proxyConfig.PORT || '80'; - this.localProxy.protocol = proxyConfig.PROTOCOL || 'http'; - this.localProxy.username = proxyConfig.USERNAME; - this.localProxy.password = proxyConfig.PASSWORD; + next.enabled = true; + next.host = proxyConfig.HOST; + next.port = proxyConfig.PORT || '80'; + next.protocol = proxyConfig.PROTOCOL || 'http'; + next.username = proxyConfig.USERNAME; + next.password = proxyConfig.PASSWORD; } const data = await this.prismaRepository.proxy.findUnique({ @@ -382,13 +387,15 @@ export class ChannelStartupService { }); if (data?.enabled) { - this.localProxy.enabled = true; - this.localProxy.host = data?.host; - this.localProxy.port = data?.port; - this.localProxy.protocol = data?.protocol; - this.localProxy.username = data?.username; - this.localProxy.password = data?.password; + next.enabled = true; + next.host = data?.host; + next.port = data?.port; + next.protocol = data?.protocol; + next.username = data?.username; + next.password = data?.password; } + + Object.assign(this.localProxy, next); } public async setProxy(data: ProxyDto) { diff --git a/src/api/services/monitor.service.ts b/src/api/services/monitor.service.ts index 438530b57e..71193174f7 100644 --- a/src/api/services/monitor.service.ts +++ b/src/api/services/monitor.service.ts @@ -7,9 +7,11 @@ import { CacheConf, Chatwoot, ConfigService, Database, DelInstance, ProviderSess import { Logger } from '@config/logger.config'; import { INSTANCE_DIR, STORE_DIR } from '@config/path.config'; import { NotFoundException } from '@exceptions'; +import { errorFields } from '@utils/log-privacy'; +import { readLogoutMarker } from '@utils/logout-marker'; import { execFileSync } from 'child_process'; import EventEmitter2 from 'eventemitter2'; -import { rmSync } from 'fs'; +import { readdirSync, rmSync } from 'fs'; import { join } from 'path'; import { CacheService } from './cache.service'; @@ -26,6 +28,11 @@ export class WAMonitoringService { ) { this.removeInstance(); this.noConnection(); + this.eventEmitter.on('logout.finished', (instance: any) => { + for (const [name, finishing] of Object.entries(this.finishingLogouts)) { + if (finishing === instance) delete this.finishingLogouts[name]; + } + }); Object.assign(this.db, configService.get('DATABASE')); Object.assign(this.redis, configService.get('CACHE')); @@ -38,6 +45,8 @@ export class WAMonitoringService { private readonly logger = new Logger('WAMonitoringService'); public readonly waInstances: Record = {}; + // Deleted instances whose logout has not reached WhatsApp yet, by name: out of the API, finishing it. + public readonly finishingLogouts: Record = {}; private readonly delInstanceTimeouts: Record = {}; private readonly providerSession: ProviderSession; @@ -96,6 +105,8 @@ export class WAMonitoringService { const clientName = this.configService.get('DATABASE').CONNECTION.CLIENT_NAME; + // A deleted instance finishing its logout keeps its row until then, out of the API. + const finishing = Object.keys(this.finishingLogouts); const where = instanceNames && instanceNames.length > 0 ? { @@ -104,7 +115,7 @@ export class WAMonitoringService { }, clientName, } - : { clientName }; + : { clientName, ...(finishing.length ? { name: { notIn: finishing } } : {}) }; const instances = await this.prismaRepository.instance.findMany({ where, @@ -188,7 +199,13 @@ export class WAMonitoringService { } } - public async cleaningStoreData(instanceName: string) { + /** + * `keepForLogout`: keep what a pending logout still needs: the session (creds, key files, logout + * marker), the proxy, and the Instance row itself, since Session and Proxy reference it ON DELETE + * CASCADE. Its token is cleared, so the deleted instance's key authenticates nothing. The row goes + * when the logout has reached WhatsApp (BaileysStartupService.finishLogout). + */ + public async cleaningStoreData(instanceName: string, { keepForLogout = false } = {}) { if (this.configService.get('CHATWOOT').ENABLED) { const instancePath = join(STORE_DIR, 'chatwoot', instanceName); execFileSync('rm', ['-rf', instancePath]); @@ -200,9 +217,11 @@ export class WAMonitoringService { if (!instance) return; - rmSync(join(INSTANCE_DIR, instance.id), { recursive: true, force: true }); + if (!keepForLogout) { + rmSync(join(INSTANCE_DIR, instance.id), { recursive: true, force: true }); - await this.prismaRepository.session.deleteMany({ where: { sessionId: instance.id } }); + await this.prismaRepository.session.deleteMany({ where: { sessionId: instance.id } }); + } await this.prismaRepository.chat.deleteMany({ where: { instanceId: instance.id } }); await this.prismaRepository.contact.deleteMany({ where: { instanceId: instance.id } }); @@ -211,7 +230,7 @@ export class WAMonitoringService { await this.prismaRepository.webhook.deleteMany({ where: { instanceId: instance.id } }); await this.prismaRepository.chatwoot.deleteMany({ where: { instanceId: instance.id } }); - await this.prismaRepository.proxy.deleteMany({ where: { instanceId: instance.id } }); + if (!keepForLogout) await this.prismaRepository.proxy.deleteMany({ where: { instanceId: instance.id } }); await this.prismaRepository.rabbitmq.deleteMany({ where: { instanceId: instance.id } }); await this.prismaRepository.nats.deleteMany({ where: { instanceId: instance.id } }); await this.prismaRepository.sqs.deleteMany({ where: { instanceId: instance.id } }); @@ -221,6 +240,10 @@ export class WAMonitoringService { await this.prismaRepository.setting.deleteMany({ where: { instanceId: instance.id } }); await this.prismaRepository.label.deleteMany({ where: { instanceId: instance.id } }); + if (keepForLogout) { + await this.prismaRepository.instance.update({ where: { id: instance.id }, data: { token: null } }); + return; + } await this.prismaRepository.instance.delete({ where: { name: instanceName } }); } @@ -233,11 +256,59 @@ export class WAMonitoringService { } else if (this.redis.REDIS.ENABLED && this.redis.REDIS.SAVE_INSTANCES) { await this.loadInstancesFromRedis(); } + await this.resumeDeletedLogouts(); } catch (error) { this.logger.error(error); } } + /** + * Delete while the logout could not reach WhatsApp: the instance leaves the API now (memory and + * everything stored for it), keeping only its row, session and proxy, and finishes the logout in + * the background. The service removes those when it has (BaileysStartupService.finishLogout). + */ + public async deleteKeepingLogout(instanceName: string) { + const instance = this.waInstances[instanceName]; + await instance.markLogoutDeleted(); + this.finishingLogouts[instanceName] = instance; + this.clearDelInstanceTime(instanceName); + delete this.waInstances[instanceName]; + await this.cleaningStoreData(instanceName, { keepForLogout: true }); + this.logger.warn(`Instance "${instanceName}" - REMOVED, its logout pending`); + } + + /** + * On boot: a deleted instance with no row whose logout marker is still there finishes its logout. + * Only a delete made before the row was kept leaves that (its session went with the row); a row + * that is still there is loaded by setInstance. + */ + private async resumeDeletedLogouts() { + let ids: string[]; + try { + ids = readdirSync(INSTANCE_DIR); + } catch { + return; + } + for (const instanceId of ids) { + const marker = readLogoutMarker(instanceId); + if (!marker?.deleted || !marker.instanceName) continue; + if (await this.prismaRepository.instance.findUnique({ where: { id: instanceId } })) continue; + const instanceData = { instanceId, instanceName: marker.instanceName, integration: Integration.WHATSAPP_BAILEYS }; + const instance = channelController.init(instanceData, { + configService: this.configService, + eventEmitter: this.eventEmitter, + prismaRepository: this.prismaRepository, + cache: this.cache, + chatwootCache: this.chatwootCache, + baileysCache: this.baileysCache, + providerFiles: this.providerFiles, + }); + instance.setInstance(instanceData); + this.finishingLogouts[marker.instanceName] = instance; + await (instance as any).resumePendingLogout(); + } + } + public async saveInstance(data: any) { try { const clientName = await this.configService.get('DATABASE').CONNECTION.CLIENT_NAME; @@ -293,18 +364,50 @@ export class WAMonitoringService { ownerJid: instanceData.ownerJid, }); + // A logout that had not reached WhatsApp before the restart: connect only to deliver it. A + // deleted instance's row is kept until then, and it stays out of the API (finishingLogouts). + const pending = await (instance as any).pendingLogout?.(); + if (pending) { + if (pending.deleted) this.finishingLogouts[instanceData.instanceName] = instance; + else this.waInstances[instanceData.instanceName] = instance; + await (instance as any).resumePendingLogout(pending); + return; + } + + // In the API before it connects: a connect that fails (a database read, the network) must not + // leave the instance out of it until the process restarts. + this.waInstances[instanceData.instanceName] = instance; + if (instanceData.connectionStatus === 'open' || instanceData.connectionStatus === 'connecting') { this.logger.info( `Auto-connecting instance "${instanceData.instanceName}" (status: ${instanceData.connectionStatus})`, ); - await instance.connectToWhatsapp(); + try { + await instance.connectToWhatsapp(); + } catch (error) { + this.logger.error({ + message: `Auto-connect of instance "${instanceData.instanceName}" failed, retrying`, + error: errorFields(error?.cause ?? error), + }); + (instance as any).retryConnect?.(); + } } else { this.logger.info( `Skipping auto-connect for instance "${instanceData.instanceName}" (status: ${instanceData.connectionStatus || 'close'})`, ); } + } - this.waInstances[instanceData.instanceName] = instance; + /** setInstance for a loader: awaited, and one instance's failure never stops the others. */ + private async loadOne(instanceData: InstanceDto) { + try { + await this.setInstance(instanceData); + } catch (error) { + this.logger.error({ + message: `Loading instance "${instanceData.instanceName}" failed`, + error: errorFields(error), + }); + } } private async loadInstancesFromRedis() { @@ -331,7 +434,7 @@ export class WAMonitoringService { connectionStatus: instanceData.connectionStatus as any, // Pass connection status }; - this.setInstance(instance); + await this.loadOne(instance); }), ); } @@ -350,7 +453,7 @@ export class WAMonitoringService { await Promise.all( instances.map(async (instance) => { - this.setInstance({ + await this.loadOne({ instanceId: instance.id, instanceName: instance.name, integration: instance.integration, @@ -377,7 +480,7 @@ export class WAMonitoringService { where: { id: instanceId }, }); - this.setInstance({ + await this.loadOne({ instanceId: instance.id, instanceName: instance.name, integration: instance.integration, @@ -392,6 +495,8 @@ export class WAMonitoringService { private removeInstance() { this.eventEmitter.on('remove.instance', async (instanceName: string) => { try { + // No reconnect waiting on its backoff may bring the removed instance back, and its socket goes too. + this.waInstances[instanceName]?.shutdown?.(); await this.waInstances[instanceName]?.sendDataWebhook(Events.REMOVE_INSTANCE, null); this.clearDelInstanceTime(instanceName); diff --git a/src/main.ts b/src/main.ts index f1f00ba9ae..b6d9d733cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -112,6 +112,9 @@ async function bootstrap() { error: err['error'] || 'Internal Server Error', response: { message: err['message'] || 'Internal Server Error', + // A failed media download says whether the phone was asked to re-upload the file, and why it refused. + ...(err['reupload'] !== undefined && { reupload: err['reupload'] }), + ...(err['reuploadReason'] !== undefined && { reuploadReason: err['reuploadReason'] }), }, }); } diff --git a/src/utils/atomic-file.ts b/src/utils/atomic-file.ts new file mode 100644 index 0000000000..6e7575da9b --- /dev/null +++ b/src/utils/atomic-file.ts @@ -0,0 +1,129 @@ +import { randomBytes } from 'crypto'; +import { open, readdir, rename, unlink } from 'fs/promises'; +import { basename, dirname, join } from 'path'; + +// Session key material on disk is replaced whole or not at all. A file written in place +// (fs.writeFile truncates it, then writes) is torn or empty when the process dies, the disk +// fills up or two writes of it overlap, and a key file that does not parse reads as no key. +// So the new value goes to a temp file in the same directory, is flushed (fsync), and is +// renamed over the target, which POSIX makes atomic; the directory is then flushed, so the +// rename itself survives a power loss. + +/** `....tmp`, next to the file it replaces. */ +const TEMP = /^\..+\.(\d+)\.[0-9a-f]{8}\.tmp$/; + +/** Temp files this process is writing now. */ +const writing = new Set(); + +/** Per file, the write under way or queued last: writes of one file run one at a time, in call order. */ +const queues = new Map>(); + +/** + * Replace `file` with `data`, atomically: a reader, a crash or a failure at any moment sees the + * previous content or the new one, never a mix, and never an empty file. Writes of the same + * file run in the order they were asked for, so the last one asked for is the one that stays. + * A failed write rejects, removes its temp file and leaves the previous content. + */ +export function writeFileAtomic(file: string, data: string | Uint8Array): Promise { + const previous = queues.get(file) ?? Promise.resolve(); + const write = previous.catch(() => undefined).then(() => replace(file, data)); + queues.set(file, write); + write + .finally(() => { + if (queues.get(file) === write) queues.delete(file); + }) + .catch(() => undefined); + return write; +} + +async function replace(file: string, data: string | Uint8Array) { + const dir = dirname(file); + const temp = join(dir, `.${basename(file)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`); + writing.add(temp); + try { + const handle = await open(temp, 'wx'); + try { + await handle.writeFile(data); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temp, file); + } catch (error) { + await unlink(temp).catch(() => undefined); + throw error; + } finally { + writing.delete(temp); + } + await syncDirectory(dir); +} + +/** Per directory, a flush that has not started yet: every rename before it starts is covered by it. */ +const pendingSyncs = new Map>(); +/** Per directory, the flush running now. */ +const runningSyncs = new Map>(); + +/** Flush a directory after a rename in it. Renames that land together share one flush. */ +function syncDirectory(dir: string): Promise { + // Windows cannot open a directory to flush it. + if (process.platform === 'win32') return Promise.resolve(); + const pending = pendingSyncs.get(dir); + if (pending) return pending; + const next = (runningSyncs.get(dir) ?? Promise.resolve()) + .catch(() => undefined) + .then(() => { + pendingSyncs.delete(dir); + const running = flush(dir); + runningSyncs.set(dir, running); + running + .finally(() => { + if (runningSyncs.get(dir) === running) runningSyncs.delete(dir); + }) + .catch(() => undefined); + return running; + }); + pendingSyncs.set(dir, next); + return next; +} + +async function flush(dir: string) { + const handle = await open(dir, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +/** Whether a process with this pid runs (EPERM: it does, as another user). */ +function alive(pid: number) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +/** + * Remove the temp files a killed writer left in `dir`: those of a process that no longer runs, + * and this process's own that it is not writing now (a pid a restart reused). A temp file of + * another running process is left alone. Run when a store opens its directory. + */ +export async function removeStaleTempFiles(dir: string): Promise { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return; + } + await Promise.all( + entries.map(async (entry) => { + const pid = Number(TEMP.exec(entry)?.[1]); + if (!pid) return; + const path = join(dir, entry); + if (pid === process.pid ? writing.has(path) : alive(pid)) return; + await unlink(path).catch(() => undefined); + }), + ); +} diff --git a/src/utils/chat-state.ts b/src/utils/chat-state.ts new file mode 100644 index 0000000000..5373ac179a --- /dev/null +++ b/src/utils/chat-state.ts @@ -0,0 +1,27 @@ +import { toNumber } from 'baileys'; + +export type ChatState = { archived?: boolean; pinned?: number | null; muteEndTime?: number | null }; + +const FIELDS = ['archived', 'pinned', 'muteEndTime'] as const; + +/** + * The archive, pin and mute state a Baileys chat carries, for a webhook item. + * + * A field is included only when the chat itself carries it: an app-state action + * sets its own field (null when it clears a pin or a mute), and a history + * Conversation sets a field only when WhatsApp sent it (protobuf defaults sit on + * the prototype). A chat without the field says nothing about it, so the item + * omits it rather than claiming archived: false. + */ +export function chatState(chat: Record | undefined | null): ChatState { + const state: Record = {}; + if (!chat) return state; + for (const field of FIELDS) { + if (!Object.prototype.hasOwnProperty.call(chat, field)) continue; + const value = chat[field]; + if (value === undefined) continue; + if (value === null) state[field] = null; + else state[field] = field === 'archived' ? !!value : toNumber(value); + } + return state; +} diff --git a/src/utils/fetchLatestWaWebVersion.ts b/src/utils/fetchLatestWaWebVersion.ts index 6dcfb797e5..a7f800a7ed 100644 --- a/src/utils/fetchLatestWaWebVersion.ts +++ b/src/utils/fetchLatestWaWebVersion.ts @@ -1,10 +1,50 @@ import axios, { AxiosRequestConfig } from 'axios'; -import { fetchLatestBaileysVersion, WAVersion } from 'baileys'; +import { DEFAULT_CONNECTION_CONFIG, fetchLatestBaileysVersion, WAVersion } from 'baileys'; -export const fetchLatestWaWebVersion = async (options: AxiosRequestConfig<{}>) => { +// Every connect waits on this. An exit that accepts the connection and never answers would +// otherwise hold the connect (and every reconnect) forever: axios' default timeout is none. +export const WA_VERSION_FETCH_TIMEOUT_MS = 10_000; + +type VersionResult = { version: WAVersion; isLatest: boolean; error?: unknown }; + +/** + * `options` go to the sw.js request (axios); `fallbackOptions` to Baileys' + * fallback fetch, which takes only an undici `dispatcher` for a proxy. + * Past `timeoutMs` the answer is the version this Baileys ships with. + */ +export const fetchLatestWaWebVersion = async ( + options: AxiosRequestConfig<{}>, + fallbackOptions: RequestInit = {}, + timeoutMs = WA_VERSION_FETCH_TIMEOUT_MS, +): Promise => { + const abort = new AbortController(); + let timer: NodeJS.Timeout; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + abort.abort(); + resolve({ + version: DEFAULT_CONNECTION_CONFIG.version, + isLatest: false, + error: { message: `WhatsApp Web version fetch timed out after ${timeoutMs} ms` }, + }); + }, timeoutMs); + }); + try { + return await Promise.race([fetchVersion(options, fallbackOptions, abort.signal), deadline]); + } finally { + clearTimeout(timer); + } +}; + +const fetchVersion = async ( + options: AxiosRequestConfig<{}>, + fallbackOptions: RequestInit, + signal: AbortSignal, +): Promise => { try { const { data } = await axios.get('https://web.whatsapp.com/sw.js', { ...options, + signal, responseType: 'json', }); @@ -13,7 +53,7 @@ export const fetchLatestWaWebVersion = async (options: AxiosRequestConfig<{}>) = if (!match?.[1]) { return { - version: (await fetchLatestBaileysVersion()).version as WAVersion, + version: (await fetchLatestBaileysVersion(fallbackOptions)).version as WAVersion, isLatest: false, error: { message: 'Could not find client revision in the fetched content', @@ -28,8 +68,10 @@ export const fetchLatestWaWebVersion = async (options: AxiosRequestConfig<{}>) = isLatest: true, }; } catch (error) { + // Timed out: the deadline has already answered, so do not start the fallback. + if (signal.aborted) return { version: DEFAULT_CONNECTION_CONFIG.version, isLatest: false, error }; return { - version: (await fetchLatestBaileysVersion()).version as WAVersion, + version: (await fetchLatestBaileysVersion(fallbackOptions)).version as WAVersion, isLatest: false, error, }; diff --git a/src/utils/live-record/codec.ts b/src/utils/live-record/codec.ts new file mode 100644 index 0000000000..4c5de1746b --- /dev/null +++ b/src/utils/live-record/codec.ts @@ -0,0 +1,72 @@ +// The tagged JSON a live-check tape is written in. It walks the value itself and +// never lets JSON.stringify see a payload, because JSON loses what Evolution +// branches on: Buffer vs Uint8Array, Long, an explicit undefined, protobuf +// classes (the webhook serializer calls toJSON on instances only), Boom errors. +// +// {"$bytes":"","as":"Buffer"|"Uint8Array"} {"$long":"","u":true} +// {"$u":1} undefined {"$big":""} {"$date":""} +// {"$err":{"message","statusCode","data"}} {"$fn":""} (decodes to undefined) +// {"$proto":"proto.Message.ImageMessage", ...fields} +import { Boom } from '@hapi/boom'; +import { proto } from 'baileys'; +import Long from 'long'; + +const protoName = (value: any): string | undefined => { + const typeUrl = value?.constructor?.getTypeUrl; + if (typeof typeUrl !== 'function') return undefined; + return String(typeUrl.call(value.constructor)).split('/').pop(); +}; + +export function encode(value: any): any { + if (value === undefined) return { $u: 1 }; + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') return value; + if (typeof value === 'bigint') return { $big: value.toString() }; + if (typeof value === 'function') return { $fn: value.name || 'fn' }; + if (Long.isLong(value)) return { $long: value.toString(), u: !!value.unsigned }; + if (Buffer.isBuffer(value)) return { $bytes: value.toString('base64'), as: 'Buffer' }; + if (value instanceof Uint8Array) return { $bytes: Buffer.from(value).toString('base64'), as: 'Uint8Array' }; + if (value instanceof Date) return { $date: value.toISOString() }; + if (value instanceof Error) { + const statusCode = (value as Boom).output?.statusCode; + return { $err: { message: value.message, statusCode, data: encode((value as Boom).data) } }; + } + if (Array.isArray(value)) return value.map(encode); + const out: Record = {}; + const name = protoName(value); + if (name) out.$proto = name; + for (const key of Object.keys(value)) out[key] = encode(value[key]); + return out; +} + +const protoClass = (name: string) => { + const Ctor = name + .split('.') + .slice(1) + .reduce((node: any, part) => node?.[part], proto); + if (typeof Ctor !== 'function') throw new Error(`live-record: unknown protobuf class ${name}`); + return Ctor; +}; + +export function decode(value: any): any { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(decode); + if ('$u' in value) return undefined; + if ('$fn' in value) return undefined; + if ('$bytes' in value) { + const bytes = Buffer.from(value.$bytes, 'base64'); + return value.as === 'Buffer' ? bytes : new Uint8Array(bytes); + } + if ('$long' in value) return Long.fromString(value.$long, !!value.u); + if ('$big' in value) return BigInt(value.$big); + if ('$date' in value) return new Date(value.$date); + if ('$err' in value) { + const { message, statusCode, data } = value.$err; + return statusCode ? new Boom(message, { statusCode, data: decode(data) }) : new Error(message); + } + const out: Record = value.$proto ? new (protoClass(value.$proto))() : {}; + for (const key of Object.keys(value)) { + if (key !== '$proto') out[key] = decode(value[key]); + } + return out; +} diff --git a/src/utils/live-record/recorder.ts b/src/utils/live-record/recorder.ts new file mode 100644 index 0000000000..9071795f0c --- /dev/null +++ b/src/utils/live-record/recorder.ts @@ -0,0 +1,248 @@ +// Records a live check, and does nothing unless LIVE_RECORD_DIR is set. +// +// LIVE_RECORD_DIR/// +// events.ndjson every Baileys event the socket emitted (the input tape), and +// every batch the event buffer handed Evolution +// webhooks.ndjson every payload Evolution sent out (the golden output tape) +// manifest.json versions and conditions, never a number, a JID, a name or content +// owner.json the linked account (raw only: the scrubber reads it, never copies it) +// +// One line per record, one sequence across both tapes, values in the tagged +// codec (codec.ts) so a replay rebuilds identical ones. Everything is written +// synchronously, at the moment it happens: Evolution mutates payloads later. +// The raw files hold personal data. They stay on the machine that recorded them +// until scripts/live-scrub.ts turns them into a fixture (docs/LIVE-CHECKS.md). +import { execFileSync } from 'child_process'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { createRequire } from 'module'; +import { join } from 'path'; +import { performance } from 'perf_hooks'; + +import { encode } from './codec'; + +export type LinkMethod = 'qr' | 'code' | 'existing-session'; + +/** What Evolution knows about a socket when it builds one. */ +export type SocketFacts = { + waWebVersion: string; + linkMethod: LinkMethod; + /** The proxy's protocol, or null for none. Never its host or credentials. */ + proxyProtocol: string | null; + /** The auth creds, read when the connection opens (creds.platform is set at pairing). */ + creds: () => any; +}; + +export type Manifest = { + format: 'live-record/1'; + forkCommit: string | null; + baileysVersion: string | null; + nodeVersion: string; + waWebVersion: string | null; + /** creds.platform as WhatsApp reported it at pairing (smba, smbi, android, iphone...), when known. */ + phonePlatform: string | null; + /** From the platform: WhatsApp Business apps report smb*. Null when unknown. */ + accountType: 'business' | 'personal' | null; + linkMethod: LinkMethod | null; + proxy: { used: boolean; protocol: string | null }; + sockets: number; + startedAt: string; + openedAt: string | null; + endedAt: string | null; +}; + +const safeName = (name: string) => name.replace(/[^A-Za-z0-9._-]/g, '_') || 'instance'; + +function forkCommit(env: NodeJS.ProcessEnv): string | null { + const file = join(process.cwd(), 'FORK_SHA'); + if (existsSync(file)) return readFileSync(file, 'utf8').trim() || null; + try { + return execFileSync('git', ['describe', '--always', '--dirty', '--abbrev=8', '--match=NONE'], { + stdio: ['ignore', 'pipe', 'ignore'], + }) + .toString() + .trim(); + } catch { + return env.FORK_SHA || null; + } +} + +function baileysVersion(): string | null { + try { + const path = createRequire(join(process.cwd(), 'package.json')).resolve('baileys/package.json'); + return JSON.parse(readFileSync(path, 'utf8')).version ?? null; + } catch { + return null; + } +} + +const accountType = (platform?: string | null): Manifest['accountType'] => { + if (!platform) return null; + return /^smb/i.test(platform) ? 'business' : 'personal'; +}; + +export class LiveRecorder { + private seq = 0; + private sockets = 0; + private readonly t0 = performance.now(); + private appDepth = 0; + private broken = false; + private readonly manifest: Manifest; + + private constructor(readonly dir: string) { + this.manifest = { + format: 'live-record/1', + forkCommit: forkCommit(process.env), + baileysVersion: baileysVersion(), + nodeVersion: process.version, + waWebVersion: null, + phonePlatform: null, + accountType: null, + linkMethod: null, + proxy: { used: false, protocol: null }, + sockets: 0, + startedAt: new Date().toISOString(), + openedAt: null, + endedAt: null, + }; + this.writeManifest(); + } + + /** + * A recorder for this instance's session, or undefined when LIVE_RECORD_DIR is not set, or when + * its directory or first manifest cannot be written: a recording that cannot start costs the + * recording, never the connection it was to watch. + */ + static start(instanceName: string, env: NodeJS.ProcessEnv = process.env): LiveRecorder | undefined { + const root = env.LIVE_RECORD_DIR; + if (!root) return undefined; + try { + const stamp = new Date().toISOString().replace(/:/g, '-'); + const dir = join(root, safeName(instanceName), stamp); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + return new LiveRecorder(dir); + } catch (error) { + console.warn(`[live-record] recording not started: ${error?.code ?? error?.name ?? 'error'}`); + return undefined; + } + } + + /** Record every event this socket emits, and every batch its buffer delivers. Call before eventHandler(). */ + attach(client: any, facts: SocketFacts) { + const socket = ++this.sockets; + this.manifest.sockets = socket; + this.manifest.waWebVersion = facts.waWebVersion; + // The method that linked the account is the one asked for before the first open. + if (!this.manifest.openedAt) this.manifest.linkMethod = facts.linkMethod; + this.manifest.proxy = { used: !!facts.proxyProtocol, protocol: facts.proxyProtocol }; + this.guard(() => this.writeManifest()); + + const ev = client.ev; + const emit = ev.emit.bind(ev); + ev.emit = (event: string, data: any) => { + this.guard(() => { + const line: Record = { seq: ++this.seq, t: this.elapsed(), socket, event }; + line.buffered = typeof ev.isBuffering === 'function' ? ev.isBuffering() : false; + if (this.appDepth) line.origin = 'app'; + line.data = encode(redact(event, data)); + this.append('events.ndjson', line); + if (event === 'connection.update') this.connectionUpdate(client, facts, data); + }); + return emit(event, data); + }; + ev.process((events: Record) => { + this.guard(() => + this.append('events.ndjson', { seq: ++this.seq, t: this.elapsed(), socket, batch: Object.keys(events) }), + ); + }); + } + + /** Evolution emitting into the socket's events itself: marked, so a replay does not emit it twice. */ + fromApp(fn: () => T): T { + this.appDepth++; + try { + return fn(); + } finally { + this.appDepth--; + } + } + + /** A payload Evolution sends out (sendDataWebhook), as it was at that moment. */ + webhook(event: string, data: any, extra?: Record) { + this.guard(() => { + const line: Record = { + seq: ++this.seq, + t: this.elapsed(), + event, + data: encode(redactWebhook(data)), + }; + if (extra !== undefined) line.extra = encode(extra); + this.append('webhooks.ndjson', line); + }); + } + + private connectionUpdate(client: any, facts: SocketFacts, update: any) { + if (update?.connection === 'open') { + const platform = facts.creds()?.platform ?? null; + this.manifest.phonePlatform = platform; + this.manifest.accountType = accountType(platform); + this.manifest.openedAt ??= new Date().toISOString(); + this.manifest.endedAt = null; + const user = client.user ?? {}; + writeFileSync(join(this.dir, 'owner.json'), JSON.stringify({ id: user.id, lid: user.lid, name: user.name })); + this.writeManifest(); + } + if (update?.connection === 'close') { + this.manifest.endedAt = new Date().toISOString(); + this.writeManifest(); + } + } + + private elapsed() { + return Math.round((performance.now() - this.t0) * 10) / 10; + } + + private append(file: string, line: object) { + appendFileSync(join(this.dir, file), JSON.stringify(line) + '\n', { mode: 0o600 }); + } + + private writeManifest() { + writeFileSync(join(this.dir, 'manifest.json'), JSON.stringify(this.manifest, null, 2) + '\n', { mode: 0o600 }); + } + + /** A recorder that fails stops recording; it never breaks the socket or the webhook it was watching. */ + private guard(fn: () => void) { + if (this.broken) return; + try { + fn(); + } catch (error) { + this.broken = true; + console.warn(`[live-record] recording stopped: ${error?.message ?? error}`); + } + } +} + +/** + * Secrets with no replay value never reach the tape: the auth creds, and whatever links a device + * (the QR payload, its image, a pairing code). Replaced before encoding, by a marker that says + * one was there. + */ +function redact(event: string, data: any) { + if (event === 'creds.update') return { $redacted: 'creds', keys: Object.keys(data ?? {}) }; + if (event === 'connection.update' && data?.qr) return { ...data, qr: '$qr' }; + return data; +} + +/** qrcode.updated carries the QR payload, its image and the pairing code (connectionUpdate). */ +function redactWebhook(data: any) { + const qrcode = data?.qrcode; + if (!qrcode || typeof qrcode !== 'object') return data; + return { + ...data, + qrcode: { + ...qrcode, + ...(qrcode.code ? { code: '$qr' } : {}), + ...(qrcode.base64 ? { base64: '$qr' } : {}), + ...(qrcode.pairingCode ? { pairingCode: '$pairingCode' } : {}), + }, + }; +} diff --git a/src/utils/log-privacy.ts b/src/utils/log-privacy.ts new file mode 100644 index 0000000000..89646a827d --- /dev/null +++ b/src/utils/log-privacy.ts @@ -0,0 +1,91 @@ +import P from 'pino'; + +// What a log line may say about a message: its id, what kind of chat it is +// in, why something failed, and counts. Never its text, a phone number or JID, +// or a push name. + +/** The kind of chat a JID names, without the number in it. */ +export function jidKind(jid: unknown): string { + if (typeof jid !== 'string' || !jid) return 'unknown'; + if (jid.endsWith('@g.us')) return 'group'; + if (jid.endsWith('@lid') || jid.endsWith('@hosted.lid')) return 'lid'; + if (jid.endsWith('@s.whatsapp.net') || jid.endsWith('@c.us') || jid.endsWith('@hosted')) return 'user'; + if (jid.endsWith('@broadcast')) return 'broadcast'; + if (jid.endsWith('@newsletter')) return 'newsletter'; + return 'other'; +} + +/** + * Mask anything in a diagnostic string that looks like a URL, a JID or a phone + * number, and anything quoted. A URL goes first, and whole: a WhatsApp media + * link is signed per message (`oh`, `oe` in its query), and whoever holds it can + * fetch the file. A quoted part is input an error repeats (JSON.parse quotes + * what it failed on); a phone number is masked also as a person writes it, with + * spaces, dashes, dots or brackets. + */ +export function scrub(value: unknown): string { + return String(value ?? '') + .replace(/[a-z][a-z0-9+.-]*:\/\/[^\s'"<>]+/gi, '[url]') + .replace(/[^\s'"<>=,;:()[\]{}]+@[^\s'"<>=,;:()[\]{}]+/g, '[jid]') + .replace(/"[^"\n]{2,}"|'[^'\n]{2,}'|`[^`\n]{2,}`/g, '[quoted]') + .replace(/\+?\(?\d[\d\s().-]{5,}\d/g, (run) => (run.replace(/\D/g, '').length >= 7 ? '[number]' : run)) + .replace(/\d{6,}/g, '[number]'); +} + +/** + * What an operator needs to know about a thrown value, bounded and scrubbed: + * its name (for a value that is not an Error, what it is: Object, string...), + * its message, and its HTTP status code when it carries one (a Boom's + * output.statusCode, or statusCode / status). + */ +export function errorFields(error: unknown): { name: string; message: string; statusCode?: number } { + const e: any = error; + let name: string = typeof e; + if (typeof e?.name === 'string') name = e.name; + else if (e !== null && typeof e === 'object') name = e.constructor?.name ?? 'Object'; + const message = typeof e?.message === 'string' ? e.message : typeof e === 'string' ? e : ''; + const statusCode = [e?.output?.statusCode, e?.statusCode, e?.status].find((v) => typeof v === 'number'); + return { + name: scrub(name).slice(0, 40), + message: scrub(message).slice(0, 200), + ...(statusCode !== undefined && { statusCode }), + }; +} + +const PRIMITIVE_FIELDS = ['messageType', 'isSessionRecordError', 'opName', 'count', 'attempt', 'retryCount']; +const ERROR_FIELDS = ['err', 'error', 'ackErr']; + +/** + * The fields of a Baileys log object that are safe to print. Baileys logs + * message keys, sender and author JIDs and whole stanzas (from, notify, body) + * at level error, e.g. "failed to decrypt message" and "error in handling + * message", so everything else is dropped. + */ +export function safeLogFields(obj: Record): Record { + const out: Record = {}; + if (typeof obj?.key?.id === 'string') out.messageId = obj.key.id; + const chat = obj?.key?.remoteJid ?? obj?.jid ?? obj?.sender; + if (chat) out.chatType = jidKind(chat); + for (const field of PRIMITIVE_FIELDS) { + const v = obj?.[field]; + if (typeof v === 'number' || typeof v === 'boolean') out[field] = v; + else if (typeof v === 'string') out[field] = scrub(v).slice(0, 80); + } + // Under `error`, not `err`: pino's err serializer would rebuild the object. + const e = ERROR_FIELDS.map((field) => obj?.[field]).find((v) => v && typeof v === 'object'); + if (e) out.error = { name: scrub(e.name ?? 'Error'), message: scrub(e.message).slice(0, 200) }; + return out; +} + +/** The pino logger Evolution hands Baileys: the level asked for, and only bounded fields. */ +export function makeBaileysLogger(level: string) { + return P({ + level, + formatters: { log: (obj) => safeLogFields(obj) }, + hooks: { + logMethod(args, method) { + return method.apply(this, args.map((a) => (typeof a === 'string' ? scrub(a) : a)) as Parameters); + }, + }, + }); +} diff --git a/src/utils/logout-marker.ts b/src/utils/logout-marker.ts new file mode 100644 index 0000000000..62f0166741 --- /dev/null +++ b/src/utils/logout-marker.ts @@ -0,0 +1,36 @@ +import { INSTANCE_DIR } from '@config/path.config'; +import { writeFileAtomic } from '@utils/atomic-file'; +import { existsSync, readFileSync } from 'fs'; +import { mkdir } from 'fs/promises'; +import { join } from 'path'; + +/** + * A logout that could not reach WhatsApp is kept pending until it does, across restarts. The + * marker is a file in the instance's directory under INSTANCE_DIR, next to the session's signal + * key files, and removed by the same rm that wipes those keys. It is the second record: the first + * is on the instance's row (disconnectionObject.logoutPending, BaileysStartupService.recordPending), + * which survives the loss of that directory. No schema change: the fork stays migration-identical + * to 2.3.7. + */ +export type LogoutMarker = { instanceName: string; deleted: boolean; since: string }; + +export const LOGOUT_MARKER_FILE = 'logout-pending.json'; + +export const logoutMarkerPath = (instanceId: string) => join(INSTANCE_DIR, instanceId, LOGOUT_MARKER_FILE); + +export function readLogoutMarker(instanceId: string): LogoutMarker | undefined { + const path = logoutMarkerPath(instanceId); + if (!existsSync(path)) return undefined; + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + // Unreadable is still pending: keeping a session too long is recoverable, dropping a logout is not. + return { instanceName: undefined, deleted: false, since: undefined }; + } +} + +export async function writeLogoutMarker(instanceId: string, marker: LogoutMarker) { + await mkdir(join(INSTANCE_DIR, instanceId), { recursive: true }); + // Replaced whole: a marker half-written over the last one would lose the instance's name. + await writeFileAtomic(logoutMarkerPath(instanceId), JSON.stringify(marker)); +} diff --git a/src/utils/queryLimiter.ts b/src/utils/queryLimiter.ts new file mode 100644 index 0000000000..87bda98dc4 --- /dev/null +++ b/src/utils/queryLimiter.ts @@ -0,0 +1,53 @@ +/** + * Runs at most `max` WhatsApp queries at once; the rest wait their turn, in order. + * One per instance: a burst of events must not become a burst of IQs. + * + * Bulk queries (a history batch's pictures) never take the last slot, so a + * query for a live event waits for at most one query ahead of it, not for the + * whole bulk backlog. Waiting live queries start before waiting bulk ones. + */ +export class QueryLimiter { + private inFlight = 0; + private bulkInFlight = 0; + private readonly waitingLive: (() => void)[] = []; + private readonly waitingBulk: (() => void)[] = []; + + constructor(private readonly max: number) {} + + public async run(query: () => Promise, opts: { bulk?: boolean } = {}): Promise { + const bulk = !!opts.bulk; + if (this.canStart(bulk)) { + this.take(bulk); + } else { + // The slot is taken for us by the query that finishes, so it is ours when we wake. + await new Promise((resolve) => (bulk ? this.waitingBulk : this.waitingLive).push(resolve)); + } + try { + return await query(); + } finally { + this.inFlight--; + if (bulk) this.bulkInFlight--; + this.wake(); + } + } + + private canStart(bulk: boolean) { + return this.inFlight < this.max && (!bulk || this.bulkInFlight < this.max - 1); + } + + private take(bulk: boolean) { + this.inFlight++; + if (bulk) this.bulkInFlight++; + } + + private wake() { + while (this.waitingLive.length && this.canStart(false)) { + this.take(false); + this.waitingLive.shift()(); + } + while (this.waitingBulk.length && this.canStart(true)) { + this.take(true); + this.waitingBulk.shift()(); + } + } +} diff --git a/src/utils/use-multi-file-auth-state-prisma.ts b/src/utils/use-multi-file-auth-state-prisma.ts index d090780234..908b0b30d6 100644 --- a/src/utils/use-multi-file-auth-state-prisma.ts +++ b/src/utils/use-multi-file-auth-state-prisma.ts @@ -3,6 +3,7 @@ import { CacheService } from '@api/services/cache.service'; import { CacheConf, configService } from '@config/env.config'; import { Logger } from '@config/logger.config'; import { INSTANCE_DIR } from '@config/path.config'; +import { removeStaleTempFiles, writeFileAtomic } from '@utils/atomic-file'; import { AuthenticationState, BufferJSON, initAuthCreds, WAProto as proto } from 'baileys'; import fs from 'fs/promises'; import path from 'path'; @@ -16,42 +17,46 @@ const fixFileName = (file: string): string | undefined => { return replacedColon; }; +// A database error is not an absent session. keyExists and getAuthKey let it +// propagate: turned into "no session", it made the store start a fresh one and +// save it over the linked creds once the database answered again. export async function keyExists(sessionId: string): Promise { - try { - const key = await prismaRepository.session.findUnique({ where: { sessionId: sessionId } }); - return !!key; - } catch { - return false; - } + const key = await prismaRepository.session.findUnique({ where: { sessionId: sessionId } }); + return !!key; } +// A failed write is not a saved one: saveKey lets it propagate, so saveCreds rejects and its +// caller can try again, instead of running on creds that exist only in memory. export async function saveKey(sessionId: string, keyJson: any): Promise { const exists = await keyExists(sessionId); - try { - if (!exists) - return await prismaRepository.session.create({ - data: { - sessionId: sessionId, - creds: JSON.stringify(keyJson), - }, - }); - await prismaRepository.session.update({ - where: { sessionId: sessionId }, - data: { creds: JSON.stringify(keyJson) }, + if (!exists) + return await prismaRepository.session.create({ + data: { + sessionId: sessionId, + creds: JSON.stringify(keyJson), + }, }); - } catch { - return null; + await prismaRepository.session.update({ + where: { sessionId: sessionId }, + data: { creds: JSON.stringify(keyJson) }, + }); +} + +/** Stored creds that cannot be read are not an absent session: starting fresh would overwrite them. */ +export class UnreadableCredsError extends Error { + constructor(sessionId: string) { + super(`The stored creds of session ${sessionId} cannot be read: not replacing them`); + this.name = 'UnreadableCredsError'; } } export async function getAuthKey(sessionId: string): Promise { + const auth = await prismaRepository.session.findUnique({ where: { sessionId: sessionId } }); + if (!auth) return null; try { - const register = await keyExists(sessionId); - if (!register) return null; - const auth = await prismaRepository.session.findUnique({ where: { sessionId: sessionId } }); - return JSON.parse(auth?.creds); + return JSON.parse(auth.creds); } catch { - return null; + throw new UnreadableCredsError(sessionId); } } @@ -87,6 +92,8 @@ export default async function useMultiFileAuthStatePrisma( const localFolder = path.join(INSTANCE_DIR, sessionId); const localFile = (key: string) => path.join(localFolder, fixFileName(key) + '.json'); await fs.mkdir(localFolder, { recursive: true }); + // What a writer killed mid-write left behind; the key files themselves are always whole. + await removeStaleTempFiles(localFolder); async function writeData(data: any, key: string): Promise { const dataString = JSON.stringify(data, BufferJSON.replacer); @@ -96,7 +103,8 @@ export default async function useMultiFileAuthStatePrisma( if (cacheConfig.REDIS.ENABLED) { return await cache.hSet(sessionId, key, data); } else { - await fs.writeFile(localFile(key), dataString); + // Replaced whole, never in place: a torn key file reads as no key (atomic-file.ts). + await writeFileAtomic(localFile(key), dataString); return; } } @@ -105,6 +113,8 @@ export default async function useMultiFileAuthStatePrisma( } async function readData(key: string): Promise { + // Outside the try below: a failed creds read must fail the open, never read as "no creds". + const storedCreds = key === 'creds' ? await getAuthKey(sessionId) : undefined; try { let rawData; const cacheConfig = configService.get('CACHE'); @@ -118,12 +128,15 @@ export default async function useMultiFileAuthStatePrisma( return JSON.parse(rawData, BufferJSON.reviver); } } else { - rawData = await getAuthKey(sessionId); + if (storedCreds === null || storedCreds === undefined) return null; + try { + return JSON.parse(storedCreds, BufferJSON.reviver); + } catch { + throw new UnreadableCredsError(sessionId); + } } - - const parsedData = JSON.parse(rawData, BufferJSON.reviver); - return parsedData; - } catch { + } catch (error) { + if (error instanceof UnreadableCredsError) throw error; return null; } } @@ -182,7 +195,7 @@ export default async function useMultiFileAuthStatePrisma( ids.map(async (id) => { let value = await readData(`${type}-${id}`); if (type === 'app-state-sync-key' && value) { - value = proto.Message.AppStateSyncKeyData.create(value); + value = proto.Message.AppStateSyncKeyData.fromObject(value); } data[id] = value; diff --git a/src/utils/use-multi-file-auth-state-provider-files.ts b/src/utils/use-multi-file-auth-state-provider-files.ts index eecc3100e5..157918169d 100644 --- a/src/utils/use-multi-file-auth-state-provider-files.ts +++ b/src/utils/use-multi-file-auth-state-provider-files.ts @@ -116,7 +116,7 @@ export class AuthStateProvider { ids.map(async (id) => { let value = await readData(`${type}-${id}`); if (type === 'app-state-sync-key' && value) { - value = proto.Message.AppStateSyncKeyData.create(value); + value = proto.Message.AppStateSyncKeyData.fromObject(value); } data[id] = value; diff --git a/src/utils/use-multi-file-auth-state-redis-db.ts b/src/utils/use-multi-file-auth-state-redis-db.ts index e0981c700c..4d5d5a5457 100644 --- a/src/utils/use-multi-file-auth-state-redis-db.ts +++ b/src/utils/use-multi-file-auth-state-redis-db.ts @@ -61,7 +61,7 @@ export async function useMultiFileAuthStateRedisDb( ids.map(async (id) => { let value = await readData(`${type}-${id}`); if (type === 'app-state-sync-key' && value) { - value = proto.Message.AppStateSyncKeyData.create(value); + value = proto.Message.AppStateSyncKeyData.fromObject(value); } data[id] = value; diff --git a/src/validate/chat.schema.ts b/src/validate/chat.schema.ts index 7dae44539b..89d708d7d8 100644 --- a/src/validate/chat.schema.ts +++ b/src/validate/chat.schema.ts @@ -131,6 +131,14 @@ export const deleteMessageSchema: JSONSchema7 = { ...isNotEmpty('id', 'remoteJid', 'participant'), }; +export const getBase64FromMediaMessageSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + reupload: { type: 'boolean' }, + }, +}; + export const profilePictureSchema: JSONSchema7 = { $id: v4(), type: 'object', diff --git a/test/chat/media-base64-log.test.ts b/test/chat/media-base64-log.test.ts new file mode 100644 index 0000000000..fc44a15e9d --- /dev/null +++ b/test/chat/media-base64-log.test.ts @@ -0,0 +1,150 @@ +// With webhookBase64 on, Evolution downloads a message's media to put it in +// the webhook. When that download fails, the error Baileys throws says +// "Failed to fetch stream from ": its `oh` and `oe` +// let anyone holding it fetch the file until it expires. The log line for the +// failure must say which message and why, never the link. +// +// Both directions: an incoming message's media, and a sent message's (Evolution +// downloads what it just uploaded, from the link WhatsApp's upload answer gave). +// +// Baileys downloads from https://, so the file is +// on a local HTTPS CDN (the test certificate, trusted for the test) that +// answers 410. The undici mock agent refuses anything not on 127.0.0.1. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { rm } from 'node:fs/promises'; + +import { encryptedStream, generateWAMessage } from 'baileys'; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { deliver, makeService, WUID } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import { emitted } from '../helpers/fake-server-module'; +import { type Listening, startHttpsServer, trustTestCertificate } from '../helpers/local-net'; +import type { Profile } from '../helpers/profiles'; + +const PHONE = '972509876543'; +const DIRECT_PATH = '/v/t62.7118-24/gone.enc?ccb=11-4&oh=01_Q5Aa1wSecretSignatureXyz&oe=6A0B1C2D&_nc_sid=5e03e0'; +const LEAKS = ['http://', 'https://', 'mmg.whatsapp.net', 'oh=', 'oe=', 'Q5Aa1wSecretSignatureXyz', '_nc_sid', PHONE]; + +let cdn: Listening; +let mediaKey: Uint8Array; +let untrust: () => void; + +const previousDispatcher = getGlobalDispatcher(); +const guard = new MockAgent(); +guard.disableNetConnect(); +guard.enableNetConnect((host: string) => host.startsWith('127.0.0.1:')); + +beforeAll(async () => { + setGlobalDispatcher(guard); + const enc = await encryptedStream(Buffer.from('a photo'), 'image', {}); + mediaKey = enc.mediaKey; + await rm(enc.encFilePath, { force: true }); + untrust = trustTestCertificate(); + cdn = await startHttpsServer((_req, _body, res) => void res.writeHead(410).end()); +}); + +afterAll(async () => { + await cdn.close(); + untrust(); + setGlobalDispatcher(previousDispatcher); + await guard.close(); +}); + +beforeEach(() => { + cdn.log.splice(0); + emitted.splice(0); +}); + +/** Every line of `out` that carries any part of a media link, stripped of colour and truncated. */ +function leaks(out: string) { + return out + .replace(/\x1b\[[0-9;]*m/g, '') + .split('\n') + .filter((line) => LEAKS.some((l) => line.includes(l))) + .map((line) => line.trim().slice(0, 200)); +} + +/** The log objects printed for a failed base64 conversion. */ +function conversionLines(out: string) { + return out + .replace(/\x1b\[[0-9;]*m/g, '') + .split('\n') + .filter((l) => l.includes('Error converting media to base64')) + .map((l) => JSON.parse(l.slice(l.indexOf('{')))); +} + +describe.each(['minimal', 'stored'] as Profile[])('a failed media conversion never logs the media URL (profile %s)', (profile) => { + it('an incoming image whose download fails', async () => { + const { service, ev } = await makeService({ profile }); + Object.assign(service.localWebhook, { enabled: true, webhookBase64: true }); + const id = `3EB0DDDDDDDDDDDDDD-${profile}`; + const incoming = { + key: { remoteJid: `${PHONE}@s.whatsapp.net`, fromMe: false, id }, + message: { + imageMessage: { url: `https://127.0.0.1:${cdn.port}${DIRECT_PATH}`, directPath: DIRECT_PATH, mediaKey, mimetype: 'image/jpeg' }, + }, + messageTimestamp: 1_700_000_000, + pushName: 'Sender', + }; + + const out = await captureOutput(() => deliver(service, ev, { 'messages.upsert': { messages: [incoming], type: 'notify' } })); + + expect(cdn.log).toEqual([`GET ${DIRECT_PATH}`]); + // The message still reaches the webhook, without the media. + const upsert = emitted.find((e) => e.event === 'messages.upsert'); + expect(upsert?.data?.key?.id).toBe(id); + expect(upsert?.data?.message?.base64).toBeUndefined(); + expect(leaks(out)).toEqual([]); + expect(conversionLines(out)).toEqual([ + { + message: 'Error converting media to base64', + messageId: id, + chatType: 'user', + error: { name: 'Error', message: 'Failed to fetch stream from [url]', statusCode: 410 }, + }, + ]); + }); + + it('a sent document whose download fails', async () => { + const { service } = await makeService({ profile }); + Object.assign(service.localWebhook, { enabled: true, webhookBase64: true }); + const id = `3EB0EEEEEEEEEEEEEE-${profile}`; + Object.assign(service.client, { + onWhatsApp: async (...jids: string[]) => jids.map((jid) => ({ exists: true, jid })), + // WhatsApp's answer to an upload: where the encrypted file now is. + waUploadToServer: async () => ({ mediaUrl: `https://127.0.0.1:${cdn.port}${DIRECT_PATH}`, directPath: DIRECT_PATH }), + // What the socket sends and returns, built by Baileys (Evolution sends media as a { forward }). + sendMessage: (jid: string, content: any) => generateWAMessage(jid, content, { userJid: WUID, messageId: id } as any), + presenceSubscribe: async () => undefined, + sendPresenceUpdate: async () => undefined, + }); + + let sent: any; + const out = await captureOutput(async () => { + sent = await service.mediaMessage({ + number: PHONE, + mediatype: 'document', + mimetype: 'application/pdf', + fileName: 'a.pdf', + media: Buffer.from('%PDF-1.4 a document').toString('base64'), + }); + }); + + expect(sent?.key?.id).toBe(id); + expect(cdn.log).toEqual([`GET ${DIRECT_PATH}`]); + expect(leaks(out)).toEqual([]); + expect(conversionLines(out)).toEqual([ + { + message: 'Error converting media to base64', + messageId: id, + chatType: 'user', + error: { name: 'Error', message: 'Failed to fetch stream from [url]', statusCode: 410 }, + }, + ]); + }); +}); diff --git a/test/chat/media-download-url.test.ts b/test/chat/media-download-url.test.ts new file mode 100644 index 0000000000..0da41aab5c --- /dev/null +++ b/test/chat/media-download-url.test.ts @@ -0,0 +1,132 @@ +// A WhatsApp media link is signed per message: its `oh` and `oe` query +// parameters let anyone holding it fetch the (encrypted) file until it +// expires. A deployment running with LOG_LEVEL=ERROR,WARN and LOG_BAILEYS=error +// must not print one. Seen live: when the download, the re-upload and the +// fallback download all failed, Evolution logged the fallback's error object, +// whose message and data both carry https://mmg.whatsapp.net/...&oh=...&oe=... +// +// Baileys downloads from https://, so the first +// download goes to a local HTTPS CDN (the test certificate, trusted for the +// test) that answers 410. The phone refuses the re-upload with an +// answer, read by Baileys' own decodeMediaRetryNode. The fallback, which +// Evolution sends to https://mmg.whatsapp.net + directPath whatever the url +// says, is answered 410 in-process by the undici mock agent, so nothing leaves +// 127.0.0.1. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { rm } from 'node:fs/promises'; + +import { makeBaileysLogger } from '@utils/log-privacy'; +import { decodeMediaRetryNode, encryptedStream, getHttpStream } from 'baileys'; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import { type Listening, startHttpsServer, trustTestCertificate } from '../helpers/local-net'; + +const PHONE = '972509876543'; +const ID = '3EB0CCCCCCCCCCCCCCC1'; +// The shape WhatsApp's links have: a path, then ccb, oh (the signature), oe (the expiry, hex) and _nc_sid. +const DIRECT_PATH = '/v/t62.7118-24/gone.enc?ccb=11-4&oh=01_Q5Aa1wSecretSignatureXyz&oe=6A0B1C2D&_nc_sid=5e03e0'; +const LEAKS = ['http://', 'https://', 'mmg.whatsapp.net', 'oh=', 'oe=', 'Q5Aa1wSecretSignatureXyz', '_nc_sid', PHONE]; + +let cdn: Listening; +let mediaKey: Uint8Array; +let untrust: () => void; + +const previousDispatcher = getGlobalDispatcher(); +const guard = new MockAgent(); +guard.disableNetConnect(); +guard.enableNetConnect((host: string) => host.startsWith('127.0.0.1:')); + +beforeAll(async () => { + setGlobalDispatcher(guard); + const enc = await encryptedStream(Buffer.from('a photo'), 'image', {}); + mediaKey = enc.mediaKey; + await rm(enc.encFilePath, { force: true }); + untrust = trustTestCertificate(); + cdn = await startHttpsServer((_req, _body, res) => void res.writeHead(410).end()); + // Evolution waits 5s before its fallback download; shortened here. + const realSetTimeout = globalThis.setTimeout; + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: any, ms?: number, ...args: any[]) => + realSetTimeout(fn, ms === 5000 ? 0 : ms, ...args)) as any); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + await cdn.close(); + untrust(); + setGlobalDispatcher(previousDispatcher); + await guard.close(); +}); + +/** Every line of `out` that carries any part of a media link, stripped of colour and truncated. */ +function leaks(out: string) { + return out + .replace(/\x1b\[[0-9;]*m/g, '') + .split('\n') + .filter((line) => LEAKS.some((l) => line.includes(l))) + .map((line) => line.trim().slice(0, 200)); +} + +const cdnGone = () => guard.get('https://mmg.whatsapp.net').intercept({ path: DIRECT_PATH, method: 'GET' }).reply(410); + +describe('a failed media download never logs the media URL', () => { + it('download, re-upload and fallback all fail: no link, signature or expiry in the output', async () => { + const { service } = await makeService(); + // The phone's answer, as updateMediaMessage throws it. + const rmr = { tag: 'rmr', attrs: { jid: `${PHONE}@s.whatsapp.net`, from_me: 'false' } }; + service.client.updateMediaMessage = async () => { + throw (decodeMediaRetryNode({ tag: 'receipt', attrs: { id: ID }, content: [rmr, { tag: 'error', attrs: { code: '2' } }] } as any) as any).error; + }; + cdnGone(); + const message = { + key: { remoteJid: `${PHONE}@s.whatsapp.net`, fromMe: false, id: ID }, + message: { + imageMessage: { url: `https://127.0.0.1:${cdn.port}${DIRECT_PATH}`, directPath: DIRECT_PATH, mediaKey, mimetype: 'image/jpeg' }, + }, + }; + + let thrown: any; + const out = await captureOutput(async () => { + try { + await service.getBase64FromMediaMessage({ message }); + } catch (e) { + thrown = e; + } + }); + + expect(cdn.log).toEqual([`GET ${DIRECT_PATH}`]); + guard.assertNoPendingInterceptors(); // the fallback asked mmg.whatsapp.net, and got its 410 + expect(thrown?.status).toBe(400); + expect(leaks(out)).toEqual([]); + // The download's own lines are unchanged; the fallback and the final failure each get a line of bounded fields. + const plain = out.replace(/\x1b\[[0-9;]*m/g, '').split('\n'); + const from = (prefix: string) => plain.filter((l) => l.includes(prefix)).map((l) => l.slice(l.indexOf(prefix)).trim()); + expect(from('media download:')).toEqual([ + `media download: message=${ID}, chat=user, outcome=reupload_requested`, + `media download: message=${ID}, chat=user, outcome=reupload_failed, error=Error, status=404, reason=error_2`, + `media download: message=${ID}, chat=user, outcome=download_failed, status=410, reupload=failed`, + ]); + expect(from('media fallback:')).toEqual([`media fallback: message=${ID}, chat=user, outcome=failed, error=Error, status=410`]); + expect(from('media processing failed:')).toEqual([`media processing failed: message=${ID}, chat=user, error=Error, status=410`]); + }); + + it('the same error in a Baileys log line (the logger Evolution hands Baileys) is printed without its link', async () => { + cdnGone(); + const error = await getHttpStream(`https://mmg.whatsapp.net${DIRECT_PATH}`).catch((e) => e); + expect(error?.message).toBe(`Failed to fetch stream from https://mmg.whatsapp.net${DIRECT_PATH}`); + + const out = await captureOutput(async () => { + const logger = makeBaileysLogger('error'); + logger.error({ err: error, key: { remoteJid: `${PHONE}@s.whatsapp.net`, id: ID } }, `failed to download ${error.message}`); + await new Promise((r) => setTimeout(r, 20)); // pino's async write + }); + + expect(out).toContain('Failed to fetch stream from'); + expect(leaks(out)).toEqual([]); + }); +}); diff --git a/test/chat/media-error-no-url.test.ts b/test/chat/media-error-no-url.test.ts new file mode 100644 index 0000000000..4d9718924c --- /dev/null +++ b/test/chat/media-error-no-url.test.ts @@ -0,0 +1,229 @@ +// A media download that fails fails with Baileys' error, whose message is +// "Failed to fetch stream from " and whose Boom data carries the same +// link (data.url). A WhatsApp media link is signed per message (`oh`, `oe`, the +// `_nc_*` parameters) and whoever holds it can fetch the file until it expires; +// even its directPath alone names the file. The logs are scrubbed already, but +// the error itself used to leave the process as it was: in the HTTP answer of +// POST /chat/getBase64FromMediaMessage (the message was the error's text), in +// what main.ts's error handler posts to the errors webhook (the same object), +// and in the S3 upload's error log line (which prints that object's message). +// +// The answer keeps what a caller can act on: the CDN's status, whether the phone +// was asked to re-upload the file and why it refused, and a stable reason text. +// It never carries a link, with or without its query. +// +// The CDN here is WhatsApp's own host, answered by the undici mock agent (so the +// links are the real shape and nothing leaves the machine): Baileys downloads +// from https://, and Evolution's own fallback from +// https://mmg.whatsapp.net. +import { vi } from 'vitest'; + +const h = vi.hoisted(() => ({ waMonitor: undefined as any, chatController: undefined as any })); + +vi.mock('@api/server.module', async () => { + const fake = await import('../helpers/fake-server-module'); + return { + ...fake, + get waMonitor() { + return h.waMonitor; + }, + get chatController() { + return h.chatController; + }, + }; +}); + +import { rm } from 'node:fs/promises'; + +import { Boom } from '@hapi/boom'; +import { MEDIA_REUPLOAD_TIMEOUT_MS } from '@api/integrations/channel/whatsapp/whatsapp.baileys.service'; +import { encryptedStream, proto } from 'baileys'; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { deliver, makeService } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import { emitted, prismaRepository as prisma } from '../helpers/fake-server-module'; +import { startChatApp } from '../helpers/http-app'; + +const TOKEN = 'instance-token'; +const PHONE = '972509876543'; +const ID = '3EB0ABABABABABABABA1'; +const CDN = 'https://mmg.whatsapp.net'; +const SIGNATURE = '01_Q5Aa2wSignedHashXyz7Qp'; +/** A media link's `oe` (when it stops working): hex unix seconds, as WhatsApp writes it. */ +const oe = (fromNowS: number) => (Math.floor(Date.now() / 1000) + fromNowS).toString(16).toUpperCase(); +const DAY = 24 * 60 * 60; +/** A directPath as WhatsApp signs it. */ +const signed = (file: string, fromNowS = 14 * DAY) => + `/v/t62.7118-24/${file}_n.enc?ccb=11-4&oh=${SIGNATURE}&oe=${oe(fromNowS)}&_nc_sid=5e03e0&_nc_ohc=AbCdEfGh&mms3=true`; +const GONE_404 = signed('31415926_27182818284590_1234567890123456789'); +const GONE_410 = signed('31415926_27182818284590_1234567890123456790'); +const EXPIRED_403 = signed('31415926_27182818284590_1234567890123456791', -DAY); +const VALID_403 = signed('31415926_27182818284590_1234567890123456792'); +const REUPLOADED = signed('31415926_27182818284590_1234567890123456793'); +const STATUS: Record = { [GONE_404]: 404, [GONE_410]: 410, [EXPIRED_403]: 403, [VALID_403]: 403, [REUPLOADED]: 404 }; +/** Any part of a media link. */ +const LEAKS = ['whatsapp.net', 'http://', 'https://', 'oh=', 'oe=', '_nc_', 'mms3', '/v/t62', 't62.7118', SIGNATURE, '31415926']; + +let mediaKey: Uint8Array; +let app: Awaited>; +let service: any; +/** Every request the CDN answered: `GET `. */ +const cdnLog: string[] = []; +/** What the phone does with the next re-upload request. */ +let phone: (message: any) => Promise; + +const refuses = () => () => + Promise.reject( + new Boom('Media re-upload failed by device (NOT_FOUND)', { + data: { stanzaId: ID, result: proto.MediaRetryNotification.ResultType.NOT_FOUND }, + statusCode: 404, + }), + ); +const silent = () => () => new Promise(() => undefined); +/** What Baileys does when the phone re-uploads: point the message at the new copy, which fails too. */ +const reuploads = () => async (message: any) => { + Object.assign(message.message.imageMessage, { url: `${CDN}${REUPLOADED}`, directPath: REUPLOADED }); + return message; +}; + +const previousDispatcher = getGlobalDispatcher(); +const guard = new MockAgent(); +guard.disableNetConnect(); +guard.enableNetConnect((host: string) => host.startsWith('127.0.0.1:')); +guard + .get(CDN) + .intercept({ path: () => true, method: 'GET' }) + .reply((req: any) => { + cdnLog.push(`GET ${req.path}`); + return { statusCode: STATUS[req.path] ?? 404, data: '' }; + }) + .persist(); + +beforeAll(async () => { + setGlobalDispatcher(guard); + const enc = await encryptedStream(Buffer.from('a photo'), 'image', {}); + mediaKey = enc.mediaKey; + await rm(enc.encFilePath, { force: true }); + // Evolution waits 5s before its own fallback download, and gives the phone a + // bounded time to answer; both are shortened here. + const realSetTimeout = globalThis.setTimeout; + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: any, ms?: number, ...args: any[]) => + realSetTimeout(fn, ms === 5000 ? 0 : ms === MEDIA_REUPLOAD_TIMEOUT_MS ? 20 : ms, ...args)) as any); + + await prisma.instance.create({ data: { id: 'inst-1', name: 'test', connectionStatus: 'open', token: TOKEN, integration: 'WHATSAPP-BAILEYS' } }); + ({ service } = await makeService({ prisma })); + service.client.updateMediaMessage = (message: any) => phone(message); + h.waMonitor = { waInstances: { test: service } }; + const { ChatController } = await import('@api/controllers/chat.controller'); + h.chatController = new ChatController(h.waMonitor); + app = await startChatApp(); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + await app.close(); + setGlobalDispatcher(previousDispatcher); + await guard.close(); +}); + +beforeEach(() => { + cdnLog.splice(0); + emitted.splice(0); +}); + +/** A stored image message as a consumer holds it: JSON, with the media key an object of bytes. */ +const image = (directPath: string) => + JSON.parse( + JSON.stringify({ + key: { remoteJid: `${PHONE}@s.whatsapp.net`, fromMe: false, id: ID }, + message: { imageMessage: { url: `${CDN}${directPath}`, directPath, mediaKey: Uint8Array.from(mediaKey), mimetype: 'image/jpeg' } }, + messageTimestamp: 1_700_000_000, + }), + ); + +/** Every line of `text` that carries any part of a media link. */ +const leaks = (text: string) => + text + .replace(/\x1b\[[0-9;]*m/g, '') + .split('\n') + .filter((line) => LEAKS.some((l) => line.includes(l))) + .map((line) => line.trim().slice(0, 240)); + +async function post(body: Record) { + let res: Response; + let text = ''; + const out = await captureOutput(async () => { + res = await fetch(`${app.base}/chat/getBase64FromMediaMessage/test`, { + method: 'POST', + headers: { 'content-type': 'application/json', apikey: TOKEN }, + body: JSON.stringify(body), + }); + text = await res.text(); + }); + const headers = JSON.stringify([...res!.headers]); + return { status: res!.status, body: JSON.parse(text), leaks: leaks(`${text}\n${headers}\n${out}`) }; +} + +const failed = (status: number) => `The media could not be downloaded (HTTP ${status})`; + +describe('a failed media download never answers with the media link', () => { + it.each([ + ['a CDN 404, and the phone refuses the re-upload', GONE_404, refuses, {}, 404, { reupload: 'failed', reuploadReason: 'NOT_FOUND' }], + ['a CDN 410, and the phone does not answer in time', GONE_410, silent, {}, 410, { reupload: 'failed', reuploadReason: 'no_answer' }], + ['a CDN 403 on an expired link, and the phone refuses the re-upload', EXPIRED_403, refuses, {}, 403, { reupload: 'failed', reuploadReason: 'NOT_FOUND' }], + ['a CDN 404, the phone re-uploads, and the new link fails too', GONE_404, reuploads, {}, 404, { reupload: 'ok' }], + ['a CDN 403 on a link that has not expired: the phone is not asked', VALID_403, refuses, {}, 403, { reupload: 'not_requested' }], + ['reupload: false, and a CDN 403 on a link that has not expired', VALID_403, refuses, { reupload: false }, 403, { reupload: 'not_requested' }], + ])('%s', async (_name, link, answer, extra, status, facts) => { + phone = answer(); + const answered = await post({ message: image(link), ...extra }); + + // The download really went to the signed link, and Evolution's fallback to the same file. + expect(cdnLog[0]).toBe(`GET ${link}`); + expect(answered.status).toBe(400); + expect(answered.body).toEqual({ status: 400, error: 'Bad Request', response: { message: [failed(status)], ...facts } }); + // Not in the body, the headers, or anything logged meanwhile. + expect(answered.leaks).toEqual([]); + }); + + it('the error the service throws (what the error handler, its errors webhook and callers read) holds no link', async () => { + phone = reuploads(); + let thrown: any; + await captureOutput(async () => { + try { + await service.getBase64FromMediaMessage({ message: image(GONE_404) }); + } catch (e) { + thrown = e; + } + }); + + expect(thrown).toEqual({ status: 400, error: 'Bad Request', message: [failed(404)], reupload: 'ok' }); + expect(leaks(JSON.stringify(thrown))).toEqual([]); + expect(leaks(String(thrown?.stack ?? ''))).toEqual([]); + }); + + it('an incoming image the S3 upload cannot download: its error log does not name the link', async () => { + const { service: stored, ev } = await makeService({ profile: 'stored' }); + const config = stored.configService; + const get = config.get.bind(config); + config.get = (key: string) => (key === 'S3' ? { ...get('S3'), ENABLE: true, SAVE_VIDEO: true } : get(key)); + stored.client.updateMediaMessage = () => refuses()(); + const incoming = { + ...image(GONE_404), + key: { remoteJid: `${PHONE}@s.whatsapp.net`, fromMe: false, id: `${ID}-S3` }, + pushName: 'Sender', + }; + + const out = await captureOutput(() => deliver(stored, ev, { 'messages.upsert': { messages: [incoming], type: 'notify' } })); + + expect(cdnLog[0]).toBe(`GET ${GONE_404}`); + expect(out).toContain('Error on upload file to minio'); + expect(leaks(out)).toEqual([]); + // The message still reaches the webhook (with its own link, as every media message does), without an S3 copy. + const upsert = emitted.find((e) => e.event === 'messages.upsert'); + expect(upsert?.data?.key?.id).toBe(`${ID}-S3`); + expect(upsert?.data?.message?.mediaUrl).toBeUndefined(); + }); +}); diff --git a/test/chat/media-reupload-cancel.test.ts b/test/chat/media-reupload-cancel.test.ts new file mode 100644 index 0000000000..ee0d712b73 --- /dev/null +++ b/test/chat/media-reupload-cancel.test.ts @@ -0,0 +1,100 @@ +// A re-upload request waits for the phone's answer for MEDIA_REUPLOAD_TIMEOUT_MS, and then the +// download fails with no_answer. But Baileys' updateMediaMessage waits for that answer with no +// timeout of its own: a listener on messages.media-update and one on connection.update, removed +// only when an answer for the message arrives or the connection closes. Evolution stopped waiting +// and left them there: every unanswered request on a long connection kept two listeners, the +// message and its key, and an answer arriving later still rewrote the message and emitted +// messages.update after the API had answered no_answer. +// +// The socket's updateMediaMessage here is Baileys' own shape: it writes the request and waits with +// Baileys' own bindWaitForEvent on the real event buffer. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { rm } from 'node:fs/promises'; + +import { MEDIA_REUPLOAD_TIMEOUT_MS } from '@api/integrations/channel/whatsapp/whatsapp.baileys.service'; +import { bindWaitForEvent, encryptedStream } from 'baileys'; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { type Listening, startCdn } from '../helpers/local-net'; + +const PHONE = '972509876543@s.whatsapp.net'; +const ID = '3EB0CCCCCCCCCCCCCCC1'; +const GONE = '/v/t62.7118-24/expired.enc'; + +let cdn: Listening; +let mediaKey: Uint8Array; +const previousDispatcher = getGlobalDispatcher(); +const guard = new MockAgent(); +guard.disableNetConnect(); +guard.enableNetConnect((host: string) => host.startsWith('127.0.0.1:')); + +beforeAll(async () => { + setGlobalDispatcher(guard); + const enc = await encryptedStream(Buffer.from('a photo'), 'image', {}); + mediaKey = enc.mediaKey; + await rm(enc.encFilePath, { force: true }); + cdn = await startCdn({}); + // Evolution's 5s fallback wait and the phone's answer time, shortened. + const realSetTimeout = globalThis.setTimeout; + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: any, ms?: number, ...args: any[]) => + realSetTimeout(fn, ms === 5000 ? 0 : ms === MEDIA_REUPLOAD_TIMEOUT_MS ? 20 : ms, ...args)) as any); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + await cdn.close(); + setGlobalDispatcher(previousDispatcher); + await guard.close(); +}); + +describe('a re-upload the phone does not answer in time', () => { + it('leaves no waiter behind, and an answer arriving later changes nothing', async () => { + const { service, ev } = await makeService(); + const listening: Record = {}; + const on = ev.on.bind(ev); + const off = ev.off.bind(ev); + ev.on = (event: string, listener: any) => ((listening[event] = (listening[event] ?? 0) + 1), on(event, listener)); + ev.off = (event: string, listener: any) => ((listening[event] = (listening[event] ?? 0) - 1), off(event, listener)); + const waitForMediaUpdate = bindWaitForEvent(ev, 'messages.media-update'); + const lateUpdates: any[] = []; + service.client.updateMediaMessage = async (message: any) => { + // As Baileys: write the request (nothing to write here), then wait for the answer, untimed; an + // answer carrying an error is thrown. + let error: any; + await waitForMediaUpdate(async (update: any[]) => { + const result = update.find((u) => u.key.id === message.key.id); + if (result?.error) error = result.error; + return !!result; + }); + if (error) throw error; + lateUpdates.push(message.key.id); + ev.emit('messages.update', [{ key: message.key, update: { message: message.message } }]); + return message; + }; + + const failed = await service + .getBase64FromMediaMessage({ + message: { + key: { remoteJid: PHONE, fromMe: false, id: ID }, + message: { imageMessage: { url: `http://127.0.0.1:${cdn.port}${GONE}`, mediaKey, mimetype: 'image/jpeg' } }, + }, + }) + .catch((e: any) => e); + expect(failed?.reuploadReason).toBe('no_answer'); + await new Promise((r) => setTimeout(r, 10)); + const waiters = { + media: listening['messages.media-update'] ?? 0, + close: listening['connection.update'] ?? 0, + }; + + // The phone answers after all. + ev.emit('messages.media-update', [{ key: { remoteJid: PHONE, fromMe: false, id: ID }, media: {} }]); + await new Promise((r) => setTimeout(r, 10)); + expect({ waiters, lateUpdates }).toEqual({ waiters: { media: 0, close: 0 }, lateUpdates: [] }); + }); +}); diff --git a/test/chat/media-reupload-reason.test.ts b/test/chat/media-reupload-reason.test.ts new file mode 100644 index 0000000000..8033fd46ce --- /dev/null +++ b/test/chat/media-reupload-reason.test.ts @@ -0,0 +1,206 @@ +// When the phone refuses to re-upload an expired file, it says why: its +// answer to the request (a notification) carries +// either an or an encrypted MediaRetryNotification whose result is +// NOT_FOUND, DECRYPTION_ERROR or GENERAL_ERROR. Baileys turns that into the +// error updateMediaMessage throws. Evolution records the reason, and only the +// reason (never content or JIDs), in the re-upload log line (`reason=`) and on +// the download's error (`reuploadReason`, which the HTTP answer carries). +// +// The phone's answer is built as the notification node and read by Baileys' +// own decodeMediaRetryNode and decryptMediaRetryData. The Boom for a refused +// result is the one updateMediaMessage builds inline (messages-send.js), with +// no builder of its own, so it is written out here the same way. +import { vi } from 'vitest'; + +const h = vi.hoisted(() => ({ waMonitor: undefined as any, chatController: undefined as any })); + +vi.mock('@api/server.module', async () => { + const fake = await import('../helpers/fake-server-module'); + return { + ...fake, + get waMonitor() { + return h.waMonitor; + }, + get chatController() { + return h.chatController; + }, + }; +}); + +import { randomBytes } from 'node:crypto'; + +import { Boom } from '@hapi/boom'; +import { MEDIA_REUPLOAD_TIMEOUT_MS } from '@api/integrations/channel/whatsapp/whatsapp.baileys.service'; +import { + aesEncryptGCM, + decodeMediaRetryNode, + decryptMediaRetryData, + encryptedStream, + getStatusCodeForMediaRetry, + hkdf, + proto, +} from 'baileys'; +import { rm } from 'node:fs/promises'; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import { prismaRepository as prisma } from '../helpers/fake-server-module'; +import { startChatApp } from '../helpers/http-app'; +import { type Listening, startCdn } from '../helpers/local-net'; + +const TOKEN = 'instance-token'; +const PHONE = '972509876543'; +const ID = '3EB0FFFFFFFFFFFFFFF1'; +const CAPTION = 'Zq7 a private caption Zq7'; +const GONE = '/v/t62.7118-24/expired.enc'; +const { NOT_FOUND, DECRYPTION_ERROR, GENERAL_ERROR } = proto.MediaRetryNotification.ResultType; + +let cdn: Listening; +let mediaKey: Uint8Array; +let app: Awaited>; +let service: any; +/** What the phone answers to the next re-upload request: an error to throw, or nothing at all. */ +let phone: () => Promise; + +const previousDispatcher = getGlobalDispatcher(); +const guard = new MockAgent(); +guard.disableNetConnect(); +guard.enableNetConnect((host: string) => host.startsWith('127.0.0.1:')); + +beforeAll(async () => { + setGlobalDispatcher(guard); + const enc = await encryptedStream(Buffer.from('a photo'), 'image', {}); + mediaKey = enc.mediaKey; + await rm(enc.encFilePath, { force: true }); + cdn = await startCdn({}); + // Evolution waits 5s before its own fallback download, and gives the phone a + // bounded time to answer; both are shortened here. + const realSetTimeout = globalThis.setTimeout; + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: any, ms?: number, ...args: any[]) => + realSetTimeout(fn, ms === 5000 ? 0 : ms === MEDIA_REUPLOAD_TIMEOUT_MS ? 20 : ms, ...args)) as any); + + await prisma.instance.create({ data: { id: 'inst-1', name: 'test', connectionStatus: 'open', token: TOKEN, integration: 'WHATSAPP-BAILEYS' } }); + ({ service } = await makeService({ prisma })); + service.client.updateMediaMessage = () => phone(); + h.waMonitor = { waInstances: { test: service } }; + const { ChatController } = await import('@api/controllers/chat.controller'); + h.chatController = new ChatController(h.waMonitor); + app = await startChatApp(); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + await app.close(); + await cdn.close(); + setGlobalDispatcher(previousDispatcher); + await guard.close(); +}); + +beforeEach(() => void cdn.log.splice(0)); + +/** The phone's notification for a re-upload request: an , or an encrypted result. */ +function answerNode(answer: { code: string } | { result: number }) { + const rmr = { tag: 'rmr', attrs: { jid: `${PHONE}@s.whatsapp.net`, from_me: 'false' } }; + if ('code' in answer) return { tag: 'receipt', attrs: { id: ID }, content: [rmr, { tag: 'error', attrs: { code: answer.code } }] }; + const plain = proto.MediaRetryNotification.encode({ stanzaId: ID, result: answer.result }).finish(); + const iv = randomBytes(12); + const retryKey = hkdf(mediaKey, 32, { info: 'WhatsApp Media Retry Notification' }); + const ciphertext = aesEncryptGCM(plain, retryKey, iv, Buffer.from(ID)); + const encrypt = { tag: 'encrypt', attrs: {}, content: [{ tag: 'enc_p', attrs: {}, content: ciphertext }, { tag: 'enc_iv', attrs: {}, content: iv }] }; + return { tag: 'receipt', attrs: { id: ID }, content: [encrypt, rmr] }; +} + +/** What Baileys' updateMediaMessage throws when the phone answers with this node. */ +function refusal(node: any): Error { + const event: any = decodeMediaRetryNode(node); + if (event.error) return event.error; + const media: any = decryptMediaRetryData(event.media, mediaKey, ID); + const resultStr = proto.MediaRetryNotification.ResultType[media.result]; + return new Boom(`Media re-upload failed by device (${resultStr})`, { + data: media, + statusCode: getStatusCodeForMediaRetry(media.result) || 404, + }); +} + +const refuses = (answer: { code: string } | { result: number }) => () => Promise.reject(refusal(answerNode(answer))); +const expiredImage = () => ({ + key: { remoteJid: `${PHONE}@s.whatsapp.net`, fromMe: false, id: ID }, + message: { imageMessage: { url: `http://127.0.0.1:${cdn.port}${GONE}`, mediaKey, mimetype: 'image/jpeg', caption: CAPTION } }, +}); + +async function download() { + let thrown: any; + const out = await captureOutput(async () => { + try { + await service.getBase64FromMediaMessage({ message: expiredImage() }); + } catch (e) { + thrown = e; + } + }); + const plain = out.replace(/\x1b\[[0-9;]*m/g, ''); + return { thrown, out: plain, lines: plain.split('\n').filter((l) => l.includes('media download:')).map((l) => l.slice(l.indexOf('media download:'))) }; +} + +const at = (fields: string) => `media download: message=${ID}, chat=user, ${fields}`; + +describe('a refused re-upload says why', () => { + it.each([ + ['NOT_FOUND', NOT_FOUND, 404], + ['DECRYPTION_ERROR', DECRYPTION_ERROR, 412], + ['GENERAL_ERROR', GENERAL_ERROR, 418], + ])('the phone answers %s: the log line and the error carry it', async (name, result, status) => { + phone = refuses({ result }); + const { thrown, out, lines } = await download(); + + expect(lines).toEqual([ + at('outcome=reupload_requested'), + at(`outcome=reupload_failed, error=Error, status=${status}, reason=${name}`), + at('outcome=download_failed, status=404, reupload=failed'), + ]); + expect(thrown).toEqual({ status: 400, error: 'Bad Request', message: [expect.any(String)], reupload: 'failed', reuploadReason: name }); + expect(out).not.toContain(PHONE); + expect(out).not.toContain('Zq7'); + }); + + it('the phone answers with an error code: the reason is that code', async () => { + phone = refuses({ code: '2' }); + const { thrown, lines } = await download(); + + expect(lines[1]).toBe(at('outcome=reupload_failed, error=Error, status=404, reason=error_2')); + expect(thrown.reuploadReason).toBe('error_2'); + }); + + it('the phone answers with neither an error nor a result: missing_ciphertext', async () => { + phone = () => Promise.reject(refusal({ tag: 'receipt', attrs: { id: ID }, content: [{ tag: 'rmr', attrs: { jid: `${PHONE}@s.whatsapp.net`, from_me: 'false' } }] })); + const { thrown, lines } = await download(); + + expect(lines[1]).toBe(at('outcome=reupload_failed, error=Error, status=404, reason=missing_ciphertext')); + expect(thrown.reuploadReason).toBe('missing_ciphertext'); + }); + + it('the phone does not answer in time: no_answer', async () => { + phone = () => new Promise(() => undefined); + const { thrown, lines } = await download(); + + expect(lines[1]).toBe(at('outcome=reupload_failed, error=ReuploadTimeoutError, status=none, reason=no_answer')); + expect(thrown.reuploadReason).toBe('no_answer'); + }); + + it('the HTTP answer carries the reason', async () => { + phone = refuses({ result: NOT_FOUND }); + const res = await fetch(`${app.base}/chat/getBase64FromMediaMessage/test`, { + method: 'POST', + headers: { 'content-type': 'application/json', apikey: TOKEN }, + body: JSON.stringify({ message: JSON.parse(JSON.stringify({ ...expiredImage(), message: { imageMessage: { ...expiredImage().message.imageMessage, mediaKey: Uint8Array.from(mediaKey) } } })) }), + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + status: 400, + error: 'Bad Request', + response: { message: [expect.any(String)], reupload: 'failed', reuploadReason: 'NOT_FOUND' }, + }); + }); +}); diff --git a/test/chat/media-skip-reupload.test.ts b/test/chat/media-skip-reupload.test.ts new file mode 100644 index 0000000000..1dae523b50 --- /dev/null +++ b/test/chat/media-skip-reupload.test.ts @@ -0,0 +1,197 @@ +// POST /chat/getBase64FromMediaMessage takes an optional `reupload` (boolean, +// default true). A consumer that only wants what is still on WhatsApp's +// servers sends reupload: false: Evolution does not hand Baileys a +// reuploadRequest, so the phone is never asked, and a file that has expired on +// the CDN (404 or 410, or 403 on a link whose `oe` has passed) fails at once, +// without Evolution's own 5s fallback, with an error that says the file is gone +// and no re-upload was attempted. +// Omitting the field keeps today's behaviour: the phone is asked. +import { vi } from 'vitest'; + +const h = vi.hoisted(() => ({ waMonitor: undefined as any, chatController: undefined as any })); + +vi.mock('@api/server.module', async () => { + const fake = await import('../helpers/fake-server-module'); + return { + ...fake, + get waMonitor() { + return h.waMonitor; + }, + get chatController() { + return h.chatController; + }, + }; +}); + +import { readFile, rm } from 'node:fs/promises'; + +import { encryptedStream } from 'baileys'; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { prismaRepository as prisma } from '../helpers/fake-server-module'; +import { startChatApp } from '../helpers/http-app'; +import { type Listening, startCdn } from '../helpers/local-net'; + +const TOKEN = 'instance-token'; +const ID = '3EB0DDDDDDDDDDDDDDD1'; +const PLAIN = Buffer.from('a photo, as the person sent it '.repeat(200)); +const LIVE = '/v/t62.7118-24/reuploaded.enc'; +const GONE = '/v/t62.7118-24/expired.enc'; +const GONE_403 = '/v/t62.7118-24/expired-403.enc'; +/** A media link's `oe` (when it stops working): hex unix seconds, as WhatsApp writes it. */ +const oe = (fromNowS: number) => (Math.floor(Date.now() / 1000) + fromNowS).toString(16).toUpperCase(); +const DAY = 24 * 60 * 60; +const EXPIRED_LINK_403 = `${GONE_403}?ccb=11-4&oh=01_Q5Aa&oe=${oe(-DAY)}&_nc_sid=5e03e0`; +const VALID_LINK_403 = `${GONE_403}?ccb=11-4&oh=01_Q5Aa&oe=${oe(14 * DAY)}&_nc_sid=5e03e0`; +const GONE_MESSAGE = + "The media is no longer on WhatsApp's servers (HTTP 404), and no re-upload from the phone was attempted (reupload: false)"; + +let cdn: Listening; +let mediaKey: Uint8Array; +let app: Awaited>; +let asked: string[]; + +// Refuse any connection that is not to 127.0.0.1, before any lookup: Evolution's +// fallback retries the download at mmg.whatsapp.net, which must fail here, not leave. +const previousDispatcher = getGlobalDispatcher(); +const guard = new MockAgent(); +guard.disableNetConnect(); +guard.enableNetConnect((host: string) => host.startsWith('127.0.0.1:')); + +beforeAll(async () => { + setGlobalDispatcher(guard); + const enc = await encryptedStream(PLAIN, 'image', {}); + mediaKey = enc.mediaKey; + const body = await readFile(enc.encFilePath); + await rm(enc.encFilePath, { force: true }); + cdn = await startCdn({ [LIVE]: body, [EXPIRED_LINK_403]: 403, [VALID_LINK_403]: 403 }); + // Evolution waits 5s before its own fallback download; shortened, so a test that + // takes it is not slow, and one that skips it is told apart by what it answers. + const realSetTimeout = globalThis.setTimeout; + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: any, ms?: number, ...args: any[]) => + realSetTimeout(fn, ms === 5000 ? 0 : ms, ...args)) as any); + + await prisma.instance.create({ data: { id: 'inst-1', name: 'test', connectionStatus: 'open', token: TOKEN, integration: 'WHATSAPP-BAILEYS' } }); + const { service } = await makeService({ prisma }); + asked = []; + service.client.updateMediaMessage = async (message: any) => { + asked.push(message.key.id); + // What Baileys does when the phone re-uploads: point the message at the new copy. + message.message.imageMessage.url = `http://127.0.0.1:${cdn.port}${LIVE}`; + return message; + }; + h.waMonitor = { waInstances: { test: service } }; + const { ChatController } = await import('@api/controllers/chat.controller'); + h.chatController = new ChatController(h.waMonitor); + app = await startChatApp(); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + await app.close(); + await cdn.close(); + setGlobalDispatcher(previousDispatcher); + await guard.close(); +}); + +beforeEach(() => { + cdn.log.splice(0); + asked.length = 0; +}); + +/** A stored image message as a consumer holds it: JSON, with the media key (a Uint8Array, as Baileys decodes it) an object of bytes. */ +const expiredImage = () => + JSON.parse( + JSON.stringify({ + key: { remoteJid: '972500000001@s.whatsapp.net', fromMe: false, id: ID }, + message: { imageMessage: { url: `http://127.0.0.1:${cdn.port}${GONE}`, mediaKey: Uint8Array.from(mediaKey), mimetype: 'image/jpeg', fileLength: PLAIN.length } }, + messageTimestamp: 1_700_000_000, + }), + ); + +async function post(body: Record) { + const res = await fetch(`${app.base}/chat/getBase64FromMediaMessage/test`, { + method: 'POST', + headers: { 'content-type': 'application/json', apikey: TOKEN }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; +} + +describe('a media download can skip asking the phone to re-upload', () => { + it('reupload: false and an expired file: the phone is not asked, and the error says the file is gone', async () => { + const answer = await post({ message: expiredImage(), reupload: false }); + + expect(asked).toEqual([]); + expect(cdn.log).toEqual([`GET ${GONE}`]); + expect(answer).toEqual({ + status: 400, + body: { status: 400, error: 'Bad Request', response: { message: [GONE_MESSAGE], reupload: 'not_requested' } }, + }); + }); + + it('reupload: false and a CDN 403 on a link whose oe has passed: the phone is not asked, and the error says the file is gone', async () => { + const message = expiredImage(); + message.message.imageMessage.url = `http://127.0.0.1:${cdn.port}${EXPIRED_LINK_403}`; + const answer = await post({ message, reupload: false }); + + expect(asked).toEqual([]); + expect(cdn.log).toEqual([`GET ${EXPIRED_LINK_403}`]); + expect(answer).toEqual({ + status: 400, + body: { status: 400, error: 'Bad Request', response: { message: [GONE_MESSAGE.replace('HTTP 404', 'HTTP 403')], reupload: 'not_requested' } }, + }); + }); + + it('reupload: false and a CDN 403 on a link whose oe has not passed: not reported as a file that is gone', async () => { + const message = expiredImage(); + message.message.imageMessage.url = `http://127.0.0.1:${cdn.port}${VALID_LINK_403}`; + const answer = await post({ message, reupload: false }); + + expect(asked).toEqual([]); + // The first GET is the download; Evolution's own fallback then tries mmg.whatsapp.net, refused here. + expect(cdn.log).toEqual([`GET ${VALID_LINK_403}`]); + // Its answer is that fallback's own failure, not the "no longer on WhatsApp's servers" 400: what + // kind of error and its network code, never its text (media-error-no-url.test.ts). + expect(answer).toEqual({ + status: 400, + body: { + status: 400, + error: 'Bad Request', + response: { message: ['The media could not be downloaded (TypeError, UND_MOCK_ERR_MOCK_NOT_MATCHED)'], reupload: 'not_requested' }, + }, + }); + }); + + it('reupload must be a boolean', async () => { + const answer = await post({ message: expiredImage(), reupload: 'no' }); + + expect(asked).toEqual([]); + expect(cdn.log).toEqual([]); + expect(answer).toEqual({ + status: 400, + // Evolution's validation error: the router wraps the list of schema errors in another list. + body: { status: 400, error: 'Bad Request', response: { message: [['reupload is not of a type(s) boolean']] } }, + }); + }); + + it('reupload: false does not stop a download of a file that is still there', async () => { + const message = expiredImage(); + message.message.imageMessage.url = `http://127.0.0.1:${cdn.port}${LIVE}`; + const answer = await post({ message, reupload: false }); + + expect(answer.status).toBe(201); + expect(Buffer.from(answer.body.base64, 'base64').equals(PLAIN)).toBe(true); + }); + + it('reupload omitted: as before, the phone is asked and the download succeeds', async () => { + const answer = await post({ message: expiredImage() }); + + expect(asked).toEqual([ID]); + expect(cdn.log).toEqual([`GET ${GONE}`, `GET ${LIVE}`]); + expect(answer.status).toBe(201); + expect(Buffer.from(answer.body.base64, 'base64').equals(PLAIN)).toBe(true); + }); +}); diff --git a/test/connect/one-socket.test.ts b/test/connect/one-socket.test.ts new file mode 100644 index 0000000000..d2d116ac9d --- /dev/null +++ b/test/connect/one-socket.test.ts @@ -0,0 +1,219 @@ +// An instance owns one WhatsApp socket at a time. Building a new one (a +// reconnect, /instance/connect, a restart) must first let go of the old one: +// stop listening to it, then end it. Otherwise the old socket keeps talking: +// its close starts a reconnect that ends the new one, its QR codes replace the +// new one's, and two logged-in sockets on one session get 440 (replaced) from +// WhatsApp in turn, forever. Two connects that overlap, which is what +// /instance/connect polled every 2s does while Evolution is reconnecting, +// must build one socket between them. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); +// This file is about which socket is live; the version fetch has its own tests. +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import { Boom } from '@hapi/boom'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { fakeSocket, stubAuthState } from '../helpers/connect'; + +socketSpy.mockImplementation(fakeSocket); + +// Longer than any reconnect backoff: past this, no reconnect is coming. +const NEVER = 5 * 60_000; + +beforeEach(() => { + socketSpy.mockClear(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +async function flush() { + for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r)); +} + +type Sock = ReturnType; +const built = (): Sock[] => socketSpy.mock.results.map((r) => r.value); +const live = () => built().filter((s) => !s.ended); +/** Nothing of Evolution's is still listening to this socket. */ +const detached = (s: Sock) => + s.handlers() + s.ws.listenerCount('CB:call') + s.ws.listenerCount('CB:ack,class:call') === 0; + +async function service() { + const { service } = await makeService(); + stubAuthState(service); + return service; +} + +/** WhatsApp closes the socket: Baileys ends it with the reason, which it announces as a close. */ +function dropped(sock: Sock, statusCode: number) { + sock.end(new Boom('closed', { statusCode })); +} + +async function opened(sock: Sock) { + sock.ev.emit('connection.update', { connection: 'open' }); + await flush(); +} + +async function waitOutAnyReconnect() { + await vi.advanceTimersByTimeAsync(NEVER); + await flush(); +} + +describe('one instance, one socket', () => { + it('two overlapping connects build one socket', async () => { + const s = await service(); + const [a, b] = await Promise.all([s.connectToWhatsapp(), s.connectToWhatsapp()]); + await waitOutAnyReconnect(); + expect({ built: built().length, same: a === b, live: live().length }).toEqual({ built: 1, same: true, live: 1 }); + }); + + it('a connect while a reconnect is waiting replaces the old socket once, and the reconnect does not follow', async () => { + const s = await service(); + await s.connectToWhatsapp(); + const [first] = built(); + dropped(first, 408); + await flush(); + // /instance/connect sees state "close" during the backoff and connects. + await s.connectToWhatsapp(); + await waitOutAnyReconnect(); + const [, second] = built(); + expect({ + built: built().length, + live: live().length, + current: s.client === second, + firstEnded: first.ended, + firstDetached: detached(first), + }).toEqual({ built: 2, live: 1, current: true, firstEnded: true, firstDetached: true }); + }); + + it('a connect ends the socket it replaces, after it has stopped listening to it', async () => { + const s = await service(); + await s.connectToWhatsapp(); + const [first] = built(); + await opened(first); + // /instance/restart on Baileys, or any second connect while the first socket is still up. + await s.connectToWhatsapp(); + await waitOutAnyReconnect(); + // Ending the first socket announces its close; had Evolution still been listening, it would reconnect. + expect({ + built: built().length, + live: live().length, + firstEnded: first.ended, + firstDetached: detached(first), + }).toEqual({ built: 2, live: 1, firstEnded: true, firstDetached: true }); + }); + + it('what a replaced socket still emits does not drive the instance', async () => { + const s = await service(); + await s.connectToWhatsapp(); + const [first] = built(); + await s.connectToWhatsapp(); + const [, second] = built(); + await opened(second); + // The replaced socket's buffered close arrives late (a 440 from WhatsApp, say). + first.ev.emit('connection.update', { + connection: 'close', + lastDisconnect: { error: new Boom('replaced', { statusCode: 440 }), date: new Date() }, + }); + await flush(); + await waitOutAnyReconnect(); + expect({ built: built().length, state: s.connectionStatus.state, current: s.client === second }).toEqual({ + built: 2, + state: 'open', + current: true, + }); + }); + + // Guards the fix rather than the bug: letting a connect replace a waiting reconnect must not + // lose that reconnect when the connect fails before it has built a socket. + it('a connect that fails leaves the waiting reconnect in place', async () => { + const s = await service(); + await s.connectToWhatsapp(); + const [first] = built(); + dropped(first, 408); + await flush(); + const read = s.defineAuthState; + s.defineAuthState = async () => { + s.defineAuthState = read; + throw new Error('database unavailable'); + }; + await expect(s.connectToWhatsapp()).rejects.toBeDefined(); + await waitOutAnyReconnect(); + expect({ built: built().length, live: live().length }).toEqual({ built: 2, live: 1 }); + }); + + it('a 440 on the live socket is still reconnected', async () => { + const s = await service(); + await s.connectToWhatsapp(); + const [first] = built(); + await opened(first); + dropped(first, 440); + await flush(); + await waitOutAnyReconnect(); + expect({ built: built().length, live: live().length }).toEqual({ built: 2, live: 1 }); + }); + + // Two more ways a socket was built outside that guard: reloadConnection (after a profile or privacy + // update) called createClient directly, and shutdown (the instance is removed) did not stop a + // socket already being built, which then went live for an instance that no longer exists. + describe('every socket goes through the same guard', () => { + /** Hold the next socket build at its first wait (reading the auth state) until released. */ + function holdNextBuild(s: any) { + let release: () => void; + const gate = new Promise((r) => (release = r)); + const read = s.defineAuthState; + s.defineAuthState = async () => { + await gate; + return read(); + }; + return () => release(); + } + + it('a reload while a connect is being built joins it: one socket', async () => { + const s = await service(); + const release = holdNextBuild(s); + const connect = s.connectToWhatsapp(); + await flush(); + const reload = s.reloadConnection(); + await flush(); + release(); + await Promise.all([connect, reload]); + await waitOutAnyReconnect(); + expect({ built: built().length, live: live().length }).toEqual({ built: 1, live: 1 }); + }); + + it('a socket being built when the instance is shut down never goes live', async () => { + const s = await service(); + const release = holdNextBuild(s); + const connect = s.connectToWhatsapp().catch(() => undefined); + await flush(); + s.shutdown(); + release(); + await connect; + await waitOutAnyReconnect(); + expect({ built: built().length, live: live().length }).toEqual({ built: 0, live: 0 }); + }); + + it('a reload after the instance is shut down builds nothing', async () => { + const s = await service(); + await s.connectToWhatsapp(); + s.shutdown(); + await s.reloadConnection().catch(() => undefined); + await waitOutAnyReconnect(); + expect({ built: built().length, live: live().length }).toEqual({ built: 1, live: 0 }); + }); + }); +}); diff --git a/test/connect/pairing-code.test.ts b/test/connect/pairing-code.test.ts new file mode 100644 index 0000000000..9136bc4ce4 --- /dev/null +++ b/test/connect/pairing-code.test.ts @@ -0,0 +1,163 @@ +// Linking with a phone number: while the socket waits to be paired, Baileys +// rotates the QR every qrTimeout (45s, createClient), emitting connection.update +// { qr } each time, and after the last ref it closes the socket (408) and +// Evolution opens a new one. Evolution asked for a NEW pairing code on every QR +// (connectionUpdate), and Baileys' requestPairingCode (rc14 socket.js) makes a +// fresh code and sends link_code_companion_reg with +// should_show_push_notification 'true': the code the person is typing dies and +// their phone gets another "link a device" push, every 45s. +// +// The QR count (QRCODE_LIMIT) was reset only when the instance is built and +// after the limit is hit, so a later connect attempt on the same instance +// started with the budget earlier attempts had used, and was refused early. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + // connectionUpdate waits a second before asking for the code; the wait is not what is tested. + return { ...orig, default: socketSpy, makeWASocket: socketSpy, delay: (ms: number) => orig.delay(Math.min(ms, 5)) }; +}); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { fakeSocket, stubAuthState } from '../helpers/connect'; +import { emitted } from '../helpers/fake-server-module'; +import { loopbackOnly } from '../helpers/local-net'; + +const PHONE = '972500000009'; + +let codes = 0; +const sockets: any[] = []; +socketSpy.mockImplementation(() => { + const socket: any = { + ...fakeSocket(), + requestPairingCode: vi.fn(async () => `CODE${String(++codes).padStart(4, '0')}`), + logout: vi.fn(async () => undefined), + }; + socket.ws.close = vi.fn(); + sockets.push(socket); + return socket; +}); + +let guard: ReturnType; +beforeAll(() => void (guard = loopbackOnly())); +afterAll(() => { + expect(guard.refused).toEqual([]); + guard.restore(); +}); + +const qrUpdates = () => emitted.filter((e) => e.event === 'qrcode.updated' && e.data?.qrcode); +const refusals = () => emitted.filter((e) => e.event === 'connection.update' && e.data?.state === 'refused'); +const requests = () => sockets.reduce((n, s) => n + s.requestPairingCode.mock.calls.length, 0); + +async function until(done: () => boolean) { + for (let i = 0; i < 400 && !done(); i++) await new Promise((r) => setTimeout(r, 5)); + if (!done()) throw new Error('timed out waiting'); +} + +/** Baileys shows the next QR ref; wait until Evolution has handled it (its webhook, or the refusal). */ +async function nextQr(n: number) { + const socket = sockets.at(-1); + const before = qrUpdates().length + refusals().length; + socket.ev.emit('connection.update', { qr: `2@ref-${n},noise,identity,adv` }); + await until(() => qrUpdates().length + refusals().length > before); + await new Promise((r) => setTimeout(r, 10)); +} + +/** The QR refs ran out: Baileys ends the socket with 408, and Evolution reconnects on its own. */ +async function refsEnded() { + const count = sockets.length; + sockets.at(-1).ev.emit('connection.update', { connection: 'close', lastDisconnect: { error: { output: { statusCode: 408 } } } }); + await until(() => sockets.length > count); +} + +async function service() { + const { service } = await makeService(); + stubAuthState(service); + return service; +} + +describe('one pairing code per socket, and a fresh QR budget per connect attempt', () => { + const limit = process.env.QRCODE_LIMIT; + beforeEach(() => { + emitted.splice(0); + sockets.splice(0); + }); + afterEach(() => { + if (limit === undefined) delete process.env.QRCODE_LIMIT; + else process.env.QRCODE_LIMIT = limit; + }); + + it('QR refreshes on one socket keep the pairing code the person is typing', async () => { + const s = await service(); + await s.connectToWhatsapp(PHONE); + + await nextQr(1); + await nextQr(2); + await nextQr(3); + + expect(sockets).toHaveLength(1); + expect(sockets[0].requestPairingCode.mock.calls).toEqual([[PHONE]]); + const code = qrUpdates()[0].data.qrcode.pairingCode; + expect(code).toMatch(/^CODE\d{4}$/); + expect(qrUpdates().map((e) => e.data.qrcode.pairingCode)).toEqual([code, code, code]); + expect(s.qrCode.pairingCode).toBe(code); + }); + + it('a new socket, after the refs run out, asks for a new code once', async () => { + const s = await service(); + await s.connectToWhatsapp(PHONE); + await nextQr(1); + await nextQr(2); + await refsEnded(); + await nextQr(3); + await nextQr(4); + + expect(sockets).toHaveLength(2); + expect(sockets.map((x) => x.requestPairingCode.mock.calls.length)).toEqual([1, 1]); + const [first, second] = [qrUpdates()[0], qrUpdates()[2]].map((e) => e.data.qrcode.pairingCode); + expect(second).not.toBe(first); + expect(qrUpdates().map((e) => e.data.qrcode.pairingCode)).toEqual([first, first, second, second]); + }); + + it('within one connect attempt the QR budget spans reconnects, and is refused at the limit', async () => { + process.env.QRCODE_LIMIT = '3'; + const s = await service(); + await s.connectToWhatsapp(PHONE); + await nextQr(1); + await nextQr(2); + await refsEnded(); + await nextQr(3); + expect(refusals()).toHaveLength(0); + await nextQr(4); + + expect(refusals()).toHaveLength(1); + expect(requests()).toBe(2); + }); + + it('a new connect attempt (logout, then connect) starts with a fresh QR budget', async () => { + process.env.QRCODE_LIMIT = '3'; + const s = await service(); + await s.connectToWhatsapp(PHONE); + await nextQr(1); + await nextQr(2); + + // What a client does for each new code request: log out, then connect again. + await s.logoutInstance(); + await s.connectToWhatsapp(PHONE); + expect(s.qrCode).toEqual({ pairingCode: undefined, code: undefined, base64: undefined, count: 0 }); + await nextQr(3); + await nextQr(4); + + expect(refusals()).toHaveLength(0); + expect(s.qrCode.count).toBe(2); + expect(sockets.map((x) => x.requestPairingCode.mock.calls.length)).toEqual([1, 1]); + }); +}); diff --git a/test/connect/pending-logout-connect-log.test.ts b/test/connect/pending-logout-connect-log.test.ts new file mode 100644 index 0000000000..f5851b1ae5 --- /dev/null +++ b/test/connect/pending-logout-connect-log.test.ts @@ -0,0 +1,76 @@ +// A logout that could not reach WhatsApp stays pending across a restart (a +// marker in the instance's directory); on boot Evolution connects only to +// deliver it. When that connect fails (the network is still down), the line +// it logs must say why, as the reconnect loop's does: the error's name, its +// message (scrubbed: no URL, JID or phone number) and its status code when it +// has one. It printed the thrown value's toString(), which for the 500 that +// openConnection throws is "[object Object]". +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); +const tmp = await vi.hoisted(async () => { + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + return mkdtempSync(join(tmpdir(), 'evo-pending-log-')); +}); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('@config/path.config', async (importOriginal) => ({ ...(await importOriginal()), INSTANCE_DIR: tmp })); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import { rmSync } from 'node:fs'; + +import { Boom } from '@hapi/boom'; +import { writeLogoutMarker } from '@utils/logout-marker'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import { fakeSocket } from '../helpers/connect'; + +socketSpy.mockImplementation(fakeSocket); + +const PHONE = '972509876543'; +const URL_SECRET = 'https://web.whatsapp.com/check?token=Zq7secret'; + +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +describe('a failed connect for a pending logout says why', () => { + it('a Boom: its name, scrubbed message and status code', async () => { + await writeLogoutMarker('inst-1', { instanceName: 'test', deleted: false, since: new Date(0).toISOString() }); + const { service } = await makeService(); + service.defineAuthState = async () => { + throw new Boom(`request to ${URL_SECRET} for ${PHONE}@s.whatsapp.net failed, reason: getaddrinfo ENOTFOUND`, { + statusCode: 503, + }); + }; + + let resumed: boolean | undefined; + const out = await captureOutput(async () => { + resumed = await service.resumePendingLogout(); + }); + service.stopReconnecting(); + + expect(resumed).toBe(true); + const plain = out.replace(/\x1b\[[0-9;]*m/g, ''); + const logged = plain + .split('\n') + .filter((l) => l.includes('Connect for a pending logout failed')) + .map((l) => JSON.parse(l.slice(l.indexOf('{')))); + expect(logged).toEqual([ + { + message: 'Connect for a pending logout failed', + error: { name: 'Error', message: 'request to [url] for [jid] failed, reason: getaddrinfo ENOTFOUND', statusCode: 503 }, + }, + ]); + expect(plain).not.toContain(PHONE); + expect(plain).not.toContain('Zq7secret'); + }); +}); diff --git a/test/connect/queued-after-shutdown.test.ts b/test/connect/queued-after-shutdown.test.ts new file mode 100644 index 0000000000..913b7b632f --- /dev/null +++ b/test/connect/queued-after-shutdown.test.ts @@ -0,0 +1,62 @@ +// Evolution handles a socket's batches one at a time, on a queue (eventProcessingQueue): a batch +// can wait there behind a slow one (a large history sync) for a long time. The check that the +// batch's socket is still the instance's ran when the batch was queued, not when it ran. So a +// batch queued before the instance was removed (shut down: DELETE, DEL_INSTANCE) still ran after +// it: webhooks for an instance that no longer exists, and rows written again under it after its +// data was deleted. A lookup finishing late (a profile picture) did the same. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { describe, expect, it } from 'vitest'; + +import { makeService, settle } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; + +const CONTACT = '972500000001@s.whatsapp.net'; + +describe('an instance shut down while batches wait on its queue', () => { + it('runs none of them afterwards: no webhook, no row', async () => { + emitted.length = 0; + const { service, ev, prisma } = await makeService({ profile: 'stored' }); + service.eventHandler(); + let release: () => void; + service.eventProcessingQueue = new Promise((r) => (release = r)); + + ev.emit('contacts.upsert', [{ id: CONTACT, notify: 'Tal' }]); + service.shutdown(); + release(); + await settle(service); + + expect({ webhooks: emitted.map((e) => e.event), contacts: prisma.contact.rows.length }).toEqual({ + webhooks: [], + contacts: 0, + }); + }); + + it('forwards nothing a lookup finishes after it', async () => { + emitted.length = 0; + const { service, ev } = await makeService(); + let answer: (url: string) => void; + service.client.profilePictureUrl = () => new Promise((r) => (answer = r)); + service.eventHandler(); + ev.emit('contacts.upsert', [{ id: CONTACT, notify: 'Tal' }]); + await settle(service); + await vi.waitFor(() => expect(answer).toBeDefined()); + emitted.length = 0; + + service.shutdown(); + answer('https://pps.whatsapp.net/v/t61/picture.jpg'); + await settle(service); + await new Promise((r) => setTimeout(r, 20)); + expect(emitted.map((e) => e.event)).toEqual([]); + }); + + it('still announces its own removal, which the monitor sends after shutting it down', async () => { + emitted.length = 0; + const { service } = await makeService(); + service.shutdown(); + await service.sendDataWebhook('remove.instance', null); + expect(emitted.map((e) => e.event)).toEqual(['remove.instance']); + }); +}); diff --git a/test/connect/reconnect-backoff.test.ts b/test/connect/reconnect-backoff.test.ts new file mode 100644 index 0000000000..fec97e0176 --- /dev/null +++ b/test/connect/reconnect-backoff.test.ts @@ -0,0 +1,154 @@ +// When a socket closes with a code that is not a logout, Evolution connects +// again. Every attempt builds a socket, fetches the WhatsApp Web version and +// opens a connection through the instance's proxy. If the exit is dead, every +// attempt closes again at once, so the attempts must be spaced out: 1s, 2s, 4s +// and so on, doubling up to one a minute, and back to 1s once a connection has +// opened. They never stop: an instance that gave up would sit disconnected with +// valid credentials until someone noticed. A reconnect still waiting when the +// instance is logged out or deleted must not happen. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); +// This file is about when a reconnect happens; the version fetch has its own tests. +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import { Boom } from '@hapi/boom'; +import { WAMonitoringService } from '@api/services/monitor.service'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { fakeSocket, stubAuthState } from '../helpers/connect'; + +socketSpy.mockImplementation(fakeSocket); + +const LADDER = [1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000]; +// Longer than any delay the ladder may use: past this, no reconnect is coming. +const NEVER = 5 * 60_000; + +beforeEach(() => { + socketSpy.mockClear(); + // setImmediate stays real: flush() uses it to let the event queue and the connect path run. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +async function flush() { + for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r)); +} + +const sockets = () => socketSpy.mock.results.length; +const current = () => socketSpy.mock.results[socketSpy.mock.results.length - 1].value; + +async function connected() { + const { service, prisma } = await makeService(); + stubAuthState(service); + await service.connectToWhatsapp(); + expect(sockets()).toBe(1); + return { service, prisma }; +} + +function closeWith(statusCode: number) { + current().ev.emit('connection.update', { + connection: 'close', + lastDisconnect: { error: new Boom('closed', { statusCode }), date: new Date() }, + }); +} + +/** Close the live socket and return how long Evolution waited before the next socket (null: none came). */ +async function reconnectDelay(statusCode: number): Promise { + const before = sockets(); + const start = Date.now(); + closeWith(statusCode); + await flush(); + while (sockets() === before) { + if (Date.now() - start > NEVER) return null; + await vi.advanceTimersByTimeAsync(250); + await flush(); + } + return Date.now() - start; +} + +async function open() { + current().ev.emit('connection.update', { connection: 'open' }); + await flush(); +} + +describe('reconnecting after a close', () => { + it('waits 1s, 2s, 4s... up to a minute between attempts, and never stops', async () => { + await connected(); + const delays: (number | null)[] = []; + for (let i = 0; i < 25; i++) delays.push(await reconnectDelay(500)); + expect(delays).toEqual([...LADDER, ...Array(25 - LADDER.length).fill(60_000)]); + }); + + it('backs off the same way whichever reconnectable code closed it', async () => { + await connected(); + const delays: (number | null)[] = []; + for (const code of [408, 428, 500, 503, 411, 408]) delays.push(await reconnectDelay(code)); + expect(delays).toEqual(LADDER.slice(0, 6)); + }); + + it('starts again from 1s once a connection has opened', async () => { + await connected(); + const before = [await reconnectDelay(408), await reconnectDelay(408), await reconnectDelay(408)]; + await open(); + const after = [await reconnectDelay(408), await reconnectDelay(408)]; + expect({ before, after }).toEqual({ before: [1_000, 2_000, 4_000], after: [1_000, 2_000] }); + }); + + it('tries again when a reconnect attempt itself fails', async () => { + const { service } = await connected(); + // The next reconnect cannot read the session's credentials (a database blip); the one after can. + const read = service.defineAuthState; + let failures = 1; + service.defineAuthState = async () => { + if (failures-- > 0) throw new Error('database unavailable'); + return read(); + }; + expect(await reconnectDelay(500)).toBe(1_000 + 2_000); + }); + + it('does not reconnect after a logout code', async () => { + for (const code of [401, 402, 403, 406]) { + socketSpy.mockClear(); + await connected(); + expect(await reconnectDelay(code)).toBeNull(); + } + }); + + it('drops a waiting reconnect when the instance is logged out', async () => { + const { service } = await connected(); + closeWith(500); + await flush(); + await service.logoutInstance(); + await vi.advanceTimersByTimeAsync(NEVER); + await flush(); + expect(sockets()).toBe(1); + }); + + it('drops a waiting reconnect when the instance is deleted', async () => { + const { service, prisma } = await connected(); + const { ConfigService } = await import('@config/env.config'); + const monitor = new WAMonitoringService(service.eventEmitter, new ConfigService(), prisma, null, null, null, null); + monitor.waInstances.test = service; + closeWith(500); + await flush(); + // What DELETE /instance/delete emits when the instance is not connected (instance.controller.ts deleteInstance). + service.eventEmitter.emit('remove.instance', 'test', 'inner'); + await flush(); + await vi.advanceTimersByTimeAsync(NEVER); + await flush(); + expect(sockets()).toBe(1); + }); +}); diff --git a/test/connect/reconnect-failure-log.test.ts b/test/connect/reconnect-failure-log.test.ts new file mode 100644 index 0000000000..1893155308 --- /dev/null +++ b/test/connect/reconnect-failure-log.test.ts @@ -0,0 +1,134 @@ +// When a reconnect attempt fails before a socket exists (the network is down, +// the credentials cannot be read), Evolution logs it and schedules the next +// one. Seen live during an outage: the line said +// `{ message: 'Reconnect attempt failed', error: '[object Object]' }`, every +// time, so the operator could not tell why. The line must say what failed: +// the error's name, its message (scrubbed: no URL, JID or phone number) and +// its status code when it has one. Two shapes are thrown in practice: a Boom +// (an Error with output.statusCode) and a plain object that is not an Error. +// reloadConnection (after a privacy or profile picture change) rebuilds the +// socket the same way and printed the raw error, message and all. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import { Boom } from '@hapi/boom'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import { fakeSocket, stubAuthState } from '../helpers/connect'; + +socketSpy.mockImplementation(fakeSocket); + +const PHONE = '972509876543'; +const URL_SECRET = 'https://web.whatsapp.com/check?token=Zq7secret'; + +beforeEach(() => { + socketSpy.mockClear(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +async function flush() { + for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r)); +} + +/** Connect, close with a reconnectable code, and let the first reconnect attempt throw `thrown`. */ +async function failedReconnect(thrown: unknown) { + const { service } = await makeService(); + stubAuthState(service); + await service.connectToWhatsapp(); + const read = service.defineAuthState; + let failures = 1; + service.defineAuthState = async () => { + if (failures-- > 0) throw thrown; + return read(); + }; + const out = await captureOutput(async () => { + const socket = socketSpy.mock.results[socketSpy.mock.results.length - 1].value; + socket.ev.emit('connection.update', { + connection: 'close', + lastDisconnect: { error: new Boom('closed', { statusCode: 503 }), date: new Date() }, + }); + await flush(); + await vi.advanceTimersByTimeAsync(1_000); + await flush(); + }); + const plain = out.replace(/\x1b\[[0-9;]*m/g, ''); + const lines = plain.split('\n').filter((l) => l.includes('Reconnect attempt failed')); + return { plain, logged: lines.map((l) => JSON.parse(l.slice(l.indexOf('{')))) }; +} + +describe('a failed reconnect says why', () => { + it('a Boom: its name, scrubbed message and status code', async () => { + const { plain, logged } = await failedReconnect( + new Boom(`request to ${URL_SECRET} for ${PHONE}@s.whatsapp.net failed, reason: getaddrinfo ENOTFOUND`, { statusCode: 503 }), + ); + + expect(logged).toEqual([ + { + message: 'Reconnect attempt failed', + error: { name: 'Error', message: 'request to [url] for [jid] failed, reason: getaddrinfo ENOTFOUND', statusCode: 503 }, + }, + ]); + expect(plain).not.toContain(PHONE); + expect(plain).not.toContain('Zq7secret'); + }); + + it('a plain object that is not an Error: its message, scrubbed, and no status code when it has none', async () => { + const { plain, logged } = await failedReconnect({ code: 'ECONNREFUSED', message: `connect ECONNREFUSED to ${URL_SECRET} as ${PHONE}` }); + + expect(logged).toEqual([ + { + message: 'Reconnect attempt failed', + error: { name: 'Object', message: 'connect ECONNREFUSED to [url] as [number]' }, + }, + ]); + expect(plain).not.toContain(PHONE); + expect(plain).not.toContain('Zq7secret'); + }); +}); + +describe('a failed reload says why, scrubbed', () => { + it('a Boom: its name, scrubbed message and status code, and nothing unscrubbed anywhere', async () => { + const { service } = await makeService(); + service.defineAuthState = async () => { + throw new Boom(`request to ${URL_SECRET} for ${PHONE}@s.whatsapp.net failed, reason: getaddrinfo ENOTFOUND`, { + statusCode: 503, + }); + }; + + let thrown: any; + const out = await captureOutput(async () => { + await service.reloadConnection().catch((e: any) => (thrown = e)); + }); + + expect(thrown?.status).toBe(500); + const plain = out.replace(/\x1b\[[0-9;]*m/g, ''); + expect(plain).not.toContain(PHONE); + expect(plain).not.toContain('Zq7secret'); + const logged = plain + .split('\n') + .filter((l) => l.includes('Reload connection failed')) + .map((l) => JSON.parse(l.slice(l.indexOf('{')))); + expect(logged).toEqual([ + { + message: 'Reload connection failed', + error: { name: 'Error', message: 'request to [url] for [jid] failed, reason: getaddrinfo ENOTFOUND', statusCode: 503 }, + }, + ]); + }); +}); diff --git a/test/connect/stored-settings.test.ts b/test/connect/stored-settings.test.ts new file mode 100644 index 0000000000..b32ce341d8 --- /dev/null +++ b/test/connect/stored-settings.test.ts @@ -0,0 +1,98 @@ +// connectToWhatsapp loads the instance's settings from the database and then +// builds the socket, which takes several of them as config: syncFullHistory +// (history at link time), groupsIgnore and readStatus (shouldIgnoreJid) and +// alwaysOnline (markOnlineOnConnect). If the socket is built before the Setting +// row has been read, it is built from defaults. A real database round trip +// takes time, so the fake one here does too. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); +// This file is about settings; the version fetch has its own tests. +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { fakeSocket, stubAuthState } from '../helpers/connect'; +import { loopbackOnly } from '../helpers/local-net'; + +socketSpy.mockImplementation(fakeSocket); + +const SETTING_READ_MS = 50; +const GROUP = '120363000000000000@g.us'; +const DM = '972500000001@s.whatsapp.net'; + +let guard: ReturnType; +beforeAll(() => void (guard = loopbackOnly())); +afterAll(() => { + expect(guard.refused).toEqual([]); + guard.restore(); +}); + +/** A service as the monitor builds it on boot: the Setting row is in the database, nothing is in memory yet. */ +async function bootedService(settings: Record) { + const { service, prisma } = await makeService(); + await prisma.setting.create({ + data: { instanceId: 'inst-1', rejectCall: false, msgCall: '', readMessages: false, readStatus: false, ...settings }, + }); + const read = prisma.setting.findUnique; + prisma.setting.findUnique = async (args: any) => { + await new Promise((r) => setTimeout(r, SETTING_READ_MS)); + return read(args); + }; + stubAuthState(service); + return service; +} + +async function connect(service: any) { + const before = socketSpy.mock.calls.length; + await service.connectToWhatsapp(); + const config = socketSpy.mock.calls[before][0]; + return { + syncFullHistory: config.syncFullHistory, + markOnlineOnConnect: config.markOnlineOnConnect, + ignoresGroup: !!config.shouldIgnoreJid(GROUP), + ignoresDm: !!config.shouldIgnoreJid(DM), + }; +} + +// Evolution's own rule (createClient, shouldIgnoreJid): with full history on, groups are kept even when groupsIgnore is set. +const cases = [ + { + name: 'full history, groups ignored, always online', + settings: { syncFullHistory: true, groupsIgnore: true, alwaysOnline: true }, + expected: { syncFullHistory: true, markOnlineOnConnect: true, ignoresGroup: false, ignoresDm: false }, + }, + { + name: 'no full history, groups ignored, always online', + settings: { syncFullHistory: false, groupsIgnore: true, alwaysOnline: true }, + expected: { syncFullHistory: false, markOnlineOnConnect: true, ignoresGroup: true, ignoresDm: false }, + }, +]; + +describe('a connect uses the instance stored settings, not defaults', () => { + for (const { name, settings, expected } of cases) { + it(`on boot (${name})`, async () => { + const service = await bootedService(settings); + expect(await connect(service)).toEqual(expected); + }); + + // A reconnect comes to an instance that has been running, so the first connect's reads have landed. + // loadSettings, unlike loadProxy, does not clear the values before reading, so this case holds + // on the code before the fix too: it guards the reconnect path against a fix that breaks it. + it(`on a restart or reconnect (${name})`, async () => { + const service = await bootedService(settings); + await connect(service); + await new Promise((r) => setTimeout(r, SETTING_READ_MS * 2)); + expect(await connect(service)).toEqual(expected); + }); + } +}); diff --git a/test/events/group-update-transports.test.ts b/test/events/group-update-transports.test.ts new file mode 100644 index 0000000000..773b7ebdca --- /dev/null +++ b/test/events/group-update-transports.test.ts @@ -0,0 +1,151 @@ +// The same two spellings of the group update event (see group-update-webhook.test.ts) +// in every other transport. Each one upper-cases `groups.update` to GROUPS_UPDATE +// and compares it with the instance's stored events (which //set only +// accepts as GROUP_UPDATE) and, where it has one, with its global env config +// (keyed GROUP_UPDATE, except SQS). The real controllers run; only the client +// each one publishes through (socket.io, AMQP channel, NATS, SQS, Kafka, Pusher) +// is a recording fake, so nothing leaves the process. +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +const GROUP = { id: '120363000000000001@g.us', subject: 'Synthetic group' }; +const monitor: any = { waInstances: { test: { instanceId: 'inst-1' } } }; +const emitData = (event: string) => ({ + instanceName: 'test', + origin: 'test', + event, + data: event === 'groups.update' ? GROUP : { key: { id: 'X' } }, + serverUrl: 'http://127.0.0.1', + dateTime: '2026-09-27T00:00:00.000Z', + sender: '972500000000@s.whatsapp.net', + apiKey: null, + local: true, +}); + +// Each transport: its table and config section, how to build it with a recording +// client, and how to read the event names it published from that client. +type Transport = { + name: string; + config?: string; + build: (prisma: any) => Promise<{ ctrl: any; published: () => string[] }>; +}; + +const transports: Transport[] = [ + { + name: 'websocket', + build: async (prisma) => { + const { WebsocketController } = await import('@api/integrations/event/websocket/websocket.controller'); + const sent: string[] = []; + const ctrl: any = new WebsocketController(prisma, monitor); + ctrl.io = { emit: (e: string) => sent.push(e), of: () => ({ emit: (e: string) => sent.push(e) }) }; + return { ctrl, published: () => sent }; + }, + }, + { + name: 'rabbitmq', + config: 'RABBITMQ', + build: async (prisma) => { + const { RabbitmqController } = await import('@api/integrations/event/rabbitmq/rabbitmq.controller'); + const sent: string[] = []; + const ctrl: any = new RabbitmqController(prisma, monitor); + ctrl.amqpChannel = { + assertExchange: async () => undefined, + assertQueue: async () => undefined, + bindQueue: async () => undefined, + publish: async (_x: string, _k: string, body: Buffer) => void sent.push(JSON.parse(body.toString()).event), + }; + return { ctrl, published: () => sent }; + }, + }, + { + name: 'nats', + config: 'NATS', + build: async (prisma) => { + const { NatsController } = await import('@api/integrations/event/nats/nats.controller'); + const sent: string[] = []; + const ctrl: any = new NatsController(prisma, monitor); + ctrl.natsClient = { publish: (_s: string, body: Uint8Array) => void sent.push(JSON.parse(Buffer.from(body).toString()).event) }; + return { ctrl, published: () => sent }; + }, + }, + { + name: 'sqs', + build: async (prisma) => { + const { SqsController } = await import('@api/integrations/event/sqs/sqs.controller'); + const sent: string[] = []; + const ctrl: any = new SqsController(prisma, monitor); + ctrl.sqs = { sendMessage: (p: any) => void sent.push(JSON.parse(p.MessageBody).event) }; + return { ctrl, published: () => sent }; + }, + }, + { + name: 'kafka', + config: 'KAFKA', + build: async (prisma) => { + const { KafkaController } = await import('@api/integrations/event/kafka/kafka.controller'); + const sent: string[] = []; + const ctrl: any = new KafkaController(prisma, monitor); + ctrl.producer = { send: async (r: any) => void sent.push(JSON.parse(r.messages[0].value).event) }; + return { ctrl, published: () => sent }; + }, + }, + { + name: 'pusher', + config: 'PUSHER', + build: async (prisma) => { + const { PusherController } = await import('@api/integrations/event/pusher/pusher.controller'); + const sent: string[] = []; + const client = { trigger: (_c: string, _e: string, d: any) => void sent.push(d.event) }; + const ctrl: any = new PusherController(prisma, monitor); + ctrl.pusherClients = { test: client }; + ctrl.globalPusherClient = client; + return { ctrl, published: () => sent }; + }, + }, +]; + +async function emitThrough(t: Transport, events: string[] | null, names: string[]) { + const row = events && { enabled: true, events, appId: 'app', key: 'k', secret: 's', cluster: 'eu', useTLS: true }; + const prisma: any = { [t.name]: { findUnique: async () => row }, instance: { findMany: async () => [] } }; + const { ctrl, published } = await t.build(prisma); + ctrl.status = true; // the transport is ENABLED (read from env at construction) + for (const event of names) await ctrl.emit(emitData(event)); + return published(); +} + +describe.each(transports)('$name', (t) => { + beforeEach(async () => void (await import('@config/env.config'))); + + it.each(['GROUP_UPDATE', 'GROUPS_UPDATE'])('per instance, subscribed as %s, receives groups.update', async (name) => { + expect(await emitThrough(t, [name], ['groups.update'])).toEqual(['groups.update']); + }); + + it('per instance, other subscriptions are unchanged and do not bring group updates', async () => { + const events = ['messages.upsert', 'groups.upsert', 'group-participants.update', 'groups.update']; + expect(await emitThrough(t, ['MESSAGES_UPSERT', 'GROUPS_UPSERT', 'GROUP_PARTICIPANTS_UPDATE'], events)).toEqual([ + 'messages.upsert', + 'groups.upsert', + 'group-participants.update', + ]); + expect(await emitThrough(t, ['GROUP_UPDATE'], ['messages.upsert', 'groups.upsert'])).toEqual([]); + }); + + if (t.config) { + it('global, enabled by its GROUPS_UPDATE env var (config key GROUP_UPDATE), receives groups.update', async () => { + const { configService } = await import('@config/env.config'); + const section = configService.get(t.config!); + const saved = structuredClone(section); + if ('GLOBAL_ENABLED' in section) section.GLOBAL_ENABLED = true; + else section.GLOBAL.ENABLED = true; + section.EVENTS = Object.assign(section.EVENTS, Object.fromEntries(Object.keys(section.EVENTS).map((k) => [k, false]))); + section.EVENTS.GROUP_UPDATE = true; + try { + expect(await emitThrough(t, null, ['groups.update', 'messages.upsert'])).toEqual(['groups.update']); + } finally { + Object.assign(section, saved); + Object.assign(section.EVENTS, saved.EVENTS); + } + }); + } +}); diff --git a/test/events/group-update-webhook.test.ts b/test/events/group-update-webhook.test.ts new file mode 100644 index 0000000000..0a7cad4430 --- /dev/null +++ b/test/events/group-update-webhook.test.ts @@ -0,0 +1,93 @@ +// Group updates have two spellings. Baileys emits `groups.update`, which every +// transport upper-cases to GROUPS_UPDATE before comparing it with what the +// instance subscribed to. But the only spelling /webhook/set (and every other +// //set) accepts is GROUP_UPDATE (EventController.events), and the +// global env config is keyed GROUP_UPDATE too. So a webhook subscribed to group +// updates, per instance or global, never receives one, and GROUP_UPDATE is the +// spelling a client can store. +// +// Through the real WebhookController, to a real HTTP destination on 127.0.0.1. +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +const GROUP = { id: '120363000000000001@g.us', subject: 'Synthetic group' }; + +describe('a webhook subscribed to group updates', () => { + const hits: { path: string; body: any }[] = []; + let base: string; + const server = createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => (hits.push({ path: req.url ?? '', body: JSON.parse(body) }), res.end('ok'))); + }); + + beforeAll(async () => { + server.listen(0, '127.0.0.1'); + await new Promise((r) => server.once('listening', r)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + afterAll(() => new Promise((r) => server.close(r))); + beforeEach(() => void hits.splice(0)); + + /** Emit one event through a WebhookController whose instance row subscribes to `events` (null: no row). */ + async function emit(events: string[] | null, event: string, data: any = GROUP) { + const { WebhookController } = await import('@api/integrations/event/webhook/webhook.controller'); + const row = events && { enabled: true, events, url: `${base}/hook`, headers: {}, webhookBase64: false, webhookByEvents: false }; + const prisma: any = { webhook: { findUnique: async () => row, findFirst: async () => row } }; + const monitor: any = { waInstances: { test: { instanceId: 'inst-1' } } }; + await new WebhookController(prisma, monitor).emit({ + instanceName: 'test', + origin: 'test', + event, + data, + serverUrl: 'http://127.0.0.1', + dateTime: '2026-09-27T00:00:00.000Z', + sender: '972500000000@s.whatsapp.net', + apiKey: null, + local: true, + }); + } + + it.each(['GROUP_UPDATE', 'GROUPS_UPDATE'])('per instance, subscribed as %s, receives groups.update', async (name) => { + await emit([name], 'groups.update'); + expect(hits.map((h) => h.path)).toEqual(['/hook']); + expect(hits[0].body.event).toBe('groups.update'); + expect(hits[0].body.instance).toBe('test'); + expect(hits[0].body.data).toEqual(GROUP); + }); + + it('per instance, a group update subscription does not bring other events', async () => { + await emit(['GROUP_UPDATE'], 'messages.upsert', { key: { id: 'X' } }); + await emit(['GROUP_UPDATE'], 'groups.upsert'); + await emit(['GROUP_UPDATE'], 'group-participants.update'); + expect(hits).toEqual([]); + }); + + it('per instance, other subscriptions are unchanged and do not bring group updates', async () => { + const subscribed = ['MESSAGES_UPSERT', 'GROUPS_UPSERT', 'GROUP_PARTICIPANTS_UPDATE']; + for (const event of ['messages.upsert', 'groups.upsert', 'group-participants.update', 'groups.update']) + await emit(subscribed, event); + expect(hits.map((h) => h.body.event)).toEqual(['messages.upsert', 'groups.upsert', 'group-participants.update']); + }); + + it('global, enabled by WEBHOOK_EVENTS_GROUPS_UPDATE (config key GROUP_UPDATE), receives groups.update', async () => { + const { configService } = await import('@config/env.config'); + const webhook = configService.get('WEBHOOK'); + const saved = structuredClone(webhook); + Object.assign(webhook.GLOBAL, { ENABLED: true, URL: `${base}/global`, WEBHOOK_BY_EVENTS: false }); + webhook.EVENTS = Object.fromEntries(Object.keys(webhook.EVENTS).map((k) => [k, false])); + webhook.EVENTS.GROUP_UPDATE = true; + try { + await emit(null, 'groups.update'); + await emit(null, 'messages.upsert', { key: { id: 'X' } }); + } finally { + Object.assign(webhook, saved); + } + expect(hits.map((h) => [h.path, h.body.event])).toEqual([['/global', 'groups.update']]); + expect(hits[0].body.data).toEqual(GROUP); + }); +}); diff --git a/test/fixtures/live/2026-09-27-rig-session/events.ndjson b/test/fixtures/live/2026-09-27-rig-session/events.ndjson new file mode 100644 index 0000000000..9f63a77e34 --- /dev/null +++ b/test/fixtures/live/2026-09-27-rig-session/events.ndjson @@ -0,0 +1,54 @@ +{"seq":1,"t":37.7,"socket":1,"event":"connection.update","buffered":true,"data":{"connection":"connecting","receivedPendingNotifications":false,"qr":{"$u":1}}} +{"seq":2,"t":43.5,"socket":1,"batch":["connection.update"]} +{"seq":4,"t":1181.6,"socket":1,"event":"creds.update","buffered":true,"data":{"$redacted":"creds","keys":["noiseKey","pairingEphemeralKeyPair","signedIdentityKey","signedPreKey","registrationId","advSecretKey","processedHistoryMessages","nextPreKeyId","firstUnuploadedPreKeyId","accountSyncCounter","accountSettings","registered","account","me","signalIdentities","platform","routingInfo","lastAccountSyncTimestamp","myAppStateKeyId","lastPropHash"]}} +{"seq":5,"t":1184.8,"socket":1,"batch":["creds.update"]} +{"seq":6,"t":1726.8,"socket":1,"event":"connection.update","buffered":false,"data":{"receivedPendingNotifications":true}} +{"seq":7,"t":1728.5,"socket":1,"batch":["connection.update"]} +{"seq":8,"t":1978.5,"socket":1,"event":"creds.update","buffered":false,"data":{"$redacted":"creds","keys":["me"]}} +{"seq":9,"t":1979.3,"socket":1,"batch":["creds.update"]} +{"seq":10,"t":1979.5,"socket":1,"event":"connection.update","buffered":false,"data":{"connection":"open"}} +{"seq":11,"t":1985.2,"socket":1,"event":"connection.update","buffered":false,"data":{"isOnline":false}} +{"seq":12,"t":1985.7,"socket":1,"batch":["connection.update"]} +{"seq":13,"t":1990.1,"socket":1,"batch":["connection.update"]} +{"seq":15,"t":100574,"socket":1,"event":"contacts.upsert","buffered":true,"data":[{"id":"972500000001@s.whatsapp.net","name":"Name 2","username":{"$u":1},"lid":"100000000000001@lid","phoneNumber":"972500000001@s.whatsapp.net"}]} +{"seq":16,"t":100577.3,"socket":1,"event":"lid-mapping.update","buffered":true,"data":{"lid":"100000000000001@lid","pn":"972500000001@s.whatsapp.net"}} +{"seq":17,"t":100579.4,"socket":1,"batch":["lid-mapping.update"]} +{"seq":18,"t":100584.5,"socket":1,"batch":["contacts.upsert"]} +{"seq":22,"t":152322.3,"socket":1,"event":"messages.update","buffered":true,"data":[{"key":{"remoteJid":"100000000000002@lid","id":"3AFFFFFFFFFFFFFFFFF1","fromMe":false,"participant":{"$u":1}},"update":{"status":4,"messageTimestamp":1790521569}}]} +{"seq":23,"t":152323.5,"socket":1,"batch":["messages.update"]} +{"seq":25,"t":160776.5,"socket":1,"event":"chats.update","buffered":true,"data":[{"id":"100000000000002@lid","archived":true,"conditional":{"$u":1}}]} +{"seq":26,"t":160777.3,"socket":1,"event":"chats.update","buffered":true,"data":[{"id":"100000000000002@lid","pinned":null,"conditional":{"$u":1}}]} +{"seq":27,"t":160778,"socket":1,"event":"chats.lock","buffered":true,"data":{"id":"100000000000002@lid","locked":false}} +{"seq":28,"t":160778.3,"socket":1,"batch":["chats.lock"]} +{"seq":29,"t":160779.7,"socket":1,"batch":["chats.update"]} +{"seq":31,"t":168490.3,"socket":1,"event":"chats.update","buffered":true,"data":[{"id":"100000000000002@lid","archived":false,"conditional":{"$u":1}}]} +{"seq":32,"t":168491.1,"socket":1,"batch":["chats.update"]} +{"seq":34,"t":237566.9,"socket":1,"event":"chats.upsert","buffered":true,"data":[{"id":"120363000000000001@g.us","name":"Name 3","conversationTimestamp":1790521654}]} +{"seq":35,"t":237571.4,"socket":1,"event":"groups.upsert","buffered":true,"data":[{"id":"120363000000000001@g.us","notify":{"$u":1},"addressingMode":"pn","subject":"Name 3","subjectOwner":"100000000000000@lid","subjectOwnerPn":"972500000000@s.whatsapp.net","subjectOwnerUsername":{"$u":1},"subjectTime":1790521654,"size":2,"creation":1790521654,"owner":"100000000000000@lid","ownerPn":"972500000000@s.whatsapp.net","ownerUsername":{"$u":1},"owner_country_code":"IL","desc":{"$u":1},"descId":{"$u":1},"descOwner":{"$u":1},"descOwnerPn":{"$u":1},"descOwnerUsername":{"$u":1},"descTime":{"$u":1},"linkedParent":{"$u":1},"restrict":false,"announce":false,"isCommunity":false,"isCommunityAnnounce":false,"joinApprovalMode":false,"memberAddMode":true,"participants":[{"id":"100000000000000@lid","phoneNumber":"972500000000@s.whatsapp.net","lid":{"$u":1},"username":{"$u":1},"admin":"superadmin"},{"id":"100000000000002@lid","phoneNumber":"972500000002@s.whatsapp.net","lid":{"$u":1},"username":{"$u":1},"admin":null}],"ephemeralDuration":{"$u":1},"author":"100000000000000@lid","authorPn":"972500000000@s.whatsapp.net","authorUsername":{"$u":1}}]} +{"seq":36,"t":237572.1,"socket":1,"batch":["groups.upsert"]} +{"seq":37,"t":237575.5,"socket":1,"event":"messages.upsert","buffered":true,"data":{"messages":[{"$proto":"proto.WebMessageInfo","messageStubParameters":["psum "],"labels":[],"userReceipt":[],"reactions":[],"pollUpdates":[],"eventResponses":[],"statusMentions":[],"messageAddOns":[],"statusMentionSources":[],"supportAiCitations":[],"key":{"$proto":"proto.MessageKey","remoteJid":"120363000000000001@g.us","fromMe":false,"id":"740000002","participant":"100000000000000@lid"},"messageTimestamp":{"$long":"1790521655","u":true},"participant":"100000000000000@lid","messageStubType":20}],"type":"append"}} +{"seq":38,"t":237579.7,"socket":1,"batch":["chats.upsert","messages.upsert"]} +{"seq":41,"t":238782.4,"socket":1,"event":"messages.upsert","buffered":true,"data":{"messages":[{"key":{"remoteJid":"120363000000000001@g.us","remoteJidAlt":{"$u":1},"remoteJidUsername":{"$u":1},"fromMe":true,"id":"2AFFFFFFFFFFFFFFFFF3","participant":"100000000000000@lid","participantAlt":{"$u":1},"participantUsername":{"$u":1},"addressingMode":"lid"},"category":{"$u":1},"messageTimestamp":1790521656,"pushName":"Owner","broadcast":false,"status":2,"message":{"$proto":"proto.Message","senderKeyDistributionMessage":{"$proto":"proto.Message.SenderKeyDistributionMessage","groupId":"120363000000000001@g.us","axolotlSenderKeyDistributionMessage":{"$bytes":"RMVqn/2oPBvhRQQ+vwdRq4i0599AxnAj8q/Pr9jnNcXjV5tkyRCvLV57OMKvISU752SeJ4v1ClqwM5Ovanxyf9JBA4LO7usOgLnpu0Ki","as":"Uint8Array","fake":1}},"messageContextInfo":{"$proto":"proto.MessageContextInfo","threadId":[],"messageSecret":{"$bytes":"yafjJLN3NVieG57h7IIxT18A3rYpFB+bEswMJ/ttSLA=","as":"Uint8Array","fake":1}},"protocolMessage":{"$proto":"proto.Message.ProtocolMessage","type":30,"memberLabel":{"$proto":"proto.MemberLabel","label":"","labelTimestamp":{"$long":"1790521655","u":false}}}},"verifiedBizName":"Owner"}],"type":"notify"}} +{"seq":42,"t":238783.1,"socket":1,"batch":["messages.upsert"]} +{"seq":44,"t":248606.5,"socket":1,"event":"messages.upsert","buffered":true,"data":{"messages":[{"$proto":"proto.WebMessageInfo","messageStubParameters":["lor sit "],"labels":[],"userReceipt":[],"reactions":[],"pollUpdates":[],"eventResponses":[],"statusMentions":[],"messageAddOns":[],"statusMentionSources":[],"supportAiCitations":[],"key":{"$proto":"proto.MessageKey","remoteJid":"120363000000000001@g.us","fromMe":false,"id":"510000004","participant":"100000000000000@lid"},"messageTimestamp":{"$long":"1790521666","u":true},"participant":"100000000000000@lid","messageStubType":21}],"type":"append"}} +{"seq":45,"t":248608.1,"socket":1,"event":"groups.update","buffered":true,"data":[{"id":"120363000000000001@g.us","subject":"Name 4","author":"100000000000000@lid","authorPn":{"$u":1},"authorUsername":{"$u":1}}]} +{"seq":46,"t":248608.4,"socket":1,"event":"chats.update","buffered":true,"data":[{"id":"120363000000000001@g.us","name":"Name 4"}]} +{"seq":47,"t":248609.6,"socket":1,"batch":["chats.update","messages.upsert","groups.update"]} +{"seq":50,"t":255184.8,"socket":1,"event":"messages.upsert","buffered":true,"data":{"messages":[{"$proto":"proto.WebMessageInfo","messageStubParameters":[" amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore lorem ip"],"labels":[],"userReceipt":[],"reactions":[],"pollUpdates":[],"eventResponses":[],"statusMentions":[],"messageAddOns":[],"statusMentionSources":[],"supportAiCitations":[],"key":{"$proto":"proto.MessageKey","remoteJid":"120363000000000001@g.us","fromMe":false,"id":"4100000005","participant":"100000000000000@lid"},"messageTimestamp":{"$long":"1790521672","u":true},"participant":"100000000000000@lid","messageStubType":28}],"type":"append"}} +{"seq":51,"t":255185.3,"socket":1,"event":"group-participants.update","buffered":true,"data":{"id":"120363000000000001@g.us","author":"100000000000000@lid","authorPn":{"$u":1},"authorUsername":{"$u":1},"participants":[{"id":"100000000000002@lid","phoneNumber":"972500000002@s.whatsapp.net","admin":null}],"action":"remove"}} +{"seq":52,"t":255185.5,"socket":1,"batch":["group-participants.update"]} +{"seq":53,"t":255186.5,"socket":1,"batch":["messages.upsert"]} +{"seq":55,"t":262753.8,"socket":1,"event":"messages.upsert","buffered":true,"data":{"messages":[{"$proto":"proto.WebMessageInfo","messageStubParameters":[" amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore lorem ip"],"labels":[],"userReceipt":[],"reactions":[],"pollUpdates":[],"eventResponses":[],"statusMentions":[],"messageAddOns":[],"statusMentionSources":[],"supportAiCitations":[],"key":{"$proto":"proto.MessageKey","remoteJid":"120363000000000001@g.us","fromMe":false,"id":"840000006","participant":"100000000000000@lid"},"messageTimestamp":{"$long":"1790521680","u":true},"participant":"100000000000000@lid","messageStubType":27}],"type":"append"}} +{"seq":56,"t":262754.7,"socket":1,"event":"group-participants.update","buffered":true,"data":{"id":"120363000000000001@g.us","author":"100000000000000@lid","authorPn":{"$u":1},"authorUsername":{"$u":1},"participants":[{"id":"100000000000002@lid","phoneNumber":"972500000002@s.whatsapp.net","admin":null}],"action":"add"}} +{"seq":57,"t":262754.8,"socket":1,"batch":["group-participants.update"]} +{"seq":58,"t":262756.7,"socket":1,"batch":["messages.upsert"]} +{"seq":60,"t":601899.7,"socket":1,"event":"creds.update","buffered":false,"data":{"$redacted":"creds","keys":["noiseKey","pairingEphemeralKeyPair","signedIdentityKey","signedPreKey","registrationId","advSecretKey","processedHistoryMessages","nextPreKeyId","firstUnuploadedPreKeyId","accountSyncCounter","accountSettings","registered","account","me","signalIdentities","platform","routingInfo","lastAccountSyncTimestamp","myAppStateKeyId","lastPropHash"]}} +{"seq":61,"t":601900.7,"socket":1,"batch":["creds.update"]} +{"seq":62,"t":975850.7,"socket":1,"event":"messages.upsert","buffered":true,"data":{"messages":[{"key":{"remoteJid":"100000000000002@lid","remoteJidAlt":"972500000002@s.whatsapp.net","remoteJidUsername":{"$u":1},"fromMe":false,"id":"3AFFFFFFFFFFFFFFFFF7","participant":"","participantAlt":{"$u":1},"participantUsername":{"$u":1},"addressingMode":"lid"},"category":{"$u":1},"messageTimestamp":1790522393,"pushName":"Name 5","broadcast":false,"message":{"$proto":"proto.Message","conversation":"ons","messageContextInfo":{"$proto":"proto.MessageContextInfo","threadId":[],"deviceListMetadata":{"$proto":"proto.DeviceListMetadata","senderKeyIndexes":[],"recipientKeyIndexes":[],"senderKeyHash":{"$bytes":"I5Jh4NW30cAwCw==","as":"Uint8Array","fake":1},"senderTimestamp":{"$long":"1790519013","u":true},"recipientKeyHash":{"$bytes":"du0CJVs95FDSlw==","as":"Uint8Array","fake":1},"recipientTimestamp":{"$long":"1790338634","u":true}},"deviceListMetadataVersion":2,"messageSecret":{"$bytes":"xm/1WTK3DwFOXhTt118a9sxR6phRPmPy/gzLmVoCBjI=","as":"Uint8Array","fake":1}}}}],"type":"notify"}} +{"seq":63,"t":975852,"socket":1,"event":"contacts.update","buffered":true,"data":[{"id":"100000000000002@lid","notify":"Name 5","verifiedName":{"$u":1}}]} +{"seq":64,"t":975852.3,"socket":1,"event":"chats.update","buffered":true,"data":[{"id":"100000000000002@lid","messages":[{"message":{"key":{"remoteJid":"100000000000002@lid","remoteJidAlt":"972500000002@s.whatsapp.net","remoteJidUsername":{"$u":1},"fromMe":false,"id":"3AFFFFFFFFFFFFFFFFF7","participant":"","participantAlt":{"$u":1},"participantUsername":{"$u":1},"addressingMode":"lid"},"category":{"$u":1},"messageTimestamp":1790522393,"pushName":"Name 5","broadcast":false,"message":{"$proto":"proto.Message","conversation":"ons","messageContextInfo":{"$proto":"proto.MessageContextInfo","threadId":[],"deviceListMetadata":{"$proto":"proto.DeviceListMetadata","senderKeyIndexes":[],"recipientKeyIndexes":[],"senderKeyHash":{"$bytes":"I5Jh4NW30cAwCw==","as":"Uint8Array","fake":1},"senderTimestamp":{"$long":"1790519013","u":true},"recipientKeyHash":{"$bytes":"du0CJVs95FDSlw==","as":"Uint8Array","fake":1},"recipientTimestamp":{"$long":"1790338634","u":true}},"deviceListMetadataVersion":2,"messageSecret":{"$bytes":"xm/1WTK3DwFOXhTt118a9sxR6phRPmPy/gzLmVoCBjI=","as":"Uint8Array","fake":1}}}}}],"conversationTimestamp":1790522393,"unreadCount":1}]} +{"seq":65,"t":975852.8,"socket":1,"batch":["chats.update","messages.upsert","contacts.update"]} +{"seq":70,"t":982202.4,"socket":1,"event":"messages.upsert","buffered":true,"data":{"messages":[{"key":{"remoteJid":"100000000000002@lid","remoteJidAlt":"972500000002@s.whatsapp.net","remoteJidUsername":{"$u":1},"fromMe":false,"id":"3AFFFFFFFFFFFFFFFFF8","participant":"","participantAlt":{"$u":1},"participantUsername":{"$u":1},"addressingMode":"lid"},"category":{"$u":1},"messageTimestamp":1790522399,"pushName":"Name 5","broadcast":false,"message":{"$proto":"proto.Message","imageMessage":{"$proto":"proto.Message.ImageMessage","interactiveAnnotations":[],"scanLengths":[10736,36644,13660,16018],"annotations":[],"url":"https://example.invalid/1","mimetype":"image/jpeg","fileSha256":{"$bytes":"3jIO+TEbNIv1XkEOFHukY69vOCqotjwsGtFcDv6bT60=","as":"Uint8Array","fake":1},"fileLength":{"$long":"77060","u":true},"height":2048,"width":945,"mediaKey":{"$bytes":"v+lG1QOSwT+2PCe/80+f6XKeKEZeHwCNze0boDGgjh8=","as":"Uint8Array","fake":1},"fileEncSha256":{"$bytes":"v8DEQOf7nNkxXe74QzxyyC1wIdfkLN9IWvAvVzxlELI=","as":"Uint8Array","fake":1},"directPath":"tur adipiscing elit sed do eiusmod tempor incididunt ut labore lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore lorem ipsum dolor sit amet consectetur adipiscing elit ","mediaKeyTimestamp":{"$long":"1790522397","u":false},"jpegThumbnail":{"$bytes":"/mSuHpBcYSR+7bQUQoyOURa2t4c8toD4DQpP4X586D8W3Vronhm989lmrL4fMXowBGgT9GF76rBwFYd5oHDCWlox+ZmQqUy4VQ+27NJ2D6w7KTngDveGJXE/gBT6UD1Wp9LOvMNwkPfXMC4j3EVyNild+S9STRQ5bBbMnn1tU/YOzkGmC2JhKRyH+mMpdQcFm4ziNxC+T/ojXxTIfkVLmiOVKhAVbrWxM3HSl3muxZgQt3S8IxBQyiEZ2ZdheJXy5CU4rxH4hBf3R76X+1zerw0VxqI5yMS4VmXk0X/wubJNcRnFQBmiuLk6yMK44qDl17ATNh2zXxLRFqYU30Rha8BO6dRh3HZYAvrM17uPZD+OKDHJm54cTlHk61EzA9BdiVLcSH6pG7xzga1zvQJFB4/VLLPaFk0mif9Ydk1jkWmANn3PrJ1iFymn0XO7PAenUq0pR4roZ41pUYGXYkjBVLuMkMdq5P6kNtNWN/SiFszAIEtCbOIeFm3usNLsnKj7F+HVmhOLe1rT6uTFYJF7EzgAQZ4/MjkijemeRfo6y+ZiTi8KcHekYh5uFEQkljsmhS6LGEqFiSAY73whQd1K8vGxYkPHVsdoga48yiO+emAqpCt5QrJgcMqWibLF079hxZdzi+6IDl79nfKPKik7fSG21KfBWBWEACteUbUmqnb6P0AwDXA4bgacd6oY3s8ngjEGGwvgHdvZDMdkav6/Q7G17FeCVJhrVyhOtT53bXHoncXnLVCYtKJbW5pc9TC6BwxQuOzlxTshYKpuJLuT/G3R3a8GUfREorEh3dZLJU9o6jVmZJZ5jnLEdEmxL2ZIsZX7FxaAxiZ3FQPaZQwVznx4bh8NkBMplZXp9/c/2p668joyymcR3IThB4oVhZPchjtyvNTbZ3Y57KCu3FKTLpDDnL3qdv/z3rKondwSP3ThFnqYZ05RXe2viuO3+Urhwd4WAtj8iYcgact62CL/2SI5rU4zg2KukhvRkYElpIh7g/wjFMLwL5E9/QmzZFCxuVo7CvX0fQSSn1mByDUT4Y0WFWrJl6RnbelZaaYpe3Ty3B0N7LSBhE4r8ukDa2wjrdVWJj86iE8mflilfExv20b1OjAPxhGgLeGCDQ628ZSl4JkzGfseisywrH9g2exLV01UNrDPZsB2uYv7v7Zvp0O1a2VJeOVj/zmPxKSIO81YMbQvHEGgitenrHxcPpsqhPGsLWzwMbaJmLjGgUUsFwgUuKcwN4Lr63oG+OqUStJdqrhdS02w9bVvnp6TFKTx+tV2o3G2bxATpQ7ODUD2H0tpj1jUfBN6zzPqHW0BADv/YwVPzXLL5xiQKSEK/GlgAa7SbhpyN+gUsFl3NPMmkcilw6toGvAeyOu2IiABgzwudLde499cM5vJ4Lb7RPAZfAlJusnNeJOQQ9/aCVFDGWduJjL88SQBLsdki518FgnW+EFXTcHbg6gyNF3x0fvc0I/aW790fU5Y+/V9DntHwqc/U/fxjXCirTIzG7tAGTKu/vHqEhJ6YHSe8Q9QXo4ufjvCMmJawpJtlgkRQbzi2iRzc/ivgAXsLypDhBI+DSlRp8WDhYLo1nDk78xrmzHmOt4enQToVwI=","as":"Uint8Array","fake":1},"contextInfo":{"$proto":"proto.ContextInfo","mentionedJid":[],"groupMentions":[],"statusAttributions":[],"statusSourceType":0},"firstScanSidecar":{"$bytes":"tCO2Lfa2lx3upQ==","as":"Uint8Array","fake":1},"firstScanLength":10736,"scansSidecar":{"$bytes":"VhshEorYGlY74CThpO8TeolkSD9lBL9Iz8clozPyMUXB5J5QHuTFsQ==","as":"Uint8Array","fake":1},"midQualityFileSha256":{"$bytes":"KIp21nO7212crP+1UGlo/BO+QoWgy1JcqC3uoazC5FQ=","as":"Uint8Array","fake":1},"imageSourceType":0},"messageContextInfo":{"$proto":"proto.MessageContextInfo","threadId":[],"deviceListMetadata":{"$proto":"proto.DeviceListMetadata","senderKeyIndexes":[],"recipientKeyIndexes":[],"senderKeyHash":{"$bytes":"I5Jh4NW30cAwCw==","as":"Uint8Array","fake":1},"senderTimestamp":{"$long":"1790519013","u":true},"recipientKeyHash":{"$bytes":"du0CJVs95FDSlw==","as":"Uint8Array","fake":1},"recipientTimestamp":{"$long":"1790338634","u":true}},"deviceListMetadataVersion":2,"messageSecret":{"$bytes":"3ekIumLVue4GSjI+lzREKduDdSlTVHodQbLqttE4Zzw=","as":"Uint8Array","fake":1}}}}],"type":"notify"}} +{"seq":71,"t":982203.6,"socket":1,"event":"contacts.update","buffered":true,"data":[{"id":"100000000000002@lid","notify":"Name 5","verifiedName":{"$u":1}}]} +{"seq":72,"t":982203.9,"socket":1,"event":"chats.update","buffered":true,"data":[{"id":"100000000000002@lid","messages":[{"message":{"key":{"remoteJid":"100000000000002@lid","remoteJidAlt":"972500000002@s.whatsapp.net","remoteJidUsername":{"$u":1},"fromMe":false,"id":"3AFFFFFFFFFFFFFFFFF8","participant":"","participantAlt":{"$u":1},"participantUsername":{"$u":1},"addressingMode":"lid"},"category":{"$u":1},"messageTimestamp":1790522399,"pushName":"Name 5","broadcast":false,"message":{"$proto":"proto.Message","imageMessage":{"$proto":"proto.Message.ImageMessage","interactiveAnnotations":[],"scanLengths":[10736,36644,13660,16018],"annotations":[],"url":"https://example.invalid/1","mimetype":"image/jpeg","fileSha256":{"$bytes":"3jIO+TEbNIv1XkEOFHukY69vOCqotjwsGtFcDv6bT60=","as":"Uint8Array","fake":1},"fileLength":{"$long":"77060","u":true},"height":2048,"width":945,"mediaKey":{"$bytes":"v+lG1QOSwT+2PCe/80+f6XKeKEZeHwCNze0boDGgjh8=","as":"Uint8Array","fake":1},"fileEncSha256":{"$bytes":"v8DEQOf7nNkxXe74QzxyyC1wIdfkLN9IWvAvVzxlELI=","as":"Uint8Array","fake":1},"directPath":"tur adipiscing elit sed do eiusmod tempor incididunt ut labore lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore lorem ipsum dolor sit amet consectetur adipiscing elit ","mediaKeyTimestamp":{"$long":"1790522397","u":false},"jpegThumbnail":{"$bytes":"/mSuHpBcYSR+7bQUQoyOURa2t4c8toD4DQpP4X586D8W3Vronhm989lmrL4fMXowBGgT9GF76rBwFYd5oHDCWlox+ZmQqUy4VQ+27NJ2D6w7KTngDveGJXE/gBT6UD1Wp9LOvMNwkPfXMC4j3EVyNild+S9STRQ5bBbMnn1tU/YOzkGmC2JhKRyH+mMpdQcFm4ziNxC+T/ojXxTIfkVLmiOVKhAVbrWxM3HSl3muxZgQt3S8IxBQyiEZ2ZdheJXy5CU4rxH4hBf3R76X+1zerw0VxqI5yMS4VmXk0X/wubJNcRnFQBmiuLk6yMK44qDl17ATNh2zXxLRFqYU30Rha8BO6dRh3HZYAvrM17uPZD+OKDHJm54cTlHk61EzA9BdiVLcSH6pG7xzga1zvQJFB4/VLLPaFk0mif9Ydk1jkWmANn3PrJ1iFymn0XO7PAenUq0pR4roZ41pUYGXYkjBVLuMkMdq5P6kNtNWN/SiFszAIEtCbOIeFm3usNLsnKj7F+HVmhOLe1rT6uTFYJF7EzgAQZ4/MjkijemeRfo6y+ZiTi8KcHekYh5uFEQkljsmhS6LGEqFiSAY73whQd1K8vGxYkPHVsdoga48yiO+emAqpCt5QrJgcMqWibLF079hxZdzi+6IDl79nfKPKik7fSG21KfBWBWEACteUbUmqnb6P0AwDXA4bgacd6oY3s8ngjEGGwvgHdvZDMdkav6/Q7G17FeCVJhrVyhOtT53bXHoncXnLVCYtKJbW5pc9TC6BwxQuOzlxTshYKpuJLuT/G3R3a8GUfREorEh3dZLJU9o6jVmZJZ5jnLEdEmxL2ZIsZX7FxaAxiZ3FQPaZQwVznx4bh8NkBMplZXp9/c/2p668joyymcR3IThB4oVhZPchjtyvNTbZ3Y57KCu3FKTLpDDnL3qdv/z3rKondwSP3ThFnqYZ05RXe2viuO3+Urhwd4WAtj8iYcgact62CL/2SI5rU4zg2KukhvRkYElpIh7g/wjFMLwL5E9/QmzZFCxuVo7CvX0fQSSn1mByDUT4Y0WFWrJl6RnbelZaaYpe3Ty3B0N7LSBhE4r8ukDa2wjrdVWJj86iE8mflilfExv20b1OjAPxhGgLeGCDQ628ZSl4JkzGfseisywrH9g2exLV01UNrDPZsB2uYv7v7Zvp0O1a2VJeOVj/zmPxKSIO81YMbQvHEGgitenrHxcPpsqhPGsLWzwMbaJmLjGgUUsFwgUuKcwN4Lr63oG+OqUStJdqrhdS02w9bVvnp6TFKTx+tV2o3G2bxATpQ7ODUD2H0tpj1jUfBN6zzPqHW0BADv/YwVPzXLL5xiQKSEK/GlgAa7SbhpyN+gUsFl3NPMmkcilw6toGvAeyOu2IiABgzwudLde499cM5vJ4Lb7RPAZfAlJusnNeJOQQ9/aCVFDGWduJjL88SQBLsdki518FgnW+EFXTcHbg6gyNF3x0fvc0I/aW790fU5Y+/V9DntHwqc/U/fxjXCirTIzG7tAGTKu/vHqEhJ6YHSe8Q9QXo4ufjvCMmJawpJtlgkRQbzi2iRzc/ivgAXsLypDhBI+DSlRp8WDhYLo1nDk78xrmzHmOt4enQToVwI=","as":"Uint8Array","fake":1},"contextInfo":{"$proto":"proto.ContextInfo","mentionedJid":[],"groupMentions":[],"statusAttributions":[],"statusSourceType":0},"firstScanSidecar":{"$bytes":"tCO2Lfa2lx3upQ==","as":"Uint8Array","fake":1},"firstScanLength":10736,"scansSidecar":{"$bytes":"VhshEorYGlY74CThpO8TeolkSD9lBL9Iz8clozPyMUXB5J5QHuTFsQ==","as":"Uint8Array","fake":1},"midQualityFileSha256":{"$bytes":"KIp21nO7212crP+1UGlo/BO+QoWgy1JcqC3uoazC5FQ=","as":"Uint8Array","fake":1},"imageSourceType":0},"messageContextInfo":{"$proto":"proto.MessageContextInfo","threadId":[],"deviceListMetadata":{"$proto":"proto.DeviceListMetadata","senderKeyIndexes":[],"recipientKeyIndexes":[],"senderKeyHash":{"$bytes":"I5Jh4NW30cAwCw==","as":"Uint8Array","fake":1},"senderTimestamp":{"$long":"1790519013","u":true},"recipientKeyHash":{"$bytes":"du0CJVs95FDSlw==","as":"Uint8Array","fake":1},"recipientTimestamp":{"$long":"1790338634","u":true}},"deviceListMetadataVersion":2,"messageSecret":{"$bytes":"3ekIumLVue4GSjI+lzREKduDdSlTVHodQbLqttE4Zzw=","as":"Uint8Array","fake":1}}}}}],"conversationTimestamp":1790522399,"unreadCount":1}]} +{"seq":73,"t":982204.4,"socket":1,"batch":["chats.update","messages.upsert","contacts.update"]} diff --git a/test/fixtures/live/2026-09-27-rig-session/manifest.json b/test/fixtures/live/2026-09-27-rig-session/manifest.json new file mode 100644 index 0000000000..eff28446ae --- /dev/null +++ b/test/fixtures/live/2026-09-27-rig-session/manifest.json @@ -0,0 +1,31 @@ +{ + "format": "live-record/1", + "forkCommit": "bc522750", + "baileysVersion": "7.0.0-rc14", + "nodeVersion": "v24.21.0", + "waWebVersion": "2.3000.1048596303", + "phonePlatform": "smbi", + "accountType": "business", + "linkMethod": "existing-session", + "proxy": { + "used": false, + "protocol": null + }, + "sockets": 1, + "startedAt": "2026-09-27T15:03:37.370Z", + "openedAt": "2026-09-27T15:03:39.334Z", + "endedAt": null, + "checkId": "rig-session", + "date": "2026-09-27", + "phoneModel": "iPhone 16", + "osVersion": "iOS 18.6", + "whatsappAppVersion": "WhatsApp Business 25.24", + "countryCode": "972", + "replay": { + "owner": { + "id": "972500000000:6@s.whatsapp.net", + "lid": "100000000000000:6@lid", + "name": "Owner" + } + } +} diff --git a/test/fixtures/live/2026-09-27-rig-session/scrub-report.json b/test/fixtures/live/2026-09-27-rig-session/scrub-report.json new file mode 100644 index 0000000000..d4a7cb5c45 --- /dev/null +++ b/test/fixtures/live/2026-09-27-rig-session/scrub-report.json @@ -0,0 +1,12 @@ +{ + "leakGate": "pass", + "events": 54, + "webhooks": 23, + "people": 3, + "groups": 1, + "names": 5, + "texts": 6, + "messageIds": 8, + "bytes": 13, + "urls": 4 +} diff --git a/test/fixtures/live/2026-09-27-rig-session/webhooks.ndjson b/test/fixtures/live/2026-09-27-rig-session/webhooks.ndjson new file mode 100644 index 0000000000..b3f73ed618 --- /dev/null +++ b/test/fixtures/live/2026-09-27-rig-session/webhooks.ndjson @@ -0,0 +1,23 @@ +{"seq":3,"t":50.6,"event":"connection.update","data":{"instance":"test","state":"connecting","statusReason":200}} +{"seq":14,"t":2386.8,"event":"connection.update","data":{"instance":"test","wuid":"972500000000@s.whatsapp.net","profileName":"Owner","profilePictureUrl":"https://example.invalid/2","state":"open","statusReason":200}} +{"seq":19,"t":100585.4,"event":"contacts.upsert","data":[{"remoteJid":"972500000001@s.whatsapp.net","pushName":null,"lid":"100000000000001@lid","phoneNumber":"972500000001@s.whatsapp.net","instanceId":"piscing elit sed do eiusmod tempor i"}]} +{"seq":20,"t":100593.3,"event":"contacts.upsert","data":[{"remoteJid":"972500000001@s.whatsapp.net","pushName":"Name 2","profilePicUrl":null,"instanceId":"piscing elit sed do eiusmod tempor i","saved":true}]} +{"seq":21,"t":100852.9,"event":"contacts.update","data":[{"remoteJid":"972500000001@s.whatsapp.net","pushName":"Name 2","profilePicUrl":null,"instanceId":"piscing elit sed do eiusmod tempor i"}]} +{"seq":24,"t":152326.8,"event":"messages.update","data":{"keyId":"3AFFFFFFFFFFFFFFFFF1","remoteJid":"100000000000002@lid","fromMe":false,"participant":{"$u":1},"status":"READ","pollUpdates":{"$u":1},"instanceId":"piscing elit sed do eiusmod tempor i"}} +{"seq":30,"t":160782.5,"event":"chats.update","data":[{"remoteJid":"100000000000002@lid","instanceId":"piscing elit sed do eiusmod tempor i","archived":true,"pinned":null}]} +{"seq":33,"t":168496.5,"event":"chats.update","data":[{"remoteJid":"100000000000002@lid","instanceId":"piscing elit sed do eiusmod tempor i","archived":false}]} +{"seq":39,"t":237581.2,"event":"groups.upsert","data":[{"id":"120363000000000001@g.us","notify":{"$u":1},"addressingMode":"pn","subject":"Name 3","subjectOwner":"100000000000000@lid","subjectOwnerPn":"972500000000@s.whatsapp.net","subjectOwnerUsername":{"$u":1},"subjectTime":1790521654,"size":2,"creation":1790521654,"owner":"100000000000000@lid","ownerPn":"972500000000@s.whatsapp.net","ownerUsername":{"$u":1},"owner_country_code":"IL","desc":{"$u":1},"descId":{"$u":1},"descOwner":{"$u":1},"descOwnerPn":{"$u":1},"descOwnerUsername":{"$u":1},"descTime":{"$u":1},"linkedParent":{"$u":1},"restrict":false,"announce":false,"isCommunity":false,"isCommunityAnnounce":false,"joinApprovalMode":false,"memberAddMode":true,"participants":[{"id":"100000000000000@lid","phoneNumber":"972500000000@s.whatsapp.net","lid":{"$u":1},"username":{"$u":1},"admin":"superadmin"},{"id":"100000000000002@lid","phoneNumber":"972500000002@s.whatsapp.net","lid":{"$u":1},"username":{"$u":1},"admin":null}],"ephemeralDuration":{"$u":1},"author":"100000000000000@lid","authorPn":"972500000000@s.whatsapp.net","authorUsername":{"$u":1}}]} +{"seq":40,"t":237594.2,"event":"chats.upsert","data":[{"remoteJid":"120363000000000001@g.us","instanceId":"piscing elit sed do eiusmod tempor i","name":"Name 3","unreadMessages":0}]} +{"seq":43,"t":238786,"event":"messages.edited","data":{"$proto":"proto.Message.ProtocolMessage","type":30,"memberLabel":{"$proto":"proto.MemberLabel","label":"","labelTimestamp":{"$long":"1790521655","u":false}}}} +{"seq":48,"t":248614.8,"event":"groups.update","data":[{"id":"120363000000000001@g.us","subject":"Name 4","author":"100000000000000@lid","authorPn":{"$u":1},"authorUsername":{"$u":1}}]} +{"seq":49,"t":248618.1,"event":"chats.update","data":[{"remoteJid":"120363000000000001@g.us","instanceId":"piscing elit sed do eiusmod tempor i"}]} +{"seq":54,"t":255472.6,"event":"group-participants.update","data":{"id":"120363000000000001@g.us","author":"100000000000000@lid","authorPn":{"$u":1},"authorUsername":{"$u":1},"participants":[{"id":"100000000000002@lid","phoneNumber":"972500000002@s.whatsapp.net","admin":null}],"action":"remove","participantsData":[{"jid":"100000000000002@lid","phoneNumber":"972500000002@s.whatsapp.net","name":{"$u":1},"imgUrl":{"$u":1}}]}} +{"seq":59,"t":263034.1,"event":"group-participants.update","data":{"id":"120363000000000001@g.us","author":"100000000000000@lid","authorPn":{"$u":1},"authorUsername":{"$u":1},"participants":[{"id":"100000000000002@lid","phoneNumber":"972500000002@s.whatsapp.net","admin":null}],"action":"add","participantsData":[{"jid":"100000000000002@lid","phoneNumber":"972500000002@s.whatsapp.net","name":"Name 5","imgUrl":"https://example.invalid/3"}]}} +{"seq":66,"t":975862.8,"event":"messages.upsert","data":{"key":{"remoteJid":"972500000002@s.whatsapp.net","remoteJidAlt":"100000000000002@lid","remoteJidUsername":{"$u":1},"fromMe":false,"id":"3AFFFFFFFFFFFFFFFFF7","participant":"","participantAlt":{"$u":1},"participantUsername":{"$u":1},"addressingMode":"pn"},"pushName":"Name 5","status":"DELIVERY_ACK","message":{"conversation":"ons","messageContextInfo":{"threadId":[],"deviceListMetadata":{"senderKeyIndexes":[],"recipientKeyIndexes":[],"senderKeyHash":{"$bytes":"I5Jh4NW30cAwCw==","as":"Uint8Array","fake":1},"senderTimestamp":{"low":1790519013,"high":0,"unsigned":true},"recipientKeyHash":{"$bytes":"du0CJVs95FDSlw==","as":"Uint8Array","fake":1},"recipientTimestamp":{"low":1790338634,"high":0,"unsigned":true}},"deviceListMetadataVersion":2,"messageSecret":{"$bytes":"xm/1WTK3DwFOXhTt118a9sxR6phRPmPy/gzLmVoCBjI=","as":"Uint8Array","fake":1}}},"contextInfo":{"$u":1},"messageType":"conversation","messageTimestamp":1790522393,"instanceId":"piscing elit sed do eiusmod tempor i","source":"ios"}} +{"seq":67,"t":976146.6,"event":"contacts.update","data":{"remoteJid":"972500000002@s.whatsapp.net","pushName":"Name 5","profilePicUrl":"https://example.invalid/4","instanceId":"piscing elit sed do eiusmod tempor i"}} +{"seq":68,"t":976154.9,"event":"chats.update","data":[{"remoteJid":"100000000000002@lid","instanceId":"piscing elit sed do eiusmod tempor i"}]} +{"seq":69,"t":976342.8,"event":"contacts.update","data":[{"remoteJid":"100000000000002@lid","pushName":{"$u":1},"profilePicUrl":"https://example.invalid/4","instanceId":"piscing elit sed do eiusmod tempor i"}]} +{"seq":74,"t":982212.9,"event":"messages.upsert","data":{"key":{"remoteJid":"972500000002@s.whatsapp.net","remoteJidAlt":"100000000000002@lid","remoteJidUsername":{"$u":1},"fromMe":false,"id":"3AFFFFFFFFFFFFFFFFF8","participant":"","participantAlt":{"$u":1},"participantUsername":{"$u":1},"addressingMode":"pn"},"pushName":"Name 5","status":"DELIVERY_ACK","message":{"imageMessage":{"interactiveAnnotations":[],"scanLengths":[10736,36644,13660,16018],"annotations":[],"url":"https://example.invalid/1","mimetype":"image/jpeg","fileSha256":{"$bytes":"3jIO+TEbNIv1XkEOFHukY69vOCqotjwsGtFcDv6bT60=","as":"Uint8Array","fake":1},"fileLength":{"low":77060,"high":0,"unsigned":true},"height":2048,"width":945,"mediaKey":{"$bytes":"v+lG1QOSwT+2PCe/80+f6XKeKEZeHwCNze0boDGgjh8=","as":"Uint8Array","fake":1},"fileEncSha256":{"$bytes":"v8DEQOf7nNkxXe74QzxyyC1wIdfkLN9IWvAvVzxlELI=","as":"Uint8Array","fake":1},"directPath":"tur adipiscing elit sed do eiusmod tempor incididunt ut labore lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore lorem ipsum dolor sit amet consectetur adipiscing elit ","mediaKeyTimestamp":{"low":1790522397,"high":0,"unsigned":false},"jpegThumbnail":{"$bytes":"/mSuHpBcYSR+7bQUQoyOURa2t4c8toD4DQpP4X586D8W3Vronhm989lmrL4fMXowBGgT9GF76rBwFYd5oHDCWlox+ZmQqUy4VQ+27NJ2D6w7KTngDveGJXE/gBT6UD1Wp9LOvMNwkPfXMC4j3EVyNild+S9STRQ5bBbMnn1tU/YOzkGmC2JhKRyH+mMpdQcFm4ziNxC+T/ojXxTIfkVLmiOVKhAVbrWxM3HSl3muxZgQt3S8IxBQyiEZ2ZdheJXy5CU4rxH4hBf3R76X+1zerw0VxqI5yMS4VmXk0X/wubJNcRnFQBmiuLk6yMK44qDl17ATNh2zXxLRFqYU30Rha8BO6dRh3HZYAvrM17uPZD+OKDHJm54cTlHk61EzA9BdiVLcSH6pG7xzga1zvQJFB4/VLLPaFk0mif9Ydk1jkWmANn3PrJ1iFymn0XO7PAenUq0pR4roZ41pUYGXYkjBVLuMkMdq5P6kNtNWN/SiFszAIEtCbOIeFm3usNLsnKj7F+HVmhOLe1rT6uTFYJF7EzgAQZ4/MjkijemeRfo6y+ZiTi8KcHekYh5uFEQkljsmhS6LGEqFiSAY73whQd1K8vGxYkPHVsdoga48yiO+emAqpCt5QrJgcMqWibLF079hxZdzi+6IDl79nfKPKik7fSG21KfBWBWEACteUbUmqnb6P0AwDXA4bgacd6oY3s8ngjEGGwvgHdvZDMdkav6/Q7G17FeCVJhrVyhOtT53bXHoncXnLVCYtKJbW5pc9TC6BwxQuOzlxTshYKpuJLuT/G3R3a8GUfREorEh3dZLJU9o6jVmZJZ5jnLEdEmxL2ZIsZX7FxaAxiZ3FQPaZQwVznx4bh8NkBMplZXp9/c/2p668joyymcR3IThB4oVhZPchjtyvNTbZ3Y57KCu3FKTLpDDnL3qdv/z3rKondwSP3ThFnqYZ05RXe2viuO3+Urhwd4WAtj8iYcgact62CL/2SI5rU4zg2KukhvRkYElpIh7g/wjFMLwL5E9/QmzZFCxuVo7CvX0fQSSn1mByDUT4Y0WFWrJl6RnbelZaaYpe3Ty3B0N7LSBhE4r8ukDa2wjrdVWJj86iE8mflilfExv20b1OjAPxhGgLeGCDQ628ZSl4JkzGfseisywrH9g2exLV01UNrDPZsB2uYv7v7Zvp0O1a2VJeOVj/zmPxKSIO81YMbQvHEGgitenrHxcPpsqhPGsLWzwMbaJmLjGgUUsFwgUuKcwN4Lr63oG+OqUStJdqrhdS02w9bVvnp6TFKTx+tV2o3G2bxATpQ7ODUD2H0tpj1jUfBN6zzPqHW0BADv/YwVPzXLL5xiQKSEK/GlgAa7SbhpyN+gUsFl3NPMmkcilw6toGvAeyOu2IiABgzwudLde499cM5vJ4Lb7RPAZfAlJusnNeJOQQ9/aCVFDGWduJjL88SQBLsdki518FgnW+EFXTcHbg6gyNF3x0fvc0I/aW790fU5Y+/V9DntHwqc/U/fxjXCirTIzG7tAGTKu/vHqEhJ6YHSe8Q9QXo4ufjvCMmJawpJtlgkRQbzi2iRzc/ivgAXsLypDhBI+DSlRp8WDhYLo1nDk78xrmzHmOt4enQToVwI=","as":"Uint8Array","fake":1},"contextInfo":{"mentionedJid":[],"groupMentions":[],"statusAttributions":[],"statusSourceType":0},"firstScanSidecar":{"$bytes":"tCO2Lfa2lx3upQ==","as":"Uint8Array","fake":1},"firstScanLength":10736,"scansSidecar":{"$bytes":"VhshEorYGlY74CThpO8TeolkSD9lBL9Iz8clozPyMUXB5J5QHuTFsQ==","as":"Uint8Array","fake":1},"midQualityFileSha256":{"$bytes":"KIp21nO7212crP+1UGlo/BO+QoWgy1JcqC3uoazC5FQ=","as":"Uint8Array","fake":1},"imageSourceType":0},"messageContextInfo":{"threadId":[],"deviceListMetadata":{"senderKeyIndexes":[],"recipientKeyIndexes":[],"senderKeyHash":{"$bytes":"I5Jh4NW30cAwCw==","as":"Uint8Array","fake":1},"senderTimestamp":{"low":1790519013,"high":0,"unsigned":true},"recipientKeyHash":{"$bytes":"du0CJVs95FDSlw==","as":"Uint8Array","fake":1},"recipientTimestamp":{"low":1790338634,"high":0,"unsigned":true}},"deviceListMetadataVersion":2,"messageSecret":{"$bytes":"3ekIumLVue4GSjI+lzREKduDdSlTVHodQbLqttE4Zzw=","as":"Uint8Array","fake":1}}},"contextInfo":{"mentionedJid":[],"groupMentions":[],"statusAttributions":[],"statusSourceType":0},"messageType":"imageMessage","messageTimestamp":1790522399,"instanceId":"piscing elit sed do eiusmod tempor i","source":"ios"}} +{"seq":75,"t":982216.6,"event":"contacts.update","data":{"remoteJid":"972500000002@s.whatsapp.net","pushName":"Name 5","profilePicUrl":"https://example.invalid/4","instanceId":"piscing elit sed do eiusmod tempor i"}} +{"seq":76,"t":982224.8,"event":"chats.update","data":[{"remoteJid":"100000000000002@lid","instanceId":"piscing elit sed do eiusmod tempor i"}]} +{"seq":77,"t":982225.2,"event":"contacts.update","data":[{"remoteJid":"100000000000002@lid","pushName":{"$u":1},"profilePicUrl":"https://example.invalid/4","instanceId":"piscing elit sed do eiusmod tempor i"}]} diff --git a/test/fixtures/tls/local.crt b/test/fixtures/tls/local.crt new file mode 100644 index 0000000000..52b8c1add4 --- /dev/null +++ b/test/fixtures/tls/local.crt @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB3TCCAYKgAwIBAgIUcpxdfmimev0xnVOBp30LsQ3fXmkwCgYIKoZIzj0EAwIw +HTEbMBkGA1UEAwwSY2lyY2xlcy10ZXN0LWxvY2FsMCAXDTI2MDkyNzExMDY0OFoY +DzIxMjYwOTAzMTEwNjQ4WjAdMRswGQYDVQQDDBJjaXJjbGVzLXRlc3QtbG9jYWww +WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARypC7eLcffgdSa94MD28JqueDQe+lq +FTwwhi4T+FXlPhytAbSgH5P8hBRWnmLWqbxISl3Bv9ybWhPzcVZR/xk0o4GdMIGa +MB0GA1UdDgQWBBRO2yAfPb8kGAXLFunbY4TswqvS9jAfBgNVHSMEGDAWgBRO2yAf +Pb8kGAXLFunbY4TswqvS9jAPBgNVHRMBAf8EBTADAQH/MEcGA1UdEQRAMD6HBH8A +AAGCEHdlYi53aGF0c2FwcC5jb22CGXJhdy5naXRodWJ1c2VyY29udGVudC5jb22C +CWxvY2FsaG9zdDAKBggqhkjOPQQDAgNJADBGAiEAmB9pBffyItARIOB/IxD/I6xd +WjNkCeYWmvsBbVjqHpYCIQCzGJGHTsKMIh+gJyPw879CfwLbeTJ30JqcXNaJp/zD +aw== +-----END CERTIFICATE----- diff --git a/test/fixtures/tls/local.key b/test/fixtures/tls/local.key new file mode 100644 index 0000000000..ef90c85876 --- /dev/null +++ b/test/fixtures/tls/local.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg5EGYsjHtP6Aw3DcU +QSKXDpuyA0v0t9LVzD8gAM9PRqShRANCAARypC7eLcffgdSa94MD28JqueDQe+lq +FTwwhi4T+FXlPhytAbSgH5P8hBRWnmLWqbxISl3Bv9ybWhPzcVZR/xk0 +-----END PRIVATE KEY----- diff --git a/test/global-setup.ts b/test/global-setup.ts new file mode 100644 index 0000000000..41e473dd84 --- /dev/null +++ b/test/global-setup.ts @@ -0,0 +1,15 @@ +// Say which Baileys every run tested, and fail the run when it is not the one +// the job expected (BAILEYS_EXPECT), so a green run can never be a run against +// the wrong version. +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +export default function () { + const require = createRequire(import.meta.url); + const dir = process.env.BAILEYS_DIR ?? dirname(require.resolve('baileys/package.json')); + const { version } = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + console.log(`[harness] baileys ${version} from ${dir}`); + const expected = process.env.BAILEYS_EXPECT; + if (expected && expected !== version) throw new Error(`BAILEYS_EXPECT=${expected}, but the tests resolve baileys ${version} (${dir})`); +} diff --git a/test/handlers/chat-state.test.ts b/test/handlers/chat-state.test.ts new file mode 100644 index 0000000000..106912b783 --- /dev/null +++ b/test/handlers/chat-state.test.ts @@ -0,0 +1,116 @@ +// Archiving, pinning and muting a chat reach Evolution from Baileys, and were +// dropped on the way out: every chats.update item was reduced to +// { remoteJid, instanceId }, and the history chat list to its name. A consumer +// that hides archived chats could not tell which ones were. +// +// Where the state comes from in Baileys 7.0.0-rc14: +// - app-state actions (lib/Utils/chat-utils.js processSyncAction): +// archiveChatAction emits chats.update { id, archived }, pinAction +// { id, pinned: | null }, muteAction { id, muteEndTime: | null }. +// Each action carries its own field only; an update that carries none (a +// read marker, say) says nothing about the archive state. +// - history (lib/Utils/history.js) passes each HistorySync Conversation through +// as a chat, with archived, pinned and muteEndTime set when WhatsApp sent them. +// - the event buffer (lib/Utils/event-buffer.js) folds a chats.update into a +// chats.upsert of the same batch, so an upsert can carry the state too. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { historyEvent, msg, syncActionEvents } from '../helpers/baileys-fixtures'; +import { deliver, makeService } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; + +const ALPHA = '972500000001@s.whatsapp.net'; +const BRAVO = '972500000002@s.whatsapp.net'; +const CHARLIE = '972500000003@s.whatsapp.net'; +const GROUP = '120363000000000001@g.us'; +const INITIAL_BOOTSTRAP = 0; + +const items = (event: string) => emitted.filter((e) => e.event === event).flatMap((e) => [].concat(e.data)); + +describe('chat updates and the history chat list carry the archive state', () => { + beforeEach(() => void emitted.splice(0)); + + it('an archive action says archived: true, an unarchive action archived: false', async () => { + const { service, ev } = await makeService(); + await deliver(service, ev, syncActionEvents(['archive', ALPHA], { archiveChatAction: { archived: true } })); + await deliver(service, ev, syncActionEvents(['archive', ALPHA], { archiveChatAction: { archived: false } })); + + expect(items('chats.update')).toEqual([ + { remoteJid: ALPHA, instanceId: 'inst-1', archived: true }, + { remoteJid: ALPHA, instanceId: 'inst-1', archived: false }, + ]); + }); + + it('pin and mute actions say pinned and muteEndTime, and null when cleared', async () => { + const { service, ev } = await makeService(); + await deliver(service, ev, syncActionEvents(['pin_v1', ALPHA], { pinAction: { pinned: true } })); + await deliver(service, ev, syncActionEvents(['pin_v1', ALPHA], { pinAction: { pinned: false } })); + await deliver(service, ev, syncActionEvents(['mute', BRAVO], { muteAction: { muted: true, muteEndTimestamp: 1_800_000_000_000 } })); + await deliver(service, ev, syncActionEvents(['mute', BRAVO], { muteAction: { muted: false } })); + + expect(items('chats.update')).toEqual([ + { remoteJid: ALPHA, instanceId: 'inst-1', pinned: 1_700_000_000 }, + { remoteJid: ALPHA, instanceId: 'inst-1', pinned: null }, + { remoteJid: BRAVO, instanceId: 'inst-1', muteEndTime: 1_800_000_000_000 }, + { remoteJid: BRAVO, instanceId: 'inst-1', muteEndTime: null }, + ]); + }); + + it('an update without archive information does not claim the chat is unarchived', async () => { + const { service, ev } = await makeService(); + await deliver(service, ev, syncActionEvents(['markChatAsRead', ALPHA], { markChatAsReadAction: { read: true } })); + + expect(items('chats.update')).toEqual([{ remoteJid: ALPHA, instanceId: 'inst-1' }]); + }); + + for (const profile of ['minimal', 'stored'] as const) { + it(`history: an archived conversation is archived on its chats.set item (${profile})`, async () => { + const { service, ev, prisma } = await makeService({ profile }); + await deliver(service, ev, { + 'messaging-history.set': historyEvent({ + syncType: INITIAL_BOOTSTRAP, + progress: 100, + conversations: [ + { id: ALPHA, name: 'Alpha', archived: true, messages: [msg(ALPHA, 'A1', 'hi')] }, + { id: BRAVO, name: 'Bravo', pinned: 1_700_000_100, muteEndTime: 1_800_000_000_000, messages: [msg(BRAVO, 'B1', 'hi')] }, + { id: CHARLIE, name: 'Charlie', messages: [msg(CHARLIE, 'C1', 'hi')] }, + ], + }), + }); + + expect(items('chats.set')).toEqual([ + { remoteJid: ALPHA, instanceId: 'inst-1', name: 'Alpha', archived: true }, + { remoteJid: BRAVO, instanceId: 'inst-1', name: 'Bravo', pinned: 1_700_000_100, muteEndTime: 1_800_000_000_000 }, + { remoteJid: CHARLIE, instanceId: 'inst-1', name: 'Charlie' }, + ]); + // The Chat table has no column for any of it: a stored row keeps its own shape. + const stored = prisma.chat.rows.map(({ id: _id, ...row }: any) => row); + expect(stored).toEqual( + profile === 'stored' + ? [ + { remoteJid: ALPHA, instanceId: 'inst-1', name: 'Alpha' }, + { remoteJid: BRAVO, instanceId: 'inst-1', name: 'Bravo' }, + { remoteJid: CHARLIE, instanceId: 'inst-1', name: 'Charlie' }, + ] + : [], + ); + }); + } + + it('an archive action folded into a chat upsert of the same batch reaches chats.upsert', async () => { + const { service, ev } = await makeService(); + // What the socket emits for a group it learns was created (lib/Socket/messages-recv.js). + await deliver(service, ev, { + 'chats.upsert': [{ id: GROUP, name: 'Group', conversationTimestamp: 1_700_000_000 }], + ...syncActionEvents(['archive', GROUP], { archiveChatAction: { archived: true } }), + }); + + expect(items('chats.upsert')).toEqual([ + { remoteJid: GROUP, instanceId: 'inst-1', name: 'Group', unreadMessages: 0, archived: true }, + ]); + }); +}); diff --git a/test/handlers/creds-save-retry.test.ts b/test/handlers/creds-save-retry.test.ts new file mode 100644 index 0000000000..5e64bb10a2 --- /dev/null +++ b/test/handlers/creds-save-retry.test.ts @@ -0,0 +1,33 @@ +// Baileys emits creds.update whenever the account's credentials change (pairing, pre-key uploads, +// app-state keys), and Evolution saves them. The save was not awaited and its failure not handled: +// a database blip lost the update (the process ran on creds only in memory, and a restart opened +// the old ones), and the rejection went unhandled. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { describe, expect, it } from 'vitest'; + +import { deliver, makeService, settle } from '../helpers/baileys-service'; + +describe('a creds.update whose save fails', () => { + it('is saved again until it lands', async () => { + const { service, ev } = await makeService(); + let attempts = 0; + let saved = false; + service.instance.authState.saveCreds = async () => { + attempts++; + if (attempts === 1) throw new Error("Can't reach database server"); + saved = true; + }; + await deliver( + service, + ev, + { 'creds.update': { me: { id: '972500000000:1@s.whatsapp.net' } } }, + { buffered: false }, + ); + await vi.waitFor(() => expect(saved).toBe(true), { timeout: 3_000 }); + await settle(service); + expect(attempts).toBe(2); + }); +}); diff --git a/test/handlers/get-message-miss.test.ts b/test/handlers/get-message-miss.test.ts new file mode 100644 index 0000000000..faaa835d05 --- /dev/null +++ b/test/handlers/get-message-miss.test.ts @@ -0,0 +1,86 @@ +// Evolution hands Baileys its getMessage (the socket config's `getMessage`), and +// Baileys calls it to answer a recipient's retry request for a message this +// instance sent, when the message is no longer in its own recent-message cache. +// Baileys relays whatever comes back if it is truthy, under the original message +// id; only a falsy answer (its own default is `async () => undefined`) means "not +// available, send nothing" (lib/Socket/messages-recv.js, sendMessagesAgain). +// Evolution answers a database miss with `{ conversation: '' }`, so the recipient +// is sent an empty message. A deployment that stores no messages (for example +// DATABASE_SAVE_DATA_NEW_MESSAGE=false, the `minimal` profile) misses every time. +// A lookup that THROWS (a database blip) took the catch path, which answered the +// same `{ conversation: '' }`, so the retry was used up on an empty message too. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { describe, expect, it } from 'vitest'; + +import { deliver, makeService } from '../helpers/baileys-service'; +import type { Profile } from '../helpers/profiles'; + +const CHAT = '972500000001@s.whatsapp.net'; + +/** getMessage's query over the fake's rows: WHERE "instanceId" = $1 AND "key"->>'id' = $2. */ +function answerFromRows(prisma: any) { + prisma.$queryRaw = async (_sql: TemplateStringsArray, instanceId: string, id: string) => + prisma.message.rows.filter((r: any) => r.instanceId === instanceId && r.key?.id === id); +} + +describe('getMessage, as Baileys calls it to answer a retry request', () => { + it.each(['minimal', 'stored'])('under the %s profile, answers a message it does not have with undefined', async (profile) => { + const { service, prisma } = await makeService({ profile }); + answerFromRows(prisma); + const answer = await service.getMessage({ remoteJid: CHAT, fromMe: true, id: '3EB0MISSING0000000001' }); + expect(answer).toBeUndefined(); + }); + + it('under the minimal profile, a message the instance sent is not stored, so a retry for it finds nothing', async () => { + const { service, prisma, ev } = await makeService({ profile: 'minimal' }); + answerFromRows(prisma); + const key = { remoteJid: CHAT, fromMe: true, id: '3EB0SENT0000000000001' }; + await deliver(service, ev, { + 'messages.upsert': { messages: [{ key, message: { conversation: 'the real text' }, messageTimestamp: 1_700_000_000 }], type: 'notify' }, + }); + expect(prisma.message.rows).toHaveLength(0); + expect(await service.getMessage(key)).toBeUndefined(); + }); + + it('under the stored profile, a stored message is still returned as stored, so Baileys can resend it', async () => { + const { service, prisma, ev } = await makeService({ profile: 'stored' }); + answerFromRows(prisma); + const key = { remoteJid: CHAT, fromMe: true, id: '3EB0SENT0000000000002' }; + await deliver(service, ev, { + 'messages.upsert': { messages: [{ key, message: { conversation: 'the real text' }, messageTimestamp: 1_700_000_000 }], type: 'notify' }, + }); + expect(prisma.message.rows.map((r: any) => r.key?.id)).toEqual([key.id]); + expect(await service.getMessage(key)).toEqual({ conversation: 'the real text' }); + }); +}); + +describe('getMessage, when the database lookup fails', () => { + it.each(['minimal', 'stored'])('under the %s profile, answers undefined, not an empty message', async (profile) => { + const { service, prisma } = await makeService({ profile }); + prisma.$queryRaw = async () => { + throw new Error('Connection terminated unexpectedly'); + }; + const key = { remoteJid: CHAT, fromMe: true, id: '3EB0BLIP0000000000001' }; + expect(await service.getMessage(key)).toBeUndefined(); + expect(await service.getMessage(key, true)).toBeUndefined(); + }); + + it('under the stored profile, once the database answers again, the stored message is returned as stored', async () => { + const { service, prisma, ev } = await makeService({ profile: 'stored' }); + answerFromRows(prisma); + const key = { remoteJid: CHAT, fromMe: true, id: '3EB0BLIP0000000000002' }; + await deliver(service, ev, { + 'messages.upsert': { messages: [{ key, message: { conversation: 'the real text' }, messageTimestamp: 1_700_000_000 }], type: 'notify' }, + }); + const working = prisma.$queryRaw; + prisma.$queryRaw = async () => { + throw new Error('Connection terminated unexpectedly'); + }; + expect(await service.getMessage(key)).toBeUndefined(); + prisma.$queryRaw = working; + expect(await service.getMessage(key)).toEqual({ conversation: 'the real text' }); + }); +}); diff --git a/test/handlers/group-metadata.test.ts b/test/handlers/group-metadata.test.ts new file mode 100644 index 0000000000..34c46e01c4 --- /dev/null +++ b/test/handlers/group-metadata.test.ts @@ -0,0 +1,123 @@ +// Evolution keeps a group metadata cache (updateGroupMetadataCache) that +// Baileys reads before every group send (cachedGroupMetadata). Its +// groups.update handler refreshed that cache with a groupMetadata query per +// group, all at once. Baileys' groupFetchAllParticipating (rc14 groups.js) +// emits groups.update with the FULL metadata of every group, so each +// fetchAllGroups cost one groupMetadata query per group on top of the listing +// itself, although the event already carried the answer: 259 queries every +// listing for one measured account. +// +// An item that carries participants is complete metadata and fills the cache +// as it is. A partial item (a subject or setting change, Baileys' +// process-message) still refetches that group, once, a few at a time. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { deliver, makeService, settle } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; + +/** The most metadata queries the service may have in flight at once. */ +const MAX_IN_FLIGHT = 4; +const MEMBER = (i: number) => `9725${String(i).padStart(8, '0')}@s.whatsapp.net`; + +let extractGroupMetadata: (node: any) => any; + +/** What WhatsApp answers for one group, turned into metadata by the Baileys under test. */ +function groupMeta(id: string, subject: string) { + const node = { + tag: 'group', + attrs: { id: id.split('@')[0], subject, s_t: '1700000000', creation: '1690000000', creator: MEMBER(1) }, + content: [ + { tag: 'participant', attrs: { jid: MEMBER(1), type: 'superadmin' } }, + { tag: 'participant', attrs: { jid: MEMBER(2) } }, + ], + }; + return extractGroupMetadata({ tag: 'result', attrs: {}, content: [node] }); +} + +const groupIds = (prefix: string, n: number) => + Array.from({ length: n }, (_, i) => `120363${prefix}${String(i).padStart(6, '0')}@g.us`); + +/** A fake socket side for groups: the listing emits groups.update as rc14 does; groupMetadata is counted. */ +function groupServer(service: any, listed: string[]) { + const state = { inFlight: 0, maxInFlight: 0 }; + const groupMetadata = vi.fn(async (id: string) => { + state.inFlight++; + state.maxInFlight = Math.max(state.maxInFlight, state.inFlight); + await new Promise((r) => setTimeout(r, 2)); + state.inFlight--; + return groupMeta(id, `Fetched ${id.slice(-8, -5)}`); + }); + service.client.groupMetadata = groupMetadata; + service.client.groupFetchAllParticipating = async () => { + const data = Object.fromEntries(listed.map((id) => [id, groupMeta(id, `Listed ${id.slice(-8, -5)}`)])); + service.client.ev.emit('groups.update', Object.values(data)); + return data; + }; + const perGroup = () => { + const counts: Record = {}; + for (const [id] of groupMetadata.mock.calls) counts[id] = (counts[id] ?? 0) + 1; + return counts; + }; + return { groupMetadata, state, perGroup }; +} + +async function quiet(service: any, fn: { mock: { calls: unknown[] } }) { + let last = -1; + while (fn.mock.calls.length !== last) { + last = fn.mock.calls.length; + await settle(service); + await new Promise((r) => setTimeout(r, 20)); + } +} + +describe('the group metadata cache is filled from groups.update', () => { + beforeEach(async () => { + emitted.splice(0); + ({ extractGroupMetadata } = await import('baileys/lib/Socket/groups.js' as any)); + }); + + it('listing all groups twice queries no group metadata, and the cache holds what the listing carried', async () => { + const GROUPS = groupIds('1000', 12); + const { service, ev } = await makeService(); + const server = groupServer(service, GROUPS); + await deliver(service, ev, {}); // wire the handlers + + await service.fetchAllGroups({ getParticipants: 'false' }); + await quiet(service, server.groupMetadata); + await service.fetchAllGroups({ getParticipants: 'false' }); + await quiet(service, server.groupMetadata); + + expect(server.groupMetadata).not.toHaveBeenCalled(); + for (const id of GROUPS) { + expect(await service.getGroupMetadataCache(id)).toEqual(groupMeta(id, `Listed ${id.slice(-8, -5)}`)); + } + expect(server.groupMetadata).not.toHaveBeenCalled(); + // The webhook is what it was: every listed group, as Baileys emitted it. + const updates = emitted.filter((e) => e.event === 'groups.update'); + expect(updates).toHaveLength(2); + expect(updates[0].data.map((g: any) => g.id)).toEqual(GROUPS); + }); + + it('partial updates refetch each group once, a few at a time', async () => { + const PARTIAL = groupIds('2000', 12); + const { service, ev } = await makeService(); + const server = groupServer(service, []); + + await deliver(service, ev, {}); // wire the handlers + // Hand-written: Baileys emits these from process-message.js (group stubs), which has no builder here. + // Two changes to one group arrive as two batches (in one batch the event buffer merges them). + ev.emit('groups.update', [{ id: PARTIAL[0], subject: 'Renamed' }]); + ev.emit('groups.update', [{ id: PARTIAL[0], announce: true }]); + ev.emit('groups.update', PARTIAL.slice(1).map((id) => ({ id, restrict: true }))); + await quiet(service, server.groupMetadata); + + expect(server.perGroup()).toEqual(Object.fromEntries(PARTIAL.map((id) => [id, 1]))); + expect(server.state.maxInFlight).toBeLessThanOrEqual(MAX_IN_FLIGHT); + expect((await service.getGroupMetadataCache(PARTIAL[0])).subject).toBe(`Fetched ${PARTIAL[0].slice(-8, -5)}`); + expect(server.groupMetadata).toHaveBeenCalledTimes(PARTIAL.length); + }); +}); diff --git a/test/handlers/group-participants.test.ts b/test/handlers/group-participants.test.ts new file mode 100644 index 0000000000..b17f720651 --- /dev/null +++ b/test/handlers/group-participants.test.ts @@ -0,0 +1,222 @@ +// GROUP_PARTICIPANTS_UPDATE carries `participantsData` (CHANGELOG 2.3.5), one +// item per participant of the event, so a consumer can read the phone number of +// a participant WhatsApp addresses by a private @lid: +// { jid, phoneNumber?, name?, imgUrl? } +// +// Baileys 7 (rc9+) emits group-participants.update with `participants` as +// GroupParticipant objects ({ id, phoneNumber?, lid?, admin?, ... }), built in +// Socket/messages-recv.js from the group notification and emitted by +// Utils/process-message.js from the GROUP_PARTICIPANT_* stub. The handler read +// them as strings, so every item came out as { jid: , +// phoneNumber: "[object Object]" } and never found its name or picture. +// +// Inputs run through Baileys' own processMessage from a stub message and reach +// Evolution through the real event buffer; group metadata is Baileys' own +// extractGroupMetadata over a synthetic node. `participants` stays +// exactly what Baileys emitted, for consumers that already read it. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { WAMessageStubType } from 'baileys'; +import P from 'pino'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { deliver, makeService, settle, WUID } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import { emitted } from '../helpers/fake-server-module'; +import { realSignalRepository } from '../helpers/socket-history'; + +const GROUP = '120363000000000077@g.us'; +const lid = (n: number) => `1000000000077${String(n).padStart(2, '0')}@lid`; +const pn = (n: number) => `97250007770${n}@s.whatsapp.net`; +const ADMIN = { lid: lid(1), pn: pn(1) }; + +/** A participant exactly as Baileys rc14 builds it from a group notification (messages-recv.js). */ +const lidMember = (n: number, withPhone: boolean, admin: string | null = null) => ({ + id: lid(n), + phoneNumber: withPhone ? pn(n) : undefined, + lid: undefined, + username: undefined, + admin, +}); +const pnMember = (n: number, admin: string | null = null) => ({ + id: pn(n), + phoneNumber: undefined, + lid: lid(n), + username: undefined, + admin, +}); + +let extractGroupMetadata: (node: any) => any; +let processMessage: (message: any, ctx: any) => Promise; + +/** The group as WhatsApp describes it after the change: members addressed by @lid, with their phone. */ +function groupMeta(members: number[]) { + const node = { + tag: 'group', + attrs: { + id: GROUP.split('@')[0], + subject: 'Synthetic group', + s_t: '1700000000', + creation: '1690000000', + addressing_mode: 'lid', + }, + content: members.map((n) => ({ + tag: 'participant', + attrs: { jid: lid(n), phone_number: pn(n), ...(n === 1 ? { type: 'superadmin' } : {}) }, + })), + }; + return extractGroupMetadata({ tag: 'result', attrs: {}, content: [node] }); +} + +async function groupService(members: number[]) { + const { service, prisma, ev } = await makeService(); + const { repo } = await realSignalRepository(); + service.client.signalRepository = repo; + service.client.groupMetadata = async () => groupMeta(members); + await deliver(service, ev, {}); // wire the handlers + return { service, prisma, ev, repo }; +} + +/** What the socket does with a GROUP_PARTICIPANT_* notification: processMessage over the stub, buffered. */ +async function stub(service: any, ev: any, stubType: number, participants: Record[]) { + const message = { + key: { remoteJid: GROUP, fromMe: false, id: `STUB${stubType}`, participant: ADMIN.lid, participantAlt: ADMIN.pn }, + messageStubType: stubType, + messageStubParameters: participants.map((p) => JSON.stringify(p)), + messageTimestamp: 1_700_000_000, + }; + const ctx = { + shouldProcessHistoryMsg: false, + ev, + logger: P({ level: 'silent' }), + options: {}, + placeholderResendCache: undefined, + getMessage: async () => undefined, + creds: { me: { id: WUID, lid: '999999999999999@lid', name: 'Me' }, processedHistoryMessages: [] }, + keyStore: undefined, + signalRepository: service.client.signalRepository, + }; + ev.buffer(); + await processMessage(message, ctx); + await ev.flush(); + await settle(service); +} + +/** Every GROUP_PARTICIPANTS_UPDATE payload, as a webhook consumer receives it (JSON). */ +const updates = () => + emitted.filter((e) => e.event === 'group-participants.update').map((e) => JSON.parse(JSON.stringify(e.data))); + +/** A participant as the webhook carries it inside `participants`: Baileys' object, as JSON. */ +const asJson = (p: any) => JSON.parse(JSON.stringify(p)); + +describe('group participant updates carry each participant jid and phone number', () => { + beforeEach(async () => { + emitted.splice(0); + ({ extractGroupMetadata } = await import('baileys/lib/Socket/groups.js' as any)); + ({ default: processMessage } = await import('baileys/lib/Utils/process-message.js' as any)); + }); + + it('add: the phone comes from the event, else from the group metadata; name and picture from the contact', async () => { + const { service, prisma, ev } = await groupService([1, 2, 3]); + prisma.contact.rows.push({ + remoteJid: lid(2), + pushName: 'Member Two', + profilePicUrl: 'https://pps.example/2', + instanceId: 'inst-1', + }); + const added = [lidMember(2, true), lidMember(3, false)]; + + await stub(service, ev, WAMessageStubType.GROUP_PARTICIPANT_ADD, added); + + expect(updates()).toEqual([ + { + id: GROUP, + author: ADMIN.lid, + authorPn: ADMIN.pn, + action: 'add', + participants: added.map(asJson), + participantsData: [ + { jid: lid(2), phoneNumber: pn(2), name: 'Member Two', imgUrl: 'https://pps.example/2' }, + { jid: lid(3), phoneNumber: pn(3) }, + ], + }, + ]); + }); + + it('remove: a participant no longer in the group gets its phone from the LID mapping store, or none', async () => { + const { service, ev, repo } = await groupService([1]); + await repo.lidMapping.storeLIDPNMappings([{ lid: lid(4), pn: pn(4) }]); + const removed = [lidMember(4, false), lidMember(5, false)]; + + await stub(service, ev, WAMessageStubType.GROUP_PARTICIPANT_REMOVE, removed); + + expect(updates()).toEqual([ + { + id: GROUP, + author: ADMIN.lid, + authorPn: ADMIN.pn, + action: 'remove', + participants: removed.map(asJson), + participantsData: [{ jid: lid(4), phoneNumber: pn(4) }, { jid: lid(5) }], + }, + ]); + }); + + it('promote: a participant addressed by phone number is its own phone number', async () => { + const { service, ev } = await groupService([1, 6]); + const promoted = [pnMember(6, 'admin')]; + + await stub(service, ev, WAMessageStubType.GROUP_PARTICIPANT_PROMOTE, promoted); + + expect(updates()).toEqual([ + { + id: GROUP, + author: ADMIN.lid, + authorPn: ADMIN.pn, + action: 'promote', + participants: promoted.map(asJson), + participantsData: [{ jid: pn(6), phoneNumber: pn(6) }], + }, + ]); + }); + + it('legacy string participants (Baileys 6) still resolve, and stay strings', async () => { + const { service, prisma, ev } = await groupService([1, 7]); + prisma.contact.rows.push({ + remoteJid: lid(7), + pushName: 'Member Seven', + profilePicUrl: null, + instanceId: 'inst-1', + }); + // Hand-written: no Baileys version the fork runs emits string participants. + const legacy = { id: GROUP, author: ADMIN.lid, participants: [lid(7), pn(8)], action: 'add' }; + + await deliver(service, ev, { 'group-participants.update': legacy }); + + expect(updates()).toEqual([ + { + ...legacy, + participantsData: [ + { jid: lid(7), phoneNumber: pn(7), name: 'Member Seven', imgUrl: null }, + { jid: pn(8), phoneNumber: pn(8) }, + ], + }, + ]); + }); + + it('prints no participant number or name', async () => { + const { service, prisma, ev, repo } = await groupService([1, 2]); + prisma.contact.rows.push({ remoteJid: lid(2), pushName: 'Member Two', profilePicUrl: null, instanceId: 'inst-1' }); + await repo.lidMapping.storeLIDPNMappings([{ lid: lid(4), pn: pn(4) }]); + + const out = await captureOutput(async () => { + await stub(service, ev, WAMessageStubType.GROUP_PARTICIPANT_ADD, [lidMember(2, true)]); + await stub(service, ev, WAMessageStubType.GROUP_PARTICIPANT_REMOVE, [lidMember(4, false)]); + }); + + expect(updates().flatMap((u) => u.participantsData.map((d: any) => d.phoneNumber))).toEqual([pn(2), pn(4)]); + for (const secret of ['0007770', '1000000000077', 'Member Two']) expect(out).not.toContain(secret); + }); +}); diff --git a/test/handlers/history-pictures.test.ts b/test/handlers/history-pictures.test.ts new file mode 100644 index 0000000000..1618ed3d2e --- /dev/null +++ b/test/handlers/history-pictures.test.ts @@ -0,0 +1,109 @@ +// Evolution handles events one batch at a time (eventProcessingQueue), and +// its messaging-history.set handler awaited contacts.upsert, which awaited a +// profile picture lookup for every contact in the batch before returning. A +// first link brings hundreds of new contacts, so the next history batch and +// every live message waited behind hundreds of picture IQs. +// +// The history batch goes out as soon as it is read; the pictures follow on +// contacts.update when their lookups finish, and those lookups never take the +// last query slot, so a live sender's lookup does not queue behind them. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { historyEvent, msg } from '../helpers/baileys-fixtures'; +import { deliver, makeService, settle } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; + +const INITIAL_BOOTSTRAP = 0; +const RECENT = 3; +const MAX_IN_FLIGHT = 4; + +const jid = (i: number) => `9725${String(i).padStart(8, '0')}@s.whatsapp.net`; +const url = (j: string) => `https://pps.whatsapp.test/${j.split('@')[0]}.jpg`; +const LIVE = jid(900); + +const of = (event: string) => emitted.filter((e) => e.event === event); +const itemsFor = (event: string, j: string) => + of(event) + .flatMap((e) => [].concat(e.data)) + .filter((c: any) => c?.remoteJid === j); + +async function until(done: () => boolean, what: string) { + for (let i = 0; i < 300 && !done(); i++) await new Promise((r) => setTimeout(r, 5)); + if (!done()) throw new Error(`timed out waiting for ${what}`); +} + +const incoming = (j: string, id: string, pushName: string) => ({ + 'messages.upsert': { messages: [{ ...msg(j, id, 'hi').message, pushName }], type: 'notify' }, + 'contacts.update': [{ id: j, notify: pushName, verifiedName: undefined }], +}); + +/** Emit a batch the way the socket does, without waiting for Evolution to handle it. */ +async function emit(ev: any, events: Record) { + ev.buffer(); + for (const [name, payload] of Object.entries(events)) ev.emit(name, payload); + await ev.flush(); +} + +const history = (syncType: number, jids: string[]) => ({ + 'messaging-history.set': historyEvent({ + syncType, + progress: 50, + conversations: jids.map((j, i) => ({ id: j, name: `Contact ${j.slice(8, 12)}`, messages: [msg(j, `H${j.slice(8, 12)}${i}`, 'hi')] })), + }), +}); + +describe('history and live messages never wait on profile picture lookups', () => { + let open = () => undefined as void; + beforeEach(() => void emitted.splice(0)); + // A failing run must not leave lookups hanging on a closed gate. + afterEach(() => open()); + + it('a history batch with slow picture lookups holds up neither the next batch nor a live message', async () => { + const FIRST = Array.from({ length: 30 }, (_, i) => jid(i)); + const SECOND = Array.from({ length: 30 }, (_, i) => jid(100 + i)); + const HISTORY = new Set([...FIRST, ...SECOND]); + const { service, ev } = await makeService(); + await deliver(service, ev, {}); // wire the handlers + + // History contacts' lookups hang until the gate opens; any other lookup answers at once. + const gate = new Promise((r) => (open = r)); + const state = { inFlight: 0, maxInFlight: 0 }; + const lookup = vi.fn(async (j: string) => { + state.inFlight++; + state.maxInFlight = Math.max(state.maxInFlight, state.inFlight); + if (HISTORY.has(j)) await gate; + else await new Promise((r) => setTimeout(r, 1)); + state.inFlight--; + return url(j); + }); + service.client.profilePictureUrl = lookup; + + await emit(ev, history(INITIAL_BOOTSTRAP, FIRST)); + await emit(ev, history(RECENT, SECOND)); + await emit(ev, incoming(LIVE, 'L1', 'Live')); + await emit(ev, incoming(LIVE, 'L2', 'Live')); + + // Everything arrives while not one history picture has been answered. + await until(() => of('messages.set').length === 2, 'both history batches'); + await until(() => of('messages.upsert').length === 2, 'both live messages'); + await until(() => itemsFor('contacts.update', LIVE).length === 2, "the live sender's contact payloads"); + expect(of('messages.upsert').map((e) => e.data.key.id)).toEqual(['L1', 'L2']); + for (const j of HISTORY) expect(itemsFor('contacts.upsert', j)).toHaveLength(1); + // The live sender's picture was looked up beside the stalled history lookups, not behind them. + expect(itemsFor('contacts.update', LIVE).map((c: any) => c.profilePicUrl)).toEqual([url(LIVE), url(LIVE)]); + for (const j of HISTORY) expect(itemsFor('contacts.update', j)).toEqual([]); + expect(state.maxInFlight).toBeLessThanOrEqual(MAX_IN_FLIGHT); + + // The pictures follow on contacts.update once WhatsApp answers, one lookup per contact. + open(); + await until(() => [...HISTORY].every((j) => itemsFor('contacts.update', j).length === 1), 'the history pictures'); + await settle(service); + for (const j of HISTORY) expect(itemsFor('contacts.update', j).map((c: any) => c.profilePicUrl)).toEqual([url(j)]); + expect(lookup.mock.calls.filter(([j]) => HISTORY.has(j))).toHaveLength(HISTORY.size); + expect(state.maxInFlight).toBeLessThanOrEqual(MAX_IN_FLIGHT); + }); +}); diff --git a/test/handlers/late-pictures.test.ts b/test/handlers/late-pictures.test.ts new file mode 100644 index 0000000000..784408551b --- /dev/null +++ b/test/handlers/late-pictures.test.ts @@ -0,0 +1,89 @@ +// A contact's picture is looked up after its contacts.upsert, in the background, and sent on +// contacts.update when the lookups of the whole batch finish. That update carried the name the +// contact had in the batch, so a rename that arrived meanwhile was overwritten for the consumer +// by the old name. And a lookup answered after WhatsApp said the picture was removed wrote the +// old picture back, in the cache and on the update. Each test applies the webhooks in the order +// they were sent, as a consumer does, and checks where the consumer ends. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { deliver, makeService, settle } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; + +const A = '972500000001@s.whatsapp.net'; +const B = '972500000002@s.whatsapp.net'; +const OLD_PICTURE = 'https://pps.whatsapp.test/a/old.jpg'; + +/** A profilePictureUrl whose answers the test gives, one call at a time. */ +function heldPictures(service: any) { + const calls: { jid: string; answer: (url: string | null) => void }[] = []; + service.client.profilePictureUrl = (jid: string) => + new Promise((answer) => calls.push({ jid, answer })); + const next = async (jid: string) => { + await vi.waitFor(() => expect(calls.some((c) => c.jid === jid)).toBe(true)); + const i = calls.findIndex((c) => c.jid === jid); + return calls.splice(i, 1)[0].answer; + }; + return { next }; +} + +/** What a consumer holds for a contact after applying every contacts webhook in order. */ +function consumerView(jid: string) { + const view: Record = {}; + for (const e of emitted.filter((e) => e.event === 'contacts.upsert' || e.event === 'contacts.update')) { + for (const item of [].concat(e.data) as any[]) { + if (item?.remoteJid !== jid) continue; + for (const [k, v] of Object.entries(item)) if (v !== undefined) view[k] = v; + } + } + return view; +} + +describe('a picture lookup that finishes late', () => { + beforeEach(() => void emitted.splice(0)); + + it('does not bring back a name the contact has changed since', async () => { + const { service, ev } = await makeService(); + const pictures = heldPictures(service); + await deliver(service, ev, { + 'contacts.upsert': [ + { id: A, name: 'Old' }, + { id: B, name: 'Bea' }, + ], + }); + const answerA = await pictures.next(A); + const answerB = await pictures.next(B); + + // A is renamed while B's picture is still being looked up. + answerA('https://pps.whatsapp.test/a/1.jpg'); + const rename = deliver(service, ev, { 'contacts.update': [{ id: A, name: 'New' }] }, { buffered: false }); + await rename; + answerB('https://pps.whatsapp.test/b/1.jpg'); + await settle(service); + await new Promise((r) => setTimeout(r, 20)); + + expect(consumerView(A).pushName).toBe('New'); + }); + + it('does not bring back a picture WhatsApp said was removed', async () => { + const { service, ev } = await makeService(); + const pictures = heldPictures(service); + await deliver(service, ev, { 'contacts.upsert': [{ id: A, name: 'Ann' }] }); + const answerA = await pictures.next(A); + + // WhatsApp's picture notification: removed. The lookup started before it answers after it. + await deliver(service, ev, { 'contacts.update': [{ id: A, imgUrl: 'removed' }] }, { buffered: false }); + answerA(OLD_PICTURE); + await settle(service); + await new Promise((r) => setTimeout(r, 20)); + + const next = await service.cachedProfilePicture(A); + expect({ consumer: consumerView(A).profilePicUrl, kept: next.profilePictureUrl }).toEqual({ + consumer: null, + kept: null, + }); + }); +}); diff --git a/test/handlers/lid-mapping.test.ts b/test/handlers/lid-mapping.test.ts new file mode 100644 index 0000000000..5b03de8902 --- /dev/null +++ b/test/handlers/lid-mapping.test.ts @@ -0,0 +1,126 @@ +// WhatsApp addresses many personal chats by a private @lid, and Baileys (rc13+) +// learns which phone number each @lid belongs to: from the history sync +// (`lidPnMappings` on messaging-history.set, built from phoneNumberToLidMappings +// and from each conversation's pnJid / lidJid) and live (`lid-mapping.update`). +// A consumer learns a mapping from one CONTACTS_UPSERT item: +// { remoteJid: , pushName: null, lid: , phoneNumber: , instanceId } +// +// History runs through the production path: Baileys' own processMessage inside +// ev.createBufferedFunction, with a real signal repository. That matters because +// Baileys' event buffer drops `lidPnMappings` when it flushes a buffered +// messaging-history.set (lib/Utils/event-buffer.js, consolidateEvents), so a fix +// that only reads the field off the event is not enough. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { msg, syncActionEvents } from '../helpers/baileys-fixtures'; +import { deliver, makeService, settle } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; +import { realSignalRepository, socketHistory } from '../helpers/socket-history'; + +const INITIAL_BOOTSTRAP = 0; +const RECENT = 3; + +const lid = (n: number) => `1000000000000${String(n).padStart(2, '0')}@lid`; +const pn = (n: number) => `9725000000${String(n).padStart(2, '0')}@s.whatsapp.net`; + +/** The item a consumer reads a mapping from. */ +const mapping = (n: number) => ({ remoteJid: pn(n), pushName: null, lid: lid(n), phoneNumber: pn(n), instanceId: 'inst-1' }); + +/** Every CONTACTS_UPSERT item, as the webhook carries it. */ +const contactItems = () => + emitted.filter((e) => e.event === 'contacts.upsert').flatMap((e) => [].concat(e.data ?? []) as any[]); + +/** The CONTACTS_UPSERT items that carry a mapping, ordered by lid. */ +const mappingItems = () => + contactItems() + .filter((item) => item && ('lid' in item || 'phoneNumber' in item)) + .sort((a, b) => String(a.lid).localeCompare(String(b.lid))); + +/** A service wired to a real signal repository, fed history the way the socket feeds it. */ +async function linkTime() { + const { service, ev } = await makeService(); + const { repo, keys, creds } = await realSignalRepository(); + Object.assign(service.client, { signalRepository: repo, __keys: keys, __creds: creds }); + service.eventHandler(); + service.__wired = true; + const history = async (sync: Record) => { + await socketHistory(ev, service.client, { syncType: INITIAL_BOOTSTRAP, progress: 100, ...sync }); + await settle(service); + }; + return { service, ev, history }; +} + +describe('@lid to phone mappings reach CONTACTS_UPSERT', () => { + beforeEach(() => void emitted.splice(0)); + + it('(a) a conversation keyed by the @lid, mapped by phoneNumberToLidMappings or by its own pnJid', async () => { + const { history } = await linkTime(); + await history({ + conversations: [ + { id: lid(1), messages: [msg(lid(1), 'A1', 'hi')] }, + { id: lid(2), pnJid: pn(2), messages: [msg(lid(2), 'A2', 'hello')] }, + ], + phoneNumberToLidMappings: [{ lidJid: lid(1), pnJid: pn(1) }], + }); + expect(mappingItems()).toEqual([mapping(1), mapping(2)]); + }); + + it('(b) a conversation keyed by the phone number, whose lidJid is set', async () => { + const { history } = await linkTime(); + await history({ conversations: [{ id: pn(3), lidJid: lid(3), messages: [msg(pn(3), 'B1', 'hi')] }] }); + expect(mappingItems()).toEqual([mapping(3)]); + }); + + it('(c) a mapping in phoneNumberToLidMappings with no conversation of its own', async () => { + const { history } = await linkTime(); + await history({ + conversations: [{ id: '120363000000000001@g.us', messages: [msg('120363000000000001@g.us', 'C1', 'group')] }], + phoneNumberToLidMappings: [{ lidJid: lid(4), pnJid: pn(4) }], + }); + expect(mappingItems()).toEqual([mapping(4)]); + }); + + it('(d) the mapping arrives in a later history batch than its conversation', async () => { + const { history } = await linkTime(); + await history({ conversations: [{ id: lid(5), messages: [msg(lid(5), 'D1', 'hi')] }] }); + await history({ syncType: RECENT, conversations: [], phoneNumberToLidMappings: [{ lidJid: lid(5), pnJid: pn(5) }] }); + expect(mappingItems()).toEqual([mapping(5)]); + }); + + it('(d) the mapping arrives in an earlier history batch than its conversation', async () => { + const { history } = await linkTime(); + await history({ conversations: [], phoneNumberToLidMappings: [{ lidJid: lid(6), pnJid: pn(6) }] }); + await history({ syncType: RECENT, conversations: [{ id: lid(6), messages: [msg(lid(6), 'D2', 'hi')] }] }); + expect(mappingItems()).toEqual([mapping(6)]); + }); + + it('(e) live, from the phone (pnForLidChatAction -> lid-mapping.update)', async () => { + const { service, ev } = await makeService(); + const events = syncActionEvents(['pnForLidChat', lid(7)], { pnForLidChatAction: { pnJid: pn(7) } }); + expect(Object.keys(events)).toContain('lid-mapping.update'); + await deliver(service, ev, events); + expect(mappingItems()).toEqual([mapping(7)]); + }); + + it('invents no mapping for an unmapped @lid, and links no unrelated pair', async () => { + const { history } = await linkTime(); + await history({ + conversations: [ + { id: lid(8), messages: [msg(lid(8), 'N1', 'nobody told us who I am')] }, + { id: lid(9), messages: [msg(lid(9), 'N2', 'hi')] }, + ], + phoneNumberToLidMappings: [ + { lidJid: lid(9), pnJid: pn(9) }, + { lidJid: lid(10), pnJid: pn(10) }, + ], + }); + expect(mappingItems()).toEqual([mapping(9), mapping(10)]); + const items = contactItems(); + expect(items.filter((i) => i.lid === lid(8) || (i.remoteJid === lid(8) && i.phoneNumber))).toEqual([]); + expect(items.filter((i) => (i.lid === lid(9) && i.phoneNumber !== pn(9)) || (i.lid === lid(10) && i.phoneNumber !== pn(10)))).toEqual([]); + }); +}); diff --git a/test/handlers/lid-send.test.ts b/test/handlers/lid-send.test.ts new file mode 100644 index 0000000000..d548f634b2 --- /dev/null +++ b/test/handlers/lid-send.test.ts @@ -0,0 +1,70 @@ +// A private chat can be keyed by an @lid (WhatsApp's linked identity) instead +// of a phone number. Baileys' onWhatsApp answers exists:false for an @lid, so +// a send that trusts that answer refuses a chat the person is actually in. +// Upstream fixed it in evolution-api #2544. A phone number that is genuinely +// not on WhatsApp must still be refused. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import type { Profile } from '../helpers/profiles'; + +const LID = '123456789012345@lid'; +const ABSENT = '972501112233'; + +function fakeSocket(service: any) { + const sent: { jid: string; content: any }[] = []; + const presence: { type: string; jid: string }[] = []; + const asked: string[] = []; + Object.assign(service.client, { + // Baileys' own answer: nothing but a phone number it can resolve exists. + onWhatsApp: async (...jids: string[]) => { + asked.push(...jids); + return jids.map((jid) => ({ exists: false, jid })); + }, + sendMessage: async (jid: string, content: any) => { + sent.push({ jid, content }); + return { key: { remoteJid: jid, fromMe: true, id: 'OUT1' }, message: { conversation: content.text }, messageTimestamp: 1_700_000_000, status: 1 }; + }, + presenceSubscribe: async () => undefined, + sendPresenceUpdate: async (type: string, jid: string) => void presence.push({ type, jid }), + }); + return { sent, presence, asked }; +} + +describe.each(['minimal', 'stored'])('sending to an @lid chat (%s)', (profile) => { + it('sendText to an @lid chat reaches the socket, addressed to that @lid', async () => { + const { service } = await makeService({ profile }); + const { sent } = fakeSocket(service); + + const res = await service.textMessage({ number: LID, text: 'hello' }); + + expect(sent.map(({ jid, content }) => ({ jid, text: content.text }))).toEqual([{ jid: LID, text: 'hello' }]); + expect(res.key).toEqual({ remoteJid: LID, fromMe: true, id: 'OUT1' }); + }); + + it('a typing presence to an @lid chat reaches the socket, addressed to that @lid', async () => { + const { service } = await makeService({ profile }); + const { presence } = fakeSocket(service); + + await service.sendPresence({ number: LID, presence: 'composing', delay: 1 }); + + expect(presence).toEqual([ + { type: 'composing', jid: LID }, + { type: 'paused', jid: LID }, + ]); + }); + + it('control: a phone number not on WhatsApp is still refused, and nothing is sent', async () => { + const { service } = await makeService({ profile }); + const { sent, asked } = fakeSocket(service); + + await expect(service.textMessage({ number: ABSENT, text: 'hello' })).rejects.toMatchObject({ status: 400 }); + + expect(asked).toEqual([`${ABSENT}@s.whatsapp.net`]); + expect(sent).toEqual([]); + }); +}); diff --git a/test/handlers/lid-webhook-key.test.ts b/test/handlers/lid-webhook-key.test.ts new file mode 100644 index 0000000000..852c1a302e --- /dev/null +++ b/test/handlers/lid-webhook-key.test.ts @@ -0,0 +1,107 @@ +// WhatsApp can address a private chat by an @lid (a linked identity) instead of +// the person's phone number. Baileys then hands Evolution a key with the @lid in +// remoteJid and the phone JID in remoteJidAlt. Evolution shows the phone JID as +// remoteJid in the messages.upsert webhook (consumers have always keyed chats by +// phone), but the phone keeps the message under the @lid: a consumer that later +// asks for something about that message (a media re-upload) must be able to name +// it the way WhatsApp does. So the webhook key keeps the original @lid in +// remoteJidAlt, the swap upstream develop makes. A group message's participant +// is not rewritten, so it keeps its @lid and phone as Baileys gave them. +// +// The keys are built by Baileys' own decodeMessageNode from a message stanza, +// so they carry exactly the fields this Baileys version produces. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { decodeMessageNode } from 'baileys'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { deliver, makeService, WUID } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; +import type { Profile } from '../helpers/profiles'; + +const ME_LID = '100000000000001@lid'; +const LID = '123456789012345@lid'; +const PHONE = '972509876543@s.whatsapp.net'; +const GROUP = '120363000000000001@g.us'; + +/** What Baileys emits in messages.upsert for this stanza, with a text body. */ +function received(attrs: Record) { + const { fullMessage } = decodeMessageNode({ tag: 'message', attrs, content: [] } as any, WUID, ME_LID); + return { ...fullMessage, message: { conversation: 'hello' } }; +} + +const lidDm = () => + received({ id: '3EB0DDDDDDDDDDDDDDD1', from: LID, sender_pn: PHONE, addressing_mode: 'lid', type: 'text', t: '1700000000', notify: 'Sender' }); + +const lidGroup = () => + received({ + id: '3EB0DDDDDDDDDDDDDDD2', + from: GROUP, + participant: LID, + participant_pn: PHONE, + addressing_mode: 'lid', + type: 'text', + t: '1700000000', + notify: 'Sender', + }); + +async function webhookKey(profile: Profile, message: any) { + const { service, ev } = await makeService({ profile }); + await deliver(service, ev, { 'messages.upsert': { messages: [message], type: 'notify' } }); + const upserts = emitted.filter((e) => e.event === 'messages.upsert'); + expect(upserts).toHaveLength(1); + return upserts[0].data.key; +} + +describe.each(['minimal', 'stored'])("a message's webhook key keeps its original @lid address (%s)", (profile) => { + beforeEach(() => void emitted.splice(0)); + + it('a DM WhatsApp addresses by @lid: remoteJid is the phone, remoteJidAlt the original @lid', async () => { + const message = lidDm(); + // What Baileys hands Evolution. + expect(message.key).toEqual({ remoteJid: LID, remoteJidAlt: PHONE, fromMe: false, id: '3EB0DDDDDDDDDDDDDDD1', addressingMode: 'lid' }); + + expect(await webhookKey(profile, message)).toEqual({ + remoteJid: PHONE, + remoteJidAlt: LID, + fromMe: false, + id: '3EB0DDDDDDDDDDDDDDD1', + addressingMode: 'pn', + }); + }); + + it('a group message from an @lid participant keeps the @lid participant and its phone', async () => { + const message = lidGroup(); + expect(message.key).toEqual({ + remoteJid: GROUP, + fromMe: false, + id: '3EB0DDDDDDDDDDDDDDD2', + participant: LID, + participantAlt: PHONE, + addressingMode: 'lid', + }); + + expect(await webhookKey(profile, message)).toEqual({ + remoteJid: GROUP, + fromMe: false, + id: '3EB0DDDDDDDDDDDDDDD2', + participant: LID, + participantAlt: PHONE, + addressingMode: 'lid', + }); + }); + + it('control: a DM addressed by phone is unchanged, its @lid in remoteJidAlt', async () => { + const message = received({ id: '3EB0DDDDDDDDDDDDDDD3', from: PHONE, sender_lid: LID, addressing_mode: 'pn', type: 'text', t: '1700000000', notify: 'Sender' }); + + expect(await webhookKey(profile, message)).toEqual({ + remoteJid: PHONE, + remoteJidAlt: LID, + fromMe: false, + id: '3EB0DDDDDDDDDDDDDDD3', + addressingMode: 'pn', + }); + }); +}); diff --git a/test/handlers/log-privacy.test.ts b/test/handlers/log-privacy.test.ts new file mode 100644 index 0000000000..aa66caa031 --- /dev/null +++ b/test/handlers/log-privacy.test.ts @@ -0,0 +1,309 @@ +// A deployment can run Evolution with LOG_LEVEL=ERROR,WARN and LOG_BAILEYS=error +// on the promise that payloads never reach the logs. Each case drives one path +// that handles a message, a contact or a call with a distinctive text, phone +// number and push name, and asserts that none of the three is printed: +// not by Evolution's logger, not by a bare console call, and not by the pino +// logger Evolution hands Baileys. +import { vi } from 'vitest'; + +const sockets = vi.hoisted(() => ({ make: undefined as undefined | ((config: any) => any) })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); +// createClient() builds the socket with makeWASocket; the fake returns the +// harness client plus a ws emitter, and keeps the config (and so the logger) +// Evolution passed. +vi.mock('baileys', async (importOriginal) => { + const real: any = await importOriginal(); + const makeWASocket = (config: any) => sockets.make!(config); + return { ...real, default: makeWASocket, makeWASocket }; +}); + +import { EventEmitter } from 'node:events'; + +import { binaryNodeToString, decryptMessageNode } from 'baileys'; +import { describe, expect, it } from 'vitest'; + +import { historyEvent, msg } from '../helpers/baileys-fixtures'; +import { deliver, makeService, settle, WUID } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import type { Profile } from '../helpers/profiles'; + +const PHONE = '972509876543'; +const TEXT = 'Zq7 the private sentence Zq7'; +const NAME = 'Dana Kfirovich'; +const SENDER = `${PHONE}@s.whatsapp.net`; +// An old-format group id embeds its creator's phone number. +const GROUP = `${PHONE}-1600000000@g.us`; +const SECRETS = { text: 'Zq7', phone: PHONE, name: NAME }; + +/** Each secret that appears in the output, with the line it appeared on (truncated). */ +function leaks(out: string) { + const found: string[] = []; + for (const [what, secret] of Object.entries(SECRETS)) { + const line = out.split('\n').find((l) => l.includes(secret)); + if (line !== undefined) + found.push( + `${what}: ${line + .replace(/\x1b\[[0-9;]*m/g, '') + .trim() + .slice(0, 160)}`, + ); + } + return found; +} + +const incoming = (id: string, extra: Record = {}) => ({ + ...msg(SENDER, id, TEXT).message, + pushName: NAME, + ...extra, +}); + +/** Run createClient() against the fake socket, so the ws handlers and the Baileys logger are Evolution's own. */ +async function connect(service: any, ev: any) { + const client = service.client; + let config: any; + sockets.make = (c: any) => ((config = c), { ...client, ev, ws: new EventEmitter() }); + service.defineAuthState = async () => ({ state: { creds: {}, keys: {} }, saveCreds: async () => undefined }); + service.localSettings ??= {}; + await service.createClient(); + service.__wired = true; // createClient() already called eventHandler() + return { socket: service.client, config }; +} + +describe.each(['minimal', 'stored'] as Profile[])( + 'nothing a message carries reaches the logs (profile %s)', + (profile) => { + it('an ordinary incoming message', async () => { + const { service, ev } = await makeService({ profile }); + const out = await captureOutput(() => + deliver(service, ev, { 'messages.upsert': { messages: [incoming('M1')], type: 'notify' } }), + ); + expect(leaks(out)).toEqual([]); + }); + + it('an incoming message whose push name renames a known chat, when the chat update fails', async () => { + const { service, prisma, ev } = await makeService({ profile }); + prisma.chat.rows.push({ id: 'chat-1', remoteJid: SENDER, instanceId: 'inst-1', name: 'Old name' }); + prisma.chat.update = async () => { + throw new Error('database unavailable'); + }; + const out = await captureOutput(() => + deliver(service, ev, { 'messages.upsert': { messages: [incoming('M2')], type: 'notify' } }), + ); + expect(leaks(out)).toEqual([]); + }); + + it('a message the phone re-sent on request (placeholder resend)', async () => { + const { service, ev } = await makeService({ profile }); + const out = await captureOutput(() => + deliver(service, ev, { 'messages.upsert': { messages: [incoming('M3')], type: 'notify', requestId: 'R1' } }), + ); + expect(leaks(out)).toEqual([]); + }); + + it('a history batch', async () => { + const { service, ev } = await makeService({ profile }); + const event = historyEvent({ + syncType: 3, // RECENT + conversations: [{ id: SENDER, name: NAME, messages: [msg(SENDER, 'H1', TEXT, { pushName: NAME })] }], + pushnames: [{ id: SENDER, pushname: NAME }], + }); + const out = await captureOutput(() => deliver(service, ev, { 'messaging-history.set': event })); + expect(leaks(out)).toEqual([]); + }); + + it('an on-demand history sync', async () => { + const { service, ev } = await makeService({ profile }); + const event = historyEvent({ + syncType: 6, // ON_DEMAND + conversations: [{ id: SENDER, messages: [msg(SENDER, 'H2', TEXT, { pushName: NAME })] }], + }); + const out = await captureOutput(() => deliver(service, ev, { 'messaging-history.set': event })); + expect(leaks(out)).toEqual([]); + }); + + it('a message that failed to decrypt (No session record), through Baileys and then Evolution', async () => { + const { service, ev } = await makeService({ profile }); + const { socket, config } = await connect(service, ev); + const stanza = { + tag: 'message', + attrs: { from: SENDER, id: 'D1', t: '1700000000', type: 'text', notify: NAME }, + content: [{ tag: 'enc', attrs: { v: '2', type: 'msg' }, content: new Uint8Array([1, 2, 3]) }], + }; + const repository = { + lidMapping: { getLIDForPN: async () => null, storeLIDPNMappings: async () => undefined }, + decryptMessage: async () => { + throw new Error('No session record'); + }, + }; + const out = await captureOutput(async () => { + const { fullMessage, decrypt } = decryptMessageNode( + stanza as any, + socket.user.id, + undefined as any, + repository as any, + config.logger, + ); + await decrypt(); + expect(fullMessage.messageStubParameters).toEqual(['No session record']); + await deliver(service, ev, { 'messages.upsert': { messages: [fullMessage], type: 'notify' } }); + await new Promise((r) => setTimeout(r, 20)); // pino's async write + }); + expect(leaks(out)).toEqual([]); + }); + + it('a message Baileys fails to handle (its "error in handling message" log)', async () => { + const { service, ev } = await makeService({ profile }); + const { config } = await connect(service, ev); + const node = { + tag: 'message', + attrs: { from: SENDER, id: 'E1', notify: NAME }, + content: [{ tag: 'body', attrs: {}, content: TEXT }], + }; + // The call Baileys makes at messages-recv.js:1436 (rc14). + const out = await captureOutput(async () => { + config.logger.error( + { error: new Error('boom'), node: binaryNodeToString(node as any) }, + 'error in handling message', + ); + await new Promise((r) => setTimeout(r, 20)); + }); + expect(leaks(out)).toEqual([]); + }); + + it('a message status update', async () => { + const { service, ev } = await makeService({ profile }); + const out = await captureOutput(() => + deliver(service, ev, { + 'messages.update': [{ key: { remoteJid: SENDER, fromMe: true, id: `S-${profile}` }, update: { status: 4 } }], + }), + ); + expect(leaks(out)).toEqual([]); + // Nothing about an ordinary status update is an error or a warning. + if (profile === 'minimal') expect(out).toBe(''); + }); + + it('an incoming call (the raw call stanzas and the call event)', async () => { + const { service, ev } = await makeService({ profile }); + const { socket } = await connect(service, ev); + const offer = { + tag: 'call', + attrs: { from: SENDER, id: 'C1', t: '1700000000', notify: NAME }, + content: [{ tag: 'offer', attrs: { 'call-id': 'CALL1', 'call-creator': SENDER }, content: undefined }], + }; + const ack = { tag: 'ack', attrs: { from: SENDER, id: 'C1', class: 'call', type: 'offer' } }; + const call = [ + { + chatId: SENDER, + from: SENDER, + id: 'CALL1', + date: new Date(1_700_000_000_000), + offline: false, + status: 'offer', + isVideo: false, + isGroup: false, + }, + ]; + const out = await captureOutput(async () => { + socket.ws.emit('CB:call', offer); + socket.ws.emit('CB:ack,class:call', ack); + await deliver(service, ev, { call }); + }); + expect(leaks(out)).toEqual([]); + }); + + it('a group metadata cache lookup (miss, then hit)', async () => { + const { service } = await makeService({ profile }); + service.client.groupMetadata = async (id: string) => ({ + id, + subject: TEXT, + participants: [{ id: SENDER, admin: null }], + }); + const out = await captureOutput(async () => { + await service.getGroupMetadataCache(GROUP); + await service.getGroupMetadataCache(GROUP); + }); + expect(leaks(out)).toEqual([]); + }); + + it('a group participants update whose participant lookup fails', async () => { + const { service, ev } = await makeService({ profile }); + service.client.groupMetadata = async () => { + throw new Error('item-not-found'); + }; + const out = await captureOutput(() => + deliver(service, ev, { + 'group-participants.update': { + id: `${PHONE}-1600000001@g.us`, + author: SENDER, + participants: [SENDER], + action: 'add', + }, + }), + ); + expect(leaks(out)).toEqual([]); + }); + + it('an outgoing text message', async () => { + const { service } = await makeService({ profile }); + Object.assign(service.client, { + onWhatsApp: async (...jids: string[]) => jids.map((jid) => ({ exists: true, jid })), + sendMessage: async (jid: string, content: any) => ({ + key: { remoteJid: jid, fromMe: true, id: 'OUT1' }, + message: { conversation: content.text ?? TEXT }, + messageTimestamp: 1_700_000_000, + status: 1, + }), + presenceSubscribe: async () => undefined, + sendPresenceUpdate: async () => undefined, + }); + let sent: any; + const out = await captureOutput(async () => { + sent = await service.textMessage({ number: PHONE, text: TEXT }); + await settle(service); + }); + expect(sent?.key?.id).toBe('OUT1'); + expect(leaks(out)).toEqual([]); + }); + + it('a raw node sent through baileysSendNode', async () => { + const { service } = await makeService({ profile }); + service.client.sendNode = async () => undefined; + const stanza = { + tag: 'message', + attrs: { to: SENDER, id: 'N1' }, + content: [{ tag: 'body', attrs: {}, content: TEXT }], + }; + const out = await captureOutput(() => service.baileysSendNode(stanza)); + expect(leaks(out)).toEqual([]); + }); + }, +); + +// An exception's message is arbitrary text: JSON.parse quotes the input it choked on, and a +// library error can carry a number as a person typed it. errorFields scrubbed URLs, JIDs and runs +// of 6+ digits, so a quoted text and a number written with spaces or dashes went through. +describe('errorFields', () => { + it('keeps neither a quoted text nor a formatted phone number from an error message', async () => { + const { errorFields } = await import('@utils/log-privacy'); + const quoted = (() => { + try { + JSON.parse('see you at the cafe on Herzl street'); + } catch (error) { + return error; + } + })(); + const phone = new Error('Could not deliver: recipient +972 54-111-2233 (054-111-2233) is not reachable'); + const fields = [errorFields(quoted), errorFields(phone)]; + const text = JSON.stringify(fields); + expect({ + text: text.includes('see you') || text.includes('Herzl'), + phone: /111.?2233|54.?111/.test(text), + }).toEqual({ text: false, phone: false }); + // What went wrong is still said. + expect(fields.map((f) => f.name)).toEqual(['SyntaxError', 'Error']); + }); +}); diff --git a/test/handlers/profile-pictures.test.ts b/test/handlers/profile-pictures.test.ts new file mode 100644 index 0000000000..0f7c0ffbcd --- /dev/null +++ b/test/handlers/profile-pictures.test.ts @@ -0,0 +1,213 @@ +// Evolution asks WhatsApp for a contact's profile picture (one IQ each) on +// every contacts.upsert, every contacts.update and every inbound message, and +// for every group on every fetchAllGroups. Baileys 7.0.0-rc14 emits a +// contacts.update for every inbound message that carries a pushName +// (chats.js upsertMessage), so a live message costs two picture IQs, and a +// history batch of N contacts fires N of them at once (Promise.all). At link +// time that is thousands of IQs in a burst from a device that has just linked. +// +// A lookup is kept per jid for a while, a jid already being looked up is not +// looked up again, and only a few lookups run at once. The webhook payloads +// keep their profilePicUrl, served from what was looked up. WhatsApp says when +// a picture changes (a `picture` notification, which Baileys emits as +// contacts.update with imgUrl 'changed' or 'removed'), and that refreshes it. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { historyEvent, msg } from '../helpers/baileys-fixtures'; +import { deliver, makeService, settle } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; + +const INITIAL_BOOTSTRAP = 0; +const PUSH_NAME = 4; +const HOUR = 60 * 60 * 1000; +/** The most picture lookups the service may have in flight at once. */ +const MAX_IN_FLIGHT = 4; + +const jid = (i: number) => `9725${String(i).padStart(8, '0')}@s.whatsapp.net`; +const url = (j: string, v = 1) => `https://pps.whatsapp.test/${j.split('@')[0]}/v${v}.jpg`; + +/** A fake profilePictureUrl IQ that takes a moment, and records how many ran at once. */ +function pictureServer(service: any) { + const state = { inFlight: 0, maxInFlight: 0, version: 1 }; + const fn = vi.fn(async (j: string) => { + state.inFlight++; + state.maxInFlight = Math.max(state.maxInFlight, state.inFlight); + await new Promise((r) => setTimeout(r, 2)); + state.inFlight--; + return url(j, state.version); + }); + service.client.profilePictureUrl = fn; + const perJid = () => { + const counts: Record = {}; + for (const [j] of fn.mock.calls) counts[j] = (counts[j] ?? 0) + 1; + return counts; + }; + return { fn, state, perJid }; +} + +/** Wait until the handlers have stopped asking for pictures. */ +async function quiet(service: any, fn: { mock: { calls: unknown[] } }) { + let last = -1; + while (fn.mock.calls.length !== last) { + last = fn.mock.calls.length; + await settle(service); + await new Promise((r) => setTimeout(r, 20)); + } +} + +const itemsFor = (event: string, j: string) => + emitted + .filter((e) => e.event === event) + .flatMap((e) => [].concat(e.data)) + .filter((c: any) => c?.remoteJid === j); + +/** What the socket emits for an incoming text: the message, and the sender's profile name (chats.js upsertMessage). */ +const incoming = (j: string, id: string, pushName: string) => ({ + 'messages.upsert': { messages: [{ ...msg(j, id, 'hi').message, pushName }], type: 'notify' }, + 'contacts.update': [{ id: j, notify: pushName, verifiedName: undefined }], +}); + +describe('profile pictures are looked up once per contact, not once per event', () => { + beforeEach(() => void emitted.splice(0)); + afterEach(() => void vi.useRealTimers()); + + it('a history batch of many contacts asks for each picture once, a few at a time, and the payload keeps the picture', async () => { + const N = 40; + const { service, ev } = await makeService(); + const server = pictureServer(service); + const contacts = Array.from({ length: N }, (_, i) => jid(i)); + + await deliver(service, ev, { + 'messaging-history.set': historyEvent({ + syncType: INITIAL_BOOTSTRAP, + progress: 100, + conversations: contacts.map((j, i) => ({ id: j, name: `Contact ${i}`, messages: [msg(j, `H${i}`, 'hi')] })), + }), + }); + await quiet(service, server.fn); + // The same people again, as the push-name sync that follows the bootstrap. + await deliver(service, ev, { + 'messaging-history.set': historyEvent({ + syncType: PUSH_NAME, + pushnames: contacts.map((j, i) => ({ id: j, pushname: `Profile ${i}` })), + }), + }); + await quiet(service, server.fn); + + expect(server.fn).toHaveBeenCalledTimes(N); + expect(Object.values(server.perJid()).every((n) => n === 1)).toBe(true); + expect(server.state.maxInFlight).toBeLessThanOrEqual(MAX_IN_FLIGHT); + + for (const j of contacts) { + const updates = itemsFor('contacts.update', j); + expect(updates).toHaveLength(2); + expect(updates.map((u: any) => u.profilePicUrl)).toEqual([url(j), url(j)]); + } + }); + + it('a burst of inbound messages from a few senders asks once per sender, not per message', async () => { + const SENDERS = [jid(101), jid(102), jid(103)]; + const M = 30; + const { service, ev } = await makeService(); + const server = pictureServer(service); + + for (let i = 0; i < M; i++) { + const sender = SENDERS[i % SENDERS.length]; + await deliver(service, ev, incoming(sender, `M${i}`, `Sender ${sender.slice(4, 7)}`)); + } + await quiet(service, server.fn); + + expect(server.perJid()).toEqual(Object.fromEntries(SENDERS.map((j) => [j, 1]))); + for (const j of SENDERS) { + const payloads = [...itemsFor('contacts.upsert', j), ...itemsFor('contacts.update', j)]; + // Each message yields one contact payload from messages.upsert and one from contacts.update. + expect(payloads).toHaveLength((2 * M) / SENDERS.length); + expect(new Set(payloads.map((p: any) => p.profilePicUrl))).toEqual(new Set([url(j)])); + } + }); + + it('a lookup is kept for an hour, then asked again', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const A = jid(201); + const { service, ev } = await makeService(); + const server = pictureServer(service); + + await deliver(service, ev, incoming(A, 'T1', 'Alpha')); + await quiet(service, server.fn); + vi.setSystemTime(Date.now() + HOUR - 60_000); + await deliver(service, ev, incoming(A, 'T2', 'Alpha')); + await quiet(service, server.fn); + expect(server.fn).toHaveBeenCalledTimes(1); + + vi.setSystemTime(Date.now() + 2 * 60_000); + server.state.version = 2; + await deliver(service, ev, incoming(A, 'T3', 'Alpha')); + await quiet(service, server.fn); + expect(server.fn).toHaveBeenCalledTimes(2); + expect(itemsFor('contacts.update', A).at(-1).profilePicUrl).toBe(url(A, 2)); + }); + + it('a picture notification refreshes the picture, and a removal clears it without asking', async () => { + const A = jid(301); + const B = jid(302); + const { service, ev } = await makeService(); + const server = pictureServer(service); + + await deliver(service, ev, { ...incoming(A, 'P1', 'Alpha') }); + await deliver(service, ev, { ...incoming(B, 'P2', 'Bravo') }); + await quiet(service, server.fn); + expect(server.fn).toHaveBeenCalledTimes(2); + + // Baileys (messages-recv.js, notification type `picture`) emits no builder-made event: hand-written. + server.state.version = 2; + await deliver(service, ev, { 'contacts.update': [{ id: A, imgUrl: 'changed' }] }); + await deliver(service, ev, { 'contacts.update': [{ id: B, imgUrl: 'removed' }] }); + await quiet(service, server.fn); + + expect(server.perJid()).toEqual({ [A]: 2, [B]: 1 }); + expect(itemsFor('contacts.update', A).at(-1).profilePicUrl).toBe(url(A, 2)); + expect(itemsFor('contacts.update', B).at(-1).profilePicUrl).toBeNull(); + + // And the refreshed picture is what the next message carries, without another lookup. + await deliver(service, ev, incoming(A, 'P3', 'Alpha')); + await quiet(service, server.fn); + expect(server.fn).toHaveBeenCalledTimes(3); + expect(itemsFor('contacts.update', A).at(-1).profilePicUrl).toBe(url(A, 2)); + }); + + it('listing all groups twice asks for each group picture once', async () => { + const GROUPS = Array.from({ length: 12 }, (_, i) => `1203630000000${String(i).padStart(5, '0')}@g.us`); + const { service } = await makeService(); + const server = pictureServer(service); + const meta = (id: string) => ({ id, subject: `Group ${id.slice(-8, -5)}`, participants: [], creation: 1_700_000_000 }); + service.client.groupFetchAllParticipating = async () => Object.fromEntries(GROUPS.map((id) => [id, meta(id)])); + + const first = await service.fetchAllGroups({ getParticipants: 'false' }); + const second = await service.fetchAllGroups({ getParticipants: 'false' }); + + expect(server.fn).toHaveBeenCalledTimes(GROUPS.length); + expect(first.map((g: any) => g.pictureUrl)).toEqual(GROUPS.map((id) => url(id))); + expect(second.map((g: any) => g.pictureUrl)).toEqual(GROUPS.map((id) => url(id))); + }); + + it('an explicit picture request still asks WhatsApp, and later events carry what it found', async () => { + const A = jid(401); + const { service, ev } = await makeService(); + const server = pictureServer(service); + + await deliver(service, ev, incoming(A, 'E1', 'Alpha')); + await quiet(service, server.fn); + server.state.version = 2; + expect((await service.profilePicture(A)).profilePictureUrl).toBe(url(A, 2)); + expect(server.fn).toHaveBeenCalledTimes(2); + + await deliver(service, ev, incoming(A, 'E2', 'Alpha')); + await quiet(service, server.fn); + expect(server.fn).toHaveBeenCalledTimes(2); + expect(itemsFor('contacts.update', A).at(-1).profilePicUrl).toBe(url(A, 2)); + }); +}); diff --git a/test/handlers/saved-names.test.ts b/test/handlers/saved-names.test.ts new file mode 100644 index 0000000000..e95dcbb834 --- /dev/null +++ b/test/handlers/saved-names.test.ts @@ -0,0 +1,117 @@ +// A contact has two names: the one the phone's owner SAVED in their address +// book (Baileys `name`, from an app-state contact action) and the contact's own +// PROFILE name (the message stanza's pushName, Baileys `notify`). Evolution +// folds both into one `pushName` on contacts.upsert, so a consumer cannot tell +// them apart and a profile name that arrives later replaces the saved one. +// Each contacts.upsert item says `saved: true` only when the name is certainly +// the saved one, and `saved: false` otherwise. +// +// Where the names come from in Baileys 7.0.0-rc14: +// - contactAction (lib/Utils/sync-action-utils.js processContactAction) and +// lidContactAction (lib/Utils/chat-utils.js) emit contacts.upsert with +// name = fullName || firstName || username, and `username` alongside. A name +// equal to the username is the contact's handle, not a saved name. +// - history (lib/Utils/history.js) builds name = displayName || name || +// username for every conversation, and PUSH_NAME syncs carry only `notify`: +// neither is certainly a saved name. +// - a live message (lib/Socket/chats.js upsertMessage) carries the sender's +// pushName, and emits contacts.update [{ id, notify, verifiedName }]. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { historyEvent, msg, syncActionEvents } from '../helpers/baileys-fixtures'; +import { deliver, makeService } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; + +const ALPHA = '972500000001@s.whatsapp.net'; +const BRAVO = '972500000002@s.whatsapp.net'; +const CHARLIE = '972500000003@s.whatsapp.net'; +const DELTA = '972500000004@s.whatsapp.net'; +const ECHO_LID = '100000000005@lid'; +const INITIAL_BOOTSTRAP = 0; +const PUSH_NAME = 4; + +const upserts = (jid: string) => + emitted + .filter((e) => e.event === 'contacts.upsert') + .flatMap((e) => [].concat(e.data)) + .filter((c: any) => c?.remoteJid === jid); + +/** An item from Evolution's contacts.upsert handler (Baileys contacts.upsert, or history). */ +const item = (remoteJid: string, pushName: string, saved: boolean) => ({ + remoteJid, + pushName, + profilePicUrl: null, + instanceId: 'inst-1', + saved, +}); + +/** An item from Evolution's messages.upsert handler, which looks the picture up (none here). */ +const fromMessage = (remoteJid: string, pushName: string, saved: boolean) => ({ + ...item(remoteJid, pushName, saved), + profilePicUrl: undefined, +}); + +/** What the socket emits for an incoming text: the message, and the sender's profile name (chats.js upsertMessage). */ +const incoming = (jid: string, id: string, pushName: string) => ({ + 'messages.upsert': { messages: [{ ...msg(jid, id, 'hi').message, pushName }], type: 'notify' }, + 'contacts.update': [{ id: jid, notify: pushName, verifiedName: undefined }], +}); + +describe('contacts.upsert says whether a name is the one the owner saved', () => { + beforeEach(() => void emitted.splice(0)); + + it('app-state contact action: the address-book name is saved, a bare username is not', async () => { + const { service, ev } = await makeService(); + await deliver(service, ev, syncActionEvents(['contact', ALPHA], { contactAction: { fullName: 'Alpha Saved', firstName: 'Alpha' } })); + await deliver(service, ev, syncActionEvents(['contact', BRAVO], { contactAction: { firstName: 'Bravo' } })); + await deliver(service, ev, syncActionEvents(['contact', CHARLIE], { contactAction: { username: 'charlie.handle' } })); + await deliver(service, ev, syncActionEvents(['lid_contact', ECHO_LID], { lidContactAction: { fullName: 'Echo Saved' } })); + + expect(upserts(ALPHA)).toEqual([item(ALPHA, 'Alpha Saved', true)]); + expect(upserts(BRAVO)).toEqual([item(BRAVO, 'Bravo', true)]); + expect(upserts(CHARLIE)).toEqual([item(CHARLIE, 'charlie.handle', false)]); + expect(upserts(ECHO_LID)).toEqual([item(ECHO_LID, 'Echo Saved', true)]); + }); + + it('a live message: the sender profile name is kept, and is not saved', async () => { + const { service, ev } = await makeService(); + await deliver(service, ev, incoming(DELTA, '3EB0DDDDDDDDDDDDDDD1', 'delta profile')); + + expect(upserts(DELTA)).toEqual([fromMessage(DELTA, 'delta profile', false)]); + }); + + it('history: display names, usernames, chat names and push names are never marked saved', async () => { + const { service, ev } = await makeService(); + await deliver(service, ev, { + 'messaging-history.set': historyEvent({ + syncType: INITIAL_BOOTSTRAP, + progress: 100, + conversations: [ + { id: ALPHA, displayName: 'Alpha Display', messages: [msg(ALPHA, 'A1', 'hi')] }, + { id: BRAVO, username: 'bravo.handle', messages: [msg(BRAVO, 'B1', 'hi')] }, + { id: CHARLIE, name: 'Charlie Chat', messages: [msg(CHARLIE, 'C1', 'hi')] }, + ], + }), + }); + await deliver(service, ev, { + 'messaging-history.set': historyEvent({ syncType: PUSH_NAME, pushnames: [{ id: DELTA, pushname: 'delta profile' }] }), + }); + + expect(upserts(ALPHA)).toEqual([item(ALPHA, 'Alpha Display', false)]); + expect(upserts(BRAVO)).toEqual([item(BRAVO, 'bravo.handle', false)]); + expect(upserts(CHARLIE)).toEqual([item(CHARLIE, 'Charlie Chat', false)]); + expect(upserts(DELTA)).toEqual([item(DELTA, 'delta profile', false)]); + }); + + it('the saved name and a later profile name for the same person stay distinguishable', async () => { + const { service, ev } = await makeService(); + await deliver(service, ev, syncActionEvents(['contact', ALPHA], { contactAction: { fullName: 'Alpha Saved', firstName: 'Alpha' } })); + await deliver(service, ev, incoming(ALPHA, '3EB0AAAAAAAAAAAAAAA1', 'alpha profile')); + + expect(upserts(ALPHA)).toEqual([item(ALPHA, 'Alpha Saved', true), fromMessage(ALPHA, 'alpha profile', false)]); + }); +}); diff --git a/test/handlers/settings-read-failure.test.ts b/test/handlers/settings-read-failure.test.ts new file mode 100644 index 0000000000..262d6b542b --- /dev/null +++ b/test/handlers/settings-read-failure.test.ts @@ -0,0 +1,67 @@ +// eventHandler() starts every event batch by reading the instance's Setting row +// (findSettings), inside the one try that covers the whole batch. When that read +// failed, the batch was dropped: messages.upsert, creds.update and +// connection.update alike, with nothing sent to the webhook. A database blip +// therefore lost the messages that arrived during it. +// +// A failed settings read must not cost the batch. The batch runs with the last +// known settings (loaded at connect, kept by /settings/set), and the failure is +// logged without anything the batch carries. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { msg } from '../helpers/baileys-fixtures'; +import { deliver, makeService } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import { emitted } from '../helpers/fake-server-module'; +import type { Profile } from '../helpers/profiles'; + +const PHONE = '972509876543'; +const SENDER = `${PHONE}@s.whatsapp.net`; +const GROUP = '120363000000000002@g.us'; +const TEXT = 'Zq9 a message that arrived during a database blip Zq9'; +const DB_ERROR = "Can't reach database server at `127.0.0.1:5432`"; + +beforeEach(() => void emitted.splice(0)); + +/** A connected instance whose stored settings ignore groups, and whose next Setting read fails. */ +async function serviceWithFailingSettingsRead(profile: Profile) { + const { service, prisma, ev } = await makeService({ profile }); + await prisma.setting.create({ + data: { instanceId: 'inst-1', rejectCall: false, msgCall: '', groupsIgnore: true, readMessages: false, readStatus: false }, + }); + await service.loadSettings(); // what connectToWhatsapp does before the socket opens + const read = prisma.setting.findUnique; + let failed = false; + prisma.setting.findUnique = async (args: any) => { + if (!failed) { + failed = true; + throw Object.assign(new Error(DB_ERROR), { code: 'P1001' }); + } + return read(args); + }; + return { service, ev }; +} + +describe.each(['minimal', 'stored'] as Profile[])('a failed settings read does not drop an event batch (profile %s)', (profile) => { + it('the batch messages still reach the webhook, under the last known settings', async () => { + const { service, ev } = await serviceWithFailingSettingsRead(profile); + const direct = msg(SENDER, 'B1', TEXT).message; + // Stored groupsIgnore is true, so this one is skipped; defaults (no settings) would send it. + const group = msg(GROUP, 'B2', TEXT, { key: { remoteJid: GROUP, fromMe: false, id: 'B2', participant: SENDER } }).message; + const out = await captureOutput(() => deliver(service, ev, { 'messages.upsert': { messages: [direct, group], type: 'notify' } })); + + const upserts = emitted + .filter((e) => e.event === 'messages.upsert') + .map((e) => ({ id: e.data?.key?.id, remoteJid: e.data?.key?.remoteJid, text: e.data?.message?.conversation })); + expect(upserts).toEqual([{ id: 'B1', remoteJid: SENDER, text: TEXT }]); + + // The failure is logged, and nothing the batch carries is. + const lines = out.split('\n').map((l) => l.replace(/\x1b\[[0-9;]*m/g, '')); + expect(lines.some((l) => l.includes('Settings read failed') && l.includes(DB_ERROR))).toBe(true); + expect(lines.filter((l) => l.includes('Zq9') || l.includes(PHONE))).toEqual([]); + }); +}); diff --git a/test/harness/baileys-version.test.ts b/test/harness/baileys-version.test.ts new file mode 100644 index 0000000000..26fca432cf --- /dev/null +++ b/test/harness/baileys-version.test.ts @@ -0,0 +1,22 @@ +// The harness tests the Baileys that package.json pins, and a deep import +// reaches the same build as the package itself. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const pinned = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')).dependencies.baileys; +const resolved = process.env.BAILEYS_RESOLVED_DIR!; +const version = JSON.parse(readFileSync(join(resolved, 'package.json'), 'utf8')).version; + +describe('harness: Baileys resolution', () => { + it.runIf(!process.env.BAILEYS_DIR)('tests the version package.json pins', () => { + expect(version).toBe(pinned); + }); + + it('resolves deep imports from the same build', async () => { + const deep = await import('baileys/lib/Utils/event-buffer.js' as any); + const top = await import('baileys'); + expect(deep.makeEventBuffer).toBe((top as any).makeEventBuffer); + }); +}); diff --git a/test/harness/service.test.ts b/test/harness/service.test.ts new file mode 100644 index 0000000000..fdfea1c2ab --- /dev/null +++ b/test/harness/service.test.ts @@ -0,0 +1,36 @@ +// The harness drives Evolution's real BaileysStartupService through Baileys' +// real event buffer, under a configuration profile. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { beforeEach, describe, expect, it } from 'vitest'; + +import { deliver, makeService } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; + +const incoming = { + key: { remoteJid: '972500000001@s.whatsapp.net', fromMe: false, id: '3EB0AAAAAAAAAAAAAAAA' }, + message: { conversation: 'hello' }, + messageTimestamp: 1_700_000_000, + pushName: 'Sender', +}; + +describe('harness: an incoming text message', () => { + beforeEach(() => void emitted.splice(0)); + + it('reaches the messages.upsert webhook, and is not stored under the minimal profile', async () => { + const { service, prisma, ev } = await makeService({ profile: 'minimal' }); + await deliver(service, ev, { 'messages.upsert': { messages: [incoming], type: 'notify' } }); + const upsert = emitted.find((e) => e.event === 'messages.upsert'); + expect(upsert?.data?.key?.id).toBe(incoming.key.id); + expect(upsert?.data?.message?.conversation).toBe('hello'); + expect(prisma.message.rows).toHaveLength(0); + }); + + it('is stored under the stored profile', async () => { + const { service, prisma, ev } = await makeService({ profile: 'stored' }); + await deliver(service, ev, { 'messages.upsert': { messages: [incoming], type: 'notify' } }); + expect(prisma.message.rows.map((r: any) => r.key?.id)).toEqual([incoming.key.id]); + }); +}); diff --git a/test/helpers/app-state.ts b/test/helpers/app-state.ts new file mode 100644 index 0000000000..476fd2948a --- /dev/null +++ b/test/helpers/app-state.ts @@ -0,0 +1,45 @@ +// Build a real encrypted app-state patch with Baileys' own encoder, and decode it +// with whatever key the code under test hands back. No WhatsApp involved. +import { randomBytes } from 'node:crypto'; + +import { decodePatches, encodeSyncdPatch, newLTHashState, proto } from 'baileys'; + +export function freshAppStateKey() { + const keyId = randomBytes(6).toString('base64'); + // As Baileys receives it in APP_STATE_SYNC_KEY_SHARE: a decoded proto message. + const key = proto.Message.AppStateSyncKeyData.decode( + proto.Message.AppStateSyncKeyData.encode({ + keyData: randomBytes(32), + fingerprint: { rawId: 1, currentIndex: 0, deviceIndexes: [0] }, + timestamp: Date.now(), + }).finish(), + ); + return { keyId, key }; +} + +/** A contact saved on the phone, as the phone syncs it to a linked device. */ +export async function contactPatch(keyId: string, key: any, jid: string, fullName: string) { + const { patch } = await encodeSyncdPatch( + { + type: 'critical_unblock_low', + index: ['contact', jid], + syncAction: { contactAction: { fullName } }, + apiVersion: 2, + operation: proto.SyncdMutation.SyncdOperation.SET, + } as any, + keyId, + newLTHashState(), + async () => key, + ); + return { ...patch, version: { version: 1 } }; +} + +/** Names Baileys recovers from the patch; an Error when it throws (rc9), [] when it skips (rc13+). */ +export async function decodeContactNames(patch: any, getKey: (id: string) => Promise) { + try { + const { mutationMap } = await decodePatches('critical_unblock_low', [patch], newLTHashState(), getKey, {}, 0); + return Object.values(mutationMap).map((m: any) => m.syncAction.value?.contactAction?.fullName); + } catch (error) { + return error as Error; + } +} diff --git a/test/helpers/auth-writer/payload.ts b/test/helpers/auth-writer/payload.ts new file mode 100644 index 0000000000..c8bebbb255 --- /dev/null +++ b/test/helpers/auth-writer/payload.ts @@ -0,0 +1,21 @@ +// A key value that is large and says which write it is: `fill` is `tag`, in base 36, +// repeated to `size` characters. A file that parses to a value whose fill is the one its +// tag and size give holds one complete write; anything else is torn. +export const payload = (tag: number, size: number) => { + const unit = `${tag.toString(36)}:`; + return { tag, size, fill: unit.repeat(Math.ceil(size / unit.length)).slice(0, size) }; +}; + +/** What a key file holds: one complete payload (its tag), or why not. */ +export function complete(raw: string | undefined): { tag: number } | 'missing' | 'empty' | 'torn' { + if (raw === undefined) return 'missing'; + if (raw === '') return 'empty'; + let value: any; + try { + value = JSON.parse(raw); + } catch { + return 'torn'; + } + const whole = Number.isSafeInteger(value?.tag) && Number.isSafeInteger(value?.size) && value.fill === payload(value.tag, value.size).fill; + return whole ? { tag: value.tag } : 'torn'; +} diff --git a/test/helpers/auth-writer/server-module.js b/test/helpers/auth-writer/server-module.js new file mode 100644 index 0000000000..29ceecd4ee --- /dev/null +++ b/test/helpers/auth-writer/server-module.js @@ -0,0 +1,12 @@ +// What the auth store gets for @api/server.module in the child process: the session table +// only, in memory (creds live there; keys are files). The real module builds the whole +// application at import. +const rows = new Map(); +exports.prismaRepository = { + session: { + findUnique: async ({ where }) => rows.get(where.sessionId) ?? null, + create: async ({ data }) => (rows.set(data.sessionId, { ...data }), rows.get(data.sessionId)), + update: async ({ where, data }) => (rows.set(where.sessionId, { ...rows.get(where.sessionId), ...data }), rows.get(where.sessionId)), + delete: async ({ where }) => rows.delete(where.sessionId), + }, +}; diff --git a/test/helpers/auth-writer/writer.ts b/test/helpers/auth-writer/writer.ts new file mode 100644 index 0000000000..1c24687479 --- /dev/null +++ b/test/helpers/auth-writer/writer.ts @@ -0,0 +1,39 @@ +// A child process around the real Prisma auth store, whose keys are files under +// INSTANCE_DIR (./instances of the process's cwd). test/unit/auth-key-atomic-write.test.ts +// bundles it with esbuild (aliases from tsconfig, @api/server.module replaced by +// server-module.js) and runs it with node. +// +// loop prints "ready" once the store is open, then writes +// the key `session-` over and over, tag from, from+1..., +// until it is killed. +// fault writes tag 1 (small) and prints "wrote 1", then tag 2 +// (`size`), and prints "rejected " if that write +// rejects, else "wrote 2". +import useMultiFileAuthStatePrisma from '@utils/use-multi-file-auth-state-prisma'; + +import { payload } from './payload'; + +async function main() { + const [mode, session, id, ...rest] = process.argv.slice(2); + const auth = await useMultiFileAuthStatePrisma(session, null as any); + const write = (tag: number, size: number) => auth.state.keys.set({ session: { [id]: payload(tag, size) } } as any); + if (mode === 'loop') { + const [from, size] = rest.map(Number); + process.stdout.write('ready\n'); + for (let tag = from; ; tag++) await write(tag, size); + } + if (mode === 'fault') { + await write(1, 1000); + process.stdout.write('wrote 1\n'); + const outcome = await write(2, Number(rest[0])).then( + () => 'wrote 2', + (error) => `rejected ${error?.code ?? error?.name}`, + ); + process.stdout.write(`${outcome}\n`); + } +} + +main().catch((error) => { + process.stderr.write(`${error?.stack ?? error}\n`); + process.exit(1); +}); diff --git a/test/helpers/baileys-fixtures.ts b/test/helpers/baileys-fixtures.ts new file mode 100644 index 0000000000..183dd26a03 --- /dev/null +++ b/test/helpers/baileys-fixtures.ts @@ -0,0 +1,30 @@ +// Fixtures are WhatsApp-side inputs (a HistorySync proto, an app-state sync +// action). The Baileys version under test turns them into events, so each +// version hands Evolution exactly what it would in production, no more. +import { processHistoryMessage, processSyncAction, proto } from 'baileys'; + +import { WUID } from './baileys-service'; + +/** What Baileys emits as messaging-history.set for this HistorySync payload. */ +export function historyEvent(sync: Record) { + const data: any = (processHistoryMessage as any)(proto.HistorySync.fromObject(sync)); + return { ...data, isLatest: true }; +} + +/** The events Baileys emits for one decoded app-state mutation. */ +export function syncActionEvents(index: string[], action: Record) { + const events: Record = {}; + const collector: any = { emit: (name: string, data: any) => void (events[name] = data) }; + const syncAction = { index, syncAction: { value: proto.SyncActionValue.fromObject({ timestamp: 1_700_000_000, ...action }) } }; + (processSyncAction as any)(syncAction, collector, { id: WUID, name: 'Me' }, undefined, undefined); + return events; +} + +export const msg = (remoteJid: string, id: string, text: string, extra: Record = {}) => ({ + message: { + key: { remoteJid, fromMe: false, id }, + message: { conversation: text }, + messageTimestamp: 1_700_000_000, + ...extra, + }, +}); diff --git a/test/helpers/baileys-service.ts b/test/helpers/baileys-service.ts new file mode 100644 index 0000000000..3597f28641 --- /dev/null +++ b/test/helpers/baileys-service.ts @@ -0,0 +1,63 @@ +// Build a BaileysStartupService with fakes around it, and a fake socket whose +// event emitter is the REAL Baileys event buffer, so eventHandler() receives +// batches the way it does in production. +import EventEmitter2 from 'eventemitter2'; +import { makeEventBuffer } from 'baileys'; +import P from 'pino'; + +import { fakePrisma } from './fake-prisma'; +import { applyProfile, type Profile } from './profiles'; + +export const WUID = '972500000000@s.whatsapp.net'; + +/** + * `prisma` and `eventEmitter` default to fresh ones. Pass the ones the rest of a + * test uses (the server module's Prisma, the monitor's emitter) when the service + * must share them, as it does in production. + */ +export async function makeService(opts: { profile?: Profile; prisma?: any; eventEmitter?: EventEmitter2 } = {}) { + applyProfile(opts.profile ?? 'minimal'); + const { BaileysStartupService } = await import('@api/integrations/channel/whatsapp/whatsapp.baileys.service'); + const { CacheService } = await import('@api/services/cache.service'); + const { LocalCache } = await import('@cache/localcache'); + const { ConfigService } = await import('@config/env.config'); + const configService = new ConfigService(); + const prisma = opts.prisma ?? fakePrisma(); + const cache = new CacheService(new LocalCache(configService, 'instance')); + const baileysCache = new CacheService(new LocalCache(configService, 'baileys')); + const service: any = new BaileysStartupService(configService, opts.eventEmitter ?? new EventEmitter2(), prisma, cache, null as any, baileysCache, null as any); + service.setInstance({ instanceName: 'test', instanceId: 'inst-1', integration: 'WHATSAPP-BAILEYS' }); + const ev = makeEventBuffer(P({ level: 'silent' }) as any); + service.client = { + ev, + user: { id: WUID }, + profilePictureUrl: async () => undefined, + signalRepository: { lidMapping: { getPNForLID: async () => undefined, getLIDForPN: async () => undefined } }, + }; + service.instance.wuid = WUID; + // eventHandler() saves creds on every creds.update (Baileys emits one per history batch). + service.instance.authState = { saveCreds: async () => undefined }; + return { service, prisma, ev }; +} + +/** Wire eventHandler() to the fake socket and emit a batch through the real event buffer. */ +export async function deliver(service: any, ev: any, events: Record, opts: { buffered?: boolean } = {}) { + if (!service.__wired) { + service.eventHandler(); + service.__wired = true; + } + // Baileys emits history and most message events inside a buffered function + // (upsertMessage is ev.createBufferedFunction), so buffered is the production path. + if (opts.buffered !== false) ev.buffer(); + for (const [name, payload] of Object.entries(events)) ev.emit(name, payload); + if (opts.buffered !== false) await ev.flush(); + await settle(service); +} + +/** eventHandler() chains every batch on eventProcessingQueue; handlers fire async work after it. */ +export async function settle(service: any) { + for (let i = 0; i < 5; i++) { + await service.eventProcessingQueue; + await new Promise((r) => setTimeout(r, 5)); + } +} diff --git a/test/helpers/capture-output.ts b/test/helpers/capture-output.ts new file mode 100644 index 0000000000..8200bc848c --- /dev/null +++ b/test/helpers/capture-output.ts @@ -0,0 +1,50 @@ +// Everything the process prints while `fn` runs: console.* and raw +// stdout/stderr, including direct writes to file descriptors 1 and 2 (pino, +// which Baileys logs through, writes to the fd with sonic-boom and never +// touches process.stdout). +import fs from 'node:fs'; + +export async function captureOutput(fn: () => Promise) { + const out: string[] = []; + const fmt = (args: any[]) => args.map((a) => (typeof a === 'string' ? a : safe(a))).join(' '); + const names = ['log', 'info', 'warn', 'error', 'debug'] as const; + const saved = names.map((n) => console[n]); + const stdout = process.stdout.write.bind(process.stdout); + const stderr = process.stderr.write.bind(process.stderr); + const fsWrite = fs.write; + const fsWriteSync = fs.writeSync; + const isStd = (fd: any) => fd === 1 || fd === 2; + names.forEach((n) => (console[n] = (...a: any[]) => void out.push(fmt(a)))); + (process.stdout as any).write = (c: any) => (out.push(String(c)), true); + (process.stderr as any).write = (c: any) => (out.push(String(c)), true); + (fs as any).writeSync = (fd: any, data: any, ...rest: any[]) => { + if (!isStd(fd)) return (fsWriteSync as any)(fd, data, ...rest); + out.push(String(data)); + return typeof data === 'string' ? Buffer.byteLength(data) : data.length; + }; + (fs as any).write = (fd: any, data: any, ...rest: any[]) => { + if (!isStd(fd)) return (fsWrite as any)(fd, data, ...rest); + out.push(String(data)); + const cb = rest.find((r) => typeof r === 'function'); + const n = typeof data === 'string' ? Buffer.byteLength(data) : data.length; + if (cb) process.nextTick(cb, null, n, data); + }; + try { + await fn(); + } finally { + names.forEach((n, i) => (console[n] = saved[i])); + (process.stdout as any).write = stdout; + (process.stderr as any).write = stderr; + (fs as any).write = fsWrite; + (fs as any).writeSync = fsWriteSync; + } + return out.join('\n'); +} + +function safe(v: any) { + try { + return JSON.stringify(v, (_k, x) => (typeof x === 'bigint' ? String(x) : x)); + } catch { + return String(v); + } +} diff --git a/test/helpers/connect.ts b/test/helpers/connect.ts new file mode 100644 index 0000000000..f479a1a2ea --- /dev/null +++ b/test/helpers/connect.ts @@ -0,0 +1,116 @@ +// Run Evolution's real connect (connectToWhatsapp -> createClient) with the +// socket factory replaced, so a test can read the socket config Evolution +// builds without opening a WebSocket. The test file mocks `baileys` so that +// makeWASocket is `socketSpy`, which returns fakeSocket(). +import { EventEmitter } from 'node:events'; + +import { Boom } from '@hapi/boom'; +import { initAuthCreds, makeEventBuffer } from 'baileys'; +import P from 'pino'; + +import { makeService } from './baileys-service'; + +export function fakeSocket(config?: any) { + const ev = makeEventBuffer(P({ level: 'silent' }) as any); + // ev.process subscriptions still attached: a socket Evolution has let go of should have none. + let handlers = 0; + const process = ev.process.bind(ev); + ev.process = (handler: any) => { + const off = process(handler); + handlers++; + let attached = true; + return () => { + if (attached) handlers--; + attached = false; + off(); + }; + }; + let closed = false; + let open = false; + const onClose: (() => void)[] = []; + ev.on('connection.update', (update: any) => { + if (update.connection === 'close') closed = true; + if (update.connection === 'open') open = true; + }); + const sock = { + ev, + ws: Object.assign(new EventEmitter(), { close: () => undefined }), + // connectionUpdate reads the account on 'open', and logoutInstance logs the socket out. + user: { id: '972500000000:1@s.whatsapp.net' }, + profilePictureUrl: async () => undefined, + /** How many times the socket told WhatsApp to remove this device (remove-companion-device). */ + logouts: 0, + /** Whether WhatsApp answers the remove-companion-device IQ (with type result). */ + confirmRemove: true, + /** Called once a remove-companion-device has been written, e.g. to drop the connection then. */ + afterRemove: undefined as undefined | (() => void), + // As Baileys' logout(): with a linked device it first writes remove-companion-device (sendNode, + // which throws when the ws is not open: 'Connection Closed') without waiting for any answer; then + // it ends the socket itself, locally, with loggedOut. + logout: async (msg?: string) => { + if (config?.auth?.creds?.me?.id) { + if (!open || closed) throw new Boom('Connection Closed', { statusCode: 428 }); + sock.logouts++; + sock.afterRemove?.(); + } + sock.end(new Boom(msg || 'Intentional Logout', { statusCode: 401 })); + }, + // As Baileys' query(): write the node, then wait for WhatsApp's answer; the wait fails when the + // socket closes first. Only remove-companion-device is modelled. + query: async (node: any) => { + if (node?.content?.[0]?.tag !== 'remove-companion-device') throw new Error('fake socket: query not modelled'); + if (!open || closed) throw new Boom('Connection Closed', { statusCode: 428 }); + sock.logouts++; + sock.afterRemove?.(); + if (sock.confirmRemove) return { tag: 'iq', attrs: { type: 'result', id: node.attrs.id } }; + return new Promise((_, reject) => onClose.push(() => reject(new Boom('Connection Closed', { statusCode: 428 })))); + }, + /** Whether Evolution called end() on this socket. */ + ended: false, + handlers: () => handlers, + // As Baileys' end() (Socket/socket.js): once only, and it announces the close on the socket's own events. + end: (error?: Error) => { + sock.ended = true; + if (closed) return; + closed = true; + ev.emit('connection.update', { connection: 'close', lastDisconnect: { error, date: new Date() } }); + onClose.splice(0).forEach((f) => f()); + }, + }; + return sock; +} + +/** The auth state is files on disk under the instances directory; nothing about it touches the network. */ +export function stubAuthState(service: any) { + service.defineAuthState = async () => ({ + state: { creds: initAuthCreds(), keys: { get: async () => ({}), set: async () => undefined } }, + saveCreds: async () => undefined, + removeCreds: async () => undefined, + }); +} + +export type ProxyProtocol = 'http' | 'socks5'; + +/** Set the proxy the way /proxy/set does, then connect the way Evolution does. Returns the socket config. */ +export async function connectBehind( + socketSpy: { mock: { calls: any[][] } }, + proxy?: { protocol: ProxyProtocol; port: number }, +) { + const { service } = await makeService(); + if (proxy) { + await service.setProxy({ + enabled: true, + host: '127.0.0.1', + port: String(proxy.port), + protocol: proxy.protocol, + username: '', + password: '', + }); + } + stubAuthState(service); + const before = socketSpy.mock.calls.length; + await service.connectToWhatsapp(); + const config = socketSpy.mock.calls[before]?.[0]; + if (!config) throw new Error('connectToWhatsapp did not create a socket'); + return { service, config }; +} diff --git a/test/helpers/fake-prisma.ts b/test/helpers/fake-prisma.ts new file mode 100644 index 0000000000..8ce83a700f --- /dev/null +++ b/test/helpers/fake-prisma.ts @@ -0,0 +1,119 @@ +// An in-memory stand-in for the parts of PrismaRepository the Baileys handlers use. +// Rows live in plain arrays so a test can assert on what Evolution stored. +type Row = Record; + +const matches = (row: Row, where: Row = {}) => + Object.entries(where).every(([k, v]) => { + if (k === 'remoteJid_instanceId') return row.remoteJid === v.remoteJid && row.instanceId === v.instanceId; + if (v && typeof v === 'object' && !Array.isArray(v)) { + if ('in' in v) return (v.in as any[]).includes(row[k]); + if ('notIn' in v) return !(v.notIn as any[]).includes(row[k]); + if ('path' in v) return true; // JSON path filters: not modelled, treated as match + return matches(row[k] ?? {}, v); + } + return row[k] === v; + }); + +function table(name: string, uniqueKey: (r: Row) => string | undefined) { + const rows: Row[] = []; + const find = (where?: Row) => rows.filter((r) => matches(r, where)); + const t = { + rows, + findMany: async (args: Row = {}) => find(args.where), + findFirst: async (args: Row = {}) => find(args.where)[0] ?? null, + findUnique: async (args: Row = {}) => find(args.where)[0] ?? null, + count: async (args: Row = {}) => find(args.where).length, + create: async ({ data }: Row) => (rows.push({ id: `${name}-${rows.length + 1}`, ...data }), rows[rows.length - 1]), + createMany: async ({ data, skipDuplicates }: Row) => { + let count = 0; + for (const d of [].concat(data)) { + const key = uniqueKey(d); + if (skipDuplicates && key && rows.some((r) => uniqueKey(r) === key)) continue; + rows.push({ id: `${name}-${rows.length + 1}`, ...d }); + count++; + } + return { count }; + }, + update: async ({ where, data }: Row) => { + const r = find(where)[0]; + if (r) Object.assign(r, strip(data)); + return r; + }, + updateMany: async ({ where, data }: Row) => { + const hit = find(where); + hit.forEach((r) => Object.assign(r, strip(data))); + return { count: hit.length }; + }, + upsert: async ({ where, create, update }: Row) => { + const r = find(where)[0]; + if (r) return Object.assign(r, strip(update)); + return t.create({ data: create }); + }, + delete: async ({ where }: Row) => { + const i = rows.findIndex((r) => matches(r, where)); + return i >= 0 ? rows.splice(i, 1)[0] : null; + }, + deleteMany: async ({ where }: Row = {}) => { + const hit = find(where); + hit.forEach((r) => rows.splice(rows.indexOf(r), 1)); + return { count: hit.length }; + }, + }; + return t; +} + +// Prisma ignores undefined fields in an update; so do we. +const strip = (data: Row) => Object.fromEntries(Object.entries(data ?? {}).filter(([, v]) => v !== undefined)); + +export function fakePrisma() { + const byJid = (r: Row) => (r.remoteJid ? `${r.remoteJid}|${r.instanceId}` : undefined); + const db: any = { + contact: table('contact', byJid), + chat: table('chat', byJid), + message: table('message', (r) => r.key?.id && `${r.key.id}|${r.instanceId}`), + messageUpdate: table('messageUpdate', () => undefined), + setting: table('setting', (r) => r.instanceId), + session: table('session', (r) => r.sessionId), + label: table('label', () => undefined), + isOnWhatsapp: table('isOnWhatsapp', (r) => r.remoteJid), + instance: table('instance', (r) => r.id), + proxy: table('proxy', (r) => r.instanceId), + webhook: table('webhook', (r) => r.instanceId), + chatwoot: table('chatwoot', (r) => r.instanceId), + // The rest of what deleting an instance clears (monitor.service.ts cleaningStoreData). + rabbitmq: table('rabbitmq', (r) => r.instanceId), + nats: table('nats', (r) => r.instanceId), + sqs: table('sqs', (r) => r.instanceId), + integrationSession: table('integrationSession', () => undefined), + typebot: table('typebot', () => undefined), + websocket: table('websocket', (r) => r.instanceId), + }; + // As the real schema: every table that belongs to an instance references Instance ON DELETE + // CASCADE (Session by sessionId), so deleting the row deletes them too. + const cascade = (removed: Row[]) => { + for (const instance of removed) { + for (const [name, t] of Object.entries(db) as [string, any][]) { + if (name === 'instance' || !Array.isArray(t?.rows)) continue; + const key = name === 'session' ? 'sessionId' : 'instanceId'; + for (const r of t.rows.filter((r: Row) => r[key] === instance.id)) t.rows.splice(t.rows.indexOf(r), 1); + } + } + }; + const { delete: del, deleteMany } = db.instance; + db.instance.delete = async (args: Row) => { + const removed = await del(args); + if (removed) cascade([removed]); + return removed; + }; + db.instance.deleteMany = async (args: Row = {}) => { + const removed = db.instance.rows.filter((r: Row) => matches(r, args.where)); + const result = await deleteMany(args); + cascade(removed); + return result; + }; + db.$transaction = async (ops: any) => (typeof ops === 'function' ? ops(db) : Promise.all(ops)); + db.$queryRaw = async () => []; + db.$executeRaw = async () => 0; + db.$queryRawUnsafe = async () => []; + return db; +} diff --git a/test/helpers/fake-server-module.ts b/test/helpers/fake-server-module.ts new file mode 100644 index 0000000000..069da72f3c --- /dev/null +++ b/test/helpers/fake-server-module.ts @@ -0,0 +1,14 @@ +// Replaces @api/server.module, which builds the whole application at import +// (Prisma client, monitor, every controller). Handlers reach three things +// through it: eventManager (webhooks and other transports), chatbotController +// and waMonitor. Tests read what was emitted from `emitted`. +import { vi } from 'vitest'; + +import { fakePrisma } from './fake-prisma'; + +export const emitted: { event: string; data: any; extra?: any }[] = []; +export const eventManager = { emit: vi.fn(async (e: any) => void emitted.push({ event: e.event, data: e.data, extra: e.extra })) }; +export const chatbotController = { emit: vi.fn(async () => undefined) }; +export const waMonitor = { waInstances: {} }; +export const prismaRepository = fakePrisma(); +export const cache = undefined; diff --git a/test/helpers/http-app.ts b/test/helpers/http-app.ts new file mode 100644 index 0000000000..2370380f7b --- /dev/null +++ b/test/helpers/http-app.ts @@ -0,0 +1,62 @@ +// T3: Evolution's real router and real Prisma against a throwaway Postgres. +// main.ts builds the app inside bootstrap() and does not export it, so this +// mirrors the parts a route needs: JSON body parsing, the router, and the +// error handler's status mapping (main.ts, "app.use((err, req, res, next) ..."). +import type { AddressInfo } from 'node:net'; + +async function serve(mount: string, router: any) { + const express = (await import('express')).default; + const app = express(); + app.use(express.json({ limit: '10mb' })); + app.use(mount, router); + app.use((err: any, _req: any, res: any, _next: any) => + res.status(err?.status || 500).json({ + status: err?.status || 500, + error: err?.error, + response: { + message: err?.message, + ...(err?.reupload !== undefined && { reupload: err.reupload }), + ...(err?.reuploadReason !== undefined && { reuploadReason: err.reuploadReason }), + }, + }), + ); + const server = app.listen(0, '127.0.0.1'); + await new Promise((r) => server.once('listening', r)); + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + return { base, close: () => new Promise((r) => server.close(r)) }; +} + +export async function startApp() { + const { router } = await import('@api/routes/index.router'); + return serve('/', router); +} + +/** + * Only /instance, behind the guards index.router puts in front of it + * (instanceExistsGuard, instanceLoggedGuard, the apikey guard), for a test that + * fakes the server module and hands the router its own instanceController. + */ +export async function startInstanceApp() { + // index.router is where the import cycle through @exceptions (which takes HttpStatus + // from it) has to start, as it does in main.ts; entered from a router, RouterBroker + // is still undefined when the channel routers extend it. + await import('@api/routes/index.router'); + const { InstanceRouter } = await import('@api/routes/instance.router'); + const { authGuard } = await import('@api/guards/auth.guard'); + const { instanceExistsGuard, instanceLoggedGuard } = await import('@api/guards/instance.guard'); + const { configService } = await import('@config/env.config'); + const guards = [instanceExistsGuard, instanceLoggedGuard, authGuard['apikey']]; + return serve('/instance', new InstanceRouter(configService, ...guards).router); +} + +/** + * Only /chat, behind the same guards, for a test that fakes the server module and + * hands the router its own chatController (whose monitor holds the service). + */ +export async function startChatApp() { + await import('@api/routes/index.router'); + const { ChatRouter } = await import('@api/routes/chat.router'); + const { authGuard } = await import('@api/guards/auth.guard'); + const { instanceExistsGuard, instanceLoggedGuard } = await import('@api/guards/instance.guard'); + return serve('/chat', new ChatRouter(instanceExistsGuard, instanceLoggedGuard, authGuard['apikey']).router); +} diff --git a/test/helpers/live-replay.ts b/test/helpers/live-replay.ts new file mode 100644 index 0000000000..efbad653f6 --- /dev/null +++ b/test/helpers/live-replay.ts @@ -0,0 +1,140 @@ +// Replays a scrubbed live-check fixture (test/fixtures/live/-/) +// through Baileys' real event buffer into the real BaileysStartupService, and +// compares what Evolution sends with the webhooks the live session recorded. +// +// The replay follows the tape: an event the buffer held is emitted inside a +// buffer, a batch line flushes it, an event Evolution emitted itself +// (origin: app) is left for the replayed Evolution to emit again, and events +// from a socket Evolution had already replaced are skipped (it ignored them +// live). Socket queries (profile pictures, group metadata, LID lookups) were +// not recorded: they answer as the harness does unless a test passes `client`. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { decode, encode } from '@utils/live-record/codec'; + +import { makeService, settle } from './baileys-service'; +import { emitted } from './fake-server-module'; +import type { Profile } from './profiles'; + +type Line = Record; + +const readLines = (file: string): Line[] => + readFileSync(file, 'utf8') + .split('\n') + .filter((l) => l.trim()) + .map((l) => JSON.parse(l)); + +export function loadFixture(dir: string) { + return { + events: readLines(join(dir, 'events.ndjson')), + webhooks: readLines(join(dir, 'webhooks.ndjson')), + manifest: JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')), + }; +} + +/** The events Baileys' buffer holds (BUFFERABLE_EVENT in Baileys lib/Utils/event-buffer.js, rc14). */ +const BUFFERABLE = new Set([ + 'messaging-history.set', + 'chats.upsert', + 'chats.update', + 'chats.delete', + 'contacts.upsert', + 'contacts.update', + 'messages.upsert', + 'messages.update', + 'messages.delete', + 'messages.reaction', + 'message-receipt.update', + 'groups.update', +]); + +export async function replayFixture(dir: string, opts: { profile?: Profile; client?: Record } = {}) { + const { events, manifest } = loadFixture(dir); + const { service, ev, prisma } = await makeService({ profile: opts.profile }); + if (manifest.replay?.owner?.id) service.client.user = manifest.replay.owner; + Object.assign(service.client, opts.client); + // A close must not build a real socket. + service.connectToWhatsapp = async () => service.client; + service.scheduleReconnect = () => undefined; + + const batches: string[][] = []; + ev.process((batch: Record) => void batches.push(Object.keys(batch))); + service.eventHandler(); + service.__wired = true; + const start = emitted.length; + + // Live, each batch was handled before the next one arrived. + const flush = async () => { + if (ev.isBuffering()) ev.flush(); + await service.eventProcessingQueue; + }; + let socket = 0; + for (const line of events) { + if (line.socket < socket) continue; + socket = line.socket; + if (line.batch) { + // Only a batch with a bufferable event is the buffer's flush; one of non-bufferable events + // only (a connection.update inside a buffer) was delivered at once, with the buffer still held. + if (line.batch.some((event: string) => BUFFERABLE.has(event))) await flush(); + else await service.eventProcessingQueue; + continue; + } + if (line.origin === 'app') continue; + if (!line.buffered) await flush(); + if (line.buffered && !ev.isBuffering()) ev.buffer(); + ev.emit(line.event, decode(line.data)); + } + await flush(); + await settle(service); + return { service, prisma, batches, webhooks: emitted.slice(start) }; +} + +/** Fields Evolution fills from the clock, the database or a socket query: not part of the comparison. */ +export const VOLATILE = [ + 'dateTime', + 'date_time', + 'createdAt', + 'updatedAt', + 'instanceId', + 'profilePicUrl', + 'profilePictureUrl', +]; + +function normalize(value: any, volatile: Set): any { + if (Array.isArray(value)) return value.map((v) => normalize(v, volatile)); + if (!value || typeof value !== 'object') return value; + if ('$bytes' in value) return { $bytes: value.$bytes, as: value.as }; // the scrubber tags fakes + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = volatile.has(k) ? '' : normalize(v, volatile); + return out; +} + +/** + * The differences between what the replay sent and the golden webhooks, as + * "missing " / "unexpected " lines; empty when they match. The + * comparison is of the two multisets, since background lookups interleave. + * `events` limits it to those event names (webhooks a live API call caused have + * no event on the tape). + */ +export function compareGolden( + actual: { event: string; data: any }[], + golden: Line[], + opts: { events?: string[]; volatile?: string[] } = {}, +) { + const volatile = new Set([...VOLATILE, ...(opts.volatile ?? [])]); + const keep = (event: string) => !opts.events || opts.events.includes(event); + const canon = (event: string, encoded: any) => JSON.stringify({ event, data: normalize(encoded, volatile) }); + const want = golden.filter((w) => keep(w.event)).map((w) => canon(w.event, w.data)); + const got = actual.filter((w) => keep(w.event)).map((w) => canon(w.event, encode(w.data))); + + const diff: string[] = []; + const left = [...got]; + for (const w of want) { + const i = left.indexOf(w); + if (i >= 0) left.splice(i, 1); + else diff.push(`missing ${JSON.parse(w).event}: ${w}`); + } + for (const g of left) diff.push(`unexpected ${JSON.parse(g).event}: ${g}`); + return diff; +} diff --git a/test/helpers/live-session.ts b/test/helpers/live-session.ts new file mode 100644 index 0000000000..3966930b88 --- /dev/null +++ b/test/helpers/live-session.ts @@ -0,0 +1,113 @@ +// A short synthetic live session, played on the socket Evolution's real connect +// built (the test file mocks makeWASocket with fakeSocket). The identities look +// real on purpose: the recorder must keep them, and the scrubber must replace them. +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +import { proto } from 'baileys'; +import Long from 'long'; + +import { makeService, settle } from './baileys-service'; +import { stubAuthState } from './connect'; + +export const OWNER = { id: '972529998877:14@s.whatsapp.net', lid: '987654321098765:14@lid', name: 'Noa Barak' }; +export const PERSON = { pn: '972541112233@s.whatsapp.net', lid: '123456789012345@lid', saved: 'Dana Levi', push: 'Dana' }; +export const TEXT = 'see you at the cafe on Herzl street at 8'; +export const MESSAGE_ID = '3EB0C431C26A1D6C5A5D'; +export const MESSAGE_SECRET = Buffer.from('0f1e2d3c4b5a69788796a5b4c3d2e1f00112233445566778899aabbccddeeff', 'hex'); +export const CALL_ID = 'A1B2C3D4E5F60718293A4B5C6D7E8F90'; + +/** Every identifying string in the session, for tests that look for leaks. */ +export const ORIGINALS = [ + '972529998877', + '987654321098765', + OWNER.name, + '972541112233', + '123456789012345', + PERSON.saved, + PERSON.push, + TEXT, + MESSAGE_ID.slice(2), + CALL_ID.slice(2), + MESSAGE_SECRET.toString('base64'), +]; + +export const incoming = () => { + const info = proto.WebMessageInfo.fromObject({ + key: { remoteJid: PERSON.lid, fromMe: false, id: MESSAGE_ID }, + messageTimestamp: Long.fromNumber(1758873600, true), + pushName: PERSON.push, + message: { conversation: TEXT, messageContextInfo: { messageSecret: new Uint8Array(MESSAGE_SECRET) } }, + }); + // Not proto fields: Baileys sets them on the decoded key (fromObject would drop them). + Object.assign(info.key, { remoteJidAlt: PERSON.pn, addressingMode: 'lid' }); + return info; +}; + +/** What the owner's phone answers a call with, when the instance has a call message set. */ +export const callReply = (jid: string, text: string) => + proto.WebMessageInfo.fromObject({ + key: { remoteJid: jid, fromMe: true, id: 'BAE5F00D12345678' }, + messageTimestamp: Long.fromNumber(1758873801, true), + message: { conversation: text }, + }); + +/** The socket as a linked session has it: the account, a LID mapping, a phone that sends. */ +export function linkedSocket(service: any) { + Object.assign(service.client, { + user: { ...OWNER }, + signalRepository: { + lidMapping: { + getPNForLID: async (lid: string) => (lid === PERSON.lid ? PERSON.pn : undefined), + getLIDForPN: async (pn: string) => (pn === PERSON.pn ? PERSON.lid : undefined), + }, + }, + sendMessage: async (jid: string, content: { text: string }) => callReply(jid, content.text), + }); + // WhatsApp reports the phone's platform at pairing; smba is WhatsApp Business on Android. + if (service.instance.authState?.state?.creds) service.instance.authState.state.creds.platform = 'smba'; +} + +/** Open, then a contact and a message in one buffered batch, then a call offer. */ +export async function playSession(service: any) { + linkedSocket(service); + const ev = service.client.ev; + ev.emit('connection.update', { connection: 'open' }); + ev.emit('creds.update', { me: { id: OWNER.id, lid: OWNER.lid, name: OWNER.name } }); + ev.buffer(); + ev.emit('contacts.upsert', [{ id: PERSON.pn, lid: PERSON.lid, name: PERSON.saved, notify: PERSON.push }]); + ev.emit('messages.upsert', { type: 'notify', messages: [incoming()] }); + ev.flush(); + ev.emit('call', [ + { + chatId: PERSON.lid, + from: PERSON.lid, + id: CALL_ID, + date: new Date(1758873800000), + offline: false, + status: 'offer', + isVideo: false, + isGroup: false, + }, + ]); + await settle(service); +} + +/** + * Record the session under `root` the way a live check does (LIVE_RECORD_DIR) and + * return the raw session directory. The calling test mocks makeWASocket. + */ +export async function recordSession(root: string, opts: { msgCall?: string } = {}) { + process.env.LIVE_RECORD_DIR = root; + try { + const { service, prisma } = await makeService(); + if (opts.msgCall) prisma.setting.rows.push({ instanceId: 'inst-1', msgCall: opts.msgCall }); + stubAuthState(service); + await service.connectToWhatsapp(); + await playSession(service); + } finally { + delete process.env.LIVE_RECORD_DIR; + } + const [session] = readdirSync(join(root, 'test')); + return join(root, 'test', session); +} diff --git a/test/helpers/local-net.ts b/test/helpers/local-net.ts new file mode 100644 index 0000000000..4c6f1ae927 --- /dev/null +++ b/test/helpers/local-net.ts @@ -0,0 +1,179 @@ +// A media CDN and the two kinds of proxy Evolution supports (HTTP CONNECT and +// SOCKS5), all on 127.0.0.1. Each proxy records every connection it relays and +// refuses any target that is not 127.0.0.1 (or one it was told to remap to a +// local server), so nothing a test does can leave the machine through it. +import { readFileSync } from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import net from 'node:net'; +import tls from 'node:tls'; + +export type Listening = { port: number; log: string[]; close: () => Promise }; + +const LOCAL = '127.0.0.1'; + +function track(server: net.Server) { + const sockets = new Set(); + server.on('connection', (s) => { + sockets.add(s); + s.on('close', () => sockets.delete(s)); + }); + return () => + new Promise((resolve) => { + sockets.forEach((s) => s.destroy()); + server.close(() => resolve()); + }); +} + +async function listen(server: net.Server): Promise { + await new Promise((resolve) => server.listen(0, LOCAL, resolve)); + return (server.address() as net.AddressInfo).port; +} + +/** Serves `files` (path -> bytes) and logs `METHOD path` for every request. */ +/** Serves `files` by path; a number instead of a body answers that status. Anything else is 404. */ +export async function startCdn(files: Record): Promise { + const log: string[] = []; + const server = http.createServer((req, res) => { + log.push(`${req.method} ${req.url}`); + const body = files[req.url ?? '']; + if (!body) return void res.writeHead(404).end(); + if (typeof body === 'number') return void res.writeHead(body).end(); + res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': body.length }).end(body); + }); + const close = track(server); + return { port: await listen(server), log, close }; +} + +function pipeTo(client: net.Socket, host: string, port: number, onOpen: () => void) { + const upstream = net.connect(port, host, () => { + onOpen(); + upstream.pipe(client); + client.pipe(upstream); + }); + upstream.on('error', () => client.destroy()); + client.on('error', () => upstream.destroy()); +} + +/** `host:port` the client asked for -> the 127.0.0.1 `host:port` to connect instead, or undefined to refuse. */ +type Remap = Record; +function resolveTarget(target: string, remap: Remap = {}): [string, number] | undefined { + const to = remap[target] ?? target; + const i = to.lastIndexOf(':'); + const host = to.slice(0, i); + return host === LOCAL ? [host, Number(to.slice(i + 1))] : undefined; +} + +/** An HTTP proxy: CONNECT tunnels, and absolute-URI forwarding. Logs `CONNECT host:port` / `GET url`. */ +export async function startHttpProxy(opts: { remap?: Remap } = {}): Promise { + const log: string[] = []; + const server = http.createServer((req, res) => { + log.push(`${req.method} ${req.url}`); + const target = new URL(req.url ?? ''); + if (target.hostname !== LOCAL) return void res.writeHead(403).end(); + const upstream = http.request( + { host: LOCAL, port: target.port, path: target.pathname + target.search, method: req.method, headers: req.headers }, + (up) => { + res.writeHead(up.statusCode ?? 502, up.headers); + up.pipe(res); + }, + ); + upstream.on('error', () => res.destroy()); + req.pipe(upstream); + }); + server.on('connect', (req, client: net.Socket, head) => { + log.push(`CONNECT ${req.url}`); + const target = resolveTarget(req.url ?? '', opts.remap); + if (!target) return void client.end('HTTP/1.1 403 Forbidden\r\n\r\n'); + pipeTo(client, target[0], target[1], () => { + client.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head?.length) client.unshift(head); + }); + }); + const close = track(server); + return { port: await listen(server), log, close }; +} + +/** A SOCKS5 proxy, no authentication, CONNECT only. Logs `SOCKS5 host:port`. */ +export async function startSocks5Proxy(opts: { remap?: Remap } = {}): Promise { + const log: string[] = []; + const server = net.createServer((client) => { + client.once('data', (greeting) => { + if (greeting[0] !== 5) return void client.destroy(); + client.write(Buffer.from([5, 0])); + client.once('data', (req) => { + let host: string; + let offset: number; + if (req[3] === 1) { + host = [...req.subarray(4, 8)].join('.'); + offset = 8; + } else if (req[3] === 3) { + const len = req[4]; + host = req.subarray(5, 5 + len).toString(); + offset = 5 + len; + } else return void client.destroy(); + const port = req.readUInt16BE(offset); + log.push(`SOCKS5 ${host}:${port}`); + const reply = (code: number) => Buffer.from([5, code, 0, 1, 0, 0, 0, 0, 0, 0]); + const target = resolveTarget(`${host}:${port}`, opts.remap); + if (req[1] !== 1 || !target) return void client.end(reply(2)); + pipeTo(client, target[0], target[1], () => client.write(reply(0))); + }); + }); + client.on('error', () => client.destroy()); + }); + const close = track(server); + return { port: await listen(server), log, close }; +} + +const TLS_DIR = new URL('../fixtures/tls/', import.meta.url); +/** A self-signed test certificate for 127.0.0.1, web.whatsapp.com and raw.githubusercontent.com. */ +export const testTls = { + key: readFileSync(new URL('local.key', TLS_DIR)), + cert: readFileSync(new URL('local.crt', TLS_DIR)), +}; + +/** Trust the test certificate process-wide (added to the default CAs, verification stays on). Returns undo. */ +export function trustTestCertificate() { + const before = tls.getCACertificates('default'); + tls.setDefaultCACertificates([...before, testTls.cert.toString()]); + return () => tls.setDefaultCACertificates(before); +} + +/** An HTTPS server on 127.0.0.1 with the test certificate. Logs `METHOD path` for every request. */ +export async function startHttpsServer( + handler: (req: http.IncomingMessage, body: Buffer, res: http.ServerResponse) => void, +): Promise { + const log: string[] = []; + const server = https.createServer(testTls, (req, res) => { + log.push(`${req.method} ${req.url}`); + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => handler(req, Buffer.concat(chunks), res)); + }); + const close = track(server); + return { port: await listen(server), log, close }; +} + +/** + * Refuse every socket connection that is not to the loopback address, in this + * process, whatever library opens it (http, https, axios, undici, ws). The + * refused `host:port`s are recorded, so a test can say what tried to leave. + * Returns undo. + */ +export function loopbackOnly() { + const refused: string[] = []; + const original = net.Socket.prototype.connect; + net.Socket.prototype.connect = function (this: net.Socket, ...args: any[]) { + const first = Array.isArray(args[0]) ? args[0][0] : args[0]; + const opts = typeof first === 'object' && first !== null ? first : { port: first, host: args[1] }; + const host = opts.path ? LOCAL : (opts.host ?? 'localhost'); + if (!['127.0.0.1', 'localhost', '::1'].includes(host)) { + refused.push(`${host}:${opts.port}`); + process.nextTick(() => this.destroy(new Error(`test: refused a connection to ${host}:${opts.port}`))); + return this; + } + return original.apply(this, args as any); + } as any; + return { refused, restore: () => void (net.Socket.prototype.connect = original) }; +} diff --git a/test/helpers/profiles.ts b/test/helpers/profiles.ts new file mode 100644 index 0000000000..9f6dd66a1f --- /dev/null +++ b/test/helpers/profiles.ts @@ -0,0 +1,48 @@ +// Configuration profiles. Evolution's behaviour branches on these flags, so a +// test that only ever runs with storage on says nothing about a deployment +// that has it off, and the reverse. +// +// `minimal` is a production configuration that keeps its own data outside +// Evolution: only the instance is stored, local cache, no Redis, no S3, WARN and above. +// `stored` turns on everything Evolution can store, as a default install does. +export const PROFILES = { + minimal: { + DATABASE_SAVE_DATA_INSTANCE: 'true', + DATABASE_SAVE_DATA_NEW_MESSAGE: 'false', + DATABASE_SAVE_MESSAGE_UPDATE: 'false', + DATABASE_SAVE_DATA_CONTACTS: 'false', + DATABASE_SAVE_DATA_CHATS: 'false', + DATABASE_SAVE_DATA_LABELS: 'false', + DATABASE_SAVE_DATA_HISTORIC: 'false', + DATABASE_SAVE_IS_ON_WHATSAPP: 'false', + DATABASE_DELETE_MESSAGE: 'true', + CACHE_REDIS_ENABLED: 'false', + CACHE_LOCAL_ENABLED: 'true', + S3_ENABLED: 'false', + LOG_LEVEL: 'ERROR,WARN', + LOG_BAILEYS: 'error', + }, + stored: { + DATABASE_SAVE_DATA_INSTANCE: 'true', + DATABASE_SAVE_DATA_NEW_MESSAGE: 'true', + DATABASE_SAVE_MESSAGE_UPDATE: 'true', + DATABASE_SAVE_DATA_CONTACTS: 'true', + DATABASE_SAVE_DATA_CHATS: 'true', + DATABASE_SAVE_DATA_LABELS: 'true', + DATABASE_SAVE_DATA_HISTORIC: 'true', + DATABASE_SAVE_IS_ON_WHATSAPP: 'true', + DATABASE_DELETE_MESSAGE: 'true', + CACHE_REDIS_ENABLED: 'false', + CACHE_LOCAL_ENABLED: 'true', + S3_ENABLED: 'false', + LOG_LEVEL: 'ERROR,WARN', + LOG_BAILEYS: 'error', + }, +} as const; + +export type Profile = keyof typeof PROFILES; + +/** Set every flag of the profile, so nothing leaks from the previous test. */ +export function applyProfile(profile: Profile) { + Object.assign(process.env, PROFILES[profile]); +} diff --git a/test/helpers/real-postgres.ts b/test/helpers/real-postgres.ts new file mode 100644 index 0000000000..2da949196f --- /dev/null +++ b/test/helpers/real-postgres.ts @@ -0,0 +1,58 @@ +// A throwaway Postgres database with Evolution's own migrations applied, for a +// test whose subject is what the database itself does (foreign keys, cascades, +// unique constraints), which the in-memory Prisma (fake-prisma.ts) cannot model. +// +// The server is FORK_TEST_PG_URL (default postgresql://127.0.0.1:5432/postgres) +// and must be on the loopback address. Each call creates one database, applies +// prisma/postgresql-migrations in order, and returns a real PrismaClient on it; +// drop() removes the database. +import { readdirSync, readFileSync } from 'node:fs'; +import { userInfo } from 'node:os'; +import { join } from 'node:path'; + +import { PrismaClient } from '@prisma/client'; +import pg from 'pg'; + +const MIGRATIONS = new URL('../../prisma/postgresql-migrations/', import.meta.url).pathname; + +export const pgServerUrl = () => process.env.FORK_TEST_PG_URL ?? 'postgresql://127.0.0.1:5432/postgres'; + +export async function throwawayDatabase(): Promise<{ url: string; prisma: PrismaClient; drop: () => Promise }> { + const server = new URL(pgServerUrl()); + if (!['127.0.0.1', 'localhost', '[::1]'].includes(server.hostname)) { + throw new Error(`FORK_TEST_PG_URL must be a loopback server, not ${server.hostname}`); + } + const name = `evo_fork_test_${process.pid}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + const admin = new pg.Client({ connectionString: server.toString() }); + await admin.connect(); + await admin.query(`CREATE DATABASE "${name}"`); + await admin.end(); + + const dbUrl = new URL(server.toString()); + dbUrl.pathname = `/${name}`; + // libpq defaults the user to the OS user; Prisma's engine does not. + if (!dbUrl.username) dbUrl.username = userInfo().username; + const url = dbUrl.toString(); + + const db = new pg.Client({ connectionString: url }); + await db.connect(); + for (const dir of readdirSync(MIGRATIONS) + .filter((d) => /^\d/.test(d)) + .sort()) { + await db.query(readFileSync(join(MIGRATIONS, dir, 'migration.sql'), 'utf8')); + } + await db.end(); + + const prisma = new PrismaClient({ datasourceUrl: url }); + return { + url, + prisma, + drop: async () => { + await prisma.$disconnect(); + const c = new pg.Client({ connectionString: server.toString() }); + await c.connect(); + await c.query(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); + await c.end(); + }, + }; +} diff --git a/test/helpers/socket-history.ts b/test/helpers/socket-history.ts new file mode 100644 index 0000000000..554c568a51 --- /dev/null +++ b/test/helpers/socket-history.ts @@ -0,0 +1,65 @@ +// History exactly as the socket delivers it at link time: Baileys' own +// processMessage handles a HISTORY_SYNC_NOTIFICATION (inline payload, no download) +// inside ev.createBufferedFunction, as Socket/chats.js upsertMessage does, with a REAL +// signal repository. So the event Evolution sees is whatever this Baileys version +// really emits (rc13+: mappings stored in the repository, then dropped by the buffer). +import { deflateSync } from 'node:zlib'; + +import * as B from 'baileys'; +import P from 'pino'; + +const logger: any = P({ level: 'silent' }); + +export async function realSignalRepository() { + const { makeLibSignalRepository } = await import('baileys/lib/Signal/libsignal.js' as any); + const mem: Record> = {}; + const store = { + get: async (type: string, ids: string[]) => Object.fromEntries(ids.map((id) => [id, mem[type]?.[id]])), + set: async (data: any) => { + for (const t in data) + for (const id in data[t]) { + mem[t] ??= {}; + data[t][id] == null ? delete mem[t][id] : (mem[t][id] = data[t][id]); + } + }, + }; + const keys = (B as any).addTransactionCapability((B as any).makeCacheableSignalKeyStore(store, logger), logger, { + maxCommitRetries: 1, + delayBetweenTriesMs: 1, + }); + const creds = (B as any).initAuthCreds(); + return { repo: makeLibSignalRepository({ creds, keys }, logger, async () => undefined), keys, creds }; +} + +export async function socketHistory(ev: any, client: any, sync: Record) { + const { default: processMessage } = await import('baileys/lib/Utils/process-message.js' as any); + const { proto } = B as any; + const hs = proto.HistorySync.fromObject(sync); + const inline = deflateSync(Buffer.from(proto.HistorySync.encode(hs).finish())); + const msg = { + key: { remoteJid: '972500000000@s.whatsapp.net', fromMe: true, id: 'HS1' }, + messageTimestamp: 1_700_000_001, + message: { + protocolMessage: { + type: proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION, + historySyncNotification: { syncType: hs.syncType, chunkOrder: 1, progress: 100, initialHistBootstrapInlinePayload: inline }, + }, + }, + }; + const ctx = { + shouldProcessHistoryMsg: true, + ev, + logger, + options: {}, + placeholderResendCache: undefined, + getMessage: async () => undefined, + creds: { ...client.__creds, me: { id: '972500000000:3@s.whatsapp.net', lid: '999999999999999:3@lid', name: 'Me' }, processedHistoryMessages: [] }, + keyStore: client.__keys, + signalRepository: client.signalRepository, + }; + await ev.createBufferedFunction(async () => { + await processMessage(msg, ctx); + })(); + // createBufferedFunction flushes on a 100ms timer when it is the only buffer. + await new Promise((r) => setTimeout(r, 250)); +} diff --git a/test/instance/logout-pending-postgres.test.ts b/test/instance/logout-pending-postgres.test.ts new file mode 100644 index 0000000000..877cebb0f9 --- /dev/null +++ b/test/instance/logout-pending-postgres.test.ts @@ -0,0 +1,352 @@ +// The pending logout of a deleted instance, against a real Postgres with +// Evolution's own migrations (test/helpers/real-postgres.ts). +// +// Every table that belongs to an instance references Instance ON DELETE +// CASCADE, Session and Proxy included. So deleting the Instance row deletes the +// credentials and the proxy a pending logout still needs to reach WhatsApp: +// after that, the reconnect cannot authenticate as the old device, and the +// device stays on the phone's Linked devices. The in-memory Prisma +// (fake-prisma.ts) has no foreign keys and cannot show this. +// +// A deleted instance whose logout is pending keeps its Instance row until the +// logout has reached WhatsApp, and stays out of the API meanwhile. +import { vi } from 'vitest'; + +const { socketSpy, h } = vi.hoisted(() => ({ + socketSpy: vi.fn(), + h: { + waMonitor: undefined as any, + instanceController: undefined as any, + channelController: undefined as any, + prisma: undefined as any, + }, +})); +const tmp = await vi.hoisted(async () => { + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + return mkdtempSync(join(tmpdir(), 'evo-logout-pg-')); +}); + +vi.mock('@api/server.module', async () => { + const fake = await import('../helpers/fake-server-module'); + return { + ...fake, + get prismaRepository() { + return h.prisma; + }, + get waMonitor() { + return h.waMonitor; + }, + get instanceController() { + return h.instanceController; + }, + get channelController() { + return h.channelController; + }, + }; +}); +vi.mock('@config/path.config', async (importOriginal) => ({ ...(await importOriginal()), INSTANCE_DIR: tmp })); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); + +import { existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +import { Boom } from '@hapi/boom'; +import EventEmitter2 from 'eventemitter2'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { settle, WUID } from '../helpers/baileys-service'; +import { fakeSocket } from '../helpers/connect'; +import { emitted } from '../helpers/fake-server-module'; +import { startInstanceApp } from '../helpers/http-app'; +import { loopbackOnly } from '../helpers/local-net'; +import { throwawayDatabase } from '../helpers/real-postgres'; + +socketSpy.mockImplementation(fakeSocket); + +const TOKEN = 'instance-token'; +const ID = 'inst-pg-1'; +const DIR = join(tmp, ID); + +type Sock = ReturnType; +const built = (): Sock[] => socketSpy.mock.results.map((r) => r.value); +const current = () => built()[built().length - 1]; + +let guard: ReturnType; +let app: Awaited>; +let db: Awaited>; +let globalKey: string; +const processes: any[] = []; + +beforeAll(async () => { + guard = loopbackOnly(); + db = await throwawayDatabase(); + h.prisma = db.prisma; + app = await startInstanceApp(); + const { configService } = await import('@config/env.config'); + globalKey = configService.get('AUTHENTICATION').API_KEY.KEY; +}, 60_000); +beforeEach(() => { + socketSpy.mockClear(); + emitted.length = 0; +}); +afterEach(async () => { + for (const monitor of processes) { + for (const s of [ + ...Object.values(monitor.waInstances), + ...Object.values(monitor.finishingLogouts ?? {}), + ] as any[]) { + s.stopReconnecting?.(); + s.connectToWhatsapp = async () => undefined; + s.connect = async () => undefined; + } + } + processes.length = 0; + await h.prisma.instance.deleteMany({}); + rmSync(DIR, { recursive: true, force: true }); +}); +afterAll(async () => { + await app?.close(); + await db?.drop(); + rmSync(tmp, { recursive: true, force: true }); + expect(guard.refused).toEqual([]); + guard.restore(); +}); + +/** What a process start builds (main.ts, server.module): the monitor, the controllers, then the boot load. */ +async function startProcess() { + const { ConfigService } = await import('@config/env.config'); + const { CacheService } = await import('@api/services/cache.service'); + const { LocalCache } = await import('@cache/localcache'); + const { WAMonitoringService } = await import('@api/services/monitor.service'); + const { InstanceController } = await import('@api/controllers/instance.controller'); + const { ChannelController } = await import('@api/integrations/channel/channel.controller'); + const configService = new ConfigService(); + const cache = new CacheService(new LocalCache(configService, 'instance')); + const emitter = new EventEmitter2(); + const prisma = h.prisma; + const waMonitor = new WAMonitoringService(emitter, configService, prisma, null as any, cache, cache, cache); + const n = null as any; + h.waMonitor = waMonitor; + h.channelController = new ChannelController(prisma, waMonitor); + h.instanceController = new InstanceController( + waMonitor, + configService, + prisma, + emitter, + n, + n, + n, + cache, + cache, + cache, + n, + ); + processes.push(waMonitor); + await waMonitor.loadInstance(); + return waMonitor; +} + +/** A linked instance, stored the way Evolution stores one: its row, its creds, a signal key file, a proxy. */ +async function linkedInstance() { + const { configService } = await import('@config/env.config'); + await h.prisma.instance.create({ + data: { + id: ID, + name: 'test', + connectionStatus: 'open', + token: TOKEN, + integration: 'WHATSAPP-BAILEYS', + clientName: configService.get('DATABASE').CONNECTION.CLIENT_NAME, + }, + }); + await h.prisma.proxy.create({ + data: { + instanceId: ID, + enabled: false, + host: '127.0.0.1', + port: '1', + protocol: 'http', + username: '', + password: '', + }, + }); + await h.prisma.setting.create({ + data: { + instanceId: ID, + rejectCall: false, + msgCall: '', + readMessages: false, + groupsIgnore: false, + alwaysOnline: false, + readStatus: false, + syncFullHistory: false, + }, + }); + const { default: useMultiFileAuthStatePrisma } = await import('@utils/use-multi-file-auth-state-prisma'); + const auth = await useMultiFileAuthStatePrisma(ID, null as any); + auth.state.creds.me = { id: WUID, name: 'Dana' }; + await auth.saveCreds(); + await auth.state.keys.set({ 'pre-key': { '1': { public: Buffer.alloc(32, 1), private: Buffer.alloc(32, 2) } } }); +} + +const storedMe = async () => + (await h.prisma.session.findMany({ where: { sessionId: ID } })).map((r: any) => { + let creds: any = r.creds; + while (typeof creds === 'string') creds = JSON.parse(creds); + return creds?.me?.id; + }); + +const rows = async () => ({ + instances: await h.prisma.instance.count(), + sessions: await h.prisma.session.count(), + proxies: await h.prisma.proxy.count(), + settings: await h.prisma.setting.count(), +}); + +/** Booted, connected, then dropped (a dead exit): the socket closed and the first reconnect (1s) is waiting. */ +async function waitingToReconnect() { + await linkedInstance(); + const monitor = await startProcess(); + await vi.waitFor(() => expect(built()).toHaveLength(1)); + const service = monitor.waInstances.test; + await opened(current()); + // The open's webhook goes out after its database writes, which a real database can make later + // than the close below: wait for it, so it is not read as sent while the logout is pending. + await vi.waitFor(() => + expect(emitted.some((e) => e.event === 'connection.update' && e.data?.state === 'open')).toBe(true), + ); + current().end(new Boom('Connection Terminated', { statusCode: 428 })); + await vi.waitFor(() => expect(service.connectionStatus.state).toBe('close'), { interval: 5 }); + emitted.length = 0; + return { monitor, service }; +} + +async function opened(sock: Sock) { + sock.ev.emit('connection.update', { connection: 'open' }); + await new Promise((r) => setTimeout(r, 20)); +} + +async function call(method: 'GET' | 'DELETE', route: string, key = TOKEN) { + const res = await fetch(`${app.base}/instance/${route}/test`, { method, headers: { apikey: key } }); + return { status: res.status, body: await res.json() }; +} + +async function fetchInstances(key = globalKey) { + const res = await fetch(`${app.base}/instance/fetchInstances`, { headers: { apikey: key } }); + const body = await res.json(); + return { status: res.status, names: Array.isArray(body) ? body.map((i: any) => i.name) : body }; +} + +async function createNamed(name: string) { + const res = await fetch(`${app.base}/instance/create`, { + method: 'POST', + headers: { apikey: globalKey, 'content-type': 'application/json' }, + body: JSON.stringify({ instanceName: name, integration: 'WHATSAPP-BAILEYS' }), + }); + return res.status; +} + +async function reconnected(count: number) { + await vi.waitFor(() => expect(built()).toHaveLength(count), { timeout: 3_000 }); + return current(); +} + +/** The API's view of a deleted instance whose logout is pending: only connectionState, and a taken name. */ +async function hiddenFromTheApi() { + expect({ + state: await call('GET', 'connectionState', globalKey), + other: (await call('GET', 'connect', globalKey)).status, + ownKey: (await call('GET', 'connectionState')).status, + listed: await fetchInstances(), + byOwnKey: (await fetchInstances(TOKEN)).status, + create: await createNamed('test'), + }).toEqual({ + state: { status: 200, body: { instance: { instanceName: 'test', state: 'close', logoutPending: true } } }, + other: 404, + ownKey: 401, + listed: { status: 200, names: [] }, + byOwnKey: 401, + create: 403, + }); +} + +describe('deleting an instance whose logout cannot reach WhatsApp (real Postgres)', () => { + it('keeps the credentials and the proxy the logout needs, hidden from the API, until it is delivered', async () => { + const { service } = await waitingToReconnect(); + + expect((await call('DELETE', 'delete')).status).toBe(202); + expect({ me: await storedMe(), proxies: (await rows()).proxies, settings: (await rows()).settings }).toEqual({ + me: [WUID], + proxies: 1, + settings: 0, + }); + await hiddenFromTheApi(); + + // The connection returns: the logout goes out as the linked device, then everything goes. + const sock = await reconnected(2); + await opened(sock); + await settle(service); + expect({ logouts: sock.logouts, rows: await rows(), dir: existsSync(DIR) }).toEqual({ + logouts: 1, + rows: { instances: 0, sessions: 0, proxies: 0, settings: 0 }, + dir: false, + }); + expect((await call('GET', 'connectionState', globalKey)).status).toBe(404); + expect(emitted.map((e) => e.event)).toEqual([]); + }); + + it('survives a restart mid-pending: the next process logs out the linked device, then removes it', async () => { + const { service } = await waitingToReconnect(); + expect((await call('DELETE', 'delete')).status).toBe(202); + // The process dies before the connection returns. + service.stopReconnecting(); + service.connect = async () => undefined; + socketSpy.mockClear(); + + const monitor = await startProcess(); + const sock = await reconnected(1); + expect(monitor.waInstances.test).toBeUndefined(); + await hiddenFromTheApi(); + + // The reconnect authenticates as the linked device, so the logout it sends is the real one. + expect(sock.logouts).toBe(0); + await opened(sock); + await settle(monitor.finishingLogouts.test ?? service); + expect({ logouts: sock.logouts, rows: await rows(), dir: existsSync(DIR) }).toEqual({ + logouts: 1, + rows: { instances: 0, sessions: 0, proxies: 0, settings: 0 }, + dir: false, + }); + expect(monitor.finishingLogouts.test).toBeUndefined(); + expect((await call('GET', 'connectionState', globalKey)).status).toBe(404); + }); + + it('survives a restart that lost the instances volume: the row alone keeps it pending and hidden', async () => { + const { service } = await waitingToReconnect(); + expect((await call('DELETE', 'delete')).status).toBe(202); + service.stopReconnecting(); + service.connect = async () => undefined; + socketSpy.mockClear(); + rmSync(DIR, { recursive: true, force: true }); + + const monitor = await startProcess(); + const sock = await reconnected(1); + expect(monitor.waInstances.test).toBeUndefined(); + await hiddenFromTheApi(); + const finishing = monitor.finishingLogouts.test; + await opened(sock); + await settle(finishing); + expect({ logouts: sock.logouts, rows: await rows() }).toEqual({ + logouts: 1, + rows: { instances: 0, sessions: 0, proxies: 0, settings: 0 }, + }); + }); +}); diff --git a/test/instance/logout-pending.test.ts b/test/instance/logout-pending.test.ts new file mode 100644 index 0000000000..95948e5278 --- /dev/null +++ b/test/instance/logout-pending.test.ts @@ -0,0 +1,647 @@ +// A logout must reach WhatsApp. Baileys' logout sends remove-companion-device, +// which is what takes the device off the person's Linked devices; it can only +// be sent on an open socket, with the session's credentials. When the socket is +// down or a reconnect is waiting, wiping the credentials locally (as the fork +// did) means the device can never be removed and stays on the phone. +// +// So a logout that cannot reach WhatsApp is kept pending: the credentials stay, +// a marker in the instance's directory says so (it survives a restart), the +// instance forwards and stores nothing, and it reconnects only to deliver the +// logout. When the connection opens it sends the logout at once; the session +// is wiped once the socket ends with loggedOut. If WhatsApp answers loggedOut +// on the reconnect (the device was already removed), that also finishes it. +// +// Everything runs through the real /instance router and guards, the real +// InstanceController, WAMonitoringService and ChannelController, and +// Evolution's own Prisma auth store (creds in the session table, keys in files +// under INSTANCE_DIR). The socket is the fake one (test/helpers/connect.ts), +// whose logout behaves as Baileys' does. +import { vi } from 'vitest'; + +const { socketSpy, h } = vi.hoisted(() => ({ + socketSpy: vi.fn(), + h: { waMonitor: undefined as any, instanceController: undefined as any, channelController: undefined as any }, +})); +const tmp = await vi.hoisted(async () => { + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + return mkdtempSync(join(tmpdir(), 'evo-logout-')); +}); + +vi.mock('@api/server.module', async () => { + const fake = await import('../helpers/fake-server-module'); + return { + ...fake, + get waMonitor() { + return h.waMonitor; + }, + get instanceController() { + return h.instanceController; + }, + get channelController() { + return h.channelController; + }, + }; +}); +vi.mock('@config/path.config', async (importOriginal) => ({ ...(await importOriginal()), INSTANCE_DIR: tmp })); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); + +import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +import { Boom } from '@hapi/boom'; +import { proto } from 'baileys'; +import EventEmitter2 from 'eventemitter2'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { settle, WUID } from '../helpers/baileys-service'; +import { fakeSocket } from '../helpers/connect'; +import { emitted, prismaRepository as prisma } from '../helpers/fake-server-module'; +import { startInstanceApp } from '../helpers/http-app'; +import { loopbackOnly } from '../helpers/local-net'; + +socketSpy.mockImplementation(fakeSocket); + +const TOKEN = 'instance-token'; +const DIR = join(tmp, 'inst-1'); +const MARKER = join(DIR, 'logout-pending.json'); +const PENDING = { + status: 202, + body: { + status: 'PENDING', + error: false, + response: { message: 'Logout pending: WhatsApp will be told when the connection returns' }, + }, +}; + +type Sock = ReturnType; +const built = (): Sock[] => socketSpy.mock.results.map((r) => r.value); +const current = () => built()[built().length - 1]; + +let guard: ReturnType; +let app: Awaited>; +let globalKey: string; +const processes: any[] = []; + +beforeAll(async () => { + guard = loopbackOnly(); + app = await startInstanceApp(); + const { configService } = await import('@config/env.config'); + globalKey = configService.get('AUTHENTICATION').API_KEY.KEY; +}); +beforeEach(() => { + socketSpy.mockClear(); + emitted.length = 0; +}); +afterEach(async () => { + // Stop every process's instances without them answering with a reconnect. + for (const monitor of processes) { + for (const s of [ + ...Object.values(monitor.waInstances), + ...Object.values(monitor.finishingLogouts ?? {}), + ] as any[]) { + s.stopReconnecting?.(); + s.connectToWhatsapp = async () => undefined; + s.connect = async () => undefined; + } + } + processes.length = 0; + for (const t of Object.values(prisma) as any[]) if (Array.isArray(t?.rows)) t.rows.length = 0; + rmSync(DIR, { recursive: true, force: true }); +}); +afterAll(async () => { + await app.close(); + rmSync(tmp, { recursive: true, force: true }); + expect(guard.refused).toEqual([]); + guard.restore(); +}); + +/** What a process start builds (main.ts, server.module): the monitor, the controllers, then the boot load. */ +async function startProcess() { + const { ConfigService } = await import('@config/env.config'); + const { CacheService } = await import('@api/services/cache.service'); + const { LocalCache } = await import('@cache/localcache'); + const { WAMonitoringService } = await import('@api/services/monitor.service'); + const { InstanceController } = await import('@api/controllers/instance.controller'); + const { ChannelController } = await import('@api/integrations/channel/channel.controller'); + const configService = new ConfigService(); + const cache = new CacheService(new LocalCache(configService, 'instance')); + const emitter = new EventEmitter2(); + const waMonitor = new WAMonitoringService(emitter, configService, prisma, null as any, cache, cache, cache); + const n = null as any; + h.waMonitor = waMonitor; + h.channelController = new ChannelController(prisma, waMonitor); + h.instanceController = new InstanceController( + waMonitor, + configService, + prisma, + emitter, + n, + n, + n, + cache, + cache, + cache, + n, + ); + processes.push(waMonitor); + await waMonitor.loadInstance(); + return waMonitor; +} + +/** A linked instance, stored the way Evolution stores one: its row, its creds, a signal key file, a proxy. */ +async function linkedInstance() { + const { configService } = await import('@config/env.config'); + await prisma.instance.create({ + data: { + id: 'inst-1', + name: 'test', + connectionStatus: 'open', + token: TOKEN, + integration: 'WHATSAPP-BAILEYS', + clientName: configService.get('DATABASE').CONNECTION.CLIENT_NAME, + }, + }); + await prisma.proxy.create({ + data: { + instanceId: 'inst-1', + enabled: false, + host: '127.0.0.1', + port: '1', + protocol: 'http', + username: '', + password: '', + }, + }); + await prisma.setting.create({ data: { instanceId: 'inst-1', rejectCall: false, msgCall: '', readMessages: false } }); + const { default: useMultiFileAuthStatePrisma } = await import('@utils/use-multi-file-auth-state-prisma'); + const auth = await useMultiFileAuthStatePrisma('inst-1', null as any); + auth.state.creds.me = { id: WUID, name: 'Dana' }; + await auth.saveCreds(); + await auth.state.keys.set({ 'pre-key': { '1': { public: Buffer.alloc(32, 1), private: Buffer.alloc(32, 2) } } }); +} + +const storedMe = () => prisma.session.rows.map((r: any) => JSON.parse(JSON.parse(r.creds)).me?.id); + +/** Booted, connected, then dropped (a dead exit): the socket closed and the first reconnect (1s) is waiting. */ +async function waitingToReconnect() { + await linkedInstance(); + const monitor = await startProcess(); + await vi.waitFor(() => expect(built()).toHaveLength(1)); + const service = monitor.waInstances.test; + await opened(current()); + current().end(new Boom('Connection Terminated', { statusCode: 428 })); + await vi.waitFor(() => expect(service.connectionStatus.state).toBe('close'), { interval: 5 }); + emitted.length = 0; + return { monitor, service }; +} + +async function opened(sock: Sock) { + sock.ev.emit('connection.update', { connection: 'open' }); + await new Promise((r) => setTimeout(r, 20)); +} + +async function call(method: 'GET' | 'DELETE', route: string, key = TOKEN) { + const res = await fetch(`${app.base}/instance/${route}/test`, { method, headers: { apikey: key } }); + return { status: res.status, body: await res.json() }; +} + +/** Wait for the reconnect the pending logout makes, and return its socket. */ +async function reconnected(count: number) { + await vi.waitFor(() => expect(built()).toHaveLength(count), { timeout: 3_000 }); + return current(); +} + +/** A live message and a history batch arriving on a pending instance, the way Baileys emits them (buffered). */ +async function deliverWhilePending(sock: Sock, service: any) { + const message = { + key: { remoteJid: '972500000001@s.whatsapp.net', fromMe: false, id: 'M1' }, + messageTimestamp: 1_790_000_000, + pushName: 'Noa', + message: { conversation: 'hello' }, + }; + sock.ev.buffer(); + sock.ev.emit('messages.upsert', { messages: [message], type: 'notify' }); + sock.ev.emit('messaging-history.set', { + chats: [{ id: '972500000002@s.whatsapp.net', name: 'Tal' }], + contacts: [{ id: '972500000002@s.whatsapp.net', name: 'Tal' }], + messages: [{ ...message, key: { ...message.key, id: 'H1' } }], + lidPnMappings: [{ lid: '111@lid', pn: '972500000002@s.whatsapp.net' }], + syncType: proto.HistorySync.HistorySyncType.RECENT, + isLatest: true, + }); + await sock.ev.flush(); + await settle(service); +} + +const stored = () => ({ + messages: prisma.message.rows.length, + chats: prisma.chat.rows.length, + contacts: prisma.contact.rows.length, +}); + +describe('a logout that cannot reach WhatsApp', () => { + it('is kept pending, forwards nothing, and is delivered once when the connection returns', async () => { + const { service } = await waitingToReconnect(); + + expect(await call('DELETE', 'logout')).toEqual(PENDING); + // The credentials the logout needs are kept, and the marker says it is pending. + expect(storedMe()).toEqual([WUID]); + expect(readdirSync(DIR).sort()).toEqual(['logout-pending.json', 'pre-key-1.json']); + expect(JSON.parse(readFileSync(MARKER, 'utf8'))).toMatchObject({ instanceName: 'test', deleted: false }); + expect(await call('GET', 'connectionState')).toEqual({ + status: 200, + body: { instance: { instanceName: 'test', state: 'close', logoutPending: true } }, + }); + + // /instance/connect (polled by a consumer) answers the same and starts nothing. + expect(await call('GET', 'connect')).toEqual({ + status: 200, + body: { instance: { instanceName: 'test', state: 'close', logoutPending: true } }, + }); + + // It reconnects only to deliver the logout, and forwards and stores nothing meanwhile. + const sock = await reconnected(2); + await deliverWhilePending(sock, service); + expect({ emitted: emitted.map((e) => e.event), stored: stored() }).toEqual({ + emitted: [], + stored: { messages: 0, chats: 0, contacts: 0 }, + }); + + // The connection returns: the logout goes out at once, exactly once, and then the session is wiped. + await opened(sock); + await settle(service); + expect(sock.logouts).toBe(1); + expect({ + me: storedMe(), + dir: existsSync(DIR), + row: prisma.instance.rows.map((r: any) => r.connectionStatus), + }).toEqual({ me: [], dir: false, row: ['close'] }); + expect(await call('GET', 'connectionState')).toEqual({ + status: 200, + body: { instance: { instanceName: 'test', state: 'close' } }, + }); + // Nothing reconnects afterwards. + await new Promise((r) => setTimeout(r, 1_500)); + expect(built()).toHaveLength(2); + }); + + it('is resumed after a restart', async () => { + const { service } = await waitingToReconnect(); + expect(await call('DELETE', 'logout')).toEqual(PENDING); + // The process dies before the connection returns. + service.stopReconnecting(); + service.connect = async () => undefined; + socketSpy.mockClear(); + + const monitor = await startProcess(); + const sock = await reconnected(1); + const resumed = monitor.waInstances.test; + await deliverWhilePending(sock, resumed); + expect({ emitted: emitted.map((e) => e.event), stored: stored() }).toEqual({ + emitted: [], + stored: { messages: 0, chats: 0, contacts: 0 }, + }); + + await opened(sock); + await settle(resumed); + expect({ logouts: sock.logouts, me: storedMe(), dir: existsSync(DIR) }).toEqual({ logouts: 1, me: [], dir: false }); + }); + + // The marker lives with the key files (INSTANCE_DIR); the row and the creds are in the database. + // A 202 promised the logout will be delivered: losing the instances volume must not undo that. The + // boot used to load the instance as a normal one (not connected), and /instance/connect then + // opened a normal session on the linked creds. + it('survives the loss of the instances volume: after a restart it is still pending, and delivered', async () => { + const { service } = await waitingToReconnect(); + expect(await call('DELETE', 'logout')).toEqual(PENDING); + service.stopReconnecting(); + service.connect = async () => undefined; + socketSpy.mockClear(); + rmSync(DIR, { recursive: true, force: true }); + + const monitor = await startProcess(); + await vi.waitFor(() => expect(monitor.waInstances.test).toBeDefined()); + const pending = { status: 200, body: { instance: { instanceName: 'test', state: 'close', logoutPending: true } } }; + expect({ state: await call('GET', 'connectionState'), connect: await call('GET', 'connect') }).toEqual({ + state: pending, + connect: pending, + }); + + const sock = await reconnected(1); + await deliverWhilePending(sock, monitor.waInstances.test); + expect(emitted.map((e) => e.event)).toEqual([]); + await opened(sock); + await settle(monitor.waInstances.test); + expect({ + logouts: sock.logouts, + me: storedMe(), + loggedOut: emitted.some((e) => e.event === 'logout.instance'), + }).toEqual({ + logouts: 1, + me: [], + loggedOut: true, + }); + }); + + // What the row records must go when the logout is delivered, or a later boot would log out a + // device linked again since. + it('once delivered, is not resumed by the next boot', async () => { + const { service } = await waitingToReconnect(); + expect(await call('DELETE', 'logout')).toEqual(PENDING); + const sock = await reconnected(2); + await opened(sock); + await settle(service); + expect(sock.logouts).toBe(1); + socketSpy.mockClear(); + + const monitor = await startProcess(); + await vi.waitFor(() => expect(monitor.waInstances.test).toBeDefined()); + await new Promise((r) => setTimeout(r, 1_200)); + expect({ sockets: built().length, pending: monitor.waInstances.test.logoutPending }).toEqual({ + sockets: 0, + pending: false, + }); + }); + + it('a deleted instance survives the loss of the instances volume: still out of the API, its name taken', async () => { + const { service } = await waitingToReconnect(); + expect((await call('DELETE', 'delete')).status).toBe(202); + service.stopReconnecting(); + service.connect = async () => undefined; + socketSpy.mockClear(); + rmSync(DIR, { recursive: true, force: true }); + + const monitor = await startProcess(); + const sock = await reconnected(1); + const create = await fetch(`${app.base}/instance/create`, { + method: 'POST', + headers: { apikey: globalKey, 'content-type': 'application/json' }, + body: JSON.stringify({ instanceName: 'test', integration: 'WHATSAPP-BAILEYS' }), + }); + expect({ listed: !!monitor.waInstances.test, create: create.status }).toEqual({ listed: false, create: 403 }); + const finishing = monitor.finishingLogouts.test; + await opened(sock); + await settle(finishing); + expect({ logouts: sock.logouts, me: storedMe(), instances: prisma.instance.rows.length }).toEqual({ + logouts: 1, + me: [], + instances: 0, + }); + }); + + // A 202 is a promise the logout survives a restart. When it cannot be recorded, the caller is told. + it('answers an error, not 202, when the pending logout cannot be recorded, and 202 once it can', async () => { + const { service } = await waitingToReconnect(); + const update = prisma.instance.update; + prisma.instance.update = async () => { + throw new Error("Can't reach database server"); + }; + const failed = await call('DELETE', 'logout'); + prisma.instance.update = update; + expect(failed.status).toBe(500); + // Still pending in this process meanwhile: the creds stay. + expect({ me: storedMe(), pending: service.logoutPending }).toEqual({ me: [WUID], pending: true }); + + expect(await call('DELETE', 'logout')).toEqual(PENDING); + }); + + it('a delete whose pending logout cannot be recorded fails, and leaves the instance in the API', async () => { + const { monitor } = await waitingToReconnect(); + const update = prisma.instance.update; + prisma.instance.update = async () => { + throw new Error("Can't reach database server"); + }; + const failed = await call('DELETE', 'delete'); + prisma.instance.update = update; + expect({ + status: failed.status, + listed: !!monitor.waInstances.test, + me: storedMe(), + instances: prisma.instance.rows.length, + }).toEqual({ status: 500, listed: true, me: [WUID], instances: 1 }); + }); + + it('finishes when WhatsApp answers loggedOut on the reconnect (the device was already removed)', async () => { + const { service } = await waitingToReconnect(); + expect(await call('DELETE', 'logout')).toEqual(PENDING); + + const sock = await reconnected(2); + sock.end(new Boom('Stream Errored (conflict)', { statusCode: 401 })); + await settle(service); + expect({ logouts: sock.logouts, me: storedMe(), dir: existsSync(DIR) }).toEqual({ logouts: 0, me: [], dir: false }); + await new Promise((r) => setTimeout(r, 1_500)); + expect(built()).toHaveLength(2); + }); + + it('on delete, leaves the API at once and keeps only what the logout needs until it is delivered', async () => { + const { monitor, service } = await waitingToReconnect(); + + expect(await call('DELETE', 'delete')).toEqual({ + status: 202, + body: { + status: 'PENDING', + error: false, + response: { message: 'Instance deleted; its logout will reach WhatsApp when the connection returns' }, + }, + }); + // Gone from the API: no instance, no settings. Kept: the row (deleting it would cascade to the + // session and the proxy), the creds, the key files and marker, the proxy. + expect(monitor.waInstances.test).toBeUndefined(); + expect({ + instances: prisma.instance.rows.length, + settings: prisma.setting.rows.length, + proxies: prisma.proxy.rows.length, + me: storedMe(), + marker: JSON.parse(readFileSync(MARKER, 'utf8')), + }).toMatchObject({ + instances: 1, + settings: 0, + proxies: 1, + me: [WUID], + marker: { instanceName: 'test', deleted: true }, + }); + // Until it finishes, connectionState (global key) says so; nothing else answers for the name. + expect(await call('GET', 'connectionState', globalKey)).toEqual({ + status: 200, + body: { instance: { instanceName: 'test', state: 'close', logoutPending: true } }, + }); + + // The name stays taken until then, so connectionState cannot mean a new instance. + const create = await fetch(`${app.base}/instance/create`, { + method: 'POST', + headers: { apikey: globalKey, 'content-type': 'application/json' }, + body: JSON.stringify({ instanceName: 'test', integration: 'WHATSAPP-BAILEYS' }), + }); + expect(create.status).toBe(403); + + const sock = await reconnected(2); + await deliverWhilePending(sock, service); + await opened(sock); + await settle(service); + expect({ + logouts: sock.logouts, + emitted: emitted.map((e) => e.event), + me: storedMe(), + dir: existsSync(DIR), + proxies: prisma.proxy.rows.length, + instances: prisma.instance.rows.length, + }).toEqual({ logouts: 1, emitted: [], me: [], dir: false, proxies: 0, instances: 0 }); + // Finished: the name is gone from the API. + expect((await call('GET', 'connectionState', globalKey)).status).toBe(404); + }); + + it('on logout then delete (a purge), resumes after a restart', async () => { + const { service } = await waitingToReconnect(); + expect(await call('DELETE', 'logout')).toEqual(PENDING); + expect((await call('DELETE', 'delete')).status).toBe(202); + service.stopReconnecting(); + service.connect = async () => undefined; + socketSpy.mockClear(); + + const monitor = await startProcess(); + const sock = await reconnected(1); + expect(monitor.waInstances.test).toBeUndefined(); + const finishing = monitor.finishingLogouts.test; + await opened(sock); + await settle(finishing); + expect({ logouts: sock.logouts, me: storedMe(), dir: existsSync(DIR), proxies: prisma.proxy.rows.length }).toEqual({ + logouts: 1, + me: [], + dir: false, + proxies: 0, + }); + }); + + // A delete made before the row was kept for the logout left a marker and no row. The boot still + // finishes it (resumeDeletedLogouts): out of the API, then the marker and key files go. + it('a deleted marker with no row (a delete from before the row was kept) is still finished on boot', async () => { + const { service } = await waitingToReconnect(); + expect((await call('DELETE', 'delete')).status).toBe(202); + service.stopReconnecting(); + service.connect = async () => undefined; + socketSpy.mockClear(); + prisma.instance.rows.length = 0; + + const monitor = await startProcess(); + const sock = await reconnected(1); + expect(monitor.waInstances.test).toBeUndefined(); + const finishing = monitor.finishingLogouts.test; + expect(finishing).toBeDefined(); + await opened(sock); + await settle(finishing); + expect({ dir: existsSync(DIR), finishing: monitor.finishingLogouts.test }).toEqual({ + dir: false, + finishing: undefined, + }); + }); + + it('a logout on an open connection still completes at once', async () => { + await linkedInstance(); + const monitor = await startProcess(); + await vi.waitFor(() => expect(built()).toHaveLength(1)); + const service = monitor.waInstances.test; + await opened(current()); + + expect(await call('DELETE', 'logout')).toEqual({ + status: 200, + body: { status: 'SUCCESS', error: false, response: { message: 'Instance logged out' } }, + }); + await settle(service); + expect({ logouts: current().logouts, me: storedMe(), marker: existsSync(MARKER) }).toEqual({ + logouts: 1, + me: [], + marker: false, + }); + }); + + // Two logouts at once (two clients, or a logout and a delete): the second answered 'done' as + // soon as it saw the first under way, while the first's remove-companion-device was still in + // flight and could still fail and leave the logout pending. + it('a second logout while the first is under way answers with the first, not before it', async () => { + await linkedInstance(); + const monitor = await startProcess(); + await vi.waitFor(() => expect(built()).toHaveLength(1)); + const service = monitor.waInstances.test; + const sock = current(); + await opened(sock); + let release: () => void; + const gate = new Promise((r) => (release = r)); + // Hold the request to remove the device, however it is sent. + const { logout, query } = sock; + sock.logout = async (msg?: string) => { + await gate; + return logout(msg); + }; + sock.query = async (node: any) => { + await gate; + return query(node); + }; + + const answered: string[] = []; + const first = call('DELETE', 'logout').then((r) => (answered.push('first'), r)); + await new Promise((r) => setTimeout(r, 50)); + const second = call('DELETE', 'logout').then((r) => (answered.push('second'), r)); + await new Promise((r) => setTimeout(r, 200)); + expect(answered).toEqual([]); + + release(); + const ok = { status: 200, body: { status: 'SUCCESS', error: false, response: { message: 'Instance logged out' } } }; + expect({ first: await first, second: await second }).toEqual({ first: ok, second: ok }); + await settle(service); + expect({ logouts: sock.logouts, me: storedMe() }).toEqual({ logouts: 1, me: [] }); + }); + + // Baileys' logout() writes remove-companion-device and then ends the socket itself with loggedOut; + // it never waits for WhatsApp. So that close is not WhatsApp's word: if the connection fails before + // WhatsApp processed the request, the device stays linked, and wiping the creds leaves it on the + // phone for good. + it('is not finished by the socket closing itself: without WhatsApp confirming, it stays pending', async () => { + await linkedInstance(); + const monitor = await startProcess(); + await vi.waitFor(() => expect(built()).toHaveLength(1)); + const service = monitor.waInstances.test; + const sock = current(); + await opened(sock); + // The request is written, then the connection fails before WhatsApp answers. + sock.confirmRemove = false; + sock.afterRemove = () => setTimeout(() => sock.end(new Boom('Connection Terminated', { statusCode: 428 })), 20); + + expect(await call('DELETE', 'logout')).toEqual(PENDING); + expect({ me: storedMe(), pending: service.logoutPending }).toEqual({ me: [WUID], pending: true }); + + // The reconnect: WhatsApp refuses the device, which is its word that it is gone. + const next = await reconnected(2); + next.end(new Boom('Connection Failure', { statusCode: 401 })); + await settle(service); + expect({ me: storedMe(), pending: service.logoutPending }).toEqual({ me: [], pending: false }); + }); +}); + +// On boot the monitor lists the instances and connects each one that was open, then registers it. +// A connect that failed there (the database answering the listing but failing the next read) +// left the instance unregistered, with no socket and no reconnect: out of the API until the +// process restarted, although the database came back a second later. +describe('a boot whose first connect fails on a database read', () => { + it('keeps the instance in the API and connects it once the database answers', async () => { + await linkedInstance(); + const read = prisma.setting.findUnique; + let failures = 1; + prisma.setting.findUnique = async (args: any) => { + if (failures-- > 0) throw new Error("Can't reach database server"); + return read(args); + }; + + const monitor = await startProcess(); + await vi.waitFor(() => expect(monitor.waInstances.test).toBeDefined(), { timeout: 1_000 }); + await vi.waitFor(() => expect(built()).toHaveLength(1), { timeout: 3_000 }); + prisma.setting.findUnique = read; + }); +}); diff --git a/test/instance/unlink-socket-down.test.ts b/test/instance/unlink-socket-down.test.ts new file mode 100644 index 0000000000..f2343801b2 --- /dev/null +++ b/test/instance/unlink-socket-down.test.ts @@ -0,0 +1,233 @@ +// Unlinking an instance (DELETE /instance/logout, DELETE /instance/delete) whose +// socket is down. Baileys' logout first sends +// remove-companion-device, and sendRawMessage throws Boom('Connection Closed', +// 428) when the ws is not open (rc14 lib/Socket/socket.js, sendRawMessage), +// before logout ends the socket. 2.3.7's logoutInstance awaits it unguarded, so +// nothing after it runs: the credentials stay stored, the row still says open, +// the socket stays up, and delete answers 400 with the instance still in memory. +// Evolution then connects with the kept credentials (the socket in flight, or the +// boot auto-connect of a row that says open) and keeps receiving the person's +// messages after they unlinked. +// +// Wiping the credentials locally instead (what this file first required) is no +// better: without them the device can never tell WhatsApp to remove it, so it +// stays on the person's Linked devices. So the logout is kept pending until the +// connection returns (test/instance/logout-pending.test.ts has the delivery): +// the credentials stay, a marker says it is pending, and the answer says so. +// +// The instance here is mid-reconnect, the way it is after a dropped connection: +// Evolution's real connect builds a real Baileys socket, pointed at a local +// "WhatsApp" that accepts the TCP connection and never answers the WebSocket +// upgrade, so the socket stays CONNECTING. The session is a linked one (creds +// with `me` in the session table, signal keys in files under INSTANCE_DIR), +// written through Evolution's own Prisma auth store. +import { vi } from 'vitest'; + +const h = vi.hoisted(() => ({ wsUrl: '', sockets: [] as any[], services: [] as any[], waMonitor: undefined as any, instanceController: undefined as any })); +const tmp = await vi.hoisted(async () => { + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + return mkdtempSync(join(tmpdir(), 'evo-unlink-')); +}); + +vi.mock('@api/server.module', async () => { + const fake = await import('../helpers/fake-server-module'); + // The router, the guards and the controller reach the monitor and the controller through the server module. + return { + ...fake, + get waMonitor() { + return h.waMonitor; + }, + get instanceController() { + return h.instanceController; + }, + }; +}); +vi.mock('@config/path.config', async (importOriginal) => ({ ...(await importOriginal()), INSTANCE_DIR: tmp })); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); +// The real socket, sent to the local "WhatsApp" instead of web.whatsapp.com. Every socket built is kept, so a reconnect shows. +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + const make = (config: any) => { + const socket = orig.makeWASocket({ ...config, waWebSocketUrl: h.wsUrl }); + h.sockets.push(socket); + return socket; + }; + return { ...orig, default: make, makeWASocket: make }; +}); + +import { existsSync, readdirSync, rmSync } from 'node:fs'; +import net from 'node:net'; +import { join } from 'node:path'; + +import EventEmitter2 from 'eventemitter2'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { makeService, settle, WUID } from '../helpers/baileys-service'; +import { prismaRepository as prisma } from '../helpers/fake-server-module'; +import { startInstanceApp } from '../helpers/http-app'; +import { loopbackOnly } from '../helpers/local-net'; + +const TOKEN = 'instance-token'; + +/** Accepts connections and never answers, so a WebSocket to it stays CONNECTING. */ +function silentWhatsapp() { + const open = new Set(); + const server = net.createServer((s) => { + open.add(s); + s.on('close', () => open.delete(s)); + s.on('error', () => undefined); + }); + return { + drop: () => open.forEach((s) => s.destroy()), + listen: () => new Promise((r) => server.listen(0, '127.0.0.1', () => r((server.address() as net.AddressInfo).port))), + close: () => new Promise((r) => (open.forEach((s) => s.destroy()), server.close(() => r()))), + }; +} + +let guard: ReturnType; +let whatsapp: ReturnType; +let app: Awaited>; + +beforeAll(async () => { + guard = loopbackOnly(); + whatsapp = silentWhatsapp(); + h.wsUrl = `ws://127.0.0.1:${await whatsapp.listen()}/ws/chat`; + app = await startInstanceApp(); +}); +afterEach(async () => { + // Tear down without the service answering the close with a reconnect. + for (const service of h.services) { + service.connectToWhatsapp = async () => undefined; + service.connect = async () => undefined; + } + for (const s of h.sockets) await s.end(undefined).catch(() => undefined); + for (const service of h.services) await settle(service); + for (const service of h.services) service.stopReconnecting(); + whatsapp.drop(); + h.sockets.length = 0; + h.services.length = 0; + for (const t of Object.values(prisma) as any[]) if (Array.isArray(t?.rows)) t.rows.length = 0; + rmSync(join(tmp, 'inst-1'), { recursive: true, force: true }); +}); +afterAll(async () => { + await app.close(); + await whatsapp.close(); + rmSync(tmp, { recursive: true, force: true }); + expect(guard.refused).toEqual([]); + guard.restore(); +}); + +/** A linked instance whose connection dropped: its row says open, and it is reconnecting on a socket that has not opened. */ +async function reconnectingInstance() { + await prisma.instance.create({ + data: { id: 'inst-1', name: 'test', connectionStatus: 'open', token: TOKEN, integration: 'WHATSAPP-BAILEYS' }, + }); + const { default: useMultiFileAuthStatePrisma } = await import('@utils/use-multi-file-auth-state-prisma'); + const auth = await useMultiFileAuthStatePrisma('inst-1', null as any); + auth.state.creds.me = { id: WUID, name: 'Dana' }; + await auth.saveCreds(); + await auth.state.keys.set({ 'pre-key': { '1': { public: Buffer.alloc(32, 1), private: Buffer.alloc(32, 2) } } }); + + const { ConfigService } = await import('@config/env.config'); + const { CacheService } = await import('@api/services/cache.service'); + const { LocalCache } = await import('@cache/localcache'); + const { WAMonitoringService } = await import('@api/services/monitor.service'); + const { InstanceController } = await import('@api/controllers/instance.controller'); + const configService = new ConfigService(); + const cache = new CacheService(new LocalCache(configService, 'instance')); + const emitter = new EventEmitter2(); + const waMonitor = new WAMonitoringService(emitter, configService, prisma, null as any, cache, cache, cache); + const n = null as any; + h.waMonitor = waMonitor; + h.instanceController = new InstanceController(waMonitor, configService, prisma, emitter, n, n, n, cache, cache, cache, n); + + const { service } = await makeService({ prisma, eventEmitter: emitter }); + h.services.push(service); + waMonitor.waInstances.test = service; + await service.connectToWhatsapp(); + await vi.waitFor(() => expect(service.connectionStatus.state).toBe('connecting')); + // The premise: a linked session is stored, and its one socket is dialling WhatsApp, not open. + expect(JSON.parse(JSON.parse(prisma.session.rows[0].creds)).me.id).toBe(WUID); + expect(readdirSync(join(tmp, 'inst-1'))).toEqual(['pre-key-1.json']); + expect(h.sockets.map((s) => s.ws.isConnecting)).toEqual([true]); + return { service, waMonitor }; +} + +async function call(route: 'logout' | 'delete') { + const res = await fetch(`${app.base}/instance/${route}/test`, { method: 'DELETE', headers: { apikey: TOKEN } }); + return { status: res.status, body: await res.json() }; +} + +/** Nothing of the session is left, and nothing is connected or connecting to WhatsApp with it: its one socket is closed, and no other was built. */ +async function expectUnlinked(service: any) { + await settle(service); + expect(prisma.session.rows).toEqual([]); + expect(existsSync(join(tmp, 'inst-1'))).toBe(false); + expect(h.sockets.map((s) => s.ws.isClosed)).toEqual([true]); +} + +/** The logout is pending: the session is kept, marked pending, and its socket is still dialling to deliver it. */ +async function expectPending() { + expect(JSON.parse(JSON.parse(prisma.session.rows[0].creds)).me.id).toBe(WUID); + expect(readdirSync(join(tmp, 'inst-1')).sort()).toEqual(['logout-pending.json', 'pre-key-1.json']); + expect(h.sockets.map((s) => s.ws.isConnecting)).toEqual([true]); +} + +describe('unlinking an instance whose connection is down', () => { + it('logout keeps the session and answers that the logout is pending', async () => { + const { service } = await reconnectingInstance(); + + expect(await call('logout')).toEqual({ + status: 202, + body: { + status: 'PENDING', + error: false, + response: { message: 'Logout pending: WhatsApp will be told when the connection returns' }, + }, + }); + + await settle(service); + await expectPending(); + }); + + it('delete removes the instance from the API and keeps the session for the logout', async () => { + const { service, waMonitor } = await reconnectingInstance(); + + expect(await call('delete')).toEqual({ + status: 202, + body: { + status: 'PENDING', + error: false, + response: { message: 'Instance deleted; its logout will reach WhatsApp when the connection returns' }, + }, + }); + + await settle(service); + await expectPending(); + expect(waMonitor.waInstances.test).toBeUndefined(); + // Its row stays until the logout is delivered: deleting it would cascade to the session. + expect(prisma.instance.rows.map((r: any) => r.name)).toEqual(['test']); + }); + + it('delete removes the instance even when its logout fails for another reason', async () => { + const { service, waMonitor } = await reconnectingInstance(); + const findFirst = prisma.session.findFirst; + prisma.session.findFirst = async () => { + prisma.session.findFirst = findFirst; + throw new Error("Can't reach database server"); + }; + + expect(await call('delete')).toEqual({ + status: 200, + body: { status: 'SUCCESS', error: false, response: { message: 'Instance deleted' } }, + }); + + await expectUnlinked(service); + expect(waMonitor.waInstances.test).toBeUndefined(); + expect(prisma.instance.rows).toEqual([]); + }); +}); diff --git a/test/live/app-state-rename.test.ts b/test/live/app-state-rename.test.ts new file mode 100644 index 0000000000..105476bb42 --- /dev/null +++ b/test/live/app-state-rename.test.ts @@ -0,0 +1,39 @@ +// Live check app-state-after-restart, recorded 2026-09-27 (docs/LIVE-CHECKS.md): +// after a restart, the owner renamed a saved contact on the phone. WhatsApp sent +// the rename as an app-state contact action with the @lid to phone mapping, and +// Evolution sent the mapping item, the contact marked saved, and the update echo. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { describe, expect, it } from 'vitest'; + +import { encode } from '@utils/live-record/codec'; + +import { compareGolden, loadFixture, replayFixture } from '../helpers/live-replay'; + +const FIXTURE = 'test/fixtures/live/2026-09-27-rig-session'; +const CONTACT = { pn: '972500000001@s.whatsapp.net', lid: '100000000000001@lid' }; + +/** Webhooks about the renamed contact only (the DM later in the session depends on the live database). */ +const aboutContact = (w: Record) => JSON.stringify(encode(w.data)).includes(CONTACT.pn.split('@')[0]); + +describe('live: a contact renamed on the phone after a restart', () => { + it('reaches contacts.upsert as a mapping item and as a saved name, then contacts.update', async () => { + const { webhooks } = await replayFixture(FIXTURE); + const contacts = webhooks.filter((w) => w.event.startsWith('contacts.') && aboutContact(w)); + const upserts = contacts.filter((w) => w.event === 'contacts.upsert').flatMap((w) => w.data); + + // The @lid to phone mapping, forwarded as its own item. + expect(upserts).toContainEqual( + expect.objectContaining({ remoteJid: CONTACT.pn, lid: CONTACT.lid, phoneNumber: CONTACT.pn }), + ); + // The name the owner saved, marked saved. + const saved = upserts.find((c) => c.saved !== undefined); + expect(saved).toMatchObject({ remoteJid: CONTACT.pn, pushName: 'Name 2', saved: true }); + + expect(contacts.map((w) => w.event)).toEqual(['contacts.upsert', 'contacts.upsert', 'contacts.update']); + const golden = loadFixture(FIXTURE).webhooks.filter((w) => w.event.startsWith('contacts.') && aboutContact(w)); + expect(compareGolden(contacts, golden)).toEqual([]); + }); +}); diff --git a/test/live/archive-toggle.test.ts b/test/live/archive-toggle.test.ts new file mode 100644 index 0000000000..6ed1376aee --- /dev/null +++ b/test/live/archive-toggle.test.ts @@ -0,0 +1,30 @@ +// Live check archive-toggle, recorded 2026-09-27 (docs/LIVE-CHECKS.md): the owner +// archived a chat on the phone, then unarchived it. WhatsApp sent app-state chat +// actions (the archive together with an unpin), and Evolution sent chats.update +// with archived: true, then archived: false. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { describe, expect, it } from 'vitest'; + +import { compareGolden, loadFixture, replayFixture } from '../helpers/live-replay'; + +const FIXTURE = 'test/fixtures/live/2026-09-27-rig-session'; +const CHAT = '100000000000002@lid'; + +describe('live: archive and unarchive on the phone', () => { + it('reaches chats.update with archived: true, then archived: false', async () => { + const { webhooks } = await replayFixture(FIXTURE); + const archive = webhooks + .filter((w) => w.event === 'chats.update') + .flatMap((w) => w.data) + .filter((c) => c.archived !== undefined); + + expect(archive).toEqual([ + expect.objectContaining({ remoteJid: CHAT, archived: true, pinned: null }), + expect.objectContaining({ remoteJid: CHAT, archived: false }), + ]); + expect(compareGolden(webhooks, loadFixture(FIXTURE).webhooks, { events: ['chats.update'] })).toEqual([]); + }); +}); diff --git a/test/live/codec.test.ts b/test/live/codec.test.ts new file mode 100644 index 0000000000..9678494fb3 --- /dev/null +++ b/test/live/codec.test.ts @@ -0,0 +1,80 @@ +// A live-check tape stores Baileys events as JSON lines, and a replay must hand +// Evolution the same values the socket did. JSON alone loses exactly the +// shapes Evolution branches on: a Buffer and a Uint8Array both become base64 +// (or an index object), a Long becomes {low, high, unsigned}, an undefined +// field disappears (Object.assign in the event buffer overwrites with it), and +// a protobuf instance becomes a plain object (the webhook serializer calls +// toJSON on instances only). +import { Boom } from '@hapi/boom'; +import { proto } from 'baileys'; +import Long from 'long'; +import { describe, expect, it } from 'vitest'; + +import { decode, encode } from '@utils/live-record/codec'; + +/** Through a file and back: encode, JSON text, parse, decode. */ +const roundTrip = (value: any) => decode(JSON.parse(JSON.stringify(encode(value)))); + +describe('live-record codec', () => { + it('keeps a Buffer a Buffer and a Uint8Array a Uint8Array, byte for byte', () => { + const back = roundTrip({ buf: Buffer.from([1, 2, 3]), u8: new Uint8Array([250, 0, 7]) }); + expect(Buffer.isBuffer(back.buf)).toBe(true); + expect([...back.buf]).toEqual([1, 2, 3]); + expect(back.u8).toBeInstanceOf(Uint8Array); + expect(Buffer.isBuffer(back.u8)).toBe(false); + expect([...back.u8]).toEqual([250, 0, 7]); + }); + + it('keeps a Long a Long, with its value and signedness', () => { + const back = roundTrip({ ts: Long.fromString('1758873600', true), big: Long.fromString('-9007199254740993') }); + expect(Long.isLong(back.ts)).toBe(true); + expect(back.ts.toString()).toBe('1758873600'); + expect(back.ts.unsigned).toBe(true); + expect(Long.isLong(back.big)).toBe(true); + expect(back.big.toString()).toBe('-9007199254740993'); + expect(back.big.unsigned).toBe(false); + }); + + it('keeps an explicit undefined field, in objects and arrays', () => { + const back = roundTrip({ a: 1, gone: undefined, list: [undefined, 2] }); + expect(Object.keys(back)).toEqual(['a', 'gone', 'list']); + expect(back.gone).toBeUndefined(); + expect(back.list).toEqual([undefined, 2]); + expect(back.list.length).toBe(2); + }); + + it('rebuilds protobuf classes, nested ones included, with their fields as they were', () => { + const message = proto.Message.fromObject({ + imageMessage: { mimetype: 'image/jpeg', mediaKey: new Uint8Array([9, 8, 7]), fileLength: Long.fromNumber(48213, true) }, + }); + const info = proto.WebMessageInfo.fromObject({ + key: { remoteJid: '972500000001@s.whatsapp.net', fromMe: false, id: '3EB0000000000000000001' }, + messageTimestamp: Long.fromNumber(1758873600, true), + status: proto.WebMessageInfo.Status.SERVER_ACK, + }); + info.message = message; + + const back = roundTrip({ info }); + expect(back.info).toBeInstanceOf(proto.WebMessageInfo); + expect(back.info.key).toBeInstanceOf(proto.MessageKey); + expect(back.info.message).toBeInstanceOf(proto.Message); + expect(back.info.message.imageMessage).toBeInstanceOf(proto.Message.ImageMessage); + expect(back.info.status).toBe(proto.WebMessageInfo.Status.SERVER_ACK); + expect(Long.isLong(back.info.messageTimestamp)).toBe(true); + expect(back.info.messageTimestamp.toString()).toBe('1758873600'); + expect(back.info.message.imageMessage.mediaKey).toBeInstanceOf(Uint8Array); + expect([...back.info.message.imageMessage.mediaKey]).toEqual([9, 8, 7]); + expect(back.info.message.imageMessage.fileLength.toString()).toBe('48213'); + // What the webhook serializer sees is the same JSON as the original's. + expect(JSON.stringify(back.info)).toBe(JSON.stringify(info)); + }); + + it('keeps a disconnect error a Boom with its status code', () => { + const back = roundTrip({ lastDisconnect: { error: new Boom('Connection Failure', { statusCode: 401 }), date: new Date(0) } }); + expect(back.lastDisconnect.error).toBeInstanceOf(Boom); + expect(back.lastDisconnect.error.output.statusCode).toBe(401); + expect(back.lastDisconnect.error.message).toBe('Connection Failure'); + expect(back.lastDisconnect.date).toBeInstanceOf(Date); + expect(back.lastDisconnect.date.getTime()).toBe(0); + }); +}); diff --git a/test/live/fixture-guard.test.ts b/test/live/fixture-guard.test.ts new file mode 100644 index 0000000000..69e85be25f --- /dev/null +++ b/test/live/fixture-guard.test.ts @@ -0,0 +1,198 @@ +// Nothing personal may reach the repository through a live-check fixture. The +// scrubber's leak gate checks its own output against the originals it saw; this +// guard is the second, independent check, run on every commit: it scans every +// file under test/fixtures/live/ for what personal data looks like, knowing only +// the scrubber's fake ranges. A finding names the file, the line, the path and +// the kind of value, never the value. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { fakeSocket } from '../helpers/connect'; +import { recordSession } from '../helpers/live-session'; +import { formatFindings, scanFixtures } from '../tools/fixture-guard'; +import { scrubSession } from '../tools/live-scrub'; + +socketSpy.mockImplementation(fakeSocket); + +let root: string; +beforeEach(() => void (root = mkdtempSync(join(tmpdir(), 'live-guard-')))); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +const PLANTED = { + phone: '972541112233', + pnJid: '972541112233:3@s.whatsapp.net', + lidJid: '123456789012345@lid', + url: 'https://mmg.whatsapp.net/v/t62.7118-24/19_A.enc?ccb=11-4&oh=01_Q5AaIBq&oe=68D1A2B3', + email: 'dana.levi@gmail.com', + bytes: 'q83vEjRWeJq8Dd7wESIzRFVmd4iZqrvM3e7/ABEiM0Q=', +}; + +describe('live fixture guard', () => { + it('finds every kind of leak in a planted fixture, and never prints the value', () => { + const dir = join(root, 'live', '2026-09-27-planted'); + mkdirSync(dir, { recursive: true }); + const clean = { seq: 1, event: 'contacts.upsert', data: [{ id: '972500000001@s.whatsapp.net', name: 'Name 2' }] }; + const lines = [ + clean, + { seq: 2, event: 'messages.upsert', data: { text: `call me on ${PLANTED.phone}` } }, + { seq: 3, event: 'contacts.upsert', data: [{ id: PLANTED.pnJid, lid: PLANTED.lidJid }] }, + { seq: 4, event: 'messages.upsert', data: { imageMessage: { url: PLANTED.url } } }, + { seq: 5, event: 'contacts.update', data: [{ about: `write to ${PLANTED.email}` }] }, + { seq: 6, event: 'messages.upsert', data: { mediaKey: { $bytes: PLANTED.bytes, as: 'Uint8Array' } } }, + { seq: 7, event: 'messages.upsert', data: { jpegThumbnail: PLANTED.bytes } }, + { + seq: 8, + event: 'presence.update', + data: { presences: { [PLANTED.pnJid]: { lastKnownPresence: 'available' } } }, + }, + ]; + writeFileSync(join(dir, 'events.ndjson'), lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); + writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ note: `owner ${PLANTED.phone}` })); + + const findings = scanFixtures(join(root, 'live')); + const kinds = findings.map((f) => `${f.file}:${f.line} ${f.kind}`); + expect(kinds).toEqual( + expect.arrayContaining([ + expect.stringMatching(/events\.ndjson:2 phone-like digit run/), + expect.stringMatching(/events\.ndjson:3 address not in the fake ranges/), + expect.stringMatching(/events\.ndjson:4 signed media URL/), + expect.stringMatching(/events\.ndjson:5 email address/), + expect.stringMatching(/events\.ndjson:6 bytes not tagged fake/), + expect.stringMatching(/events\.ndjson:7 base64 blob/), + expect.stringMatching(/events\.ndjson:8 address not in the fake ranges/), + expect.stringMatching(/manifest\.json:1 phone-like digit run/), + ]), + ); + // The clean line is clean. + expect(findings.some((f) => f.file.endsWith('events.ndjson') && f.line === 1)).toBe(false); + + const report = formatFindings(findings); + for (const value of [...Object.values(PLANTED), '972541112233', '123456789012345', 'dana.levi']) { + expect(report.includes(value), `the report printed a planted value of ${value.length} chars`).toBe(false); + } + }); + + it('reads a long camelCase identifier as a name, not a base64 blob', () => { + const dir = join(root, 'live', '2026-09-27-identifiers'); + mkdirSync(dir, { recursive: true }); + const lines = [ + // Baileys' own key names: creds keys, proto fields. + { seq: 1, event: 'connection.update', data: { receivedPendingNotifications: true } }, + { + seq: 2, + event: 'creds.update', + data: { $redacted: 'creds', keys: ['processedHistoryMessages', 'lastAccountSyncTimestamp'] }, + }, + { seq: 3, event: 'messages.upsert', data: { message: { axolotlSenderKeyDistributionMessage: {} } } }, + // A blob of letters only, not identifier-shaped, is still a blob. + { + seq: 4, + event: 'messages.upsert', + data: { thumb: 'QmFzZVNpeHRyRmxvYkxldHRlcnNPbmxWWFpBQkNE' }, + }, + ]; + writeFileSync(join(dir, 'events.ndjson'), lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); + const findings = scanFixtures(join(root, 'live')); + expect(findings.filter((f) => f.line <= 3)).toEqual([]); + expect(findings).toContainEqual(expect.objectContaining({ line: 4, kind: 'base64 blob' })); + }); + + it("accepts the scrubber's fake numeric message id, and nothing else numeric under an id", () => { + const dir = join(root, 'live', '2026-09-27-numeric-ids'); + mkdirSync(dir, { recursive: true }); + const stub = (seq: number, id: string) => ({ + seq, + event: 'messages.upsert', + data: { messages: [{ key: { remoteJid: '120363000000000001@g.us', id }, messageStubType: 20 }] }, + }); + const lines = [stub(1, '740000002'), stub(2, '4100000013'), stub(3, PLANTED.phone), stub(4, '834726190')]; + writeFileSync(join(dir, 'events.ndjson'), lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); + const flagged = scanFixtures(join(root, 'live')).map((f) => `${f.line} ${f.kind}`); + expect(flagged).toEqual(['3 phone-like digit run', '4 phone-like digit run']); + }); + + it('passes the scrubber output of a recorded session', async () => { + const raw = await recordSession(join(root, 'raw')); + // Group notifications carry numeric message ids; the creds keys are long identifiers. + const extra = [ + { + seq: 998, + t: 1, + socket: 1, + event: 'creds.update', + buffered: false, + data: { $redacted: 'creds', keys: ['processedHistoryMessages'] }, + }, + { + seq: 999, + t: 1, + socket: 1, + event: 'messages.upsert', + buffered: false, + data: { + type: 'append', + messages: [ + { key: { remoteJid: '120363401234567890@g.us', fromMe: false, id: '834726190' }, messageStubType: 20 }, + ], + }, + }, + ]; + appendFileSync(join(raw, 'events.ndjson'), extra.map((l) => JSON.stringify(l)).join('\n') + '\n'); + scrubSession(raw, { checkId: 'synthetic-session', date: '2026-09-27', outRoot: join(root, 'live') }); + expect(formatFindings(scanFixtures(join(root, 'live')))).toBe(''); + }); + + it('passes every fixture committed under test/fixtures/live/', () => { + const findings = scanFixtures(join(process.cwd(), 'test', 'fixtures', 'live')); + expect(formatFindings(findings)).toBe(''); + }); + + // The guard knew the fake ranges of addresses and numbers only: a username, a group name in a stub + // parameter, a value under a field it did not know, or a location passed as clean. + it('flags a value that is neither a scrubber fake nor a known structural value, and never prints it', () => { + const dir = join(root, 'live', '2026-09-27-unknown'); + mkdirSync(dir, { recursive: true }); + const values = { username: 'dana.levi88', group: 'dana_and_friends', unknown: 'dana.levi' }; + const key = { remoteJid: '100000000000001@lid', fromMe: false, id: '3AFFFFFFFFFFFFFFFFF1' }; + const lines = [ + { + seq: 1, + event: 'messages.upsert', + data: { messages: [{ key: { ...key, remoteJidUsername: values.username } }] }, + }, + { seq: 2, event: 'messages.upsert', data: { messages: [{ key, messageStubParameters: [values.group] }] } }, + { seq: 3, event: 'messages.upsert', data: { type: 'notify', someFutureField: values.unknown } }, + { + seq: 4, + event: 'messages.upsert', + data: { + messages: [{ key, message: { locationMessage: { degreesLatitude: 32.0853, degreesLongitude: 34.7818 } } }], + }, + }, + ]; + writeFileSync(join(dir, 'events.ndjson'), lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); + + const findings = scanFixtures(join(root, 'live')); + expect([...new Set(findings.map((f) => f.line))].sort()).toEqual([1, 2, 3, 4]); + const report = formatFindings(findings); + for (const value of [...Object.values(values), '32.0853', '34.7818']) { + expect(report.includes(value), `the report printed a planted value of ${value.length} chars`).toBe(false); + } + }); +}); diff --git a/test/live/group-rename-participants.test.ts b/test/live/group-rename-participants.test.ts new file mode 100644 index 0000000000..9d43854a8f --- /dev/null +++ b/test/live/group-rename-participants.test.ts @@ -0,0 +1,55 @@ +// Live check group-rename-participants, recorded 2026-09-27 (docs/LIVE-CHECKS.md): +// the owner created a group, renamed it, removed a member and added them back. +// Baileys 7 emits each participant as an object ({ id: @lid, phoneNumber }), and +// Evolution's participantsData must read it: a jid string and a phone JID, never +// "[object Object]". +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { describe, expect, it } from 'vitest'; + +import { compareGolden, loadFixture, replayFixture } from '../helpers/live-replay'; + +const FIXTURE = 'test/fixtures/live/2026-09-27-rig-session'; +const GROUP = '120363000000000001@g.us'; +const OWNER = { id: '100000000000000@lid', phoneNumber: '972500000000@s.whatsapp.net', admin: 'superadmin' }; +const MEMBER = { id: '100000000000002@lid', phoneNumber: '972500000002@s.whatsapp.net', admin: null }; + +// Group metadata was a socket query, not recorded: answer with the group as groups.upsert described it. +const client = { groupMetadata: async (id: string) => ({ id, subject: 'Name 4', participants: [OWNER, MEMBER] }) }; +// participantsData's name and picture come from the live database and picture queries. +const FROM_QUERIES = ['name', 'imgUrl']; + +describe('live: a group renamed, a member removed and added back', () => { + it('reaches groups.upsert, then groups.update with the new subject', async () => { + const { webhooks } = await replayFixture(FIXTURE, { client }); + const upserts = webhooks.filter((w) => w.event === 'groups.upsert').flatMap((w) => w.data); + const updates = webhooks.filter((w) => w.event === 'groups.update').flatMap((w) => w.data); + + expect(upserts).toEqual([expect.objectContaining({ id: GROUP, subject: 'Name 3', size: 2 })]); + expect(updates).toEqual([expect.objectContaining({ id: GROUP, subject: 'Name 4' })]); + const events = ['groups.upsert', 'groups.update']; + expect(compareGolden(webhooks, loadFixture(FIXTURE).webhooks, { events })).toEqual([]); + }); + + it('reaches group-participants.update with participantsData holding the @lid and the phone JID', async () => { + const { webhooks } = await replayFixture(FIXTURE, { client }); + const updates = webhooks.filter((w) => w.event === 'group-participants.update').map((w) => w.data); + + expect(updates.map((u) => u.action)).toEqual(['remove', 'add']); + for (const update of updates) { + expect(update.id).toBe(GROUP); + expect(update.participants).toEqual([{ id: MEMBER.id, phoneNumber: MEMBER.phoneNumber, admin: null }]); + expect(update.participantsData).toHaveLength(1); + const [data] = update.participantsData; + expect(typeof data.jid).toBe('string'); + expect(data.jid).toBe(MEMBER.id); + expect(data.phoneNumber).toBe(MEMBER.phoneNumber); + expect(JSON.stringify(update)).not.toContain('[object Object]'); + } + const events = ['group-participants.update']; + const golden = loadFixture(FIXTURE).webhooks; + expect(compareGolden(webhooks, golden, { events, volatile: FROM_QUERIES })).toEqual([]); + }); +}); diff --git a/test/live/live-lid-message-key.test.ts b/test/live/live-lid-message-key.test.ts new file mode 100644 index 0000000000..46a2f472a9 --- /dev/null +++ b/test/live/live-lid-message-key.test.ts @@ -0,0 +1,33 @@ +// Live check live-lid-message-key, recorded 2026-09-27 (docs/LIVE-CHECKS.md): a +// text and an image arrived in a DM that WhatsApp addressed by @lid (remoteJid +// @lid, remoteJidAlt the phone, addressingMode 'lid'). Evolution shows the phone +// as remoteJid and keeps the original @lid in remoteJidAlt, with addressingMode 'pn'. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { describe, expect, it } from 'vitest'; + +import { compareGolden, loadFixture, replayFixture } from '../helpers/live-replay'; + +const FIXTURE = 'test/fixtures/live/2026-09-27-rig-session'; +const SENDER = { pn: '972500000002@s.whatsapp.net', lid: '100000000000002@lid' }; + +describe('live: a DM addressed by @lid', () => { + it('reaches messages.upsert with the phone as remoteJid and the @lid kept in remoteJidAlt', async () => { + const { webhooks } = await replayFixture(FIXTURE); + const upserts = webhooks.filter((w) => w.event === 'messages.upsert').map((w) => w.data); + + expect(upserts.map((m) => m.messageType)).toEqual(['conversation', 'imageMessage']); + for (const message of upserts) { + expect(message.key).toMatchObject({ + remoteJid: SENDER.pn, + remoteJidAlt: SENDER.lid, + addressingMode: 'pn', + fromMe: false, + }); + } + const golden = loadFixture(FIXTURE).webhooks; + expect(compareGolden(webhooks, golden, { events: ['messages.upsert'] })).toEqual([]); + }); +}); diff --git a/test/live/recorder.test.ts b/test/live/recorder.test.ts new file mode 100644 index 0000000000..4d2a984cf8 --- /dev/null +++ b/test/live/recorder.test.ts @@ -0,0 +1,300 @@ +// A live check records the session it runs (LIVE_RECORD_DIR), so what the phone +// did can be replayed in a test later. The recorder sits on the real socket's +// events and on sendDataWebhook, and must be invisible otherwise: without the +// variable it writes nothing, and with it Evolution sends exactly the same. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import { chmodSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { proto } from 'baileys'; +import Long from 'long'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { decode, encode } from '@utils/live-record/codec'; + +import { makeService } from '../helpers/baileys-service'; +import { connectBehind, fakeSocket, stubAuthState } from '../helpers/connect'; +import { emitted } from '../helpers/fake-server-module'; +import { CALL_ID, MESSAGE_ID, OWNER, PERSON, playSession, TEXT } from '../helpers/live-session'; + +socketSpy.mockImplementation(fakeSocket); + +let root: string; +beforeEach(() => { + emitted.splice(0); + root = mkdtempSync(join(tmpdir(), 'live-record-')); +}); +afterEach(() => { + delete process.env.LIVE_RECORD_DIR; + rmSync(root, { recursive: true, force: true }); +}); + +const lines = (file: string) => + readFileSync(file, 'utf8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + +/** The one session directory the recorder made: ///. */ +function sessionDir() { + const instances = readdirSync(root); + expect(instances).toEqual(['test']); + const sessions = readdirSync(join(root, 'test')); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z$/); + return join(root, 'test', sessions[0]); +} + +/** The webhooks sent so far, each as its encoded JSON, in a stable order (background lookups interleave). */ +const sent = () => + emitted + .splice(0) + .map((e) => JSON.stringify({ event: e.event, data: encode(e.data) })) + .sort(); + +async function connected(opts: { msgCall?: string } = {}) { + const { service, prisma } = await makeService(); + if (opts.msgCall) prisma.setting.rows.push({ instanceId: 'inst-1', msgCall: opts.msgCall }); + stubAuthState(service); + await service.connectToWhatsapp(); + return service; +} + +describe('live-check recorder', () => { + it('is inert without LIVE_RECORD_DIR: nothing written, the same webhooks sent', async () => { + delete process.env.LIVE_RECORD_DIR; + await playSession(await connected()); + const without = sent(); + expect(readdirSync(root)).toEqual([]); + + process.env.LIVE_RECORD_DIR = root; + await playSession(await connected()); + const withRecorder = sent(); + + for (const event of ['connection.update', 'contacts.upsert', 'messages.upsert', 'call']) { + expect(without.map((w) => JSON.parse(w).event)).toContain(event); + } + expect(withRecorder).toEqual(without); + }); + + it('writes the events, each with its sequence and whether the buffer held it, in the codec', async () => { + process.env.LIVE_RECORD_DIR = root; + await playSession(await connected()); + const dir = sessionDir(); + expect(readdirSync(dir).sort()).toEqual(['events.ndjson', 'manifest.json', 'owner.json', 'webhooks.ndjson']); + + const events = lines(join(dir, 'events.ndjson')); + const seqs = events.map((e) => e.seq); + expect(seqs).toEqual([...seqs].sort((a, b) => a - b)); + expect(new Set(seqs).size).toBe(seqs.length); + + const emits = events.filter((e) => e.event); + expect(emits.map((e) => [e.event, e.buffered])).toEqual([ + ['connection.update', false], + ['creds.update', false], + ['contacts.upsert', true], + ['messages.upsert', true], + ['call', false], + ]); + expect(emits.every((e) => e.socket === 1 && e.origin === undefined)).toBe(true); + // The batches the buffer handed Evolution: the two buffered events arrived as one, in the buffer's key order. + expect(events.filter((e) => e.batch).map((e) => e.batch)).toEqual([ + ['connection.update'], + ['creds.update'], + ['messages.upsert', 'contacts.upsert'], + ['call'], + ]); + + // The payload decodes to the values the socket emitted: classes, Longs and bytes included. + const upsert = decode(emits[3].data); + const message = upsert.messages[0]; + expect(message).toBeInstanceOf(proto.WebMessageInfo); + expect(message.key.id).toBe(MESSAGE_ID); + expect(Long.isLong(message.messageTimestamp)).toBe(true); + expect(message.message.conversation).toBe(TEXT); + expect(message.message.messageContextInfo.messageSecret).toBeInstanceOf(Uint8Array); + expect(decode(emits[2].data)).toEqual([ + { id: PERSON.pn, lid: PERSON.lid, name: PERSON.saved, notify: PERSON.push }, + ]); + + // The auth creds never reach the tape. + expect(emits[1].data).toEqual({ $redacted: 'creds', keys: ['me'] }); + }); + + it('writes every webhook Evolution sent, as it sent it (the golden output)', async () => { + process.env.LIVE_RECORD_DIR = root; + await playSession(await connected()); + const webhooks = lines(join(sessionDir(), 'webhooks.ndjson')); + expect(webhooks.map((w) => ({ event: w.event, data: w.data }))).toEqual( + emitted.map((e) => ({ event: e.event, data: encode(e.data) })), + ); + const call = webhooks.find((w) => w.event === 'call'); + expect(call.data.id).toBe(CALL_ID); + }); + + it('marks an event Evolution emits itself (the call message), so a replay does not emit it twice', async () => { + process.env.LIVE_RECORD_DIR = root; + await playSession(await connected({ msgCall: 'In a meeting' })); + const emits = lines(join(sessionDir(), 'events.ndjson')).filter((e) => e.event); + const fromApp = emits.filter((e) => e.origin === 'app'); + expect(fromApp.map((e) => e.event)).toEqual(['messages.upsert']); + expect(decode(fromApp[0].data).messages[0].message.conversation).toBe('In a meeting'); + }); + + it('writes a manifest of versions and conditions, with no number, JID or name in it', async () => { + process.env.LIVE_RECORD_DIR = root; + const service = await connected(); + const dir = sessionDir(); + const atStart = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')); + expect(atStart.openedAt).toBeNull(); + expect(atStart.phonePlatform).toBeNull(); + + await playSession(service); + const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')); + const baileys = JSON.parse(readFileSync(join(process.env.BAILEYS_RESOLVED_DIR, 'package.json'), 'utf8')).version; + expect(manifest).toEqual({ + format: 'live-record/1', + forkCommit: expect.stringMatching(/^[0-9a-f]{8}(-dirty)?$/), + baileysVersion: baileys, + nodeVersion: process.version, + waWebVersion: '2.3000.1', + phonePlatform: 'smba', + accountType: 'business', + linkMethod: 'qr', + proxy: { used: false, protocol: null }, + sockets: 1, + startedAt: atStart.startedAt, + openedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + endedAt: null, + }); + const text = JSON.stringify(manifest); + for (const secret of [ + '972529998877', + '987654321098765', + '972541112233', + '123456789012345', + OWNER.name, + PERSON.saved, + '@', + ]) { + expect(text).not.toContain(secret); + } + // The account is kept apart, for the scrubber only. + expect(JSON.parse(readFileSync(join(dir, 'owner.json'), 'utf8'))).toEqual(OWNER); + }); + + it('records the link method a pairing code asked for, without the number', async () => { + process.env.LIVE_RECORD_DIR = root; + const { service } = await makeService(); + stubAuthState(service); + await service.connectToWhatsapp('972541112233'); + const manifest = readFileSync(join(sessionDir(), 'manifest.json'), 'utf8'); + expect(JSON.parse(manifest).linkMethod).toBe('code'); + expect(manifest).not.toContain('972541112233'); + }); + + it('records that a proxy was used and its protocol, never its address', async () => { + process.env.LIVE_RECORD_DIR = root; + await connectBehind(socketSpy, { protocol: 'socks5', port: 18461 }); + const manifest = readFileSync(join(sessionDir(), 'manifest.json'), 'utf8'); + expect(JSON.parse(manifest).proxy).toEqual({ used: true, protocol: 'socks5' }); + expect(manifest).not.toContain('127.0.0.1'); + expect(manifest).not.toContain('18461'); + }); + + // The QR payload and the pairing code link a device to the account: whoever has one can pair it. + // The events tape redacted the QR; the webhook tape wrote qrcode.updated as Evolution sent it, + // code, image and pairing code included. + describe('a link in progress', () => { + const QR = '2@Q1R2S3T4U5V6W7X8Y9Z0qrsecret,keypart,otherpart,lastpart'; + const PAIRING = 'WXYZ4321'; + + async function showQr(number?: string) { + process.env.LIVE_RECORD_DIR = root; + socketSpy.mockImplementationOnce((config: any) => ({ + ...fakeSocket(config), + requestPairingCode: async () => PAIRING, + })); + const { service } = await makeService(); + stubAuthState(service); + await service.connectToWhatsapp(number); + service.client.ev.emit('connection.update', { qr: QR }); + await vi.waitFor(() => expect(emitted.some((e) => e.event === 'qrcode.updated')).toBe(true), { timeout: 5_000 }); + const sentQr = emitted.find((e) => e.event === 'qrcode.updated').data.qrcode; + const dir = sessionDir(); + const tapes = ['events.ndjson', 'webhooks.ndjson'].map((f) => readFileSync(join(dir, f), 'utf8')).join('\n'); + return { sentQr, tapes }; + } + + it('never writes the QR payload or its image, in either tape', async () => { + const { sentQr, tapes } = await showQr(); + // Evolution still sends them: only the recording leaves them out. + expect(sentQr.code).toBe(QR); + expect({ code: tapes.includes('qrsecret'), image: tapes.includes(sentQr.base64.slice(22, 80)) }).toEqual({ + code: false, + image: false, + }); + }); + + it('never writes the pairing code, in either tape', async () => { + const { sentQr, tapes } = await showQr('972541112233'); + expect(sentQr.pairingCode).toBe(PAIRING); + expect({ pairingCode: tapes.includes(PAIRING), code: tapes.includes('qrsecret') }).toEqual({ + pairingCode: false, + code: false, + }); + }); + }); + + // The recorder is prepared right after the socket is built and before Evolution listens to it. + // Creating its directory and first manifest was outside its guard, so a LIVE_RECORD_DIR that + // cannot be written (no permission, a full volume, a file in the way) failed the connect with a + // socket already built and nobody listening to it. + it('a recording directory that cannot be created costs the recording only, never the connection', async () => { + const blocked = join(root, 'not-a-directory'); + writeFileSync(blocked, 'a file where the directory should go'); + process.env.LIVE_RECORD_DIR = join(blocked, 'records'); + socketSpy.mockClear(); + const { service } = await makeService(); + stubAuthState(service); + const outcome = await service.connectToWhatsapp().then( + () => 'connected', + (e: any) => `failed: ${e?.message}`, + ); + const sock = socketSpy.mock.results[0]?.value; + expect({ outcome, listened: sock?.handlers?.() > 0 }).toEqual({ outcome: 'connected', listened: true }); + }); + + it('a manifest that can no longer be written when a socket is attached stops the recording, not the socket', async () => { + process.env.LIVE_RECORD_DIR = root; + const { LiveRecorder } = await import('@utils/live-record/recorder'); + const recorder = LiveRecorder.start('test'); + const manifest = join(recorder.dir, 'manifest.json'); + chmodSync(manifest, 0o400); + try { + expect(() => + recorder.attach(fakeSocket(), { + waWebVersion: '2.3000.1', + linkMethod: 'qr', + proxyProtocol: null, + creds: () => ({}), + }), + ).not.toThrow(); + } finally { + chmodSync(manifest, 0o600); + } + }); +}); diff --git a/test/live/replay.test.ts b/test/live/replay.test.ts new file mode 100644 index 0000000000..ffcc8be6a4 --- /dev/null +++ b/test/live/replay.test.ts @@ -0,0 +1,100 @@ +// A scrubbed live-check fixture replays through the real event buffer into the +// real BaileysStartupService, and what Evolution sends must match the golden +// webhooks the live session recorded (scrubbed with the same identity table). +// This is the end-to-end proof of the chain: record, scrub, replay. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { fakeSocket } from '../helpers/connect'; +import { emitted } from '../helpers/fake-server-module'; +import { compareGolden, loadFixture, replayFixture } from '../helpers/live-replay'; +import { recordSession } from '../helpers/live-session'; +import { scrubSession } from '../tools/live-scrub'; + +socketSpy.mockImplementation(fakeSocket); + +let root: string; +let fixture: string; +beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), 'live-replay-')); + const raw = await recordSession(join(root, 'raw')); + fixture = scrubSession(raw, { + checkId: 'synthetic-session', + date: '2026-09-27', + outRoot: join(root, 'fixtures'), + }).dir; + emitted.splice(0); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('live-check replay', () => { + it('replays a scrubbed session through the real buffer and service, and reproduces its webhooks', async () => { + const { webhooks } = await replayFixture(fixture); + expect(webhooks.map((w) => w.event)).toContain('messages.upsert'); + expect(compareGolden(webhooks, loadFixture(fixture).webhooks)).toEqual([]); + }); + + it('delivers the batches the live buffer delivered', async () => { + const { batches } = await replayFixture(fixture); + const recorded = loadFixture(fixture) + .events.filter((e) => e.batch) + .map((e) => e.batch); + expect(batches).toEqual(recorded); + }); + + it('fails the golden comparison when what Evolution sends differs', async () => { + const file = join(fixture, 'webhooks.ndjson'); + const golden = readFileSync(file, 'utf8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + const upsert = golden.find((w) => w.event === 'messages.upsert'); + upsert.data.key.remoteJidAlt = upsert.data.key.remoteJid; // what 2.3.7 sent: the phone twice + writeFileSync(file, golden.map((w) => JSON.stringify(w)).join('\n') + '\n'); + + const { webhooks } = await replayFixture(fixture); + const diff = compareGolden(webhooks, loadFixture(fixture).webhooks); + expect(diff).toHaveLength(2); + expect(diff.join('\n')).toMatch(/missing messages\.upsert/); + expect(diff.join('\n')).toMatch(/unexpected messages\.upsert/); + }); + + // A batch line on the tape is what the buffer delivered live. A non-bufferable event (a + // connection.update) emitted while the buffer holds others is delivered at once, as its own + // batch, and the buffer keeps holding the rest. The replay flushed the buffer on every batch line, + // so it split what the live buffer delivered as one batch. + it('delivers the batches the tape recorded, a non-bufferable event inside a buffer included', async () => { + const dir = join(root, 'interleaved'); + mkdirSync(dir); + const contact = (i: number) => [{ id: `97250000000${i}@s.whatsapp.net`, notify: `Name ${i}` }]; + const lines = [ + { seq: 1, t: 1, socket: 1, event: 'contacts.upsert', buffered: true, data: contact(1) }, + { seq: 2, t: 2, socket: 1, event: 'connection.update', buffered: true, data: { isOnline: true } }, + { seq: 3, t: 3, socket: 1, batch: ['connection.update'] }, + { seq: 4, t: 4, socket: 1, event: 'contacts.update', buffered: true, data: contact(2) }, + { seq: 5, t: 5, socket: 1, batch: ['contacts.upsert', 'contacts.update'] }, + ]; + writeFileSync(join(dir, 'events.ndjson'), lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); + writeFileSync(join(dir, 'webhooks.ndjson'), ''); + writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ format: 'live-record/1' })); + + const { batches } = await replayFixture(dir); + expect(batches).toEqual(lines.filter((l) => l.batch).map((l) => l.batch)); + }); +}); diff --git a/test/live/scrub.test.ts b/test/live/scrub.test.ts new file mode 100644 index 0000000000..d724b7a1b7 --- /dev/null +++ b/test/live/scrub.test.ts @@ -0,0 +1,347 @@ +// A raw recording holds real numbers, names, texts and keys. The scrubber turns +// it into a fixture that can be committed: every identity replaced by a stable +// fake (one person keeps one index across phone JID, @lid and device suffix), +// the shapes Evolution reads kept (lengths, id prefixes, byte types), and then a +// leak gate that searches the output for every original and writes nothing when +// one survives. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { decode } from '@utils/live-record/codec'; + +import { fakeSocket } from '../helpers/connect'; +import { emitted } from '../helpers/fake-server-module'; +import { MESSAGE_ID, MESSAGE_SECRET, ORIGINALS, PERSON, recordSession, TEXT } from '../helpers/live-session'; +import { leakGate, scrubSession, UnknownFieldError } from '../tools/live-scrub'; + +socketSpy.mockImplementation(fakeSocket); + +let root: string; +let raw: string; +let out: string; +beforeEach(async () => { + emitted.splice(0); + root = mkdtempSync(join(tmpdir(), 'live-scrub-')); + raw = await recordSession(join(root, 'raw')); + out = join(root, 'fixtures'); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +const lines = (file: string) => + readFileSync(file, 'utf8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + +const scrub = (extra: Partial[1]> = {}) => + scrubSession(raw, { checkId: 'synthetic-session', date: '2026-09-27', outRoot: out, ...extra }); + +describe('live-check scrubber', () => { + it('writes the fixture files under -, and never the raw owner file', () => { + const { dir } = scrub(); + expect(dir).toBe(join(out, '2026-09-27-synthetic-session')); + expect(readdirSync(dir).sort()).toEqual(['events.ndjson', 'manifest.json', 'scrub-report.json', 'webhooks.ndjson']); + }); + + it('leaves no original anywhere in the fixture', () => { + const { dir } = scrub(); + const text = readdirSync(dir) + .map((f) => readFileSync(join(dir, f), 'utf8')) + .join('\n'); + for (const original of ORIGINALS) + expect(text.includes(original), `an original of ${original.length} chars`).toBe(false); + }); + + it('gives one person one fake across phone JID, @lid and device suffix, in both tapes', () => { + const { dir } = scrub(); + const emits = lines(join(dir, 'events.ndjson')).filter((e) => e.event); + const [contact] = decode(emits.find((e) => e.event === 'contacts.upsert').data); + expect(contact.id).toMatch(/^972500\d{6}@s\.whatsapp\.net$/); + const index = contact.id.slice(6, 12); + expect(contact.lid).toBe(`100000000${index}@lid`); + expect(index).not.toBe('000000'); // index 0 is the owner + + const upsert = decode(emits.find((e) => e.event === 'messages.upsert').data).messages[0]; + expect(upsert.key.remoteJid).toBe(contact.lid); + expect(upsert.key.remoteJidAlt).toBe(contact.id); + + // The owner is index 0, device suffix kept. + const webhooks = lines(join(dir, 'webhooks.ndjson')); + const open = webhooks.find((w) => w.event === 'connection.update' && w.data.state === 'open'); + expect(open.data.wuid).toBe('972500000000@s.whatsapp.net'); + const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')); + expect(manifest.replay.owner.id).toBe('972500000000:14@s.whatsapp.net'); + expect(manifest.replay.owner.lid).toBe('100000000000000:14@lid'); + + // Evolution showed the phone JID as remoteJid and kept the @lid: the same fakes. + const sent = decode(webhooks.find((w) => w.event === 'messages.upsert').data); + expect(sent.key.remoteJid).toBe(contact.id); + expect(sent.key.remoteJidAlt).toBe(contact.lid); + }); + + it('keeps name equality, id shape, byte length and type, and text length', () => { + const { dir } = scrub(); + const emits = lines(join(dir, 'events.ndjson')).filter((e) => e.event); + const [contact] = decode(emits.find((e) => e.event === 'contacts.upsert').data); + const message = decode(emits.find((e) => e.event === 'messages.upsert').data).messages[0]; + + // Saved name and profile name stay different; the same profile name stays the same. + expect(contact.name).not.toBe(contact.notify); + expect(contact.name).not.toBe(PERSON.saved); + expect(message.pushName).toBe(contact.notify); + + expect(message.key.id).toHaveLength(MESSAGE_ID.length); + expect(message.key.id.slice(0, 2)).toBe(MESSAGE_ID.slice(0, 2)); + expect(message.key.id).not.toBe(MESSAGE_ID); + + const secret = message.message.messageContextInfo.messageSecret; + expect(secret).toBeInstanceOf(Uint8Array); + expect(Buffer.isBuffer(secret)).toBe(false); + expect(secret.length).toBe(MESSAGE_SECRET.length); + expect(Buffer.from(secret).equals(MESSAGE_SECRET)).toBe(false); + + expect(message.message.conversation).toHaveLength(TEXT.length); + expect(message.message.conversation).not.toBe(TEXT); + + // The webhook carries the same fakes as the event it came from. + const sent = decode(lines(join(dir, 'webhooks.ndjson')).find((w) => w.event === 'messages.upsert').data); + expect(sent.key.id).toBe(message.key.id); + expect(sent.pushName).toBe(message.pushName); + expect(sent.message.conversation).toBe(message.message.conversation); + expect(Buffer.from(sent.message.messageContextInfo.messageSecret).equals(Buffer.from(secret))).toBe(true); + }); + + it('adds what the operator records, and a report of counts only', () => { + const { dir } = scrub({ + operator: { + phoneModel: 'Pixel 8', + osVersion: 'Android 15', + whatsappAppVersion: '2.25.27.78', + countryCode: '972', + }, + }); + const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')); + expect(manifest).toMatchObject({ + checkId: 'synthetic-session', + date: '2026-09-27', + phoneModel: 'Pixel 8', + osVersion: 'Android 15', + whatsappAppVersion: '2.25.27.78', + countryCode: '972', + phonePlatform: 'smba', + waWebVersion: '2.3000.1', + }); + const report = JSON.parse(readFileSync(join(dir, 'scrub-report.json'), 'utf8')); + expect(report.leakGate).toBe('pass'); + const numbers = Object.entries(report).filter(([k]) => k !== 'leakGate'); + expect(numbers.length).toBeGreaterThan(0); + for (const [, value] of numbers) expect(typeof value).toBe('number'); + expect(report.people).toBe(2); + }); + + it('keeps a numeric message id a message id, never a person', () => { + // WhatsApp gives group notifications (a create, a rename, an add) numeric ids. + const id = '8347261905'; + const stub = { + seq: 999, + t: 1, + socket: 1, + event: 'messages.upsert', + buffered: false, + data: { + type: 'append', + messages: [{ key: { remoteJid: '120363401234567890@g.us', fromMe: false, id }, messageStubType: 20 }], + }, + }; + appendFileSync(join(raw, 'events.ndjson'), JSON.stringify(stub) + '\n'); + const { dir, report } = scrub(); + + const line = lines(join(dir, 'events.ndjson')).find((e) => e.seq === 999); + const fake = line.data.messages[0].key.id; + expect(fake).toMatch(/^\d+$/); + expect(fake).toHaveLength(id.length); + expect(fake.slice(0, 2)).toBe(id.slice(0, 2)); + expect(fake).not.toBe(id); + expect(report.people).toBe(2); + expect(report.messageIds).toBeGreaterThan(0); + }); + + it('takes a country code only, never a number', () => { + expect(() => scrub({ operator: { countryCode: '972541112233' } })).toThrow(/country code/); + expect(existsSync(join(out, '2026-09-27-synthetic-session'))).toBe(false); + }); + + it('aborts and writes nothing when an original survives the rewrite', () => { + // An object key is kept as written unless it is an address: plant a saved name there. + const planted = { seq: 999, t: 1, socket: 1, event: 'labels.edit', buffered: false, data: { [PERSON.saved]: 1 } }; + appendFileSync(join(raw, 'events.ndjson'), JSON.stringify(planted) + '\n'); + let message = ''; + try { + scrub(); + } catch (error) { + message = String(error?.message); + } + expect(message).toMatch(/leak gate/i); + expect(message).toContain('events.ndjson'); + for (const original of ORIGINALS) expect(message.includes(original)).toBe(false); + expect(existsSync(out)).toBe(false); + }); + + // A string was kept as written whenever it looked like an identifier, whatever its field, and a + // decimal number always was: a username, a group name in a stub parameter, a value in a field the + // scrubber had never seen, a location. The leak gate searched only for what the scrubber replaced. + describe('what it cannot tell from structure', () => { + const plant = (data: any) => + appendFileSync( + join(raw, 'events.ndjson'), + JSON.stringify({ seq: 999, t: 1, socket: 1, event: 'messages.upsert', buffered: false, data }) + '\n', + ); + const fixtureText = (dir: string) => + readdirSync(dir) + .map((f) => readFileSync(join(dir, f), 'utf8')) + .join('\n'); + + it('replaces a username, wherever the same value appears', () => { + const username = 'dana.levi88'; + plant({ + type: 'notify', + messages: [ + { + key: { remoteJid: PERSON.lid, remoteJidUsername: username, fromMe: false, id: MESSAGE_ID }, + message: { conversation: `my handle is ${username}` }, + }, + ], + }); + const { dir } = scrub(); + expect(fixtureText(dir).includes(username)).toBe(false); + }); + + it('replaces the text of a stub parameter', () => { + const groupName = 'dana_and_friends'; + plant({ + type: 'append', + messages: [ + { + key: { remoteJid: '120363401234567890@g.us', fromMe: false, id: '8347261905' }, + messageStubType: 21, + messageStubParameters: [groupName], + }, + ], + }); + const { dir } = scrub(); + expect(fixtureText(dir).includes(groupName)).toBe(false); + }); + + it('replaces a location', () => { + plant({ + type: 'notify', + messages: [ + { + key: { remoteJid: PERSON.lid, fromMe: false, id: MESSAGE_ID }, + message: { locationMessage: { degreesLatitude: 32.0853, degreesLongitude: 34.7818 } }, + }, + ], + }); + const { dir } = scrub(); + const text = fixtureText(dir); + expect({ latitude: text.includes('32.0853'), longitude: text.includes('34.7818') }).toEqual({ + latitude: false, + longitude: false, + }); + }); + + it('stops on a field it does not know, names its path, and writes nothing', () => { + plant({ type: 'notify', messages: [], someFutureField: 'dana.levi' }); + let message = ''; + try { + scrub(); + } catch (error) { + message = String(error?.message); + } + expect(message).toMatch(/unknown field .*someFutureField/); + expect(message.includes('dana.levi')).toBe(false); + expect(existsSync(out)).toBe(false); + }); + }); + + it('its leak gate reads the raw tapes itself: a value the scrubber had kept still fails it', () => { + const line = { + seq: 1, + t: 1, + socket: 1, + event: 'messages.upsert', + data: { messages: [{ key: { remoteJidUsername: 'dana.levi88' } }] }, + }; + const raw = { events: [line], webhooks: [], instanceName: 'rig' }; + // As if a rewrite had kept every value. + expect(leakGate(raw, { 'events.ndjson': JSON.stringify(line) + '\n' })).toEqual(['events.ndjson line 1']); + // Structure it keeps is not a leak. + const structure = { seq: 1, t: 1.5, socket: 1, event: 'connection.update', data: { connection: 'open' } }; + const clean = { events: [structure], webhooks: [], instanceName: 'rig' }; + expect(leakGate(clean, { 'events.ndjson': JSON.stringify(structure) + '\n' })).toEqual([]); + }); + + it('knows every field of every committed fixture', () => { + // A committed fixture has the raw tapes' shape: scrubbing it again must not meet an unknown field. + const root = join(process.cwd(), 'test', 'fixtures', 'live'); + for (const fixture of readdirSync(root)) { + const again = join(tmpdir(), `live-rescrub-${process.pid}`, 'test', fixture); + mkdirSync(again, { recursive: true }); + for (const file of ['events.ndjson', 'webhooks.ndjson', 'manifest.json']) { + writeFileSync(join(again, file), readFileSync(join(root, fixture, file))); + } + let error: unknown; + try { + scrubSession(again, { checkId: 'rescrub', date: '2026-09-27', outRoot: join(again, 'out') }); + } catch (e) { + error = e; // Its fakes are originals now, so the leak gate may object; an unknown field may not. + } + rmSync(join(tmpdir(), `live-rescrub-${process.pid}`), { recursive: true, force: true }); + expect( + error instanceof UnknownFieldError ? `${fixture}: ${(error as Error).message}` : undefined, + ).toBeUndefined(); + } + }); + + it('keeps the markers where a QR or a pairing code was, and stops on a real one an older recording kept', () => { + const qrLine = (seq: number, qrcode: object) => + JSON.stringify({ seq, t: 1, event: 'qrcode.updated', data: { qrcode: { instance: 'test', ...qrcode } } }) + '\n'; + appendFileSync( + join(raw, 'webhooks.ndjson'), + qrLine(990, { pairingCode: '$pairingCode', code: '$qr', base64: '$qr' }), + ); + const { dir } = scrub(); + const [marked] = lines(join(dir, 'webhooks.ndjson')).filter((w) => w.seq === 990); + expect(marked.data.qrcode).toEqual({ instance: 'test', pairingCode: '$pairingCode', code: '$qr', base64: '$qr' }); + + rmSync(out, { recursive: true, force: true }); + appendFileSync(join(raw, 'webhooks.ndjson'), qrLine(991, { pairingCode: 'WXYZ4321', code: '$qr', base64: '$qr' })); + expect(() => scrub()).toThrow(UnknownFieldError); + expect(existsSync(out)).toBe(false); + }); +}); diff --git a/test/proxy/connect-waits-for-proxy.test.ts b/test/proxy/connect-waits-for-proxy.test.ts new file mode 100644 index 0000000000..e6e2da580b --- /dev/null +++ b/test/proxy/connect-waits-for-proxy.test.ts @@ -0,0 +1,126 @@ +// connectToWhatsapp loads the instance's proxy from the database and then +// builds the socket. If the socket is built before the Proxy row has been read, +// the whole account connects from the server's own IP. A real database round +// trip takes time, so the fake one here does too. +// +// Every way an instance connects goes through connectToWhatsapp: the boot +// auto-connect (monitor), /instance/create, /instance/connect, /instance/restart +// and the reconnect after a closed connection. The first connect of a fresh +// service is the boot; the second connect of the same service is a restart or +// a reconnect, where the proxy is already loaded and must stay in force. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); + +import https from 'node:https'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { fakeSocket, stubAuthState } from '../helpers/connect'; +import { + type Listening, + loopbackOnly, + startHttpProxy, + startHttpsServer, + trustTestCertificate, +} from '../helpers/local-net'; + +socketSpy.mockImplementation(fakeSocket); + +const PROXY_READ_MS = 50; + +let guard: ReturnType; +let untrust: () => void; +let web: Listening; +let proxy: Listening; + +beforeAll(async () => { + guard = loopbackOnly(); + untrust = trustTestCertificate(); + web = await startHttpsServer((req, _body, res) => { + res.writeHead(200, { 'content-type': 'text/javascript' }); + res.end('self.__swData=JSON.parse("{\\"client_revision\\":1027654321}");'); + }); + proxy = await startHttpProxy({ remap: { 'web.whatsapp.com:443': `127.0.0.1:${web.port}` } }); +}); + +afterAll(async () => { + await proxy.close(); + await web.close(); + untrust(); + guard.restore(); +}); + +beforeEach(() => { + guard.refused.splice(0); + proxy.log.splice(0); +}); + +/** A service as the monitor builds it on boot: the Proxy row is in the database, nothing is in memory yet. */ +async function bootedService() { + const { service, prisma } = await makeService(); + await prisma.proxy.create({ + data: { + instanceId: 'inst-1', + enabled: true, + host: '127.0.0.1', + port: String(proxy.port), + protocol: 'http', + username: '', + password: '', + }, + }); + const read = prisma.proxy.findUnique; + prisma.proxy.findUnique = async (args: any) => { + await new Promise((r) => setTimeout(r, PROXY_READ_MS)); + return read(args); + }; + stubAuthState(service); + return service; +} + +/** Connect, and return what the socket would do with the config Evolution gave it. */ +async function connect(service: any) { + const before = socketSpy.mock.calls.length; + await service.connectToWhatsapp(); + const config = socketSpy.mock.calls[before][0]; + // The WebSocket opens its connection with config.agent (Baileys passes it to ws); open one the same way. + const probe = await new Promise((resolve, reject) => + https + .get('https://web.whatsapp.com/sw.js', { agent: config.agent }, (res) => { + res.resume(); + res.on('end', () => resolve(`${res.statusCode}`)); + }) + .on('error', reject), + ).catch((e) => `error: ${e.message}`); + return { config, probe }; +} + +describe('a connect never starts before the instance proxy is loaded', () => { + it('on boot: the socket and the version fetch leave through the proxy', async () => { + const service = await bootedService(); + const { config, probe } = await connect(service); + expect(guard.refused).toEqual([]); + expect(proxy.log).toEqual(['CONNECT web.whatsapp.com:443', 'CONNECT web.whatsapp.com:443']); + expect(probe).toBe('200'); + expect(config.version).toEqual([2, 3000, 1027654321]); + }); + + it('on a restart or reconnect of a connected instance: still through the proxy', async () => { + const service = await bootedService(); + await connect(service); + guard.refused.splice(0); + proxy.log.splice(0); + const { probe } = await connect(service); + expect(guard.refused).toEqual([]); + expect(proxy.log).toEqual(['CONNECT web.whatsapp.com:443', 'CONNECT web.whatsapp.com:443']); + expect(probe).toBe('200'); + }); +}); diff --git a/test/proxy/media-download.test.ts b/test/proxy/media-download.test.ts new file mode 100644 index 0000000000..443fd38e9c --- /dev/null +++ b/test/proxy/media-download.test.ts @@ -0,0 +1,117 @@ +// A deployment can give each linked account its own proxy exit (Evolution's per-instance +// proxy, /proxy/set, the Proxy table). The socket leaves through it; media +// downloads must leave through the same exit, or the account's media is fetched +// from the server's own IP while its messages come from the person's. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { readFile, rm } from 'node:fs/promises'; + +import { encryptedStream } from 'baileys'; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { deliver, makeService } from '../helpers/baileys-service'; +import { emitted } from '../helpers/fake-server-module'; +import { type Listening, startCdn, startHttpProxy, startSocks5Proxy } from '../helpers/local-net'; + +const PLAIN = Buffer.from('a photo, as the person sent it '.repeat(200)); +const PATH = '/v/t62.7118-24/media.enc'; + +let cdn: Listening; +let mediaKey: Uint8Array; + +// Refuse any connection that is not to 127.0.0.1, whatever path a request takes. +const previousDispatcher = getGlobalDispatcher(); +const guard = new MockAgent(); +guard.disableNetConnect(); +guard.enableNetConnect((host: string) => host.startsWith('127.0.0.1:')); + +beforeAll(async () => { + setGlobalDispatcher(guard); + // Encrypt with Baileys' own upload path, so the CDN serves exactly what WhatsApp would. + const enc = await encryptedStream(PLAIN, 'image', {}); + mediaKey = enc.mediaKey; + const body = await readFile(enc.encFilePath); + await rm(enc.encFilePath, { force: true }); + cdn = await startCdn({ [PATH]: body }); +}); + +afterAll(async () => { + await cdn.close(); + setGlobalDispatcher(previousDispatcher); + await guard.close(); +}); + +const proxies: Listening[] = []; +beforeEach(() => { + cdn.log.splice(0); + emitted.splice(0); +}); +afterEach(async () => { + await Promise.all(proxies.splice(0).map((p) => p.close())); +}); + +const imageMessage = () => ({ + key: { remoteJid: '972500000001@s.whatsapp.net', fromMe: false, id: '3EB0BBBBBBBBBBBBBBBB' }, + message: { + imageMessage: { + url: `http://127.0.0.1:${cdn.port}${PATH}`, + mediaKey, + mimetype: 'image/jpeg', + fileLength: PLAIN.length, + }, + }, + messageTimestamp: 1_700_000_000, + pushName: 'Sender', +}); + +/** Set the instance's proxy the way /proxy/set does, then load it the way a connect does. */ +async function serviceBehind(protocol: 'http' | 'socks5') { + const proxy = protocol === 'http' ? await startHttpProxy() : await startSocks5Proxy(); + proxies.push(proxy); + const made = await makeService(); + await made.service.setProxy({ + enabled: true, + host: '127.0.0.1', + port: String(proxy.port), + protocol, + username: '', + password: '', + }); + await made.service.loadProxy(); + return { ...made, proxy }; +} + +const tunnelled = (protocol: 'http' | 'socks5') => + protocol === 'http' ? `CONNECT 127.0.0.1:${cdn.port}` : `SOCKS5 127.0.0.1:${cdn.port}`; + +describe('media downloads leave through the instance proxy', () => { + it('control: with no proxy, getBase64FromMediaMessage fetches the CDN directly', async () => { + const { service } = await makeService(); + const media = await service.getBase64FromMediaMessage({ message: imageMessage() }); + expect(Buffer.from(media.base64, 'base64').equals(PLAIN)).toBe(true); + expect(cdn.log).toEqual([`GET ${PATH}`]); + }); + + for (const protocol of ['http', 'socks5'] as const) { + it(`getBase64FromMediaMessage (/chat/getBase64FromMediaMessage, and S3 storage) goes through a ${protocol} proxy`, async () => { + const { service, proxy } = await serviceBehind(protocol); + const media = await service.getBase64FromMediaMessage({ message: imageMessage() }); + expect(Buffer.from(media.base64, 'base64').equals(PLAIN)).toBe(true); + expect(proxy.log).toEqual([tunnelled(protocol)]); + expect(cdn.log).toEqual([`GET ${PATH}`]); + }); + } + + it('the messages.upsert webhook base64 download goes through the proxy', async () => { + const { service, ev, proxy } = await serviceBehind('http'); + Object.assign(service.localWebhook, { enabled: true, webhookBase64: true }); + await deliver(service, ev, { 'messages.upsert': { messages: [imageMessage()], type: 'notify' } }); + const upsert = emitted.find((e) => e.event === 'messages.upsert'); + expect(Buffer.from(upsert?.data?.message?.base64 ?? '', 'base64').equals(PLAIN)).toBe(true); + expect(proxy.log).toEqual([tunnelled('http')]); + expect(cdn.log).toEqual([`GET ${PATH}`]); + }); +}); diff --git a/test/proxy/media-reupload-address.test.ts b/test/proxy/media-reupload-address.test.ts new file mode 100644 index 0000000000..4b10e39811 --- /dev/null +++ b/test/proxy/media-reupload-address.test.ts @@ -0,0 +1,214 @@ +// A media re-upload request names the message by its key: Baileys' +// encryptMediaRetryRequest puts key.remoteJid in the attribute and +// key.participant in . The phone looks the message up under +// the address WhatsApp stores it by. For a chat WhatsApp addresses by @lid +// that is the @lid, and a request naming the phone JID is refused within a +// second (seen live, 2026-09-27). +// +// A consumer holds the key the messages.upsert webhook gave it, which shows +// the phone JID as remoteJid: with the @lid in remoteJidAlt since the webhook +// keeps it, or (a key stored from Evolution 2.3.7) with the phone twice and +// addressingMode 'lid'. Evolution asks for the re-upload with the message's +// original key either way. A group key names its sender the same way, in +// participant / participantAlt. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { readFile, rm } from 'node:fs/promises'; + +import { Boom } from '@hapi/boom'; +import { encryptedStream, encryptMediaRetryRequest, getBinaryNodeChild, proto } from 'baileys'; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService, WUID } from '../helpers/baileys-service'; +import { type Listening, startCdn } from '../helpers/local-net'; + +const LID = '123456789012345@lid'; +const PHONE = '972509876543@s.whatsapp.net'; +const GROUP = '120363000000000001@g.us'; +const ID = '3EB0EEEEEEEEEEEEEEE1'; +const PLAIN = Buffer.from('a photo, as the person sent it '.repeat(200)); +const LIVE = '/v/t62.7118-24/reuploaded.enc'; +const GONE = '/v/t62.7118-24/expired.enc'; + +let cdn: Listening; +let mediaKey: Uint8Array; + +const previousDispatcher = getGlobalDispatcher(); +const guard = new MockAgent(); +guard.disableNetConnect(); +guard.enableNetConnect((host: string) => host.startsWith('127.0.0.1:')); + +beforeAll(async () => { + setGlobalDispatcher(guard); + const enc = await encryptedStream(PLAIN, 'image', {}); + mediaKey = enc.mediaKey; + const body = await readFile(enc.encFilePath); + await rm(enc.encFilePath, { force: true }); + cdn = await startCdn({ [LIVE]: body }); +}); + +afterAll(async () => { + await cdn.close(); + setGlobalDispatcher(previousDispatcher); + await guard.close(); +}); + +beforeEach(() => void cdn.log.splice(0)); + +/** + * A service whose phone re-uploads the file, recording the key each request + * named and the attributes Baileys' real encryptMediaRetryRequest builds + * from it. `lidForPhone` is what Baileys' LID mapping store knows. + */ +async function serviceWithPhone(lidForPhone: Record = {}) { + const made = await makeService(); + const keys: any[] = []; + const rmr: any[] = []; + made.service.client.signalRepository.lidMapping.getLIDForPN = async (pn: string) => lidForPhone[pn] ?? null; + made.service.client.updateMediaMessage = async (message: any) => { + keys.push({ ...message.key }); + const node: any = encryptMediaRetryRequest(message.key, mediaKey, WUID); + rmr.push({ ...getBinaryNodeChild(node, 'rmr').attrs }); + message.message.imageMessage.url = `http://127.0.0.1:${cdn.port}${LIVE}`; + return message; + }; + return { ...made, keys, rmr }; +} + +async function downloadWithKey(service: any, key: Record) { + const result = await service.getBase64FromMediaMessage({ + message: { + key, + message: { + imageMessage: { + url: `http://127.0.0.1:${cdn.port}${GONE}`, + mediaKey, + mimetype: 'image/jpeg', + fileLength: PLAIN.length, + }, + }, + }, + }); + expect(Buffer.from(result.base64, 'base64').equals(PLAIN)).toBe(true); + expect(cdn.log).toEqual([`GET ${GONE}`, `GET ${LIVE}`]); +} + +describe('a media re-upload names the chat by the address WhatsApp stores it under', () => { + it('a DM key from the webhook (phone, @lid in remoteJidAlt) asks under the @lid', async () => { + const { service, keys, rmr } = await serviceWithPhone(); + + await downloadWithKey(service, { + remoteJid: PHONE, + remoteJidAlt: LID, + fromMe: false, + id: ID, + addressingMode: 'pn', + }); + + expect(keys).toEqual([{ remoteJid: LID, remoteJidAlt: PHONE, fromMe: false, id: ID, addressingMode: 'lid' }]); + expect(rmr).toEqual([{ jid: LID, from_me: 'false', participant: undefined }]); + }); + + it("a DM key stored from Evolution 2.3.7 (the phone twice, addressingMode 'lid') asks under the @lid Baileys maps it to", async () => { + const { service, keys, rmr } = await serviceWithPhone({ [PHONE]: LID }); + + await downloadWithKey(service, { + remoteJid: PHONE, + remoteJidAlt: PHONE, + fromMe: false, + id: ID, + addressingMode: 'lid', + }); + + expect(keys).toEqual([{ remoteJid: LID, remoteJidAlt: PHONE, fromMe: false, id: ID, addressingMode: 'lid' }]); + expect(rmr).toEqual([{ jid: LID, from_me: 'false', participant: undefined }]); + }); + + it('a group key with the phone as participant and the @lid in participantAlt asks under the @lid participant', async () => { + const { service, keys, rmr } = await serviceWithPhone(); + + await downloadWithKey(service, { + remoteJid: GROUP, + fromMe: false, + id: ID, + participant: PHONE, + participantAlt: LID, + addressingMode: 'pn', + }); + + expect(keys).toEqual([ + { remoteJid: GROUP, fromMe: false, id: ID, participant: LID, participantAlt: PHONE, addressingMode: 'lid' }, + ]); + expect(rmr).toEqual([{ jid: GROUP, from_me: 'false', participant: LID }]); + }); + + it('control: a key already in its original @lid form is asked for as it is', async () => { + const { service, keys, rmr } = await serviceWithPhone(); + const key = { remoteJid: LID, remoteJidAlt: PHONE, fromMe: false, id: ID, addressingMode: 'lid' }; + + await downloadWithKey(service, key); + + expect(keys).toEqual([key]); + expect(rmr).toEqual([{ jid: LID, from_me: 'false', participant: undefined }]); + }); + + it('control: a group key from the webhook (the @lid participant, as Baileys gave it) is asked for as it is', async () => { + const { service, keys, rmr } = await serviceWithPhone(); + const key = { + remoteJid: GROUP, + fromMe: false, + id: ID, + participant: LID, + participantAlt: PHONE, + addressingMode: 'lid', + }; + + await downloadWithKey(service, key); + + expect(keys).toEqual([key]); + expect(rmr).toEqual([{ jid: GROUP, from_me: 'false', participant: LID }]); + }); + + it('control: a chat addressed by phone, with no @lid known, is asked for under the phone', async () => { + const { service, keys, rmr } = await serviceWithPhone(); + const key = { remoteJid: PHONE, fromMe: false, id: ID }; + + await downloadWithKey(service, key); + + expect(keys).toEqual([key]); + expect(rmr).toEqual([{ jid: PHONE, from_me: 'false', participant: undefined }]); + }); + + // A DM WhatsApp addresses by phone also carries the @lid, in remoteJidAlt (Baileys' + // extractAddressingContext: sender_lid), with addressingMode 'pn'. That is exactly the key the + // webhook shows for an @lid-addressed DM after its swap, so the key alone cannot say which address + // the phone keeps the message under. Asked only under the @lid, the phone refuses a message it + // keeps under the phone JID. + it('a DM WhatsApp addresses by phone, whose key also carries its @lid, is still re-uploaded', async () => { + const { service, keys } = await serviceWithPhone(); + const upload = service.client.updateMediaMessage; + service.client.updateMediaMessage = async (message: any) => { + if (message.key.remoteJid !== PHONE) { + keys.push({ ...message.key }); + throw new Boom('Media re-upload failed by device', { + statusCode: 404, + data: { result: proto.MediaRetryNotification.ResultType.NOT_FOUND }, + }); + } + return upload(message); + }; + + await downloadWithKey(service, { + remoteJid: PHONE, + remoteJidAlt: LID, + fromMe: false, + id: ID, + addressingMode: 'pn', + }); + + expect(keys.map((k) => k.remoteJid)).toEqual([LID, PHONE]); + }); +}); diff --git a/test/proxy/media-reupload-key-bytes.test.ts b/test/proxy/media-reupload-key-bytes.test.ts new file mode 100644 index 0000000000..ce383c5d17 --- /dev/null +++ b/test/proxy/media-reupload-key-bytes.test.ts @@ -0,0 +1,163 @@ +// A consumer hands getBase64FromMediaMessage a message over HTTP JSON, so the +// media key is no longer bytes: a base64 string, the index-keyed object +// JSON.stringify makes of a Uint8Array ({"0":..,"1":..}), or the +// {type:'Buffer',data:[..]} it makes of a Node Buffer. Baileys' download reads +// a base64 string itself (getMediaKeys), but its re-upload does not: +// updateMediaMessage derives the retry key with hkdf(mediaKey) as given +// (getMediaRetryKey, lib/Utils/messages-media.js:701), and decryptMediaRetryData +// then fails the phone's answer with "Unsupported state or unable to +// authenticate data" (seen live, 2026-09-27, on every re-upload asked for over +// HTTP). Evolution turns the key back into bytes before it asks. +// +// The socket's updateMediaMessage below is Baileys' own (lib/Socket/messages-send.js:1011), +// on Baileys' real encryptMediaRetryRequest, decodeMediaRetryNode and +// decryptMediaRetryData. The phone answers the way a phone does: a +// MediaRetryNotification encrypted under the file's true media key. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { randomBytes } from 'node:crypto'; +import { readFile, rm } from 'node:fs/promises'; + +import { Boom } from '@hapi/boom'; +import { + aesEncryptGCM, + assertMediaContent, + decodeMediaRetryNode, + decryptMediaRetryData, + encryptedStream, + encryptMediaRetryRequest, + getUrlFromDirectPath, + hkdf, + proto, +} from 'baileys'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService, WUID } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import { type Listening, loopbackOnly, startHttpsServer, trustTestCertificate } from '../helpers/local-net'; + +const ID = '3EB0ABABABABABABABA1'; +const PLAIN = Buffer.from('a photo, as the person sent it '.repeat(200)); +const GONE = '/v/t62.7118-24/expired.enc'; +const NEW = '/v/t62.7118-24/reuploaded.enc'; + +let cdn: Listening; +let host: string; +let mediaKey: Buffer; +let untrust: () => void; +let net: ReturnType; + +beforeAll(async () => { + net = loopbackOnly(); + untrust = trustTestCertificate(); + const enc = await encryptedStream(PLAIN, 'image', {}); + mediaKey = Buffer.from(enc.mediaKey); + const body = await readFile(enc.encFilePath); + await rm(enc.encFilePath, { force: true }); + // WhatsApp's media servers: the expired copy is gone, the phone's new one is there. + cdn = await startHttpsServer((req, _body, res) => { + if (req.url === NEW) return void res.writeHead(200, { 'content-length': body.length }).end(body); + res.writeHead(404).end(); + }); + host = `127.0.0.1:${cdn.port}`; + // Evolution waits 5s before its own fallback download. + const realSetTimeout = globalThis.setTimeout; + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: any, ms?: number, ...args: any[]) => + realSetTimeout(fn, ms === 5000 ? 0 : ms, ...args)) as any); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + await cdn.close(); + untrust(); + net.restore(); +}); + +beforeEach(() => { + cdn.log.splice(0); + net.refused.splice(0); +}); + +/** The phone's answer to a re-upload request: the new copy's directPath, encrypted under the file's true media key. */ +function phoneAnswers(request: any) { + const id = request.attrs.id; + const plain = proto.MediaRetryNotification.encode({ stanzaId: id, directPath: NEW, result: proto.MediaRetryNotification.ResultType.SUCCESS }).finish(); + const iv = randomBytes(12); + const retryKey = hkdf(mediaKey, 32, { info: 'WhatsApp Media Retry Notification' }); + const ciphertext = aesEncryptGCM(plain, retryKey, iv, Buffer.from(id)); + return { + tag: 'receipt', + attrs: { id }, + content: [ + { tag: 'encrypt', attrs: {}, content: [{ tag: 'enc_p', attrs: {}, content: ciphertext }, { tag: 'enc_iv', attrs: {}, content: iv }] }, + { tag: 'rmr', attrs: { jid: '972509876543@s.whatsapp.net', from_me: 'false' } }, + ], + }; +} + +async function serviceWithPhone() { + const made = await makeService(); + const asked: string[] = []; + // Baileys' updateMediaMessage, with the node round trip done in process. + made.service.client.updateMediaMessage = async (message: any) => { + asked.push(message.key.id); + const content: any = assertMediaContent(message.message); + const mediaKey = content.mediaKey; + const request = encryptMediaRetryRequest(message.key, mediaKey, WUID); + const result: any = decodeMediaRetryNode(phoneAnswers(request) as any); + if (result.error) throw result.error; + const media = decryptMediaRetryData(result.media, mediaKey, result.key.id); + if (media.result !== proto.MediaRetryNotification.ResultType.SUCCESS) { + throw new Boom(`Media re-upload failed by device (${proto.MediaRetryNotification.ResultType[media.result]})`, { data: media }); + } + content.directPath = media.directPath; + content.url = getUrlFromDirectPath(content.directPath, host); + return message; + }; + return { ...made, asked }; +} + +/** The media key as a consumer holding the message as JSON sends it. */ +const shapes: [string, () => any][] = [ + ['bytes (control)', () => Uint8Array.from(mediaKey)], + ['a base64 string', () => mediaKey.toString('base64')], + ['an index-keyed object (JSON of a Uint8Array)', () => JSON.parse(JSON.stringify(Uint8Array.from(mediaKey)))], + ["a {type:'Buffer', data} object (JSON of a Buffer)", () => JSON.parse(JSON.stringify(mediaKey))], +]; + +describe('a re-upload works when the media key arrives as text', () => { + it.each(shapes)('media key as %s: the phone is asked, and the download gets the new copy', async (_shape, key) => { + const { service, asked } = await serviceWithPhone(); + let result: any; + let thrown: any; + const out = await captureOutput(async () => { + try { + result = await service.getBase64FromMediaMessage({ + message: { + key: { remoteJid: '972509876543@s.whatsapp.net', fromMe: false, id: ID }, + message: { imageMessage: { url: `https://${host}${GONE}`, mediaKey: key(), mimetype: 'image/jpeg', fileLength: PLAIN.length } }, + }, + }); + } catch (e) { + thrown = e; + } + }); + const lines = out + .replace(/\x1b\[[0-9;]*m/g, '') + .split('\n') + .filter((l) => l.includes('media download:')) + .map((l) => l.slice(l.indexOf('media download:'))); + + expect(asked).toEqual([ID]); + expect(lines).toEqual([ + `media download: message=${ID}, chat=user, outcome=reupload_requested`, + `media download: message=${ID}, chat=user, outcome=reupload_ok`, + ]); + expect(thrown).toBeUndefined(); + expect(cdn.log).toEqual([`GET ${GONE}`, `GET ${NEW}`]); + expect(Buffer.from(result.base64, 'base64').equals(PLAIN)).toBe(true); + expect(net.refused).toEqual([]); + }); +}); diff --git a/test/proxy/media-reupload.test.ts b/test/proxy/media-reupload.test.ts new file mode 100644 index 0000000000..d8a0a8cb76 --- /dev/null +++ b/test/proxy/media-reupload.test.ts @@ -0,0 +1,475 @@ +// When a media file has expired on WhatsApp's CDN, the only copy left is on +// the sender's phone. Evolution asks the phone to re-upload it (the socket's +// updateMediaMessage), once per download, and records whether it asked and +// how that ended: a bounded log line, and `reupload` on the download's error. +// +// Baileys 7.0.0-rc14 is meant to ask by itself: downloadMediaMessage calls its +// reuploadRequest when the error has a numeric `status` of 404 or 410 +// (lib/Utils/messages.js:836). Its CDN fetch throws a Boom that carries the +// HTTP status only in `output.statusCode` (lib/Utils/messages-media.js:304), so +// that check never matches and rc14 never asks. Evolution asks itself when +// Baileys did not. +// +// A 403 asks the phone only when the link that actually failed carries an `oe` +// query parameter (hex unix seconds) that has passed by the local clock. A +// conservative heuristic, not a documented contract: measured on WhatsApp's media +// CDN (2026-09-27, 84 history-sync attachments), 403 on 34 of 34 links whose `oe` +// had passed, on 0 of 50 valid ones, and a valid link to a file the CDN dropped +// answered 404 or 410. +import { vi } from 'vitest'; + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); + +import { readFile, rm } from 'node:fs/promises'; + +import { MEDIA_REUPLOAD_TIMEOUT_MS } from '@api/integrations/channel/whatsapp/whatsapp.baileys.service'; +import { encryptedStream } from 'baileys'; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import { type Listening, startCdn, startHttpsServer, trustTestCertificate } from '../helpers/local-net'; + +const PHONE = '972509876543'; +const CAPTION = 'Zq7 a private caption Zq7'; +const ID = '3EB0CCCCCCCCCCCCCCC1'; +const PLAIN = Buffer.from('a photo, as the person sent it '.repeat(200)); +const LIVE = '/v/t62.7118-24/reuploaded.enc'; +const GONE = '/v/t62.7118-24/expired.enc'; +const GONE_AGAIN = '/v/t62.7118-24/expired-again.enc'; +const GONE_410 = '/v/t62.7118-24/expired-410.enc'; +const GONE_403 = '/v/t62.7118-24/expired-403.enc'; +/** A media link's `oe` (when it stops working): hex unix seconds, as WhatsApp writes it. */ +const oe = (fromNowS: number) => (Math.floor(Date.now() / 1000) + fromNowS).toString(16).toUpperCase(); +const DAY = 24 * 60 * 60; +const EXPIRED_LINK_403 = `${GONE_403}?ccb=11-4&oh=01_Q5Aa&oe=${oe(-DAY)}&_nc_sid=5e03e0`; +const VALID_LINK_403 = `${GONE_403}?ccb=11-4&oh=01_Q5Aa&oe=${oe(14 * DAY)}&_nc_sid=5e03e0`; +const BROKEN = '/v/t62.7118-24/broken.enc'; + +let cdn: Listening; +let mediaKey: Uint8Array; +let liveBody: Buffer; + +// Refuse any connection that is not to 127.0.0.1: Evolution's own fallback +// retries the download at mmg.whatsapp.net, which must fail here, not leave. +const previousDispatcher = getGlobalDispatcher(); +const guard = new MockAgent(); +guard.disableNetConnect(); +guard.enableNetConnect((host: string) => host.startsWith('127.0.0.1:')); + +beforeAll(async () => { + setGlobalDispatcher(guard); + const enc = await encryptedStream(PLAIN, 'image', {}); + mediaKey = enc.mediaKey; + const body = await readFile(enc.encFilePath); + liveBody = body; + await rm(enc.encFilePath, { force: true }); + cdn = await startCdn({ + [LIVE]: body, + [GONE_410]: 410, + [GONE_403]: 403, + [EXPIRED_LINK_403]: 403, + [VALID_LINK_403]: 403, + [BROKEN]: 500, + }); + // Evolution waits 5s before its own fallback download, and gives the phone a + // bounded time to answer a re-upload request; both are shortened here. + const realSetTimeout = globalThis.setTimeout; + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: any, ms?: number, ...args: any[]) => + realSetTimeout(fn, ms === 5000 ? 0 : MEDIA_REUPLOAD_TIMEOUT_MS && ms === MEDIA_REUPLOAD_TIMEOUT_MS ? 20 : ms, ...args)) as any); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + await cdn.close(); + setGlobalDispatcher(previousDispatcher); + await guard.close(); +}); + +beforeEach(() => void cdn.log.splice(0)); + +const expiredImage = (path = GONE) => ({ + key: { remoteJid: `${PHONE}@s.whatsapp.net`, fromMe: false, id: ID }, + message: { + imageMessage: { + url: `http://127.0.0.1:${cdn.port}${path}`, + mediaKey, + mimetype: 'image/jpeg', + caption: CAPTION, + fileLength: PLAIN.length, + }, + }, + messageTimestamp: 1_700_000_000, + pushName: 'Sender', +}); + +type Phone = 'reuploads' | 'fails' | 'reuploads-expired' | 'silent'; + +/** A service whose socket answers a re-upload request the way `phone` says, counting the requests. */ +async function serviceWithPhone(phone: Phone) { + const made = await makeService(); + const asked: string[] = []; + made.service.client.updateMediaMessage = async (message: any) => { + asked.push(message.key.id); + if (phone === 'silent') return new Promise(() => undefined); + if (phone === 'fails') { + // What Baileys raises when the phone reports the file is gone (messages-send.js updateMediaMessage). + const { Boom } = await import('@hapi/boom'); + // Baileys puts the phone's decoded answer in the Boom's data (its result: 2 is NOT_FOUND). + throw new Boom('Media re-upload failed by device (NOT_FOUND)', { data: { stanzaId: message.key.id, result: 2 }, statusCode: 404 }); + } + // What Baileys does on success: point the message at the new copy (by url + // alone here, since a directPath is fetched over https from the url's host). + const path = phone === 'reuploads' ? LIVE : GONE_AGAIN; + message.message.imageMessage.url = `http://127.0.0.1:${cdn.port}${path}`; + return message; + }; + return { ...made, asked }; +} + +/** Run a download, returning what it answered or threw and everything printed meanwhile. */ +async function download(service: any, path = GONE) { + let result: any; + let thrown: any; + const out = await captureOutput(async () => { + try { + result = await service.getBase64FromMediaMessage({ message: expiredImage(path) }); + } catch (e) { + thrown = e; + } + }); + const plain = out.replace(/\x1b\[[0-9;]*m/g, ''); + return { result, thrown, out: plain, lines: plain.split('\n').filter((l) => l.includes('media download:')) }; +} + +const line = (fields: string) => expect.stringContaining(`media download: message=${ID}, chat=user, ${fields}`); +const badRequest = (reupload: string, reuploadReason?: string) => ({ + status: 400, + error: 'Bad Request', + message: [expect.any(String)], + reupload, + ...(reuploadReason && { reuploadReason }), +}); + +function expectNothingPrivate(out: string) { + expect(out).not.toContain(PHONE); + expect(out).not.toContain('Zq7'); +} + +describe('a media download says whether it asked the phone to re-upload', () => { + it('the phone re-uploads: the download succeeds and the log says so', async () => { + const { service, asked } = await serviceWithPhone('reuploads'); + const { result, thrown, out, lines } = await download(service); + + expect(thrown).toBeUndefined(); + expect(Buffer.from(result.base64, 'base64').equals(PLAIN)).toBe(true); + expect(asked).toEqual([ID]); + expect(cdn.log).toEqual([`GET ${GONE}`, `GET ${LIVE}`]); + expect(lines).toEqual([line('outcome=reupload_requested'), line('outcome=reupload_ok')]); + expectNothingPrivate(out); + }); + + it('the phone cannot re-upload: the error and the log say the re-upload failed', async () => { + const { service, asked } = await serviceWithPhone('fails'); + const { thrown, out, lines } = await download(service); + + expect(asked).toEqual([ID]); + expect(thrown).toEqual(badRequest('failed', 'NOT_FOUND')); + expect(lines).toEqual([ + line('outcome=reupload_requested'), + line('outcome=reupload_failed, error=Error, status=404, reason=NOT_FOUND'), + line('outcome=download_failed, status=404, reupload=failed'), + ]); + expectNothingPrivate(out); + }); +}); + +describe('an expired media download asks the phone to re-upload, once', () => { + it('a CDN 410 asks the phone too', async () => { + const { service, asked } = await serviceWithPhone('reuploads'); + const { result, thrown, lines } = await download(service, GONE_410); + + expect(thrown).toBeUndefined(); + expect(Buffer.from(result.base64, 'base64').equals(PLAIN)).toBe(true); + expect(asked).toEqual([ID]); + expect(cdn.log).toEqual([`GET ${GONE_410}`, `GET ${LIVE}`]); + expect(lines).toEqual([line('outcome=reupload_requested'), line('outcome=reupload_ok')]); + }); + + it('a CDN 403 on a link whose oe has passed asks the phone', async () => { + const { service, asked } = await serviceWithPhone('reuploads'); + const { result, thrown, lines } = await download(service, EXPIRED_LINK_403); + + expect(thrown).toBeUndefined(); + expect(Buffer.from(result.base64, 'base64').equals(PLAIN)).toBe(true); + expect(asked).toEqual([ID]); + expect(cdn.log).toEqual([`GET ${EXPIRED_LINK_403}`, `GET ${LIVE}`]); + expect(lines).toEqual([line('outcome=reupload_requested'), line('outcome=reupload_ok')]); + }); + + it('a CDN 403 on a link whose oe has not passed does not ask the phone', async () => { + const { service, asked } = await serviceWithPhone('reuploads'); + const { thrown, out, lines } = await download(service, VALID_LINK_403); + + expect(asked).toEqual([]); + expect(cdn.log).toEqual([`GET ${VALID_LINK_403}`]); + expect(thrown).toEqual(badRequest('not_requested')); + expect(lines).toEqual([line('outcome=download_failed, status=403, reupload=not_requested')]); + expectNothingPrivate(out); + }); + + it('a CDN 403 on a link with no oe does not ask the phone', async () => { + const { service, asked } = await serviceWithPhone('reuploads'); + const { thrown, out, lines } = await download(service, GONE_403); + + expect(asked).toEqual([]); + expect(cdn.log).toEqual([`GET ${GONE_403}`]); + expect(thrown).toEqual(badRequest('not_requested')); + expect(lines).toEqual([line('outcome=download_failed, status=403, reupload=not_requested')]); + expectNothingPrivate(out); + }); + + it('a CDN 403 whose url has no oe reads it from the directPath', async () => { + // A directPath is fetched over https from the url's host, so this CDN is https. + const untrust = trustTestCertificate(); + const tlsCdn = await startHttpsServer((req, _body, res) => { + if (req.url === LIVE) return void res.writeHead(200, { 'content-length': liveBody.length }).end(liveBody); + res.writeHead(403).end(); + }); + try { + const { service } = await makeService(); + const asked: string[] = []; + service.client.updateMediaMessage = async (message: any) => { + asked.push(message.key.id); + message.message.imageMessage.directPath = LIVE; + return message; + }; + const directPath = `${GONE_403}?ccb=11-4&oh=01_Q5Aa&oe=${oe(-DAY)}&_nc_sid=5e03e0`; + const message = expiredImage(); + message.message.imageMessage.url = `https://127.0.0.1:${tlsCdn.port}${GONE_403}`; + (message.message.imageMessage as any).directPath = directPath; + + const result = await service.getBase64FromMediaMessage({ message }); + + expect(Buffer.from(result.base64, 'base64').equals(PLAIN)).toBe(true); + expect(asked).toEqual([ID]); + expect(tlsCdn.log).toEqual([`GET ${directPath}`, `GET ${LIVE}`]); + } finally { + await tlsCdn.close(); + untrust(); + } + }); + + it('a re-uploaded copy that is gone as well is not re-uploaded again', async () => { + const { service, asked } = await serviceWithPhone('reuploads-expired'); + const { thrown, out, lines } = await download(service); + + expect(asked).toEqual([ID]); + expect(cdn.log).toEqual([`GET ${GONE}`, `GET ${GONE_AGAIN}`]); + expect(thrown).toEqual(badRequest('ok')); + expect(lines).toEqual([ + line('outcome=reupload_requested'), + line('outcome=reupload_ok'), + line('outcome=download_failed, status=404, reupload=ok'), + ]); + expectNothingPrivate(out); + }); + + it('a phone that does not answer in time fails the re-upload', async () => { + const { service, asked } = await serviceWithPhone('silent'); + const { thrown, out, lines } = await download(service); + + expect(MEDIA_REUPLOAD_TIMEOUT_MS).toBe(60_000); + expect(asked).toEqual([ID]); + expect(cdn.log).toEqual([`GET ${GONE}`]); + expect(thrown).toEqual(badRequest('failed', 'no_answer')); + expect(lines).toEqual([ + line('outcome=reupload_requested'), + line('outcome=reupload_failed, error=ReuploadTimeoutError, status=none, reason=no_answer'), + line('outcome=download_failed, status=404, reupload=failed'), + ]); + expectNothingPrivate(out); + }); + + it('a failure that is not an expired file (a CDN 500) does not ask the phone', async () => { + const { service, asked } = await serviceWithPhone('reuploads'); + const { thrown, out, lines } = await download(service, BROKEN); + + expect(asked).toEqual([]); + expect(cdn.log).toEqual([`GET ${BROKEN}`]); + expect(thrown).toEqual(badRequest('not_requested')); + expect(lines).toEqual([line('outcome=download_failed, status=500, reupload=not_requested')]); + expectNothingPrivate(out); + }); +}); + +// Which link a 403 is judged by: the one the download actually requested. Baileys +// downloads the directPath (over https, from the url's host) when the message has one, +// and the url only otherwise; its error carries the link it requested (Boom data.url). +// An error without a link falls back to that same choice, never to the other link. +// `oe` is read as a query parameter: case-sensitive name, percent-decoded, never from +// the fragment, exactly one value, plain hex. The comparison uses the local clock, so +// these tests fix it. +describe('a 403 is judged by the link that actually failed', () => { + const NOW = 1_790_000_000; + const hex = (unixSeconds: number) => unixSeconds.toString(16).toUpperCase(); + const signed = (path: string, expiry: string) => `${path}?ccb=11-4&oh=01_Q5Aa&oe=${expiry}&_nc_sid=5e03e0`; + const EXPIRED = hex(NOW - DAY); + const VALID = hex(NOW + 14 * DAY); + const FORBIDDEN = '/v/t62.7118-24/forbidden.enc'; + const OTHER = '/v/t62.7118-24/other.enc'; + + let tlsCdn: Listening; + let untrust: () => void; + let clock: { mockRestore: () => void }; + let fetchSpy: { mockRestore: () => void } | undefined; + + beforeAll(async () => { + // Both links are served over https here, since a directPath always is. + untrust = trustTestCertificate(); + tlsCdn = await startHttpsServer((req, _body, res) => { + if (req.url === LIVE) return void res.writeHead(200, { 'content-length': liveBody.length }).end(liveBody); + res.writeHead(403).end(); + }); + }); + + afterAll(async () => { + await tlsCdn.close(); + untrust(); + }); + + beforeEach(() => { + tlsCdn.log.splice(0); + clock = vi.spyOn(Date, 'now').mockReturnValue(NOW * 1000); + }); + + afterEach(() => { + clock.mockRestore(); + fetchSpy?.mockRestore(); + fetchSpy = undefined; + }); + + /** An image whose url and directPath (when given) point at the https CDN. */ + const image = (urlPath: string, directPath?: string) => { + const message: any = expiredImage(); + message.message.imageMessage.url = `https://127.0.0.1:${tlsCdn.port}${urlPath}`; + if (directPath !== undefined) message.message.imageMessage.directPath = directPath; + return message; + }; + + /** A service whose phone re-uploads to LIVE, refreshing both links as Baileys does. */ + async function run(message: any) { + const { service } = await makeService(); + const asked: string[] = []; + service.client.updateMediaMessage = async (m: any) => { + asked.push(m.key.id); + m.message.imageMessage.url = `https://127.0.0.1:${tlsCdn.port}${LIVE}`; + if (m.message.imageMessage.directPath) m.message.imageMessage.directPath = LIVE; + return m; + }; + let result: any; + let thrown: any; + await captureOutput(async () => { + try { + result = await service.getBase64FromMediaMessage({ message }); + } catch (e) { + thrown = e; + } + }); + return { result, thrown, asked }; + } + + async function expectReupload(message: any, requested: string[]) { + const { result, thrown, asked } = await run(message); + + expect(thrown).toBeUndefined(); + expect(Buffer.from(result.base64, 'base64').equals(PLAIN)).toBe(true); + expect(asked).toEqual([ID]); + expect(tlsCdn.log).toEqual([...requested, LIVE].map((p) => `GET ${p}`)); + } + + async function expectNoReupload(message: any, requested: string[]) { + const { thrown, asked } = await run(message); + + expect(asked).toEqual([]); + expect(thrown).toEqual(badRequest('not_requested')); + expect(tlsCdn.log).toEqual(requested.map((p) => `GET ${p}`)); + } + + it.each([ + ['a day past', EXPIRED], + ['exactly now', hex(NOW)], + ['a day past, in lowercase hex', EXPIRED.toLowerCase()], + ['a day past, percent-encoded', [...EXPIRED].map((c) => `%${c.charCodeAt(0).toString(16)}`).join('')], + ])('asks the phone when the oe of the url that failed is %s', async (_, expiry) => { + const path = signed(FORBIDDEN, expiry); + + await expectReupload(image(path), [path]); + }); + + it.each([ + ['one second from now', signed(FORBIDDEN, hex(NOW + 1))], + ['14 days from now', signed(FORBIDDEN, VALID)], + ['missing', FORBIDDEN], + ['empty', signed(FORBIDDEN, '')], + ['not hex', signed(FORBIDDEN, `${EXPIRED}Z`)], + ['0x-prefixed', signed(FORBIDDEN, `0x${EXPIRED}`)], + ['too large to be a time', signed(FORBIDDEN, 'F'.repeat(20))], + ['given twice', `${FORBIDDEN}?oe=${EXPIRED}&oe=${EXPIRED}`], + ['spelled OE', `${FORBIDDEN}?OE=${EXPIRED}`], + ])('does not ask the phone when the oe of the url that failed is %s', async (_, path) => { + await expectNoReupload(image(path), [path]); + }); + + it('ignores an oe in the fragment, which is never sent', async () => { + await expectNoReupload(image(`${FORBIDDEN}#top?oe=${EXPIRED}`), [FORBIDDEN]); + }); + + it('does not ask the phone when the url has expired but the directPath that failed has not', async () => { + const directPath = signed(FORBIDDEN, VALID); + + await expectNoReupload(image(signed(OTHER, EXPIRED), directPath), [directPath]); + }); + + it('asks the phone when the directPath that failed has expired though the url has not', async () => { + const directPath = signed(FORBIDDEN, EXPIRED); + + await expectReupload(image(signed(OTHER, VALID), directPath), [directPath]); + }); + + it('does not ask the phone when the url has expired but the directPath that failed has no oe', async () => { + await expectNoReupload(image(signed(OTHER, EXPIRED), FORBIDDEN), [FORBIDDEN]); + }); + + describe('when the error does not say which link failed', () => { + // An error with a numeric status and no link, before any request reaches the CDN. + function failFirstFetch() { + const realFetch = globalThis.fetch; + let failed = false; + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (...args) => { + if (failed) return realFetch(...args); + failed = true; + throw Object.assign(new Error('forbidden'), { status: 403 }); + }); + } + + it('judges by the directPath, which the download requests first', async () => { + failFirstFetch(); + + await expectReupload(image(signed(OTHER, VALID), signed(FORBIDDEN, EXPIRED)), []); + }); + + it('does not fall back to an expired url when there is a directPath', async () => { + failFirstFetch(); + + await expectNoReupload(image(signed(OTHER, EXPIRED), signed(FORBIDDEN, VALID)), []); + }); + + it('judges by the url when there is no directPath', async () => { + failFirstFetch(); + + await expectReupload(image(signed(FORBIDDEN, EXPIRED)), []); + }); + }); +}); diff --git a/test/proxy/media-upload.test.ts b/test/proxy/media-upload.test.ts new file mode 100644 index 0000000000..c715ed8dea --- /dev/null +++ b/test/proxy/media-upload.test.ts @@ -0,0 +1,126 @@ +// Sending media: Baileys uploads the encrypted file to a WhatsApp media host +// with the socket config Evolution built. On an instance with a proxy the upload +// must work, and must leave through that proxy like the rest of the account. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); +// Pinned here: this file is about uploads (the version fetch has its own test). +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { DEFAULT_CONNECTION_CONFIG, getWAUploadToServer } from 'baileys'; +import P from 'pino'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { connectBehind, fakeSocket, type ProxyProtocol } from '../helpers/connect'; +import { + type Listening, + loopbackOnly, + startHttpProxy, + startHttpsServer, + startSocks5Proxy, + trustTestCertificate, +} from '../helpers/local-net'; + +socketSpy.mockImplementation(fakeSocket); + +const ENCRYPTED = Buffer.from('an encrypted photo, as Baileys uploads it '.repeat(300)); +const SHA_B64 = createHash('sha256').update(ENCRYPTED).digest('base64'); + +let guard: ReturnType; +let untrust: () => void; +let mediaHost: Listening; +let received: Buffer[]; +let dir: string; +let filePath: string; +const proxies: Listening[] = []; + +beforeAll(async () => { + guard = loopbackOnly(); + untrust = trustTestCertificate(); + mediaHost = await startHttpsServer((req, body, res) => { + received.push(body); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ url: `https://127.0.0.1:${mediaHost.port}/m/1`, direct_path: '/m/1' })); + }); + dir = await mkdtemp(join(tmpdir(), 'evo-upload-')); + filePath = join(dir, 'image-enc'); + await writeFile(filePath, ENCRYPTED); +}); + +afterAll(async () => { + await mediaHost.close(); + await rm(dir, { recursive: true, force: true }); + untrust(); + guard.restore(); +}); + +afterEach(async () => { + await Promise.all(proxies.splice(0).map((p) => p.close())); +}); + +// Baileys logs each host's failure at warn and throws only "failed on all hosts"; keep the cause. +const warnings: string[] = []; +const logger = Object.assign(P({ level: 'silent' }), { + warn: (obj: any, msg?: string) => void warnings.push(`${msg}: ${String(obj?.trace ?? '').split('\n')[0]}`), +}); + +/** Baileys' own upload, built from the socket config the way makeWASocket builds it. */ +function uploaderFor(config: any) { + const merged = { ...DEFAULT_CONNECTION_CONFIG, ...config, logger }; + return getWAUploadToServer(merged, async () => ({ + hosts: [{ hostname: `127.0.0.1:${mediaHost.port}`, maxContentLengthBytes: 1e9 }], + auth: 'test-auth', + ttl: 3600, + fetchDate: new Date(), + })); +} + +async function upload(config: any) { + received = []; + mediaHost.log.splice(0); + warnings.splice(0); + return uploaderFor(config)(filePath, { mediaType: 'image', fileEncSha256B64: SHA_B64, timeoutMs: 10_000 }).catch( + (error) => { + throw new Error(`${error.message}. ${warnings.join(' | ')}`); + }, + ); +} + +describe('media uploads on an instance with a proxy', () => { + it('control: with no proxy, the upload reaches the media host directly', async () => { + const { config } = await connectBehind(socketSpy); + const result = await upload(config); + expect(result).toEqual({ mediaUrl: `https://127.0.0.1:${mediaHost.port}/m/1`, directPath: '/m/1', meta_hmac: undefined, fbid: undefined, ts: undefined }); + expect(Buffer.concat(received).equals(ENCRYPTED)).toBe(true); + }); + + for (const protocol of ['http', 'socks5'] as ProxyProtocol[]) { + it(`works, and leaves through a ${protocol} proxy`, async () => { + const proxy = protocol === 'http' ? await startHttpProxy() : await startSocks5Proxy(); + proxies.push(proxy); + const { config } = await connectBehind(socketSpy, { protocol, port: proxy.port }); + const result = await upload(config); + expect(result).toEqual({ mediaUrl: `https://127.0.0.1:${mediaHost.port}/m/1`, directPath: '/m/1', meta_hmac: undefined, fbid: undefined, ts: undefined }); + expect(Buffer.concat(received).equals(ENCRYPTED)).toBe(true); + expect(mediaHost.log).toHaveLength(1); + expect(proxy.log).toEqual([protocol === 'http' ? `CONNECT 127.0.0.1:${mediaHost.port}` : `SOCKS5 127.0.0.1:${mediaHost.port}`]); + }); + } + + it('nothing tried to leave 127.0.0.1', () => { + expect(guard.refused).toEqual([]); + }); +}); diff --git a/test/proxy/proxy-never-falls-back.test.ts b/test/proxy/proxy-never-falls-back.test.ts new file mode 100644 index 0000000000..3a74bb0f75 --- /dev/null +++ b/test/proxy/proxy-never-falls-back.test.ts @@ -0,0 +1,153 @@ +// An instance with a proxy must never reach WhatsApp from the server's own address, not for a +// moment. Three ways it still could: +// - loadProxy (every connect) switched the proxy off, then read the Proxy row: a media download +// in that window (a reconnect while messages arrive) went out directly, and a failed read left +// the proxy off for good. +// - A proxyscrape list that could not be fetched switched the proxy off, and the socket connected +// directly. +// - A media download for a proxyscrape proxy with no socket exit to share went out directly. +import { vi } from 'vitest'; + +const { socketSpy } = vi.hoisted(() => ({ socketSpy: vi.fn() })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); + +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { readFile, rm } from 'node:fs/promises'; + +import { encryptedStream } from 'baileys'; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from 'undici'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService } from '../helpers/baileys-service'; +import { fakeSocket, stubAuthState } from '../helpers/connect'; +import { type Listening, loopbackOnly, startCdn, startHttpProxy } from '../helpers/local-net'; + +socketSpy.mockImplementation(fakeSocket); + +const PLAIN = Buffer.from('a photo, as the person sent it '.repeat(200)); +const PATH = '/v/t62.7118-24/media.enc'; + +let cdn: Listening; +let proxy: Listening; +let mediaKey: Uint8Array; +let netGuard: ReturnType; +const previousDispatcher = getGlobalDispatcher(); +const guard = new MockAgent(); +guard.disableNetConnect(); +guard.enableNetConnect((host: string) => host.startsWith('127.0.0.1:')); + +// A proxyscrape-style list server that is down: every request answers 503. +let listServer: http.Server; +let listUrl: string; + +beforeAll(async () => { + netGuard = loopbackOnly(); + setGlobalDispatcher(guard); + const enc = await encryptedStream(PLAIN, 'image', {}); + mediaKey = enc.mediaKey; + const body = await readFile(enc.encFilePath); + await rm(enc.encFilePath, { force: true }); + cdn = await startCdn({ [PATH]: body }); + proxy = await startHttpProxy(); + listServer = http.createServer((_req, res) => res.writeHead(503).end()); + await new Promise((r) => listServer.listen(0, '127.0.0.1', r)); + listUrl = `http://127.0.0.1:${(listServer.address() as AddressInfo).port}/proxyscrape/v2/list`; +}); + +afterAll(async () => { + await cdn.close(); + await proxy.close(); + await new Promise((r) => listServer.close(r)); + setGlobalDispatcher(previousDispatcher); + await guard.close(); + expect(netGuard.refused).toEqual([]); + netGuard.restore(); +}); + +beforeEach(() => { + cdn.log.splice(0); + proxy.log.splice(0); + socketSpy.mockClear(); +}); +afterEach(() => vi.restoreAllMocks()); + +const imageMessage = () => ({ + key: { remoteJid: '972500000001@s.whatsapp.net', fromMe: false, id: '3EB0BBBBBBBBBBBBBBBB' }, + message: { + imageMessage: { + url: `http://127.0.0.1:${cdn.port}${PATH}`, + mediaKey, + mimetype: 'image/jpeg', + fileLength: PLAIN.length, + }, + }, + messageTimestamp: 1_700_000_000, +}); + +async function serviceBehind(host = '127.0.0.1', port = String(proxy.port)) { + const made = await makeService(); + await made.service.setProxy({ enabled: true, host, port, protocol: 'http', username: '', password: '' }); + await made.service.loadProxy(); + return made; +} + +/** A download, and where it went: through the proxy, directly, or nowhere (it failed). */ +async function download(service: any) { + const result = await service + .getBase64FromMediaMessage({ message: imageMessage() }) + .then(() => 'ok') + .catch(() => 'failed'); + return { result, viaProxy: proxy.log.length > 0, reachedCdn: cdn.log.length > 0 }; +} + +describe('a proxied instance never leaves from the server address', () => { + it('a media download while the proxy is being reloaded still goes through it', async () => { + const { service, prisma } = await serviceBehind(); + let release: () => void; + const held = new Promise((r) => (release = r)); + const read = prisma.proxy.findUnique; + prisma.proxy.findUnique = async (args: any) => { + await held; + return read(args); + }; + const reload = service.loadProxy(); + const during = await download(service); + release(); + await reload; + expect(during).toEqual({ result: 'ok', viaProxy: true, reachedCdn: true }); + }); + + it('a proxy row that cannot be read leaves the proxy in force', async () => { + const { service, prisma } = await serviceBehind(); + prisma.proxy.findUnique = async () => { + throw new Error("Can't reach database server"); + }; + await service.loadProxy().catch(() => undefined); + expect(await download(service)).toEqual({ result: 'ok', viaProxy: true, reachedCdn: true }); + }); + + it('a proxyscrape list that cannot be fetched fails the connect instead of connecting directly', async () => { + const { service } = await serviceBehind(listUrl, '80'); + stubAuthState(service); + const outcome = await service.connectToWhatsapp().then( + () => 'connected', + () => 'failed', + ); + const direct = socketSpy.mock.calls.filter(([config]) => !config?.agent).length; + expect({ outcome, direct }).toEqual({ outcome: 'failed', direct: 0 }); + }); + + it('a media download for a proxyscrape proxy with no exit to share fails instead of going directly', async () => { + const { service } = await serviceBehind(listUrl, '80'); + expect(await download(service)).toEqual({ result: 'failed', viaProxy: false, reachedCdn: false }); + }); +}); diff --git a/test/proxy/version-fetch.test.ts b/test/proxy/version-fetch.test.ts new file mode 100644 index 0000000000..563c2df3d7 --- /dev/null +++ b/test/proxy/version-fetch.test.ts @@ -0,0 +1,162 @@ +// Every connect starts by asking WhatsApp Web for its current version +// (fetchLatestWaWebVersion: web.whatsapp.com/sw.js, falling back to Baileys' +// version file on GitHub). On an instance with a proxy that request must leave +// through the proxy too, or every account's connect is announced from the +// server's own IP. The proxy here maps those two hosts to local HTTPS servers. +import { vi } from 'vitest'; + +const { socketSpy, baileysVersion } = vi.hoisted(() => ({ socketSpy: vi.fn(), baileysVersion: { value: undefined } })); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + baileysVersion.value = orig.DEFAULT_CONNECTION_CONFIG.version; + return { ...orig, default: socketSpy, makeWASocket: socketSpy }; +}); + +import net from 'node:net'; + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { connectBehind, fakeSocket, type ProxyProtocol } from '../helpers/connect'; +import { + type Listening, + loopbackOnly, + startHttpProxy, + startHttpsServer, + startSocks5Proxy, + trustTestCertificate, +} from '../helpers/local-net'; + +socketSpy.mockImplementation(fakeSocket); + +const BAILEYS_DEFAULTS = '/WhiskeySockets/Baileys/master/src/Defaults/index.ts'; + +let guard: ReturnType; +let untrust: () => void; +let web: Listening; +let github: Listening; +let swHasRevision = true; +const proxies: Listening[] = []; + +beforeAll(async () => { + guard = loopbackOnly(); + untrust = trustTestCertificate(); + web = await startHttpsServer((req, _body, res) => { + if (req.url !== '/sw.js') return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': 'text/javascript' }); + res.end(swHasRevision ? 'self.__swData=JSON.parse("{\\"client_revision\\":1027654321}");' : 'self.__swData={};'); + }); + github = await startHttpsServer((req, _body, res) => { + if (req.url !== BAILEYS_DEFAULTS) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end(['// 1', '// 2', '// 3', '// 4', '// 5', '// 6', 'const version = [2, 3000, 1011111111]', ''].join('\n')); + }); +}); + +afterAll(async () => { + await web.close(); + await github.close(); + untrust(); + guard.restore(); +}); + +beforeEach(() => { + swHasRevision = true; + guard.refused.splice(0); + web.log.splice(0); + github.log.splice(0); +}); + +afterEach(async () => { + await Promise.all(proxies.splice(0).map((p) => p.close())); +}); + +async function proxyFor(protocol: ProxyProtocol) { + const remap = { + 'web.whatsapp.com:443': `127.0.0.1:${web.port}`, + 'raw.githubusercontent.com:443': `127.0.0.1:${github.port}`, + }; + const proxy = protocol === 'http' ? await startHttpProxy({ remap }) : await startSocks5Proxy({ remap }); + proxies.push(proxy); + return proxy; +} + +const via = (protocol: ProxyProtocol, host: string) => (protocol === 'http' ? `CONNECT ${host}:443` : `SOCKS5 ${host}:443`); + +describe('the WhatsApp Web version fetch on connect', () => { + for (const protocol of ['http', 'socks5'] as ProxyProtocol[]) { + it(`leaves through a ${protocol} proxy`, async () => { + const proxy = await proxyFor(protocol); + const { config } = await connectBehind(socketSpy, { protocol, port: proxy.port }); + expect(guard.refused).toEqual([]); + expect(proxy.log).toEqual([via(protocol, 'web.whatsapp.com')]); + expect(web.log).toEqual(['GET /sw.js']); + expect(config.version).toEqual([2, 3000, 1027654321]); + }); + } + + it('falls back to Baileys version file through the proxy too', async () => { + swHasRevision = false; + const proxy = await proxyFor('http'); + const { config } = await connectBehind(socketSpy, { protocol: 'http', port: proxy.port }); + expect(guard.refused).toEqual([]); + expect(proxy.log).toEqual([via('http', 'web.whatsapp.com'), via('http', 'raw.githubusercontent.com')]); + expect(github.log).toEqual([`GET ${BAILEYS_DEFAULTS}`]); + expect(config.version).toEqual([2, 3000, 1011111111]); + }); +}); + +/** An exit that has died: it accepts the TCP connection and never answers. Logs one line per connection. */ +async function startDeadExit(): Promise { + const log: string[] = []; + const held = new Set(); + const server = net.createServer((s) => { + log.push('connection'); + held.add(s); + s.on('error', () => undefined); + s.on('close', () => held.delete(s)); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + return { + port: (server.address() as net.AddressInfo).port, + log, + close: () => + new Promise((r) => { + held.forEach((s) => s.destroy()); + server.close(() => r()); + }), + }; +} + +// Kept last in the file: before the fix the connect it starts never finishes. +describe('when the exit never answers', () => { + it('gives up on the version fetch after 10s and connects with the version Baileys ships', async () => { + const realSetTimeout = globalThis.setTimeout; + const dead = await startDeadExit(); + proxies.push(dead); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const connecting = connectBehind(socketSpy, { protocol: 'http', port: dead.port }); + let settled = false; + connecting.then( + () => (settled = true), + () => (settled = true), + ); + // Not vi.waitFor: under fake timers it advances the clock while it polls. + for (let i = 0; i < 200 && dead.log.length === 0; i++) await new Promise((r) => realSetTimeout(r, 10)); + expect(dead.log).toEqual(['connection']); + await vi.advanceTimersByTimeAsync(9_999); + const early = settled ? 'connected' : 'waiting'; + await vi.advanceTimersByTimeAsync(1); + const outcome = await Promise.race([ + connecting.then(({ config }) => config.version), + new Promise((r) => realSetTimeout(() => r('still waiting on the version fetch'), 2_000)), + ]); + expect({ early, outcome }).toEqual({ early: 'waiting', outcome: baileysVersion.value }); + expect(guard.refused).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000000..3ca8318bb5 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,9 @@ +// Every test starts from the `minimal` production configuration (test/helpers/profiles.ts). +// A test that needs Evolution to store data asks for the `stored` profile. +import { applyProfile } from './helpers/profiles'; + +process.env.DATABASE_PROVIDER ??= 'postgresql'; +process.env.DATABASE_CONNECTION_URI ??= 'postgresql://unused:unused@127.0.0.1:1/unused'; +process.env.CHATWOOT_ENABLED ??= 'false'; +process.env.TELEMETRY_ENABLED ??= 'false'; +applyProfile('minimal'); diff --git a/test/tools/fixture-guard.ts b/test/tools/fixture-guard.ts new file mode 100644 index 0000000000..7ee7815a9b --- /dev/null +++ b/test/tools/fixture-guard.ts @@ -0,0 +1,195 @@ +// Scans live-check fixtures for what personal data looks like, independently of +// the scrubber that made them: it knows only the scrubber's fake ranges. +// +// an address (JID) whose user part is not a fake: 972500<6>, 100000000<6>, 120363<12> +// a run of 8+ digits that is neither a fake nor an epoch timestamp (s or ms); under a +// message-id key, the scrubber's numeric id fake (two digits, zeros, a short counter) is allowed +// a signed media URL (mmg/pps/media*.whatsapp.net, oh= / oe= parameters) +// an email address +// bytes ({"$bytes"}) of 16+ bytes not tagged fake by the scrubber, and any other +// base64 blob of 24+ characters that is not one of those fake bytes (a camelCase +// identifier is not a blob) +// in a tape (.ndjson), any string that is neither one of the scrubber's fakes (a fake +// address, "Name ", lorem, an example.invalid URL, a fake message id) nor a value +// its field is known to take (live-fields.ts): a username, a group name, a value in +// a field nobody listed. And any decimal number but the tape's own clock (t) and the +// scrubber's fake decimals (0.): a location. +// +// A finding names the file, the line, a masked JSON path and the kind of value, +// never the value itself. Run on every commit by test/live/fixture-guard.test.ts, +// and by scripts/live-guard.ts from lint-staged. +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +import { isCredsKey, isStructural, OWNER_LABEL } from './live-fields'; + +export type Finding = { file: string; line: number; path: string; kind: string }; + +const FAKE_USER = [/^972500\d{6}$/, /^100000000\d{6}$/, /^120363\d{12}$/, /^(0|16505361212|13135550002)$/]; +const JID = /(\d+(?:-\d+)?)(?:[:_]\d+)*@(s\.whatsapp\.net|c\.us|hosted\.lid|hosted|lid|g\.us|broadcast|newsletter)\b/g; +const WA_DOMAIN = /^(s\.whatsapp\.net|c\.us|g\.us|lid|hosted|hosted\.lid|broadcast|newsletter)$/; +const EMAIL = /[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,})/g; +const SIGNED_URL = /(mmg|pps|media[\w.-]*)\.whatsapp\.net|[?&](oh|oe)=/i; +const BASE64 = /^[A-Za-z0-9+/]{24,}={0,2}$/; +/** A camelCase name (receivedPendingNotifications), which BASE64 alone would match. */ +const IDENTIFIER = /^[a-z]+(?:[A-Z][a-z]+)+$/; +/** The scrubber's fake numeric message id: two digits kept, zeros, a short counter. */ +const FAKE_NUMERIC_ID = /^\d{2}0{3,}[1-9]\d{0,3}$/; +const ID_KEYS = new Set(['id', 'stanzaId', 'keyId', 'messageId']); +/** Numbers under these keys are sizes, counts and times, not people. */ +const NUMERIC_KEY = + /(length|size|seconds|duration|count|progress|timestamp|time|^t$|^seq$|at$|height|width|expiration|ttl)/i; + +const LOREM = + 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore '.repeat(64); +const FAKE_NAME = /^Name \d+$/; +const FAKE_URL = /^https:\/\/example\.invalid\/\d+$/; +/** The scrubber's fake id: two characters kept, then a hex counter padded with F (f), or digits: zeros, a counter. */ +const FAKE_ID = /^.{2}(?:F*[0-9A-F]{1,8}|f*[0-9a-f]{1,8})$/; +const WHOLE_JID = + /^\d+(?:-\d+)?(?:[:_]\d+)*@(s\.whatsapp\.net|c\.us|hosted\.lid|hosted|lid|g\.us|broadcast|newsletter)$/; +/** The scrubber's fake decimal: 0.001 to 0.999. */ +const FAKE_DECIMAL = /^0\.\d{1,3}$/; + +/** A tape string the scrubber wrote or kept on purpose: one of its fakes, or a value its field takes. */ +const isKnownValue = (key: string, s: string) => + s === '' || + s === 'Owner' || + s === OWNER_LABEL || + FAKE_NAME.test(s) || + FAKE_URL.test(s) || + WHOLE_JID.test(s) || + LOREM.includes(s) || + ((ID_KEYS.has(key) || s.length >= 12) && (FAKE_ID.test(s) || FAKE_NUMERIC_ID.test(s))) || + isStructural(key, s); + +const isEpoch = (d: string) => /^1\d{9}$/.test(d) || /^1\d{12}$/.test(d); +const isFakeUser = (user: string) => + user.split('-').every((part, i) => (i === 0 ? FAKE_USER.some((r) => r.test(part)) : isEpoch(part))); +/** A path segment that could itself be the leak (an address or a number used as a key) is masked. */ +const mask = (segment: string) => (/\d{5,}|@/.test(segment) ? '' : segment); + +function scanString(s: string, report: (kind: string) => void, fakeBytes: Set) { + let rest = s; + for (const m of s.matchAll(JID)) { + if (!isFakeUser(m[1])) report('address not in the fake ranges'); + rest = rest.replace(m[0], ' '); + } + for (const m of rest.matchAll(EMAIL)) if (!WA_DOMAIN.test(m[1])) report('email address'); + if (SIGNED_URL.test(s)) report('signed media URL'); + for (const run of rest.match(/\d{8,}/g) ?? []) { + if (!isFakeUser(run) && !isEpoch(run)) report('phone-like digit run'); + } + const blob = BASE64.test(s) && /[a-z]/.test(s) && /[A-Z]/.test(s) && !IDENTIFIER.test(s); + if (blob && !fakeBytes.has(s)) report('base64 blob'); +} + +/** The field a value belongs to: the last path segment that is not an array index. */ +const fieldOf = (path: string[]) => [...path].reverse().find((p) => !/^\d+$/.test(p)) ?? ''; + +function walk( + value: any, + path: string[], + report: (kind: string, path: string[]) => void, + fakeBytes: Set, + tape: boolean, +) { + const at = (kind: string) => report(kind, path); + const key = fieldOf(path); + // A commit hash can hold eight digits in a row. + if (typeof value === 'string' && path[path.length - 1] === 'forkCommit' && /^[0-9a-f]{7,40}(-dirty)?$/.test(value)) + return; + if (typeof value === 'string' && ID_KEYS.has(path[path.length - 1]) && FAKE_NUMERIC_ID.test(value)) return; + if (typeof value === 'string') { + let found = false; + scanString(value, (kind) => ((found = true), at(kind)), fakeBytes); + if (tape && !found && !isKnownValue(key, value)) at('value that is neither a fake nor known structure'); + return; + } + if (typeof value === 'number') { + if (tape && !Number.isInteger(value) && !(key === 't' && path.length === 1) && !FAKE_DECIMAL.test(String(value))) { + return at('decimal number (a location?)'); + } + if (!NUMERIC_KEY.test(key)) scanString(String(value), at, fakeBytes); + return; + } + if (Array.isArray(value)) return value.forEach((v, i) => walk(v, [...path, String(i)], report, fakeBytes, tape)); + if (!value || typeof value !== 'object') return; + if (typeof value.$bytes === 'string') { + if (!value.fake && Buffer.from(value.$bytes, 'base64').length >= 16) at('bytes not tagged fake'); + return; + } + if (typeof value.$long === 'string') { + if (!NUMERIC_KEY.test(key)) scanString(value.$long.replace('-', ''), at, fakeBytes); + return; + } + if (typeof value.$redacted === 'string') { + const keys = Array.isArray(value.keys) ? value.keys : []; + if (tape && !keys.every((k: any) => typeof k === 'string' && isCredsKey(k))) { + at('value that is neither a fake nor known structure'); + } + return; + } + for (const [k, v] of Object.entries(value)) { + scanString(k, (kind) => report(kind, [...path, k]), fakeBytes); + walk(v, [...path, k], report, fakeBytes, tape); + } +} + +/** Every fake-tagged byte string in a fixture: a plain copy of one elsewhere is not a leak. */ +function collectFakeBytes(value: any, into: Set) { + if (Array.isArray(value)) return value.forEach((v) => collectFakeBytes(v, into)); + if (!value || typeof value !== 'object') return; + if (typeof value.$bytes === 'string' && value.fake) into.add(value.$bytes); + for (const v of Object.values(value)) collectFakeBytes(v, into); +} + +const filesUnder = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const full = join(dir, name); + return statSync(full).isDirectory() ? filesUnder(full) : [full]; + }); + +/** Parse a file into [line number, JSON value or raw text] units. */ +function unitsOf(file: string): [number, any][] { + const text = readFileSync(file, 'utf8'); + const parse = (s: string) => { + try { + return JSON.parse(s); + } catch { + return s; // not JSON: scanned as text + } + }; + if (file.endsWith('.ndjson')) { + return text.split('\n').flatMap((l, i): [number, any][] => (l.trim() ? [[i + 1, parse(l)]] : [])); + } + return [[1, parse(text)]]; +} + +/** Scan files, or every file under a directory. A missing path has nothing to find. */ +export function scanFixtures(...paths: string[]): Finding[] { + const files = paths.flatMap((p) => (!existsSync(p) ? [] : statSync(p).isDirectory() ? filesUnder(p) : [p])); + const units = files.map((file) => ({ file, units: unitsOf(file) })); + const fakeBytes = new Set(); + for (const { units: us } of units) for (const [, v] of us) collectFakeBytes(v, fakeBytes); + + const findings: Finding[] = []; + for (const { file, units: us } of units) { + for (const [line, value] of us) { + walk( + value, + [], + (kind, path) => + findings.push({ file: relative(process.cwd(), file), line, path: '$.' + path.map(mask).join('.'), kind }), + fakeBytes, + file.endsWith('.ndjson'), + ); + } + } + return findings; +} + +/** One line per finding, no values; empty when there are none. */ +export function formatFindings(findings: Finding[]) { + return findings.map((f) => `${f.file}:${f.line} ${f.kind} at ${f.path}`).join('\n'); +} diff --git a/test/tools/live-fields.ts b/test/tools/live-fields.ts new file mode 100644 index 0000000000..24c2fd7a75 --- /dev/null +++ b/test/tools/live-fields.ts @@ -0,0 +1,96 @@ +// What a live-check tape may keep as written, by field. Shared by the scrubber +// (test/tools/live-scrub.ts), its leak gate and the fixture guard +// (test/tools/fixture-guard.ts): a string is kept only under a field named here +// and only when its value is one this field is known to take. Anything else is +// a person's (a name, a username, a text, an address) until the list says +// otherwise, and a field the scrubber does not know makes it stop, writing +// nothing. Add a field or a value here when a new recording needs one, never a +// pattern that a name or a username could match. +const oneOf = + (...values: string[]) => + (s: string) => + values.includes(s); + +/** Every event Baileys emits and every webhook Evolution sends (Events in src/api/types/wa.types.ts). */ +const EVENTS = new Set( + ` + application.startup instance.create instance.delete qrcode.updated connection.update status.instance + messages.set messages.upsert messages.edited messages.update messages.delete messages.media-update + messages.reaction send.message send.message.update contacts.set contacts.upsert contacts.update + presence.update chats.set chats.update chats.upsert chats.delete chats.lock groups.upsert groups.update + group-participants.update group.join-request group.member-tag.update call typebot.start + typebot.change-status labels.edit labels.association creds.update messaging-history.set + messaging-history.status remove.instance logout.instance blocklist.set blocklist.update + lid-mapping.update message-capping.update message-receipt.update newsletter-participants.update + newsletter-settings.update newsletter.reaction newsletter.view settings.update +` + .trim() + .split(/\s+/), +); +const isEvent = (s: string) => EVENTS.has(s); + +/** The harness's instance name: the scrubber names the instance this, and a replay runs under it. */ +export const REPLAY_INSTANCE = 'test'; + +export const STRUCTURAL: Record boolean> = { + // The tape itself (recorder.ts, codec.ts). + instance: oneOf(REPLAY_INSTANCE), + instanceName: oneOf(REPLAY_INSTANCE), + event: isEvent, + batch: isEvent, + origin: oneOf('app'), + $proto: (s) => /^proto(\.[A-Z][A-Za-z0-9]*)+$/.test(s), + as: oneOf('Buffer', 'Uint8Array'), + $redacted: oneOf('creds'), + // What the recorder puts where a QR, its image or a pairing code was (recorder.ts). + qr: oneOf('$qr'), + code: oneOf('$qr'), + base64: oneOf('$qr'), + pairingCode: oneOf('$pairingCode'), + $date: (s) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(s), + $fn: (s) => /^[A-Za-z_$][\w$]{0,40}$/.test(s), + // Baileys' and Evolution's enums. + connection: oneOf('open', 'connecting', 'close'), + state: oneOf('open', 'connecting', 'close', 'refused'), + addressingMode: oneOf('pn', 'lid'), + type: oneOf('notify', 'append', 'add', 'remove'), + messageType: (s) => s === 'conversation' || /^[a-z][A-Za-z]*Message$/.test(s), + mimetype: (s) => /^[a-z]+\/[a-z0-9.+-]+(;\s*[a-z0-9-]+=[A-Za-z0-9.-]+)*$/.test(s), + source: oneOf('ios', 'android', 'web', 'desktop', 'unknown'), + status: oneOf( + 'ERROR', + 'PENDING', + 'SERVER_ACK', + 'DELIVERY_ACK', + 'READ', + 'PLAYED', + 'DELETED', + 'offer', + 'ringing', + 'timeout', + 'reject', + 'accept', + 'terminate', + ), + action: oneOf('add', 'remove', 'promote', 'demote', 'modify'), + admin: oneOf('admin', 'superadmin'), + owner_country_code: (s) => /^[A-Z]{2}$/.test(s), +}; + +/** Whether `value` under `key` is structure the tape keeps as written. */ +export const isStructural = (key: string, value: string) => !!STRUCTURAL[key]?.(value); + +/** The creds keys a creds.update carries once redacted ({$redacted:'creds', keys}): Baileys' field names. */ +export const isCredsKey = (s: string) => /^[a-z][A-Za-z0-9]{1,40}$/.test(s); + +/** Numbers under these keys are sizes, counts and times, not people. */ +export const NUMERIC_KEY = + /(length|size|seconds|duration|count|progress|timestamp|time|^t$|^seq$|at$|height|width|expiration|ttl)/i; + +/** A place: always replaced, whole degrees included. */ +export const LOCATION_KEYS = new Set(['degreesLatitude', 'degreesLongitude']); + +export const isEpoch = (digits: string) => /^1\d{9}$/.test(digits) || /^1\d{12}$/.test(digits); + +/** WhatsApp's own label for the owner ("You"), which it puts where a name goes. */ +export const OWNER_LABEL = 'VocĂȘ'; diff --git a/test/tools/live-scrub.ts b/test/tools/live-scrub.ts new file mode 100644 index 0000000000..6e9cc1d64c --- /dev/null +++ b/test/tools/live-scrub.ts @@ -0,0 +1,436 @@ +// Turns a raw live-check recording (LIVE_RECORD_DIR///) into a +// fixture that can be committed: test/fixtures/live/-/. +// +// It works on the tapes as written (the tagged codec, never decoded), in three steps: +// 1. collect: pair each person's phone JID with their @lid wherever one object +// names both, so one person keeps one fake index everywhere; +// 2. rewrite every string leaf, every number that could be a person's, and every +// object key that is an address. A string under a field no list here knows +// stops the scrub: nothing is written, and the error names the field's path; +// 3. the leak gate (leakGate), independent of what step 2 decided: every value of +// the raw tapes that is not structure (live-fields.ts) is searched for in every +// value and key of the output. One hit aborts, and nothing is written. +// +// What a value becomes: +// phone JID / @lid / group 972500<6> / 100000000<6> / 120363<12>, device suffix kept; index 0 is the owner +// a number of 7-15 digits the same person's fake digits (kept: epoch timestamps, and sizes and times by field) +// a name or a username "Name ", one per distinct original ("VocĂȘ" kept) +// a message id same first two characters and length, the rest a counter (digits: zeros, then it) +// bytes ($bytes) random bytes of the same length and type, tagged fake +// a URL https://example.invalid/ +// a text (TEXT_KEYS) lorem of the same length +// a location, any other decimal 0., one per distinct original (the tape's own clock, t, is kept) +// structure (live-fields.ts) kept: event names and enums, by field, never by shape alone +// anything else stops the scrub +import { randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; + +import { + isCredsKey, + isEpoch, + isStructural, + LOCATION_KEYS, + NUMERIC_KEY, + OWNER_LABEL, + REPLAY_INSTANCE, +} from './live-fields'; + +export type Operator = { phoneModel?: string; osVersion?: string; whatsappAppVersion?: string; countryCode?: string }; +export type ScrubOptions = { checkId: string; date?: string; outRoot?: string; operator?: Operator }; + +export class LeakError extends Error {} +/** The scrub stopped on a field it does not know. Names the path, never the value. */ +export class UnknownFieldError extends Error {} + +const FAKE_PN = (i: number) => `972500${String(i).padStart(6, '0')}`; +const FAKE_LID = (i: number) => `100000000${String(i).padStart(6, '0')}`; +const FAKE_GROUP = (i: number) => `120363${String(i).padStart(12, '0')}`; +/** Addresses WhatsApp itself uses, never a person's. */ +const SERVICE_USERS = new Set(['0', '16505361212', '13135550002']); + +const JID = /^(\d+(?:-\d+)?)((?:[:_]\d+)*)@(s\.whatsapp\.net|c\.us|hosted|lid|hosted\.lid|g\.us|broadcast|newsletter)$/; +const JID_ANYWHERE = + /(\d+(?:-\d+)?)(?:[:_]\d+)*@(?:s\.whatsapp\.net|c\.us|hosted\.lid|hosted|lid|g\.us|broadcast|newsletter)/g; +const PN_SERVERS = new Set(['s.whatsapp.net', 'c.us', 'hosted']); +const LID_SERVERS = new Set(['lid', 'hosted.lid']); + +/** Key lists, one word per key. */ +const words = (list: string) => new Set(list.trim().split(/\s+/)); +const NAME_KEYS = words(` + name notify verifiedName verifiedBizName pushName username subject profileName fullName + firstName shortName displayName vname +`); +/** A username (remoteJidUsername, participantUsername...) is a name. */ +const isNameKey = (key: string) => NAME_KEYS.has(key) || /Username$/.test(key); +const TEXT_KEYS = words(` + conversation text caption desc description title body matchedText canonicalUrl fileName address + contentText footerText headerText vcard selectedDisplayText optionName comment message msgCall + messageStubParameters instanceId label directPath +`); +const ID_KEYS = words(` + id stanzaId keyId messageId callId +`); + +const isInstanceKey = (key: string) => key === 'instance' || key === 'instanceName'; + +/** A path segment that could itself be the personal part (an address or a number used as a key) is masked. */ +const maskSegment = (segment: string) => (/\d{5,}|@/.test(segment) ? '' : segment); +const pathOf = (path: string[]) => '$.' + path.map(maskSegment).join('.'); + +const LOREM = 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore '; + +/** A disjoint set over "pn:" / "lid:", so one person's forms share a root. */ +class People { + private parent = new Map(); + private index = new Map(); + find(x: string): string { + if (!this.parent.has(x)) this.parent.set(x, x); + const p = this.parent.get(x); + if (p === x) return x; + const root = this.find(p); + this.parent.set(x, root); + return root; + } + union(a: string, b: string) { + const [ra, rb] = [this.find(a), this.find(b)]; + if (ra === rb) return; + // Keep an indexed root (the owner, seeded first) as the root. + if (this.index.has(rb) && !this.index.has(ra)) this.parent.set(ra, rb); + else this.parent.set(rb, ra); + } + indexOf(x: string): number { + const root = this.find(x); + if (!this.index.has(root)) this.index.set(root, this.index.size); + return this.index.get(root); + } + get count() { + return this.index.size; + } +} + +type Line = Record; + +class Scrubber { + readonly people = new People(); + private groups = new Map(); + private names = new Map(); + private texts = new Map(); + private ids = new Map(); + private bytes = new Map(); + private urls = new Map(); + private digits = new Map(); + private decimals = new Map(); + + constructor(private readonly instanceName: string) {} + + /** Step 1: pair phone and @lid where one object names both. */ + collect(value: any) { + if (Array.isArray(value)) return value.forEach((v) => this.collect(v)); + if (!value || typeof value !== 'object' || '$bytes' in value) return; + const pns = new Set(); + const lids = new Set(); + for (const v of Object.values(value)) { + const m = typeof v === 'string' ? JID.exec(v) : null; + if (m && PN_SERVERS.has(m[3])) pns.add(m[1]); + if (m && LID_SERVERS.has(m[3])) lids.add(m[1]); + } + if (pns.size === 1 && lids.size === 1) this.people.union(`pn:${[...pns][0]}`, `lid:${[...lids][0]}`); + for (const v of Object.values(value)) this.collect(v); + } + + seedOwner(owner: { id?: string; lid?: string; name?: string }) { + const pn = owner.id && JID.exec(owner.id); + const lid = owner.lid && JID.exec(owner.lid); + if (pn) this.people.indexOf(`pn:${pn[1]}`); + if (pn && lid) this.people.union(`pn:${pn[1]}`, `lid:${lid[1]}`); + else if (lid) this.people.indexOf(`lid:${lid[1]}`); + if (owner.name) this.names.set(owner.name, 'Owner'); + } + + /** Step 2. `path` is where the value sits, for the error when its field is unknown. */ + rewrite(value: any, key = '', path: string[] = []): any { + if (typeof value === 'string') return this.string(value, key, path); + if (typeof value === 'number') return this.number(value, key, path); + if (Array.isArray(value)) return value.map((v, i) => this.rewrite(v, key, [...path, String(i)])); + if (!value || typeof value !== 'object') return value; + if ('$bytes' in value) return { $bytes: this.fakeBytes(value.$bytes), as: value.as, fake: 1 }; + if ('$long' in value) return { ...value, $long: this.digitString(value.$long, key) }; + if ('$big' in value) return { ...value, $big: this.digitString(value.$big, key) }; + if ('$u' in value || '$fn' in value || '$date' in value) return value; + if ('$redacted' in value) { + // A redacted creds.update keeps the names of the creds fields, and nothing else. + const keys = Array.isArray(value.keys) ? value.keys : []; + const known = value.$redacted === 'creds' && keys.every((k: any) => typeof k === 'string' && isCredsKey(k)); + if (!known || Object.keys(value).some((k) => k !== '$redacted' && k !== 'keys')) { + throw new UnknownFieldError(`unknown field at ${pathOf(path)} (a redacted value): nothing written`); + } + return { $redacted: value.$redacted, keys }; + } + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + const newKey = JID.test(k) || /^\d{7,15}$/.test(k) ? this.string(k, '', path) : k; + out[newKey] = this.rewrite(v, k, [...path, k]); + } + return out; + } + + /** An integer of 7+ digits is a person's unless it is an epoch, or a size or a time by its field; a decimal is a place. */ + private number(n: number, key: string, path: string[]): number { + if (LOCATION_KEYS.has(key) || !Number.isInteger(n)) { + // The tape's own clock: milliseconds since the recording started. + if (key === 't' && path.length === 1) return n; + return this.fakeDecimal(n); + } + if (Math.abs(n) < 1e6) return n; + return Number(this.digitString(String(n), key)); + } + + private digitString(value: string, key: string): string { + const digits = value.replace(/^-/, ''); + if (digits.length < 7 || !/^\d+$/.test(digits)) return value; + if (this.digits.has(digits)) return value.replace(digits, this.digits.get(digits)); + if (isEpoch(digits) || NUMERIC_KEY.test(key)) return value; + return value.replace(digits, this.fakeUser(digits, 's.whatsapp.net')); + } + + private string(s: string, key: string, path: string[]): string { + if (!s) return s; + if (s === this.instanceName && isInstanceKey(key)) return REPLAY_INSTANCE; + const jid = JID.exec(s); + if (jid) return this.fakeJid(jid[1], jid[2], jid[3]); + // A numeric message id (group notifications have them) is an id, not a person. + if (ID_KEYS.has(key) && /^\d{7,}$/.test(s) && !this.digits.has(s)) return this.fakeId(s); + if (/^\+?\d{7,15}$/.test(s)) { + const plus = s.startsWith('+') ? '+' : ''; + const digits = s.slice(plus.length); + if (this.digits.has(digits)) return plus + this.digits.get(digits); + if (isEpoch(digits)) return s; + return plus + this.fakeUser(digits, 's.whatsapp.net'); + } + if (isNameKey(key)) return this.fakeName(s); + if (TEXT_KEYS.has(key)) return this.lorem(s); + if (this.bytes.has(s)) return this.bytes.get(s); + if (s.length >= 8 && (ID_KEYS.has(key) || /^(?=.*\d)[0-9A-F]{12,64}$/.test(s))) return this.fakeId(s); + if (/^https?:\/\//i.test(s)) return this.memo(this.urls, s, (n) => `https://example.invalid/${n}`); + if (isStructural(key, s)) return s; + throw new UnknownFieldError( + `unknown field at ${pathOf(path)} (a string of ${s.length} characters): nothing written. ` + + 'Say what the field is in live-scrub.ts or live-fields.ts, then scrub again.', + ); + } + + private fakeJid(user: string, suffix: string, server: string) { + if (SERVICE_USERS.has(user)) return `${user}${suffix}@${server}`; + return `${this.fakeUser(user, server)}${suffix}@${server}`; + } + + private fakeUser(user: string, server: string): string { + if (server === 'g.us' || server === 'broadcast' || server === 'newsletter') { + const [first, ts] = user.split('-'); + if (ts) return `${this.fakeUser(first, 's.whatsapp.net')}-${ts}`; // an old group: creator phone and time + if (!this.groups.has(user)) this.groups.set(user, this.groups.size + 1); + return this.remember(user, FAKE_GROUP(this.groups.get(user))); + } + if (LID_SERVERS.has(server)) return this.remember(user, FAKE_LID(this.people.indexOf(`lid:${user}`))); + return this.remember(user, FAKE_PN(this.people.indexOf(`pn:${user}`))); + } + + private remember(original: string, fake: string) { + this.digits.set(original, fake); + return fake; + } + + private fakeName(s: string) { + if (s === OWNER_LABEL) return s; + return this.memo(this.names, s, (n) => `Name ${n}`); + } + + private fakeId(s: string) { + return this.memo(this.ids, s, (n) => { + if (/^\d+$/.test(s)) { + const counter = String(n).padStart(s.length - 2, '0'); + return s.slice(0, 2) + counter.slice(2 - s.length); + } + const body = n + .toString(16) + .toUpperCase() + .padStart(s.length - 2, 'F'); + const id = s.slice(0, 2) + body.slice(-(s.length - 2)); + return s === s.toLowerCase() ? id.toLowerCase() : id; + }); + } + + private fakeDecimal(n: number) { + return Number(this.memo(this.decimals, String(n), (i) => `0.${String(((i - 1) % 999) + 1).padStart(3, '0')}`)); + } + + private fakeBytes(b64: string) { + return this.memo(this.bytes, b64, () => randomBytes(Buffer.from(b64, 'base64').length).toString('base64')); + } + + private lorem(s: string) { + return this.memo(this.texts, s, (n) => { + const start = (n * 7) % LOREM.length; + return LOREM.repeat(Math.ceil((s.length + start) / LOREM.length) + 1).slice(start, start + s.length); + }); + } + + private memo(map: Map, s: string, make: (n: number) => string) { + if (!map.has(s)) map.set(s, make(map.size + 1)); + return map.get(s); + } + + counts() { + return { + people: this.people.count, + groups: this.groups.size, + names: this.names.size, + texts: this.texts.size, + messageIds: this.ids.size, + bytes: this.bytes.size, + urls: this.urls.size, + decimals: this.decimals.size, + }; + } +} + +/** + * Step 3, the leak gate. It reads the raw tapes itself and decides on its own what in them could be + * a person's: every string of 4+ characters that is not structure by its field (live-fields.ts), + * the user part of every address, every run of 7+ digits that is not an epoch, every integer of + * 7+ digits that is not an epoch or a size or a time by its field, every decimal but the tape's + * clock, every byte string. Then it looks for each of them in every string, number and key of the + * output. It never asks the scrubber what it replaced. Returns the hits, as file and line only. + */ +export function leakGate( + raw: { events: Line[]; webhooks: Line[]; owner?: Record; instanceName: string }, + files: Record, +): string[] { + const originals = new Set(); + const addDigits = (digits: string, key = '') => { + if (digits.length >= 7 && !isEpoch(digits) && !SERVICE_USERS.has(digits) && !NUMERIC_KEY.test(key)) { + originals.add(digits); + } + }; + const addString = (s: string, key: string) => { + if (!s || s === OWNER_LABEL || isStructural(key, s)) return; + if (s === raw.instanceName && isInstanceKey(key)) return; + for (const m of s.matchAll(JID_ANYWHERE)) for (const part of m[1].split('-')) addDigits(part); + for (const run of s.match(/\d{7,}/g) ?? []) addDigits(run); + if (s.length >= 4) originals.add(s); + }; + const walk = (value: any, key: string, depth: number) => { + if (typeof value === 'string') return addString(value, key); + if (typeof value === 'number') { + if (Number.isInteger(value) && !LOCATION_KEYS.has(key)) return addDigits(String(Math.abs(value)), key); + if (key === 't' && depth === 1) return; + return void originals.add(String(value)); + } + if (Array.isArray(value)) return value.forEach((v) => walk(v, key, depth + 1)); + if (!value || typeof value !== 'object') return; + if (typeof value.$bytes === 'string') return void (value.$bytes.length >= 4 && originals.add(value.$bytes)); + if (typeof value.$long === 'string') return addDigits(value.$long.replace(/^-/, ''), key); + if (typeof value.$big === 'string') return addDigits(value.$big.replace(/^-/, ''), key); + if ('$u' in value || '$fn' in value || '$date' in value || '$redacted' in value) return; + for (const [k, v] of Object.entries(value)) { + if (JID.test(k) || /^\d{7,15}$/.test(k)) addString(k, ''); + walk(v, k, depth + 1); + } + }; + for (const line of [...raw.events, ...raw.webhooks]) walk(line, '', 0); + const owner = raw.owner ?? {}; + for (const key of ['id', 'lid', 'name']) if (typeof owner[key] === 'string') addString(owner[key], key); + const searched = [...originals]; + + const hits: string[] = []; + for (const [file, text] of Object.entries(files)) { + const units = file.endsWith('.ndjson') + ? text.split('\n').flatMap((l, i): [number, any][] => (l.trim() ? [[i + 1, JSON.parse(l)]] : [])) + : [[1, JSON.parse(text)] as [number, any]]; + for (const [line, unit] of units) { + let hit = false; + const check = (s: string) => { + if (!hit && searched.some((o) => s.includes(o))) hit = true; + }; + const scan = (value: any, key: string) => { + if (hit) return; + if (typeof value === 'string') return isStructural(key, value) ? undefined : check(value); + if (typeof value === 'number') return check(String(value)); + if (Array.isArray(value)) return value.forEach((v) => scan(v, key)); + if (!value || typeof value !== 'object') return; + for (const [k, v] of Object.entries(value)) { + check(k); + scan(v, k); + } + }; + scan(unit, ''); + if (hit) hits.push(`${file} line ${line}`); + } + } + return hits; +} + +const readLines = (file: string): Line[] => + existsSync(file) + ? readFileSync(file, 'utf8') + .split('\n') + .filter((l) => l.trim()) + .map((l) => JSON.parse(l)) + : []; + +/** Scrub one raw session. Returns the fixture directory; throws LeakError (writing nothing) on a leak. */ +export function scrubSession(rawDir: string, opts: ScrubOptions) { + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opts.checkId)) throw new Error(`check id must be kebab-case: ${opts.checkId}`); + const date = opts.date ?? new Date().toISOString().slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error('date must be YYYY-MM-DD'); + const operator = opts.operator ?? {}; + if (operator.countryCode !== undefined && !/^\d{1,3}$/.test(operator.countryCode)) { + throw new Error('country code only (1 to 3 digits), never a phone number'); + } + + const instanceName = basename(dirname(rawDir)); + const events = readLines(join(rawDir, 'events.ndjson')); + const webhooks = readLines(join(rawDir, 'webhooks.ndjson')); + const rawManifest = JSON.parse(readFileSync(join(rawDir, 'manifest.json'), 'utf8')); + const ownerFile = join(rawDir, 'owner.json'); + const owner = existsSync(ownerFile) ? JSON.parse(readFileSync(ownerFile, 'utf8')) : {}; + + const scrubber = new Scrubber(instanceName); + scrubber.seedOwner(owner); + for (const line of [...events, ...webhooks]) scrubber.collect(line); + + const files: Record = {}; + const tape = (lines: Line[]) => lines.map((l) => JSON.stringify(scrubber.rewrite(l)) + '\n').join(''); + files['events.ndjson'] = tape(events); + files['webhooks.ndjson'] = tape(webhooks); + const fakeOwner = scrubber.rewrite({ id: owner.id, lid: owner.lid, name: owner.name }); + const manifest = { + ...rawManifest, + checkId: opts.checkId, + date, + phoneModel: operator.phoneModel ?? null, + osVersion: operator.osVersion ?? null, + whatsappAppVersion: operator.whatsappAppVersion ?? null, + countryCode: operator.countryCode ?? null, + replay: { owner: fakeOwner }, + }; + files['manifest.json'] = JSON.stringify(manifest, null, 2) + '\n'; + + // Step 3, the leak gate: fail closed. + const hits = leakGate({ events, webhooks, owner, instanceName }, files); + if (hits.length) { + throw new LeakError( + `leak gate: an original survived in ${hits.length} place(s), nothing written:\n ${hits.join('\n ')}`, + ); + } + + const report = { leakGate: 'pass', events: events.length, webhooks: webhooks.length, ...scrubber.counts() }; + files['scrub-report.json'] = JSON.stringify(report, null, 2) + '\n'; + + const dir = join(opts.outRoot ?? join(process.cwd(), 'test', 'fixtures', 'live'), `${date}-${opts.checkId}`); + if (existsSync(dir)) throw new Error(`${dir} exists: remove it or pick another check id`); + mkdirSync(dir, { recursive: true }); + for (const [file, text] of Object.entries(files)) writeFileSync(join(dir, file), text); + return { dir, report }; +} diff --git a/test/unit/auth-key-atomic-write.test.ts b/test/unit/auth-key-atomic-write.test.ts new file mode 100644 index 0000000000..d6e9515f77 --- /dev/null +++ b/test/unit/auth-key-atomic-write.test.ts @@ -0,0 +1,211 @@ +// The Prisma auth store (the one production runs, DATABASE_SAVE_DATA_INSTANCE=true) +// keeps creds in the database and every signal key (sessions, pre-keys, sender +// keys, app-state sync keys) as a JSON file under INSTANCE_DIR. It wrote a key +// with fs.writeFile over the file in place: the file is truncated first and the +// bytes follow, so a process that dies meanwhile (a crash, an OOM kill, a +// deploy's SIGKILL), a disk that fills up, or two writes of the same key at +// once leave a torn or empty file. The store reads a file that does not parse +// as no key at all, so a torn session key silently becomes a lost session. +// +// A key file on disk must always hold one complete value: the previous one or +// the new one. The crash test proves it the only way it can be proved: a child +// process runs the real store on a real filesystem and writes one key over and +// over with large, distinguishable values (4 MB each, so a write spans many +// syscalls and the window is real), and this process SIGKILLs it at random +// moments, dozens of times, reading the file after each kill. +import { vi } from 'vitest'; + +const tmp = await vi.hoisted(async () => { + const { mkdtempSync, realpathSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + return realpathSync(mkdtempSync(join(tmpdir(), 'evo-auth-atomic-'))); +}); +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +// The child's INSTANCE_DIR is ./instances of its cwd (path.config), and its cwd is `tmp`. +vi.mock('@config/path.config', async (importOriginal) => ({ + ...(await importOriginal()), + INSTANCE_DIR: (await import('node:path')).join(tmp, 'instances'), +})); + +import { type ChildProcess, spawn } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +import { build } from 'esbuild'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { complete, payload } from '../helpers/auth-writer/payload'; + +const REPO = fileURLToPath(new URL('../../', import.meta.url)); +const WRITER = join(tmp, 'writer.cjs'); +/** A session key id as Baileys names one (`session-.`). */ +const ID = '972500000001.0'; +const KEY_FILE = `session-${ID}.json`; +const SIZE = 4_000_000; +const children = new Set(); + +beforeAll(async () => { + // The real store and everything it imports, bundled once; packages stay external and are + // found through NODE_PATH. The logger reads ./package.json from the cwd. + await build({ + entryPoints: [join(REPO, 'test/helpers/auth-writer/writer.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + outfile: WRITER, + packages: 'external', + logLevel: 'error', + plugins: [ + { + name: 'server-module', + setup(b) { + b.onResolve({ filter: /^@api\/server\.module$/ }, () => ({ path: join(REPO, 'test/helpers/auth-writer/server-module.js') })); + }, + }, + ], + }); + writeFileSync(join(tmp, 'package.json'), '{"version":"0.0.0"}'); +}); + +afterAll(() => { + children.forEach((c) => c.kill('SIGKILL')); + rmSync(tmp, { recursive: true, force: true }); +}); + +/** The writer in a child process; `fileSizeBlocks` caps the size of any file it writes (ulimit -f, 512-byte blocks). */ +function writer(args: string[], fileSizeBlocks?: number) { + const env = { ...process.env, NODE_PATH: join(REPO, 'node_modules'), CACHE_REDIS_ENABLED: 'false' }; + const argv = [WRITER, ...args]; + const child = fileSizeBlocks + ? spawn('/bin/sh', ['-c', `ulimit -f ${fileSizeBlocks} && exec "$0" "$@"`, process.execPath, ...argv], { cwd: tmp, env }) + : spawn(process.execPath, argv, { cwd: tmp, env }); + children.add(child); + const lines: string[] = []; + let stderr = ''; + child.stderr.on('data', (d) => (stderr += d)); + const waiting: { prefix: string; resolve: (line: string) => void }[] = []; + createInterface({ input: child.stdout }).on('line', (line) => { + lines.push(line); + for (const w of waiting.filter((w) => line.startsWith(w.prefix))) w.resolve(line); + }); + const exited = new Promise((resolve) => + child.once('exit', (code, signal) => { + children.delete(child); + resolve(signal ?? code); + }), + ); + /** The first line that starts with `prefix`; fails if the child exits first. */ + const line = (prefix: string) => + Promise.race([ + new Promise((resolve) => waiting.push({ prefix, resolve })), + exited.then((how) => Promise.reject(new Error(`writer exited (${how}) before "${prefix}": ${stderr}`))), + ]); + return { child, lines, line, exited, stderr: () => stderr }; +} + +const folder = (session: string) => join(tmp, 'instances', session); +const onDisk = (session: string) => { + const file = join(folder(session), KEY_FILE); + return complete(existsSync(file) ? readFileSync(file, 'utf8') : undefined); +}; +/** Whatever is in the session's folder besides the key file. */ +const strays = (session: string) => readdirSync(folder(session)).filter((f) => f !== KEY_FILE); + +const open = async (session: string) => { + const { default: useMultiFileAuthStatePrisma } = await import('@utils/use-multi-file-auth-state-prisma'); + return useMultiFileAuthStatePrisma(session, null as any); +}; +const read = async (auth: any) => (await auth.state.keys.get('session', [ID]))[ID]; + +describe('a signal key file is never left half-written', () => { + it( + 'killed at random moments while writing, the key file always holds one whole value', + async () => { + const LANES = 4; + const KILLS = 16; + const tally = { kills: 0, whole: 0, torn: 0, empty: 0, missing: 0 }; + let mostStrays = 0; + + await Promise.all( + Array.from({ length: LANES }, async (_, lane) => { + const session = `crash-${lane}`; + await mkdir(folder(session), { recursive: true }); + // The value before the first kill: a complete write of tag 0. + writeFileSync(join(folder(session), KEY_FILE), JSON.stringify(payload(0, SIZE))); + for (let k = 0; k < KILLS; k++) { + // Each child writes its own tags, so a whole value also says who wrote it. + const w = writer(['loop', session, ID, String((lane * KILLS + k + 1) * 1_000_000), String(SIZE)]); + await w.line('ready'); + // The random moment: somewhere in the child's stream of writes. + await new Promise((r) => setTimeout(r, Math.random() * 60)); + w.child.kill('SIGKILL'); + expect(await w.exited).toBe('SIGKILL'); + + const state = onDisk(session); + tally.kills++; + if (typeof state === 'object') tally.whole++; + else tally[state]++; + mostStrays = Math.max(mostStrays, strays(session).length); + } + }), + ); + + expect(tally).toEqual({ kills: LANES * KILLS, whole: LANES * KILLS, torn: 0, empty: 0, missing: 0 }); + // A kill can leave the write it interrupted behind, never more: the next start clears it. + expect(mostStrays).toBeLessThanOrEqual(1); + + // A restart (a new store over the same folder) reads the value on disk, and clears what a kill left. + for (let lane = 0; lane < LANES; lane++) { + const session = `crash-${lane}`; + const state = onDisk(session) as { tag: number }; + const auth = await open(session); + expect(strays(session)).toEqual([]); + expect((await read(auth))?.tag).toBe(state.tag); + } + }, + 120_000, + ); + + it('a write that fails partway (the file size limit) keeps the previous value, reaches the caller, and leaves no temp file', async () => { + const session = 'fault'; + // 1 MiB: tag 1 (1 kB) fits, tag 2 (4 MB) fails with EFBIG after the first MiB is written. + const w = writer(['fault', session, ID, String(SIZE)], 2048); + + expect(await w.exited).toBe(0); + expect(w.stderr()).toBe(''); + expect(w.lines).toEqual(['wrote 1', 'rejected EFBIG']); + expect(onDisk(session)).toEqual({ tag: 1 }); + expect(strays(session)).toEqual([]); + }); + + it('concurrent writes of one key end with the last one, whole', async () => { + const session = 'concurrent'; + const auth = await open(session); + // Each value smaller than the one before, so the last to be asked for is the first to be written. + const size = (tag: number) => 1_000_000 + (12 - tag) * 250_000; + const tags = Array.from({ length: 12 }, (_, i) => i + 1); + + await Promise.all(tags.map((tag) => auth.state.keys.set({ session: { [ID]: payload(tag, size(tag)) } } as any))); + + expect(onDisk(session)).toEqual({ tag: 12 }); + expect(strays(session)).toEqual([]); + expect(await read(auth)).toEqual(payload(12, size(12))); + }); + + it('what was written is read back, by the same store and by a new one after a restart', async () => { + const session = 'restart'; + const auth = await open(session); + await auth.state.keys.set({ session: { [ID]: payload(7, 10_000) } } as any); + expect(await read(auth)).toEqual(payload(7, 10_000)); + + vi.resetModules(); + const restarted = await open(session); + expect(await read(restarted)).toEqual(payload(7, 10_000)); + expect(strays(session)).toEqual([]); + }); +}); diff --git a/test/unit/auth-state-app-state-key.test.ts b/test/unit/auth-state-app-state-key.test.ts new file mode 100644 index 0000000000..9dd037139a --- /dev/null +++ b/test/unit/auth-state-app-state-key.test.ts @@ -0,0 +1,112 @@ +// Every auth-state store Evolution ships revives an app-state sync key with +// AppStateSyncKeyData.create(). After a JSON round trip (a restart, or any read +// back from storage) the key's bytes are a base64 STRING, because JSON.stringify +// calls the proto's toJSON before BufferJSON.replacer sees it. create() keeps the +// string; fromObject() turns it back into bytes (Baileys' own store, +// lib/Utils/use-multi-file-auth-state.js). With the string, Baileys derives the +// wrong keys and skips every app-state patch: saved contact names, labels, mutes, +// archives, all silently gone after the first restart. +// +// Each test saves a real key through the real store, opens the store AGAIN (as a +// restart does) and decodes a real encrypted contact patch with the key it reads. +import { rmSync } from 'node:fs'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { afterAll, describe, expect, it, vi } from 'vitest'; + +import { contactPatch, decodeContactNames, freshAppStateKey } from '../helpers/app-state'; + +// The Prisma store keeps creds in the session table (the fake Prisma's) and, with +// Redis off, keys in files under INSTANCE_DIR (a temp dir here, not the repo). +const tmp = await vi.hoisted(async () => { + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + return mkdtempSync(join(tmpdir(), 'evo-auth-')); +}); +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('@config/path.config', async (importOriginal) => ({ ...(await importOriginal()), INSTANCE_DIR: tmp })); + +const JID = '972500000001@s.whatsapp.net'; +const NAME = 'Dana Levi'; + +type Open = () => Promise<{ keys: any }>; + +/** Save a key through one opening of the store, decode a patch with what a second opening reads. */ +async function saveReloadDecode(open: Open) { + const { keyId, key } = freshAppStateKey(); + const patch = await contactPatch(keyId, key, JID, NAME); + await (await open()).keys.set({ 'app-state-sync-key': { [keyId]: key } }); + const reopened = await open(); + const getKey = async (id: string) => (await reopened.keys.get('app-state-sync-key', [id]))[id]; + return decodeContactNames(patch, getKey); +} + +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +describe('an app-state sync key survives a reload in every auth store', () => { + it('prisma store (creds in the session table, keys in local files)', async () => { + const { default: useMultiFileAuthStatePrisma } = await import('@utils/use-multi-file-auth-state-prisma'); + const open = async () => (await useMultiFileAuthStatePrisma('prisma-session', null as any)).state; + expect(await saveReloadDecode(open)).toEqual([NAME]); + }); + + it('redis-db store (over Evolution cache engine, which serialises as Redis does)', async () => { + const { CacheService } = await import('@api/services/cache.service'); + const { LocalCache } = await import('@cache/localcache'); + const { ConfigService } = await import('@config/env.config'); + const { useMultiFileAuthStateRedisDb } = await import('@utils/use-multi-file-auth-state-redis-db'); + const configService = new ConfigService(); + // A new CacheService per opening, over the same engine, as after a restart with Redis. + const open = async () => + (await useMultiFileAuthStateRedisDb('redis-session', new CacheService(new LocalCache(configService, 'auth-test')))) + .state; + expect(await saveReloadDecode(open)).toEqual([NAME]); + }); + + it('provider-files store (over a local file server that stores what it is sent)', async () => { + // The file provider stores the posted `data` (a JSON string) and serves it back as JSON. + const files = new Map(); + const server = createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + const path = req.url ?? ''; + if (req.method === 'POST') { + const parsed = body ? JSON.parse(body) : {}; + if (typeof parsed.data === 'string') files.set(path, parsed.data); + res.setHeader('content-type', 'application/json'); + return res.end('{}'); + } + if (req.method === 'GET' && files.has(path)) { + res.setHeader('content-type', 'application/json'); + return res.end(files.get(path)); + } + if (req.method === 'DELETE') return files.delete(path), res.end('{}'); + res.statusCode = 404; + res.end('{}'); + }); + }); + server.listen(0, '127.0.0.1'); + await new Promise((r) => server.once('listening', r)); + try { + Object.assign(process.env, { + PROVIDER_ENABLED: 'true', + PROVIDER_HOST: '127.0.0.1', + PROVIDER_PORT: String((server.address() as AddressInfo).port), + PROVIDER_PREFIX: 'test', + }); + const { ConfigService } = await import('@config/env.config'); + const { ProviderFiles } = await import('@api/provider/sessions'); + const { AuthStateProvider } = await import('@utils/use-multi-file-auth-state-provider-files'); + const open = async () => + (await new AuthStateProvider(new ProviderFiles(new ConfigService())).authStateProvider('provider-session')).state; + expect(await saveReloadDecode(open)).toEqual([NAME]); + expect([...files.keys()].some((k) => k.includes('app-state-sync-key-'))).toBe(true); + } finally { + for (const k of ['PROVIDER_ENABLED', 'PROVIDER_HOST', 'PROVIDER_PORT', 'PROVIDER_PREFIX']) delete process.env[k]; + await new Promise((r) => server.close(r)); + } + }); +}); diff --git a/test/unit/auth-state-db-error.test.ts b/test/unit/auth-state-db-error.test.ts new file mode 100644 index 0000000000..55b6ca4901 --- /dev/null +++ b/test/unit/auth-state-db-error.test.ts @@ -0,0 +1,137 @@ +// The Prisma auth store (creds in the session table) is opened +// on every connect and reconnect. It used to turn a failed session read into +// "no session": keyExists and getAuthKey swallowed the error and returned +// false/null, so the store started a fresh, unlinked session (initAuthCreds) +// and wrote it over the linked one as soon as the database answered again. A +// database blip during a reconnect therefore unlinked the account for good. +// +// A failed read must fail the open (so the connect fails and can be retried) +// and must leave the stored creds exactly as they were. A session that is +// genuinely absent still starts fresh: that is how a new instance links. +import { rmSync } from 'node:fs'; + +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { prismaRepository } from '../helpers/fake-server-module'; + +// Creds live in the fake Prisma's session table; with Redis off, keys go to files +// under INSTANCE_DIR (a temp dir here, not the repo). +const tmp = await vi.hoisted(async () => { + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + return mkdtempSync(join(tmpdir(), 'evo-auth-dberr-')); +}); +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('@config/path.config', async (importOriginal) => ({ ...(await importOriginal()), INSTANCE_DIR: tmp })); + +const SESSION = 'linked-session'; +const ME = { id: '972500000000:7@s.whatsapp.net', name: 'Linked account' }; + +const open = async (sessionId = SESSION) => { + const { default: useMultiFileAuthStatePrisma } = await import('@utils/use-multi-file-auth-state-prisma'); + return useMultiFileAuthStatePrisma(sessionId, null as any); +}; + +const storedRow = (sessionId = SESSION) => prismaRepository.session.rows.find((r: any) => r.sessionId === sessionId); + +/** Make the session table's next read fail the way a dropped connection does; later reads succeed. */ +function failNextSessionRead() { + const read = prismaRepository.session.findUnique; + let failed = false; + prismaRepository.session.findUnique = async (args: any) => { + if (!failed) { + failed = true; + throw Object.assign(new Error("Can't reach database server at `127.0.0.1:5432`"), { code: 'P1001' }); + } + return read(args); + }; + return () => void (prismaRepository.session.findUnique = read); +} + +beforeEach(() => void prismaRepository.session.rows.splice(0)); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +describe('the prisma auth store on a database error', () => { + it('a failed session read fails the open and never replaces a linked session', async () => { + // A linked session: creds that carry the account (`me`) and are registered. + const linked = await open(); + Object.assign(linked.state.creds, { me: ME, registered: true }); + await linked.saveCreds(); + const before = storedRow()!.creds; + + const restore = failNextSessionRead(); + let outcome: { opened: boolean; error?: string; me?: unknown }; + try { + outcome = await open().then( + (s) => ({ opened: true, me: s.state.creds.me ?? null }), + (e) => ({ opened: false, error: e?.message }), + ); + } finally { + restore(); + } + // Give any write the store left behind time to land once the database answers again. + await new Promise((r) => setTimeout(r, 10)); + + // The open failed, and nothing was written over the linked creds. + expect({ + ...outcome, + rows: prismaRepository.session.rows.filter((r: any) => r.sessionId === SESSION).length, + credsUnchanged: storedRow()?.creds === before, + }).toEqual({ + opened: false, + error: "Can't reach database server at `127.0.0.1:5432`", + rows: 1, + credsUnchanged: true, + }); + + // The retried connect opens the linked session. + const retried = await open(); + expect(retried.state.creds.me).toEqual(ME); + expect(retried.state.creds.registered).toBe(true); + expect(retried.state.creds.noiseKey).toEqual(linked.state.creds.noiseKey); + }); + + it('a session that is genuinely absent still starts fresh and is stored (control)', async () => { + const fresh = await open('new-session'); + expect(fresh.state.creds.me).toBeUndefined(); + expect(fresh.state.creds.registered).toBe(false); + expect(storedRow('new-session')).toBeDefined(); + }); +}); + +// Writing had the same flaw as reading: saveKey swallowed any error and returned null, so saveCreds +// resolved while the creds were only in memory, and a restart then opened the old ones. And a +// stored creds row that cannot be parsed read as no session at all: the store started fresh and +// wrote the fresh creds over it. +describe('the prisma auth store on a failed write, and on unreadable creds', () => { + it('saveCreds fails when the creds cannot be written', async () => { + const linked = await open(); + Object.assign(linked.state.creds, { me: ME, registered: true }); + const update = prismaRepository.session.update; + prismaRepository.session.update = async () => { + throw Object.assign(new Error("Can't reach database server"), { code: 'P1001' }); + }; + let outcome: string; + try { + outcome = await linked.saveCreds().then( + () => 'saved', + () => 'failed', + ); + } finally { + prismaRepository.session.update = update; + } + expect(outcome).toBe('failed'); + }); + + it('a stored creds row that cannot be parsed fails the open and is left as it is', async () => { + await open(); + const corrupt = '{"noiseKey": {"private": {"type": "Buffer", "data": "tru'; + storedRow()!.creds = corrupt; + const outcome = await open().then( + () => 'opened', + () => 'failed', + ); + expect({ outcome, unchanged: storedRow()!.creds === corrupt }).toEqual({ outcome: 'failed', unchanged: true }); + }); +}); diff --git a/test/unit/logout-marker-atomic.test.ts b/test/unit/logout-marker-atomic.test.ts new file mode 100644 index 0000000000..8c7dd19533 --- /dev/null +++ b/test/unit/logout-marker-atomic.test.ts @@ -0,0 +1,89 @@ +// The pending-logout marker (logout-pending.json, next to the session's key +// files) is what finishes a logout WhatsApp was never told about, after a +// restart that lost the database row's record of it. It was written in place +// (fs.writeFile), so a write that failed partway, a full disk here, left half a +// marker over the whole one: it reads as pending, but with no instance name. +// +// The write fails the way a full disk fails it: the first half of the bytes +// lands, then ENOSPC. It is injected into fs/promises for whichever way the +// marker is written (writeFile on a path, or on an open file handle). +import { vi } from 'vitest'; + +const tmp = await vi.hoisted(async () => { + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + return mkdtempSync(join(tmpdir(), 'evo-marker-atomic-')); +}); +const fault = vi.hoisted(() => ({ armed: false })); + +vi.mock('@config/path.config', async (importOriginal) => ({ ...(await importOriginal()), INSTANCE_DIR: tmp })); +vi.mock('fs/promises', async (importOriginal) => { + const real: any = await importOriginal(); + const enospc = () => Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC', errno: -28, syscall: 'write' }); + const half = (data: any) => { + const bytes = Buffer.from(data); + return bytes.subarray(0, Math.floor(bytes.length / 2)); + }; + const writeFile = async (path: any, data: any, ...rest: any[]) => { + if (!fault.armed) return real.writeFile(path, data, ...rest); + await real.writeFile(path, half(data)); + throw enospc(); + }; + const open = async (...args: any[]) => { + const handle = await real.open(...args); + if (!fault.armed) return handle; + return new Proxy(handle, { + get(target, prop) { + if (prop === 'writeFile' || prop === 'write') { + return async (data: any) => { + await target.write(half(data)); + throw enospc(); + }; + } + const value = Reflect.get(target, prop); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }; + const faulty = { ...real, writeFile, open }; + return { ...faulty, default: faulty }; +}); + +import { readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +import { afterAll, describe, expect, it } from 'vitest'; + +import { LOGOUT_MARKER_FILE, readLogoutMarker, writeLogoutMarker } from '@utils/logout-marker'; + +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +describe('the pending-logout marker is never left half-written', () => { + it('a marker write that fails partway keeps the previous marker, rejects, and leaves no temp file', async () => { + const id = 'inst-marker'; + const first = { instanceName: 'first-name', deleted: false, since: new Date(0).toISOString() }; + const second = { instanceName: 'second-name', deleted: true, since: new Date(1000).toISOString() }; + await writeLogoutMarker(id, first); + + fault.armed = true; + let outcome: string; + try { + outcome = await writeLogoutMarker(id, second).then( + () => 'written', + (error) => error?.code, + ); + } finally { + fault.armed = false; + } + + expect(outcome).toBe('ENOSPC'); + expect(readLogoutMarker(id)).toEqual(first); + expect(readdirSync(join(tmp, id))).toEqual([LOGOUT_MARKER_FILE]); + + // The next write, with room on the disk, replaces it. + await writeLogoutMarker(id, second); + expect(readLogoutMarker(id)).toEqual(second); + expect(readdirSync(join(tmp, id))).toEqual([LOGOUT_MARKER_FILE]); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000000..b330774f15 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,38 @@ +// Evolution's own TypeScript source, run by vitest, against the Baileys that +// npm installed (package.json pins it). BAILEYS_DIR points the same tests at +// another Baileys build, for trying a newer release before bumping the pin. +import { createRequire } from 'node:module'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const root = fileURLToPath(new URL('.', import.meta.url)); +const require = createRequire(import.meta.url); +const baileysDir = process.env.BAILEYS_DIR ?? dirname(require.resolve('baileys/package.json')); +process.env.BAILEYS_RESOLVED_DIR = baileysDir; + +export default defineConfig({ + resolve: { + // Both the package and deep imports (baileys/lib/...) resolve to ONE directory, + // so a test can never mix two Baileys versions. + alias: [ + { find: /^baileys$/, replacement: `${baileysDir}/lib/index.js` }, + { find: /^baileys\/(.*)$/, replacement: `${baileysDir}/$1` }, + { find: /^@api\/(.*)$/, replacement: `${root}src/api/$1` }, + { find: /^@cache\/(.*)$/, replacement: `${root}src/cache/$1` }, + { find: /^@config\/(.*)$/, replacement: `${root}src/config/$1` }, + { find: /^@exceptions$/, replacement: `${root}src/exceptions` }, + { find: /^@libs\/(.*)$/, replacement: `${root}src/libs/$1` }, + { find: /^@utils\/(.*)$/, replacement: `${root}src/utils/$1` }, + { find: /^@validate\/(.*)$/, replacement: `${root}src/validate/$1` }, + ], + }, + test: { + include: ['test/**/*.test.ts'], + globalSetup: ['test/global-setup.ts'], + setupFiles: ['test/setup.ts'], + environment: 'node', + pool: 'forks', + testTimeout: 20000, + }, +});