diff --git a/.github/workflows/release-read-state.yml b/.github/workflows/release-read-state.yml new file mode 100644 index 0000000000..97bb2986a3 --- /dev/null +++ b/.github/workflows/release-read-state.yml @@ -0,0 +1,72 @@ +name: Read-state release candidate + +on: + push: + branches: + - codex/read-state-sync + - feat/group-admin-controls-and-forward + workflow_dispatch: + +permissions: + contents: read + +jobs: + release-image: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + submodules: recursive + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npm run db:generate + env: + DATABASE_PROVIDER: postgresql + + - name: Test read-state contract + run: node --import tsx --test test/read-state.test.ts + + - name: Check code quality + run: npm run lint:check + + - name: Build application + run: npm run build + + - name: Set image names + id: image + shell: bash + run: | + echo "tag=evolution-api-fork:release-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + echo "archive=evolution-api-fork-release-${GITHUB_SHA}.tar.gz" >> "$GITHUB_OUTPUT" + + - name: Build Docker candidate + run: docker build --tag "${{ steps.image.outputs.tag }}" . + + - name: Export image and checksum + shell: bash + run: | + set -euo pipefail + docker save "${{ steps.image.outputs.tag }}" | gzip -1 > "${{ steps.image.outputs.archive }}" + sha256sum "${{ steps.image.outputs.archive }}" > "${{ steps.image.outputs.archive }}.sha256" + + - name: Upload release candidate + uses: actions/upload-artifact@v4 + with: + name: release-image-${{ github.sha }} + path: | + ${{ steps.image.outputs.archive }} + ${{ steps.image.outputs.archive }}.sha256 + if-no-files-found: error + retention-days: 1 + compression-level: 0 diff --git a/package-lock.json b/package-lock.json index c45e8fef38..b25f0f7c97 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", @@ -829,9 +829,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 +2372,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 +2402,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 +2417,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 +2441,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 +2456,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 +2470,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 +2484,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 +2498,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 +2512,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 +2538,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 +2554,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 +2571,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 +2587,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 +2602,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 +2616,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 +2642,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 +2655,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 +2676,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 +2689,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 +2710,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 +2723,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 +2737,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 +2754,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 +2771,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 +2783,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": { @@ -3578,25 +3578,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 +3604,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 +3617,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": { @@ -4758,34 +4751,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", @@ -4895,12 +4860,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", @@ -5814,21 +5773,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 +5796,7 @@ }, "peerDependencies": { "audio-decode": "^2.1.3", - "jimp": "^1.6.0", + "jimp": "^1.6.1", "link-preview-js": "^3.0.0", "sharp": "*" }, @@ -5877,6 +5837,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", @@ -8336,15 +8297,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", @@ -8629,17 +8581,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" @@ -10351,38 +10304,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 +10563,13 @@ "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" - } - }, - "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/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/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" - }, - "bin": { - "pbjs": "bin/pbjs", - "pbts": "bin/pbts" + "protobufjs": "^7.5.5" } }, "node_modules/lilconfig": { @@ -11232,12 +11147,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 +11575,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.13.0", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.13.0.tgz", + "integrity": "sha512-uXRaov9dfjSpQufXIU7sMxVZnh+FilCQv2mXn+K5EJ/decP3dTWrgvPYa5r6MtRbieNSCE708Da4J0u1UGfQIw==", "funding": [ { "type": "github", @@ -11671,80 +11590,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.0.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.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "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": { @@ -12576,19 +12447,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", @@ -12957,15 +12815,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 +12832,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.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "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 +13259,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", @@ -14176,9 +13968,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" @@ -14640,16 +14432,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", @@ -14914,16 +14705,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", @@ -15944,6 +15736,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 +15862,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", diff --git a/package.json b/package.json index 56e32fcc8a..369296c3cd 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,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 +87,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", diff --git a/src/api/abstract/abstract.router.ts b/src/api/abstract/abstract.router.ts index e8449a8c8a..68fdb5c6a4 100644 --- a/src/api/abstract/abstract.router.ts +++ b/src/api/abstract/abstract.router.ts @@ -17,6 +17,21 @@ type DataValidate = { const logger = new Logger('Validate'); +const PROTECTED_INSTANCE_FIELDS = ['instanceName', 'instanceId'] as const; + +function sanitizeUntrustedInput(source: Record | undefined): Record { + if (!source || typeof source !== 'object') return {}; + const sanitized: Record = {}; + for (const [key, value] of Object.entries(source)) { + if ((PROTECTED_INSTANCE_FIELDS as readonly string[]).includes(key)) { + logger.warn(`Ignoring attempt to override protected field "${key}" via untrusted input`); + continue; + } + sanitized[key] = value; + } + return sanitized; +} + export abstract class RouterBroker { constructor() {} public routerPath(path: string, param = true) { @@ -34,10 +49,13 @@ export abstract class RouterBroker { const instance = request.params as unknown as InstanceDto; if (request?.query && Object.keys(request.query).length > 0) { - Object.assign(instance, request.query); + Object.assign(instance, sanitizeUntrustedInput(request.query as Record)); } if (request.originalUrl.includes('/instance/create')) { + // No authenticated instance exists in the path on create, so there is + // nothing to override; the body IS the instance (its name included). + // Sanitizing it here stripped instanceName and broke instance creation. Object.assign(instance, body); } diff --git a/src/api/controllers/group.controller.ts b/src/api/controllers/group.controller.ts index ebe7c03671..f2e9a2eb8e 100644 --- a/src/api/controllers/group.controller.ts +++ b/src/api/controllers/group.controller.ts @@ -5,11 +5,14 @@ import { GroupDescriptionDto, GroupInvite, GroupJid, + GroupJoinApprovalModeDto, + GroupMemberAddModeDto, GroupPictureDto, GroupSendInvite, GroupSubjectDto, GroupToggleEphemeralDto, GroupUpdateParticipantDto, + GroupUpdateParticipantRequestDto, GroupUpdateSettingDto, } from '@api/dto/group.dto'; import { InstanceDto } from '@api/dto/instance.dto'; @@ -78,6 +81,22 @@ export class GroupController { return await this.waMonitor.waInstances[instance.instanceName].toggleEphemeral(update); } + public async updateMemberAddMode(instance: InstanceDto, update: GroupMemberAddModeDto) { + return await this.waMonitor.waInstances[instance.instanceName].updateMemberAddMode(update); + } + + public async updateJoinApprovalMode(instance: InstanceDto, update: GroupJoinApprovalModeDto) { + return await this.waMonitor.waInstances[instance.instanceName].updateJoinApprovalMode(update); + } + + public async findParticipantRequests(instance: InstanceDto, groupJid: GroupJid) { + return await this.waMonitor.waInstances[instance.instanceName].findParticipantRequests(groupJid); + } + + public async updateParticipantRequests(instance: InstanceDto, update: GroupUpdateParticipantRequestDto) { + return await this.waMonitor.waInstances[instance.instanceName].updateParticipantRequests(update); + } + public async leaveGroup(instance: InstanceDto, groupJid: GroupJid) { return await this.waMonitor.waInstances[instance.instanceName].leaveGroup(groupJid); } diff --git a/src/api/controllers/sendMessage.controller.ts b/src/api/controllers/sendMessage.controller.ts index 64aa1c8468..68c55b0a73 100644 --- a/src/api/controllers/sendMessage.controller.ts +++ b/src/api/controllers/sendMessage.controller.ts @@ -1,5 +1,6 @@ import { InstanceDto } from '@api/dto/instance.dto'; import { + ForwardMessageDto, SendAudioDto, SendButtonsDto, SendContactDto, @@ -39,6 +40,10 @@ export class SendMessageController { return await this.waMonitor.waInstances[instanceName].textMessage(data); } + public async forwardMessage({ instanceName }: InstanceDto, data: ForwardMessageDto) { + return await this.waMonitor.waInstances[instanceName].forwardMessage(data); + } + public async sendMedia({ instanceName }: InstanceDto, data: SendMediaDto, file?: any) { if (isBase64(data?.media) && !data?.fileName && data?.mediatype === 'document') { throw new BadRequestException('For base64 the file name must be informed.'); diff --git a/src/api/dto/chat.dto.ts b/src/api/dto/chat.dto.ts index b11f32b054..61301b0214 100644 --- a/src/api/dto/chat.dto.ts +++ b/src/api/dto/chat.dto.ts @@ -24,6 +24,8 @@ export class getBase64FromMediaMessageDto { export class WhatsAppNumberDto { numbers: string[]; + /** Opt-in: add each confirmed number's WhatsApp LID (`@lid` or null) to the response. */ + includeLid?: boolean; } export class NumberDto { @@ -65,9 +67,17 @@ class Key { id: string; fromMe: boolean; remoteJid: string; + /** + * Group receipts address the sender, not the chat: Baileys aggregates read + * receipts by `remoteJid:participant` and rejects a markRead range whose + * not-from-me group key has no participant. Round-trip whatever the provider + * gave us in the original key. + */ + participant?: string; } export class ReadMessageDto { readMessages: Key[]; + lastMessage?: { key: Key; messageTimestamp: number }; } export class LastMessage { diff --git a/src/api/dto/group.dto.ts b/src/api/dto/group.dto.ts index 293329d2ed..b9975d3f12 100644 --- a/src/api/dto/group.dto.ts +++ b/src/api/dto/group.dto.ts @@ -54,3 +54,16 @@ export class GroupUpdateSettingDto extends GroupJid { export class GroupToggleEphemeralDto extends GroupJid { expiration: 0 | 86400 | 604800 | 7776000; } + +export class GroupMemberAddModeDto extends GroupJid { + mode: 'admin_add' | 'all_member_add'; +} + +export class GroupJoinApprovalModeDto extends GroupJid { + mode: 'on' | 'off'; +} + +export class GroupUpdateParticipantRequestDto extends GroupJid { + action: 'approve' | 'reject'; + participants: string[]; +} diff --git a/src/api/dto/sendMessage.dto.ts b/src/api/dto/sendMessage.dto.ts index ba9ecf527c..1ba725dfbb 100644 --- a/src/api/dto/sendMessage.dto.ts +++ b/src/api/dto/sendMessage.dto.ts @@ -50,6 +50,10 @@ export class Metadata { export class SendTextDto extends Metadata { text: string; } +export class ForwardMessageDto { + number: string; + messageId: string; +} export class SendPresence extends Metadata { text: string; } diff --git a/src/api/integrations/channel/whatsapp/app-state-self-heal.ts b/src/api/integrations/channel/whatsapp/app-state-self-heal.ts new file mode 100644 index 0000000000..afb12307b4 --- /dev/null +++ b/src/api/integrations/channel/whatsapp/app-state-self-heal.ts @@ -0,0 +1,346 @@ +/** + * Self-heal for a corrupted WhatsApp app-state (LTHash) collection. + * + * Baileys (decodePatches, Utils/chat-utils.js) reacts to an LTHash mismatch by + * logging a warning, `break`ing out of the patch loop and returning the state it + * had already advanced (new version + the hash of the mismatching patch). The + * caller persists that state. Nothing throws, so resyncAppState never forces a + * snapshot: every later patch starts from a wrong hash and fails the same way, + * forever, while the stored version keeps advancing. Symptom: read/unread + * changes made on the phone (regular_low) stop producing `chats.update`. + * + * The cure is the one we applied by hand: drop `app-state-sync-version-` + * so the next resync starts at v0 and asks the server for a full snapshot. + * + * A resync from v0 has no minimum version, so Baileys (processSyncAction, not an + * initial sync) re-emits EVERY record of the snapshot as a live event: stale + * read/unread markers become chats.update unreadCount 0/-1 "observed now", + * regular_high replays chats.delete / messages.delete for every chat or message + * ever deleted. Those replayed chat/message events are dropped (see + * suppressHealReplay); the state itself is still rebuilt. + * + * This module has no Baileys runtime import so it can be unit-tested alone. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +export const APP_STATE_COLLECTIONS = [ + 'critical_block', + 'critical_unblock_low', + 'regular_high', + 'regular_low', + 'regular', +] as const; +export type AppStateCollection = (typeof APP_STATE_COLLECTIONS)[number]; + +/** + * The exact Baileys warning for a patch mismatch. The snapshot variant + * ("LTHash verification failed on snapshot, continuing with partial state") is + * deliberately NOT matched: a snapshot is already the full resync, so retrying it + * could only loop. + */ +export const LTHASH_PATCH_FAILURE_MSG = 'LTHash verification failed, skipping remaining patches'; + +export type LtHashFailure = { name: AppStateCollection; version?: number }; + +/** Recognises pino-style `warn({ name, version }, msg)` calls carrying the patch failure. */ +export function parseLtHashPatchFailure(args: unknown[]): LtHashFailure | null { + const [obj, msg] = args; + if (msg !== LTHASH_PATCH_FAILURE_MSG || !obj || typeof obj !== 'object') return null; + const { name, version } = obj as { name?: unknown; version?: unknown }; + if (typeof name !== 'string' || !(APP_STATE_COLLECTIONS as readonly string[]).includes(name)) return null; + return { name: name as AppStateCollection, version: typeof version === 'number' ? version : undefined }; +} + +/** + * Wraps a pino logger so `warn` calls are inspected before being forwarded + * untouched. A Proxy (instead of a pino `logMethod` hook) keeps working when the + * configured level is above `warn`, where pino replaces the method with a no-op + * and hooks never run. `child()` loggers are wrapped too. + */ +export function watchAppStateFailures(logger: T, onFailure: (failure: LtHashFailure) => void): T { + return new Proxy(logger, { + get(target, prop) { + const value = Reflect.get(target, prop, target); + if (typeof value !== 'function') return value; + if (prop === 'warn') { + return (...args: unknown[]) => { + try { + const failure = parseLtHashPatchFailure(args); + if (failure) onFailure(failure); + } catch { + // Detection must never break Baileys' own logging. + } + return value.apply(target, args); + }; + } + if (prop === 'child') { + return (...args: unknown[]) => watchAppStateFailures(value.apply(target, args), onFailure); + } + return value.bind(target); + }, + set(target, prop, value) { + (target as Record)[prop] = value; + return true; + }, + }); +} + +/** Stored LTHash state of a collection (opaque here: only `version` is read, the rest is written back as is). */ +export type StoredAppState = { version?: number }; + +export interface AppStateHealTarget { + /** True only while the socket is open; the heal never runs otherwise. */ + isOpen(): boolean; + /** Stored version of the collection, undefined when nothing is stored. */ + readVersion(name: AppStateCollection): Promise; + /** Stored state of the collection, undefined when nothing is stored. */ + readState(name: AppStateCollection): Promise; + /** Removes the stored version (through the socket's key store, so its in-memory cache is cleared too). */ + clearVersion(name: AppStateCollection): Promise; + /** Puts `state` back only if nothing is stored now; true when it did. */ + restoreIfEmpty(name: AppStateCollection, state: StoredAppState): Promise; + /** Runs Baileys' resyncAppState([name], false). */ + resync(name: AppStateCollection): Promise; +} + +/** Structural subset of a Baileys socket, so this file needs no Baileys import. */ +export interface AppStateSocketLike { + authState: { + creds?: { me?: { id?: string } }; + keys: { + get( + type: 'app-state-sync-version', + ids: string[], + ): MaybePromise>; + set(data: { 'app-state-sync-version'?: Record }): MaybePromise; + transaction(work: () => Promise, key: string): Promise; + }; + }; + resyncAppState(collections: AppStateCollection[], isInitialSync: boolean): Promise; +} + +type MaybePromise = T | PromiseLike; + +/** + * Bridges the healer to a socket. Clearing goes through the socket's own key + * store (not Redis directly) so the in-memory makeCacheableSignalKeyStore cache + * is cleared too, inside a transaction on the same mutex key resyncAppState + * uses, so it queues behind a sync that is still running. + */ +export function createSocketHealTarget(socket: AppStateSocketLike, isOpen: () => boolean): AppStateHealTarget { + const { keys, creds } = socket.authState; + const txKey = () => creds?.me?.id || 'resync-app-state'; + const read = async (name: AppStateCollection) => + (await keys.get('app-state-sync-version', [name]))?.[name] ?? undefined; + return { + isOpen, + readVersion: async (name) => (await read(name))?.version, + readState: read, + clearVersion: async (name) => { + await keys.transaction(async () => { + await keys.set({ 'app-state-sync-version': { [name]: null } }); + }, txKey()); + }, + restoreIfEmpty: (name, state) => + keys.transaction(async () => { + if (await read(name)) return false; + await keys.set({ 'app-state-sync-version': { [name]: state } }); + return true; + }, txKey()), + resync: (name) => socket.resyncAppState([name], false), + }; +} + +/** + * Chat/message events a snapshot replay would re-emit as if they were new. + * creds.update, contacts, labels and settings are left alone (current state, + * idempotent for consumers). + */ +export const HEAL_REPLAY_SUPPRESSED_EVENTS: ReadonlySet = new Set([ + 'chats.update', + 'chats.delete', + 'chats.lock', + 'messages.update', + 'messages.delete', +]); + +type HealReplayScope = { active: boolean; suppressed: number }; +const healReplayScope = new AsyncLocalStorage(); +const REPLAY_GUARD = Symbol.for('evolution.appStateHealReplayGuard'); + +type EmitterLike = { emit: (event: any, ...args: any[]) => any }; + +/** + * Drops the events listed above when they are emitted from inside a self-heal + * resync (tracked with AsyncLocalStorage, so live events emitted concurrently by + * other nodes pass untouched). Install once per socket on `socket.ev`: Baileys' + * processSyncAction emits through that same object. + */ +export function suppressHealReplay(ev: T): T { + const marked = ev as T & { [REPLAY_GUARD]?: boolean }; + if (marked[REPLAY_GUARD]) return ev; + const emit = ev.emit; + ev.emit = function (this: unknown, event: unknown, ...args: unknown[]) { + const scope = healReplayScope.getStore(); + if (scope?.active && typeof event === 'string' && HEAL_REPLAY_SUPPRESSED_EVENTS.has(event)) { + scope.suppressed += 1; + return false; + } + return emit.call(this ?? ev, event, ...args); + } as T['emit']; + marked[REPLAY_GUARD] = true; + return ev; +} + +/** Runs `work` as a self-heal replay; returns how many events were suppressed. */ +export async function runAsHealReplay(work: () => Promise): Promise { + const scope: HealReplayScope = { active: true, suppressed: 0 }; + try { + await healReplayScope.run(scope, work); + } finally { + // Timers created inside (Baileys' buffer flush) inherit the store; they must not suppress anything. + scope.active = false; + } + return scope.suppressed; +} + +type HealLog = { warn: (msg: string) => void; error: (msg: string) => void }; + +export interface AppStateHealerOptions { + instanceName: string; + /** Resolved on every attempt: the socket changes on each reconnect. */ + getTarget: () => AppStateHealTarget | undefined; + log: HealLog; + cooldownMs?: number; + maxAttemptsPerHour?: number; + pollMs?: number; + now?: () => number; +} + +const HOUR_MS = 60 * 60 * 1000; + +/** + * One healer per instance: it only ever acts through that instance's target. + * + * Detection happens synchronously inside Baileys' sync transaction, and the + * failing state is committed when that transaction ends. Healing therefore never + * runs from the log callback: it is queued and executed later by a poller that + * is not part of the transaction (a timer started from inside it would inherit + * its AsyncLocalStorage context and write into an already-closed transaction). + */ +export class AppStateSelfHealer { + private readonly pending = new Map(); + private readonly attempts = new Map(); + private timer?: ReturnType; + private busy = false; + + private readonly cooldownMs: number; + private readonly maxAttemptsPerHour: number; + private readonly pollMs: number; + private readonly now: () => number; + + constructor(private readonly options: AppStateHealerOptions) { + this.cooldownMs = options.cooldownMs ?? 10 * 60 * 1000; + this.maxAttemptsPerHour = options.maxAttemptsPerHour ?? 3; + this.pollMs = options.pollMs ?? 5_000; + this.now = options.now ?? Date.now; + } + + /** Synchronous and side-effect free beyond queueing: safe to call from a logger. */ + report(failure: LtHashFailure): void { + this.pending.set(failure.name, failure.version); + } + + get pendingCollections(): AppStateCollection[] { + return [...this.pending.keys()]; + } + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => void this.flush(), this.pollMs); + this.timer.unref?.(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + } + + /** Processes the queue once. Collections stay queued while the socket is not open. */ + async flush(): Promise { + if (this.busy || this.pending.size === 0) return; + const target = this.options.getTarget(); + if (!target || !target.isOpen()) return; + + this.busy = true; + try { + for (const [name, reportedVersion] of [...this.pending]) { + this.pending.delete(name); + await this.heal(target, name, reportedVersion); + } + } finally { + this.busy = false; + } + } + + private async heal(target: AppStateHealTarget, name: AppStateCollection, reportedVersion?: number): Promise { + const { instanceName, log } = this.options; + const now = this.now(); + const recent = (this.attempts.get(name) ?? []).filter((at) => now - at < HOUR_MS); + const last = recent[recent.length - 1]; + + if (last !== undefined && now - last < this.cooldownMs) { + log.warn( + `app-state self-heal: ${instanceName} ${name} skipped (cooldown, last attempt ${Math.round((now - last) / 1000)}s ago)`, + ); + this.attempts.set(name, recent); + return; + } + if (recent.length >= this.maxAttemptsPerHour) { + log.warn(`app-state self-heal: ${instanceName} ${name} skipped (${recent.length} attempts in the last hour)`); + this.attempts.set(name, recent); + return; + } + recent.push(now); + this.attempts.set(name, recent); + + let previous: StoredAppState | undefined; + let cleared = false; + let failure: string | undefined; + try { + previous = await target.readState(name); + const oldVersion = previous?.version ?? reportedVersion; + log.warn(`app-state self-heal: ${instanceName} ${name} v${oldVersion ?? '?'} → snapshot`); + await target.clearVersion(name); + cleared = true; + const suppressed = await runAsHealReplay(() => target.resync(name)); + if (suppressed > 0) { + log.warn(`app-state self-heal: ${instanceName} ${name} suppressed ${suppressed} replayed chat/message events`); + } + const newVersion = await target.readVersion(name); + if (newVersion !== undefined) { + log.warn(`app-state self-heal: ${instanceName} ${name} OK (snapshot restored at v${newVersion})`); + return; + } + failure = 'no state stored after resync'; + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + // Leaving the collection cleared would let the next ordinary resync replay the + // whole snapshot unguarded; the previous (mismatching) state is the lesser evil. + let restored = false; + if (cleared && previous) { + try { + restored = await target.restoreIfEmpty(name, previous); + } catch { + // reported below as not restored + } + } + log.error( + `app-state self-heal: ${instanceName} ${name} FAILED (${failure})` + + (restored ? `; previous state v${previous?.version ?? '?'} restored` : ''), + ); + } +} diff --git a/src/api/integrations/channel/whatsapp/numbers-lid.ts b/src/api/integrations/channel/whatsapp/numbers-lid.ts new file mode 100644 index 0000000000..b447d02889 --- /dev/null +++ b/src/api/integrations/channel/whatsapp/numbers-lid.ts @@ -0,0 +1,98 @@ +/** + * Opt-in LID enrichment for POST /chat/whatsappNumbers (`includeLid: true`). + * + * Kept free of Baileys/Prisma imports so it can be tested in isolation. + */ + +/** Max numbers resolved per request. Each miss costs a USync LID query; WhatsApp may rate-limit bursts. */ +export const WHATSAPP_NUMBERS_LID_LIMIT = 50; + +export type LidPair = { lid?: string | null; pn?: string | null }; + +/** Shape of `sock.signalRepository.lidMapping.getLIDsForPNs` (one batched call, cache -> auth keys -> USync). */ +export type LidBatchLookup = (pns: string[]) => Promise; + +type LidCandidate = { jid: string; exists: boolean }; + +/** `5511999999999:12@s.whatsapp.net` -> `5511999999999`; anything that is not a phone user JID -> null. */ +function phoneUser(jid: unknown): string | null { + if (typeof jid !== 'string' || !jid.endsWith('@s.whatsapp.net')) return null; + const user = jid.split('@')[0].split(':')[0]; + return /^\d{5,20}$/.test(user) ? user : null; +} + +/** `12345:3@lid` -> `12345@lid`. Hosted LIDs and malformed values are rejected. */ +function normalizeLid(lid: unknown): string | null { + if (typeof lid !== 'string' || !lid.endsWith('@lid')) return null; + const user = lid.split('@')[0].split(':')[0]; + return /^\d{5,25}$/.test(user) ? `${user}@lid` : null; +} + +/** + * Resolves the LID of every confirmed (`exists`) phone JID with ONE batched lookup. + * The map key is the JID exactly as WhatsApp confirmed it (so a Brazilian number + * with or without the 9th digit gets the LID of the account that actually exists). + * Never throws: a failed lookup or an unresolved number yields `null`. + */ +export async function resolveLidsForConfirmedJids( + items: LidCandidate[], + lookup: LidBatchLookup, + opts: { limit?: number; warn?: (message: string) => void } = {}, +): Promise> { + const limit = opts.limit ?? WHATSAPP_NUMBERS_LID_LIMIT; + const warn = opts.warn ?? (() => undefined); + const result = new Map(); + + const jidByUser = new Map(); + for (const item of items) { + if (!item?.exists) continue; + // Already addressed by LID: nothing to look up. + if (typeof item.jid === 'string' && item.jid.endsWith('@lid')) { + result.set(item.jid, normalizeLid(item.jid)); + continue; + } + const user = phoneUser(item.jid); + if (user && !jidByUser.has(user)) jidByUser.set(user, item.jid); + result.set(item.jid, null); + } + + const users = [...jidByUser.keys()]; + if (users.length > limit) { + warn(`includeLid: ${users.length} numbers requested, resolving only the first ${limit}; the rest return lid null`); + } + const batch = users.slice(0, limit).map((user) => `${user}@s.whatsapp.net`); + if (batch.length === 0) return result; + + let pairs: LidPair[] | null | undefined; + try { + pairs = await lookup(batch); + } catch (error) { + warn(`includeLid: LID lookup failed for ${batch.length} numbers, returning lid null: ${String(error)}`); + return result; + } + + const lidByUser = new Map(); + for (const pair of Array.isArray(pairs) ? pairs : []) { + const user = phoneUser(pair?.pn); + const lid = normalizeLid(pair?.lid); + if (user && lid && !lidByUser.has(user)) lidByUser.set(user, lid); + } + + for (const item of items) { + if (!item?.exists) continue; + const user = phoneUser(item.jid); + const lid = user ? lidByUser.get(user) : undefined; + if (lid) result.set(item.jid, lid); + } + return result; +} + +/** Adds `lid` to every item. Items that are not confirmed phone numbers (groups, missing) get `lid: null`. */ +export async function attachLids( + items: T[], + lookup: LidBatchLookup, + opts: { limit?: number; warn?: (message: string) => void } = {}, +): Promise & { lid: string | null }>> { + const lids = await resolveLidsForConfirmedJids(items, lookup, opts); + return items.map((item) => ({ ...item, lid: (item.exists && lids.get(item.jid)) || null })); +} diff --git a/src/api/integrations/channel/whatsapp/read-state.ts b/src/api/integrations/channel/whatsapp/read-state.ts new file mode 100644 index 0000000000..464d928cbf --- /dev/null +++ b/src/api/integrations/channel/whatsapp/read-state.ts @@ -0,0 +1,314 @@ +import type { ReadMessageDto } from '@api/dto/chat.dto'; +import type { proto, WASocket } from 'baileys'; + +export class ReadStateValidationError extends Error {} + +type ReadStateClient = Pick; + +function isGroupJid(jid: unknown): boolean { + return typeof jid === 'string' && jid.endsWith('@g.us'); +} + +/** + * Baileys throws on a group read whose not-from-me key has no participant + * (Utils/chat-utils getMessageRange) and aggregates receipts by + * `remoteJid:participant` (Utils/messages). Catch it here so the caller gets a + * 400 naming the missing field instead of an opaque provider failure. + */ +function missingGroupParticipant(key: { remoteJid?: unknown; fromMe?: unknown; participant?: unknown }): boolean { + return ( + isGroupJid(key.remoteJid) && key.fromMe !== true && (typeof key.participant !== 'string' || !key.participant.trim()) + ); +} + +function isChatJid(jid: string): boolean { + // Baileys' isPnUser/isJidGroup/isLidUser check these suffixes. Keep this + // predicate local so focused tests need not load the full Baileys runtime. + return typeof jid === 'string' && /[^@\s]+@(s\.whatsapp\.net|g\.us|lid)$/.test(jid); +} + +/** The legacy request still sends receipts alone. A lastMessage also syncs chat state to linked devices. */ +export async function syncMessageReadState(client: ReadStateClient, data: ReadMessageDto) { + if (!Array.isArray(data.readMessages)) { + throw new ReadStateValidationError('readMessages must be an array'); + } + + const lastMessage = data.lastMessage; + if (lastMessage) { + const { key, messageTimestamp } = lastMessage; + if ( + !key || + typeof key.remoteJid !== 'string' || + !isChatJid(key.remoteJid) || + typeof key.id !== 'string' || + !key.id.trim() || + typeof key.fromMe !== 'boolean' || + !Number.isSafeInteger(messageTimestamp) || + messageTimestamp <= 0 + ) { + throw new ReadStateValidationError('lastMessage requires a valid chat key and epoch-seconds messageTimestamp'); + } + if (missingGroupParticipant(key)) { + throw new ReadStateValidationError('lastMessage from a group requires key.participant when fromMe is false'); + } + if ( + data.readMessages.some( + (read) => + !read || + !isChatJid(read.remoteJid) || + read.remoteJid !== key.remoteJid || + !read.id || + typeof read.fromMe !== 'boolean', + ) + ) { + throw new ReadStateValidationError('readMessages and lastMessage must belong to the same chat'); + } + if (data.readMessages.some((read) => read && missingGroupParticipant(read))) { + throw new ReadStateValidationError('readMessages from a group require participant when fromMe is false'); + } + } else if (data.readMessages.length === 0) { + throw new ReadStateValidationError('readMessages cannot be empty without lastMessage'); + } + + const keys: proto.IMessageKey[] = data.readMessages + .filter((read) => isChatJid(read.remoteJid)) + .map((read) => ({ + remoteJid: read.remoteJid, + fromMe: read.fromMe, + id: read.id, + ...(read.participant ? { participant: read.participant } : {}), + })); + + // Run validation before either external call. A failure in either call propagates to the HTTP request. + if (lastMessage) { + await client.chatModify({ markRead: true, lastMessages: [lastMessage] }, lastMessage.key.remoteJid); + } + if (keys.length > 0 || !lastMessage) { + await client.readMessages(keys); + } + return { message: 'Read messages', read: 'success' }; +} + +export function chatReadStateUpdates( + chats: Array>, + instanceId: string, + readStateObservedAt: string, +) { + return chats.map((chat) => ({ + remoteJid: chat.id, + instanceId, + readStateObservedAt, + ...(Number.isInteger(chat.unreadCount) ? { unreadCount: chat.unreadCount } : {}), + })); +} + +type ChatReadStateUpdate = ReturnType[number]; + +/** + * Chats are keyed by LID on recent accounts, but inbound messages are already + * normalized to the phone JID (key.remoteJidAlt). Emit the phone JID too so + * webhook consumers can match the read state to the same conversation. + * The original LID is kept in `lid`; unresolved LIDs pass through unchanged. + */ +export async function resolveReadStateLids( + updates: ChatReadStateUpdate[], + resolvePn: (lid: string) => Promise, +) { + return Promise.all( + updates.map(async (update) => { + if (typeof update.remoteJid !== 'string' || !update.remoteJid.endsWith('@lid')) return update; + let pn: string | null | undefined; + try { + pn = await resolvePn(update.remoteJid); + } catch { + pn = null; + } + const user = typeof pn === 'string' ? pn.split('@')[0].split(':')[0] : ''; + if (!/^\d{8,15}$/.test(user)) return update; + return { ...update, remoteJid: `${user}@s.whatsapp.net`, lid: update.remoteJid }; + }), + ); +} + +/** + * The inverse of resolveReadStateLids. Webhook consumers only ever see the + * phone JID, but chats migrated to LID addressing are indexed by LID on the + * phone: receipts and app-state patches must address the LID or they target a + * chat the primary device does not know. + */ +export async function toChatAddressingJid( + jid: string, + resolveLid: (pn: string) => Promise, +): Promise { + if (typeof jid !== 'string' || !jid.endsWith('@s.whatsapp.net')) return jid; + let lid: string | null | undefined; + try { + lid = await resolveLid(jid); + } catch { + lid = null; + } + const user = typeof lid === 'string' && lid.endsWith('@lid') ? lid.split('@')[0].split(':')[0] : ''; + return /^\d{8,20}$/.test(user) ? `${user}@lid` : jid; +} + +export async function readRequestToChatAddressing( + data: ReadMessageDto, + resolveLid: (pn: string) => Promise, +): Promise { + const jids = new Set(); + if (data.lastMessage?.key?.remoteJid) jids.add(data.lastMessage.key.remoteJid); + if (Array.isArray(data.readMessages)) + for (const read of data.readMessages) if (read?.remoteJid) jids.add(read.remoteJid); + const mapped = new Map(); + for (const jid of jids) mapped.set(jid, await toChatAddressingJid(jid, resolveLid)); + return { + ...data, + readMessages: Array.isArray(data.readMessages) + ? data.readMessages.map((read) => (read?.remoteJid ? { ...read, remoteJid: mapped.get(read.remoteJid) } : read)) + : data.readMessages, + ...(data.lastMessage?.key + ? { + lastMessage: { + ...data.lastMessage, + key: { + ...data.lastMessage.key, + remoteJid: mapped.get(data.lastMessage.key.remoteJid) ?? data.lastMessage.key.remoteJid, + }, + }, + } + : {}), + } as ReadMessageDto; +} + +/** + * Messages addressed by LID sometimes arrive without remoteJidAlt (history sync + * after a fresh pairing, contacts not yet resolved by the server). Without the + * phone JID, webhook consumers cannot tell who sent the message. Fill it from + * Baileys' LID mapping; leave the key untouched when the mapping is unknown. + */ +export async function fillRemoteJidAlt( + key: { remoteJid?: string | null; remoteJidAlt?: string | null } | null | undefined, + resolvePn: (lid: string) => Promise, +): Promise { + if (!key || typeof key.remoteJid !== 'string' || !key.remoteJid.endsWith('@lid') || key.remoteJidAlt) return; + let pn: string | null | undefined; + try { + pn = await resolvePn(key.remoteJid); + } catch { + return; + } + const user = typeof pn === 'string' ? pn.split('@')[0].split(':')[0] : ''; + if (/^\d{8,15}$/.test(user)) key.remoteJidAlt = `${user}@s.whatsapp.net`; +} + +/** `5511999999999:12@s.whatsapp.net` and `5511999999999@c.us` → `5511999999999@s.whatsapp.net`; LIDs keep their server. */ +export function normalizeAccountJid(jid: unknown): string | null { + if (typeof jid !== 'string') return null; + const at = jid.lastIndexOf('@'); + if (at <= 0) return null; + const user = jid.slice(0, at).split(':')[0].split('_')[0]; + const server = jid.slice(at + 1) === 'c.us' ? 's.whatsapp.net' : jid.slice(at + 1); + return user && server ? `${user}@${server}` : null; +} + +/** The instance's own account in every addressing mode (phone JID and LID), device suffix stripped. */ +export function ownAccountJids(user: { id?: string | null; lid?: string | null } | null | undefined): Set { + const own = new Set(); + for (const jid of [user?.id, user?.lid]) { + const normalized = normalizeAccountJid(jid); + if (normalized) own.add(normalized); + } + return own; +} + +export type ReceiptUpdateLike = { + key?: { remoteJid?: string | null; id?: string | null } | null; + receipt?: { userJid?: string | null; readTimestamp?: unknown } | null; +}; + +export type GroupReadSelf = { remoteJid: string; ids: string[]; readTimestamp: number }; + +/** + * Baileys reports a group receipt as `message-receipt.update` with + * `receipt.userJid` = whoever read (messages-recv.js handleReceipt: the node's + * `participant`). `key.participant` is that same reader and `key.fromMe` is + * always true there, so neither says who wrote the message. A read by the + * instance's own account is the phone reading the group (read-self); a read by + * anyone else is a participant reading OUR message and must not be reported as ours. + */ +export function collectGroupReadSelf( + updates: ReceiptUpdateLike[], + own: Set, + opts: { groupsIgnore?: boolean } = {}, +): GroupReadSelf[] { + if (opts.groupsIgnore || own.size === 0) return []; + const byGroup = new Map(); + for (const update of updates) { + const remoteJid = update?.key?.remoteJid; + const id = update?.key?.id; + const readTimestamp = update?.receipt?.readTimestamp; + if (typeof remoteJid !== 'string' || !remoteJid.endsWith('@g.us')) continue; + if (typeof id !== 'string' || !id || typeof readTimestamp !== 'number' || !Number.isFinite(readTimestamp)) continue; + const reader = normalizeAccountJid(update?.receipt?.userJid); + if (!reader || !own.has(reader)) continue; + + const entry = byGroup.get(remoteJid) ?? { remoteJid, ids: [], readTimestamp }; + if (!entry.ids.includes(id)) entry.ids.push(id); + entry.readTimestamp = Math.max(entry.readTimestamp, readTimestamp); + byGroup.set(remoteJid, entry); + } + return [...byGroup.values()]; +} + +/** + * One webhook per group is enough: the consumer clears the group's unread up to + * the message it is given. A receipt lists many ids with no order guarantee, so + * when a resolver for message timestamps is available the newest stored message + * wins; otherwise (or on any resolver failure) the last id listed is used. + */ +export async function pickLatestMessageId( + ids: string[], + resolveTimestamps?: (ids: string[]) => Promise>, +): Promise { + const last = ids[ids.length - 1]; + if (ids.length < 2 || !resolveTimestamps) return last; + try { + const candidates = ids.slice(-200); + const stamps = await resolveTimestamps(candidates); + let best = last; + let bestStamp = -Infinity; + for (const id of candidates) { + const raw = stamps?.[id]; + if (raw === null || raw === undefined) continue; // Number(null) would read as timestamp 0 + const stamp = Number(raw); + if (Number.isFinite(stamp) && stamp >= bestStamp) { + best = id; + bestStamp = stamp; + } + } + return bestStamp === -Infinity ? last : best; + } catch { + return last; + } +} + +/** Same shape the `messages.update` handler sends for a status change (consumed as MESSAGES_UPDATE). */ +export function buildGroupReadSelfPayload(input: { + keyId: string; + remoteJid: string; + instanceId: string; + readTimestamp: number; + messageId?: string; +}) { + return { + keyId: input.keyId, + remoteJid: input.remoteJid, + fromMe: false, + participant: undefined, + status: 'READ', + pollUpdates: undefined, + instanceId: input.instanceId, + ...(input.messageId ? { messageId: input.messageId } : {}), + readTimestamp: input.readTimestamp, + }; +} diff --git a/src/api/integrations/channel/whatsapp/status-ack.ts b/src/api/integrations/channel/whatsapp/status-ack.ts new file mode 100644 index 0000000000..5fedccaf9e --- /dev/null +++ b/src/api/integrations/channel/whatsapp/status-ack.ts @@ -0,0 +1,79 @@ +/** + * Ack for the top-level `` stanza. + * + * WhatsApp now delivers some accounts' contact status updates (stories) as a + * `` stanza instead of + * ``. Baileys (7.0.0-rc14 and master) registers + * no handler for it (only message, call, receipt, notification and + * `ack,class:message`), so it is never acked. About 50 minutes after the + * connection opens the server closes the stream naming the ack it is missing: + * + * + * + * Baileys maps it to "Stream Errored (ack)" / badSession (500), and on the + * reconnect the server redelivers the same pending stanza offline: the drop + * repeats every ~50 minutes. It does not depend on readStatus/shouldIgnoreJid, + * the stanza never reaches handleMessage. + * + * Upstream: WhiskeySockets/Baileys#2858 (issue), #2866 (open PR, same fix); + * reference implementation tulir/whatsmeow@a238105488 ("handle nodes"). + * + * The ack is a plain ack (no `error` attribute): a NACK keeps the stanza pending + * on the server (see Baileys#2779). It mirrors Baileys' own buildAckStanza. + * Drop this module once a Baileys release registers `CB:status` itself. + */ + +export interface StatusAckNode { + tag: string; + attrs: Record; +} + +export interface StatusAckStanza { + tag: 'ack'; + attrs: Record; +} + +export interface StatusAckSocket { + ws: { + on(event: string, listener: (node: StatusAckNode) => void): unknown; + listenerCount?(event: string): number; + }; + sendNode(stanza: StatusAckStanza): Promise; +} + +/** Same shape as Baileys' buildAckStanza for a node that is not `message`. Null when the node cannot be acked. */ +export function buildStatusAckStanza(node: StatusAckNode): StatusAckStanza | null { + const { attrs } = node ?? {}; + if (!attrs?.id || !attrs.from) return null; + + const stanza: StatusAckStanza = { + tag: 'ack', + attrs: { id: attrs.id, to: attrs.from, class: node.tag }, + }; + if (attrs.participant) stanza.attrs.participant = attrs.participant; + if (attrs.recipient) stanza.attrs.recipient = attrs.recipient; + if (attrs.type) stanza.attrs.type = attrs.type; + return stanza; +} + +/** + * Acks every incoming `` stanza, live or offline. Never throws. + * + * Call it right after makeWASocket. It does nothing and returns false when the socket + * already has a `CB:status` listener: a Baileys release with #2866 registers one inside + * makeWASocket and acks the stanza itself, so registering ours would ack it twice. + */ +export function attachStatusAck(socket: StatusAckSocket, onError: (error: unknown) => void): boolean { + if (socket.ws.listenerCount?.('CB:status')) return false; + socket.ws.on('CB:status', (node) => { + try { + const stanza = buildStatusAckStanza(node); + if (!stanza) return; + // Baileys' sendNode encodes synchronously before returning the send promise. + socket.sendNode(stanza).catch(onError); + } catch (error) { + onError(error); + } + }); + return true; +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc1..c9919ce253 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -22,11 +22,14 @@ import { GroupDescriptionDto, GroupInvite, GroupJid, + GroupJoinApprovalModeDto, + GroupMemberAddModeDto, GroupPictureDto, GroupSendInvite, GroupSubjectDto, GroupToggleEphemeralDto, GroupUpdateParticipantDto, + GroupUpdateParticipantRequestDto, GroupUpdateSettingDto, } from '@api/dto/group.dto'; import { InstanceDto, SetPresenceDto } from '@api/dto/instance.dto'; @@ -34,6 +37,7 @@ import { HandleLabelDto, LabelDto } from '@api/dto/label.dto'; import { Button, ContactMessage, + ForwardMessageDto, KeyType, MediaMessage, Options, @@ -152,7 +156,28 @@ import sharp from 'sharp'; import { PassThrough, Readable } from 'stream'; import { v4 } from 'uuid'; +import { + AppStateSelfHealer, + createSocketHealTarget, + suppressHealReplay, + watchAppStateFailures, +} from './app-state-self-heal'; import { BaileysMessageProcessor } from './baileysMessage.processor'; +import { attachLids } from './numbers-lid'; +import { + buildGroupReadSelfPayload, + chatReadStateUpdates, + collectGroupReadSelf, + fillRemoteJidAlt, + ownAccountJids, + pickLatestMessageId, + readRequestToChatAddressing, + ReadStateValidationError, + resolveReadStateLids, + syncMessageReadState, + toChatAddressingJid, +} from './read-state'; +import { attachStatusAck } from './status-ack'; import { useVoiceCallsBaileys } from './voiceCalls/useVoiceCallsBaileys'; export interface ExtendedIMessageKey extends proto.IMessageKey { @@ -224,6 +249,12 @@ async function getVideoDuration(input: Buffer | string | Readable): Promise = Promise.resolve(); +let __groupMetaLastAt = 0; +const __GROUP_META_MIN_INTERVAL_MS = 2000; + export class BaileysStartupService extends ChannelStartupService { private messageProcessor = new BaileysMessageProcessor(); @@ -251,11 +282,34 @@ export class BaileysStartupService extends ChannelStartupService { private endSession = false; private logBaileys = this.configService.get('LOG').BAILEYS; private eventProcessingQueue: Promise = Promise.resolve(); + // Survives reconnects (the socket is rebuilt each time) so cooldown/attempt limits hold. + private appStateHealer?: AppStateSelfHealer; + + private getAppStateHealer(): AppStateSelfHealer { + this.appStateHealer ??= new AppStateSelfHealer({ + instanceName: this.instance.name, + log: this.logger, + getTarget: () => + this.client + ? createSocketHealTarget(this.client, () => !this.endSession && this.stateConnection?.state === 'open') + : undefined, + }); + return this.appStateHealer; + } // 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 + private _lastStream515At = 0; + + // Reconnect behaviour for the Baileys "stream:error 515" sequence. + // After WhatsApp emits 515 it usually closes with `loggedOut`; that close is *not* a real logout + // and we should reconnect. We treat any close arriving within this grace window as 515-driven. + private static readonly STREAM_515_RECONNECT_GRACE_MS = 30_000; + // The numeric WhatsApp stream-error code that triggers the grace-period reconnect above. + private static readonly STREAM_ERROR_CODE_RECONNECT = '515'; + public stateConnection: wa.StateConnection = { state: 'close' }; public phoneNumber: string; @@ -424,9 +478,15 @@ export class BaileysStartupService extends ChannelStartupService { } if (connection === 'close') { + this.appStateHealer?.stop(); const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; - const shouldReconnect = !codesToNotReconnect.includes(statusCode); + // If a stream:error 515 (Baileys' "restart needed" handshake) just fired, + // a follow-up loggedOut is the expected restart signal — not an actual + // logout — so reconnect anyway. + const recentStream515 = Date.now() - this._lastStream515At < BaileysStartupService.STREAM_515_RECONNECT_GRACE_MS; + const shouldReconnect = + !codesToNotReconnect.includes(statusCode) || (statusCode === DisconnectReason.loggedOut && recentStream515); if (shouldReconnect) { await this.connectToWhatsapp(this.phoneNumber); } else { @@ -553,6 +613,20 @@ export class BaileysStartupService extends ChannelStartupService { } } + public async forwardMessage(data: ForwardMessageDto) { + try { + const fullMsg = (await this.getMessage({ id: data.messageId }, true)) as unknown as WAMessage; + if (!fullMsg?.message) { + throw new BadRequestException('Message not found'); + } + const number = data.number.replace(/\D/g, ''); + const jid = data.number.includes('@') ? data.number : `${number}@s.whatsapp.net`; + return await this.client.sendMessage(jid, { forward: fullMsg }); + } catch (error) { + throw new BadRequestException('Error forwarding message', error.toString()); + } + } + private async defineAuthState() { const db = this.configService.get('DATABASE'); const cache = this.configService.get('CACHE'); @@ -638,7 +712,10 @@ export class BaileysStartupService extends ChannelStartupService { const socketConfig: UserFacingSocketConfig = { ...options, version, - logger: P({ level: this.logBaileys }), + // Watches Baileys' warnings for an app-state LTHash mismatch (see app-state-self-heal.ts). + logger: watchAppStateFailures(P({ level: this.logBaileys }), (failure) => { + this.getAppStateHealer().report(failure); + }), printQRInTerminal: false, auth: { creds: this.instance.authState.state.creds, @@ -646,6 +723,11 @@ export class BaileysStartupService extends ChannelStartupService { }, msgRetryCounterCache: this.msgRetryCounterCache, generateHighQualityLinkPreview: true, + // Baileys ships with app-state MAC checks off. With them off, an app-state + // patch decoded with the wrong key is dropped silently and our own patches + // can go out inconsistent (the server answers 401 device_removed). Fail + // closed: an invalid patch MAC makes Baileys refetch a snapshot instead. + appStateMacVerification: { patch: true, snapshot: true }, getMessage: async (key) => (await this.getMessage(key)) as Promise, ...browserOptions, markOnlineOnConnect: this.localSettings.alwaysOnline, @@ -696,6 +778,13 @@ export class BaileysStartupService extends ChannelStartupService { this.endSession = false; this.client = makeWASocket(socketConfig); + // A self-heal snapshot replays every record as a live event (stale reads, old deletes): drop those. + suppressHealReplay(this.client.ev); + this.getAppStateHealer().start(); + // Baileys never acks the top-level stanza: the server drops the stream ~50 min later (Baileys#2858). + if (!attachStatusAck(this.client as any, (error) => this.logger.warn(`status ack failed: ${String(error)}`))) { + this.logger.info('Baileys already handles CB:status: status-ack.ts is no longer needed'); + } if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) { useVoiceCallsBaileys(this.localSettings.wavoipToken, this.client, this.connectionStatus.state as any, true); @@ -703,6 +792,12 @@ export class BaileysStartupService extends ChannelStartupService { this.eventHandler(); + this.client.ws.on('CB:stream:error', (node: { attrs?: { code?: string | number } }) => { + if (String(node?.attrs?.code) === BaileysStartupService.STREAM_ERROR_CODE_RECONNECT) { + this._lastStream515At = Date.now(); + } + }); + this.client.ws.on('CB:call', (packet) => { console.log('CB:call', packet); const payload = { event: 'CB:call', packet: packet }; @@ -781,9 +876,11 @@ export class BaileysStartupService extends ChannelStartupService { } >[], ) => { - const chatsRaw = chats.map((chat) => { - return { remoteJid: chat.id, instanceId: this.instanceId }; - }); + const readStateObservedAt = new Date().toISOString(); + const chatsRaw = await resolveReadStateLids( + chatReadStateUpdates(chats, this.instanceId, readStateObservedAt), + (lid) => this.client.signalRepository.lidMapping.getPNForLID(lid), + ); this.sendDataWebhook(Events.CHATS_UPDATE, chatsRaw); @@ -1034,13 +1131,16 @@ export class BaileysStartupService extends ChannelStartupService { continue; } + await fillRemoteJidAlt(m.key, (lid) => this.client.signalRepository.lidMapping.getPNForLID(lid)); + if (!m.pushName && !m.key.fromMe) { const participantJid = m.participant || m.key.participant || m.key.remoteJid; - if (participantJid && contactsMap.has(participantJid)) { - m.pushName = contactsMap.get(participantJid).name; - } else if (participantJid) { - m.pushName = participantJid.split('@')[0]; - } + // Contacts may be keyed by the phone JID while the message is LID-addressed. + const known = [participantJid, m.key.participantAlt, m.key.remoteJidAlt].find( + (jid) => jid && contactsMap.has(jid) && contactsMap.get(jid).name, + ); + // No digits fallback: a JID is not a name, and consumers would store it as one. + if (known) m.pushName = contactsMap.get(known).name; } messagesRaw.push(this.prepareMessage(m)); @@ -1475,6 +1575,7 @@ export class BaileysStartupService extends ChannelStartupService { this.logger.verbose(messageRaw); sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`); + await fillRemoteJidAlt(messageRaw.key, (lid) => this.client.signalRepository.lidMapping.getPNForLID(lid)); if (messageRaw.key.remoteJid?.includes('@lid') && messageRaw.key.remoteJidAlt) { messageRaw.key.remoteJid = messageRaw.key.remoteJidAlt; } @@ -1939,6 +2040,8 @@ export class BaileysStartupService extends ChannelStartupService { this.updateMessagesReadedByTimestamp(remoteJid, remotesJidMap[remoteJid]), ), ); + + await this.forwardGroupReadSelf(payload, settings); } if (events['presence.update']) { @@ -3670,20 +3773,27 @@ export class BaileysStartupService extends ChannelStartupService { ); } + // Opt-in only: the default response shape must not change for existing callers. + // Runs after the cache write so the real LID never leaks into IsOnWhatsapp.lid (a 'lid' marker there). + if (data.includeLid === true) { + return await attachLids(onWhatsapp, (pns) => this.client.signalRepository.lidMapping.getLIDsForPNs(pns), { + warn: (message) => this.logger.warn(message), + }); + } + return onWhatsapp; } public async markMessageAsRead(data: ReadMessageDto) { try { - const keys: proto.IMessageKey[] = []; - data.readMessages.forEach((read) => { - if (isJidGroup(read.remoteJid) || isPnUser(read.remoteJid)) { - keys.push({ remoteJid: read.remoteJid, fromMe: read.fromMe, id: read.id }); - } - }); - await this.client.readMessages(keys); - return { message: 'Read messages', read: 'success' }; + const addressed = await readRequestToChatAddressing(data, (pn) => + this.client.signalRepository.lidMapping.getLIDForPN(pn), + ); + return await syncMessageReadState(this.client, addressed); } catch (error) { + if (error instanceof ReadStateValidationError) { + throw new BadRequestException(error.message); + } throw new InternalServerErrorException('Read messages fail', error.toString()); } } @@ -3749,7 +3859,7 @@ export class BaileysStartupService extends ChannelStartupService { last_message = await this.getLastMessage(number); } else { last_message = data.lastMessage; - last_message.messageTimestamp = last_message?.messageTimestamp ?? Date.now(); + last_message.messageTimestamp = last_message?.messageTimestamp ?? Math.floor(Date.now() / 1000); number = last_message?.key?.remoteJid; } @@ -3757,7 +3867,15 @@ export class BaileysStartupService extends ChannelStartupService { throw new NotFoundException('Last message not found'); } - await this.client.chatModify({ markRead: false, lastMessages: [last_message] }, createJid(number)); + const resolveLid = (pn: string) => this.client.signalRepository.lidMapping.getLIDForPN(pn); + const chatJid = await toChatAddressingJid(createJid(number), resolveLid); + if (last_message?.key?.remoteJid) { + last_message = { + ...last_message, + key: { ...last_message.key, remoteJid: await toChatAddressingJid(last_message.key.remoteJid, resolveLid) }, + }; + } + await this.client.chatModify({ markRead: false, lastMessages: [last_message] }, chatJid); return { chatId: number, markedChatUnread: true }; } catch (error) { @@ -4285,21 +4403,27 @@ export class BaileysStartupService extends ChannelStartupService { // Group 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 }); + const task = __groupMetaChain.then(async () => { + const wait = __GROUP_META_MIN_INTERVAL_MS - (Date.now() - __groupMetaLastAt); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + __groupMetaLastAt = Date.now(); + try { + const meta = await this.client.groupMetadata(groupJid); + const cacheConf = this.configService.get('CACHE'); + if ((cacheConf?.REDIS?.ENABLED && cacheConf?.REDIS?.URI !== '') || cacheConf?.LOCAL?.ENABLED) { + await groupMetadataCache.set(groupJid, { timestamp: Date.now(), data: meta }); + } + return meta; + } catch (error) { + this.logger.error(error); + return null; } - - return meta; - } catch (error) { - this.logger.error(error); - return null; - } + }); + __groupMetaChain = task.then( + () => undefined, + () => undefined, + ); + return task; } private getGroupMetadataCache = async (groupJid: string) => { @@ -4432,6 +4556,15 @@ export class BaileysStartupService extends ChannelStartupService { descId: group.descId, restrict: group.restrict, announce: group.announce, + joinApprovalMode: group.joinApprovalMode, + memberAddMode: group.memberAddMode, + // In complete Baileys metadata, an absent ephemeral node means disabled. + ephemeralDuration: + typeof group.joinApprovalMode === 'boolean' && typeof group.memberAddMode === 'boolean' + ? group.ephemeralDuration === undefined + ? 0 + : group.ephemeralDuration + : group.ephemeralDuration, participants: group.participants, isCommunity: group.isCommunity, isCommunityAnnounce: group.isCommunityAnnounce, @@ -4450,7 +4583,8 @@ export class BaileysStartupService extends ChannelStartupService { let groups = []; for (const group of fetch) { - const picture = await this.profilePicture(group.id); + const wantDetails = getParticipants.getParticipants == 'true'; + const picture = wantDetails ? await this.profilePicture(group.id) : null; const result = { id: group.id, @@ -4465,6 +4599,15 @@ export class BaileysStartupService extends ChannelStartupService { descId: group.descId, restrict: group.restrict, announce: group.announce, + joinApprovalMode: group.joinApprovalMode, + memberAddMode: group.memberAddMode, + // In complete Baileys metadata, an absent ephemeral node means disabled. + ephemeralDuration: + typeof group.joinApprovalMode === 'boolean' && typeof group.memberAddMode === 'boolean' + ? group.ephemeralDuration === undefined + ? 0 + : group.ephemeralDuration + : group.ephemeralDuration, isCommunity: group.isCommunity, isCommunityAnnounce: group.isCommunityAnnounce, linkedParent: group.linkedParent, @@ -4597,6 +4740,43 @@ export class BaileysStartupService extends ChannelStartupService { } } + public async updateMemberAddMode(update: GroupMemberAddModeDto) { + try { + await this.client.groupMemberAddMode(update.groupJid, update.mode); + return { success: true }; + } catch (error) { + throw new BadRequestException('Error updating member add mode', error.toString()); + } + } + + public async updateJoinApprovalMode(update: GroupJoinApprovalModeDto) { + try { + await this.client.groupJoinApprovalMode(update.groupJid, update.mode); + return { success: true }; + } catch (error) { + throw new BadRequestException('Error updating join approval mode', error.toString()); + } + } + + public async findParticipantRequests(id: GroupJid) { + try { + const requests = await this.client.groupRequestParticipantsList(id.groupJid); + return { requests: requests || [] }; + } catch (error) { + throw new BadRequestException('Error fetching participant requests', error.toString()); + } + } + + public async updateParticipantRequests(update: GroupUpdateParticipantRequestDto) { + try { + const participants = update.participants.map((p) => (p.includes('@') ? p : `${p}@s.whatsapp.net`)); + const result = await this.client.groupRequestParticipantsUpdate(update.groupJid, participants, update.action); + return { updateParticipantRequests: result }; + } catch (error) { + throw new BadRequestException('Error updating participant requests', error.toString()); + } + } + public async leaveGroup(id: GroupJid) { try { await this.client.groupLeave(id.groupJid); @@ -4731,6 +4911,45 @@ export class BaileysStartupService extends ChannelStartupService { } } + /** + * The phone reading a group arrives as a receipt whose reader is our own account. + * Unlike 1:1 chats (messages.update), Baileys emits no messages.update for it, so + * forward it as the same MESSAGES_UPDATE READ webhook, once per group per batch, + * for the newest message of the batch. + */ + private async forwardGroupReadSelf(updates: MessageUserReceiptUpdate[], settings: any): Promise { + try { + const own = ownAccountJids(this.client?.user); + for (const group of collectGroupReadSelf(updates, own, { groupsIgnore: settings?.groupsIgnore })) { + const keyId = await pickLatestMessageId(group.ids, async (ids) => { + const rows = (await this.prismaRepository.$queryRaw` + SELECT "key"->>'id' AS "id", "messageTimestamp" AS "ts" FROM "Message" + WHERE "instanceId" = ${this.instanceId} + AND "key"->>'remoteJid' = ${group.remoteJid} + AND "key"->>'id' = ANY(${ids}) + `) as { id: string; ts: number | bigint | null }[]; + return Object.fromEntries(rows.map((row) => [row.id, row.ts === null ? null : Number(row.ts)])); + }); + + const dedupKey = `${this.instance.id}_${keyId}_READ_SELF`; + if (await this.baileysCache.get(dedupKey)) continue; + await this.baileysCache.set(dedupKey, true, this.UPDATE_CACHE_TTL_SECONDS); + + this.sendDataWebhook( + Events.MESSAGES_UPDATE, + buildGroupReadSelfPayload({ + keyId, + remoteJid: group.remoteJid, + instanceId: this.instanceId, + readTimestamp: group.readTimestamp, + }), + ); + } + } catch (error) { + this.logger.error(`group read-self forward failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + private async updateMessagesReadedByTimestamp(remoteJid: string, timestamp?: number): Promise { if (timestamp === undefined || timestamp === null) return 0; diff --git a/src/api/routes/group.router.ts b/src/api/routes/group.router.ts index 7086b11769..c16d951d18 100644 --- a/src/api/routes/group.router.ts +++ b/src/api/routes/group.router.ts @@ -6,11 +6,14 @@ import { GroupDescriptionDto, GroupInvite, GroupJid, + GroupJoinApprovalModeDto, + GroupMemberAddModeDto, GroupPictureDto, GroupSendInvite, GroupSubjectDto, GroupToggleEphemeralDto, GroupUpdateParticipantDto, + GroupUpdateParticipantRequestDto, GroupUpdateSettingDto, } from '@api/dto/group.dto'; import { groupController } from '@api/server.module'; @@ -21,10 +24,13 @@ import { groupInviteSchema, groupJidSchema, groupSendInviteSchema, + joinApprovalModeSchema, + memberAddModeSchema, toggleEphemeralSchema, updateGroupDescriptionSchema, updateGroupPictureSchema, updateGroupSubjectSchema, + updateParticipantRequestSchema, updateParticipantsSchema, updateSettingsSchema, } from '@validate/validate.schema'; @@ -186,6 +192,46 @@ export class GroupRouter extends RouterBroker { res.status(HttpStatus.CREATED).json(response); }) + .post(this.routerPath('memberAddMode'), ...guards, async (req, res) => { + const response = await this.groupValidate({ + request: req, + schema: memberAddModeSchema, + ClassRef: GroupMemberAddModeDto, + execute: (instance, data) => groupController.updateMemberAddMode(instance, data), + }); + + res.status(HttpStatus.CREATED).json(response); + }) + .post(this.routerPath('joinApprovalMode'), ...guards, async (req, res) => { + const response = await this.groupValidate({ + request: req, + schema: joinApprovalModeSchema, + ClassRef: GroupJoinApprovalModeDto, + execute: (instance, data) => groupController.updateJoinApprovalMode(instance, data), + }); + + res.status(HttpStatus.CREATED).json(response); + }) + .get(this.routerPath('participantRequests'), ...guards, async (req, res) => { + const response = await this.groupValidate({ + request: req, + schema: groupJidSchema, + ClassRef: GroupJid, + execute: (instance, data) => groupController.findParticipantRequests(instance, data), + }); + + res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('updateParticipantRequests'), ...guards, async (req, res) => { + const response = await this.groupValidate({ + request: req, + schema: updateParticipantRequestSchema, + ClassRef: GroupUpdateParticipantRequestDto, + execute: (instance, data) => groupController.updateParticipantRequests(instance, data), + }); + + res.status(HttpStatus.CREATED).json(response); + }) .delete(this.routerPath('leaveGroup'), ...guards, async (req, res) => { const response = await this.groupValidate({ request: req, diff --git a/src/api/routes/sendMessage.router.ts b/src/api/routes/sendMessage.router.ts index cd073dba3d..dee71cbf08 100644 --- a/src/api/routes/sendMessage.router.ts +++ b/src/api/routes/sendMessage.router.ts @@ -1,5 +1,6 @@ import { RouterBroker } from '@api/abstract/abstract.router'; import { + ForwardMessageDto, SendAudioDto, SendButtonsDto, SendContactDto, @@ -19,6 +20,7 @@ import { audioMessageSchema, buttonsMessageSchema, contactMessageSchema, + forwardMessageSchema, listMessageSchema, locationMessageSchema, mediaMessageSchema, @@ -61,6 +63,16 @@ export class MessageRouter extends RouterBroker { return res.status(HttpStatus.CREATED).json(response); }) + .post(this.routerPath('forwardMessage'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: forwardMessageSchema, + ClassRef: ForwardMessageDto, + execute: (instance, data) => sendMessageController.forwardMessage(instance, data), + }); + + return res.status(HttpStatus.CREATED).json(response); + }) .post(this.routerPath('sendMedia'), ...guards, upload.single('file'), async (req, res) => { const bodyData = req.body; diff --git a/src/utils/use-multi-file-auth-state-prisma.ts b/src/utils/use-multi-file-auth-state-prisma.ts index d090780234..bd11afa2fe 100644 --- a/src/utils/use-multi-file-auth-state-prisma.ts +++ b/src/utils/use-multi-file-auth-state-prisma.ts @@ -182,7 +182,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..17f5d126c6 100644 --- a/src/validate/chat.schema.ts +++ b/src/validate/chat.schema.ts @@ -38,6 +38,10 @@ export const whatsappNumberSchema: JSONSchema7 = { description: '"numbers" must be an array of numeric strings', }, }, + includeLid: { + type: 'boolean', + description: '"includeLid" must be a boolean', + }, }, }; @@ -47,20 +51,38 @@ export const readMessageSchema: JSONSchema7 = { properties: { readMessages: { type: 'array', - minItems: 1, uniqueItems: true, items: { properties: { id: { type: 'string' }, fromMe: { type: 'boolean', enum: [true, false] }, remoteJid: { type: 'string' }, + participant: { type: 'string', minLength: 1 }, }, required: ['id', 'fromMe', 'remoteJid'], ...isNotEmpty('id', 'remoteJid'), }, }, + lastMessage: { + type: 'object', + properties: { + key: { + type: 'object', + properties: { + id: { type: 'string', minLength: 1 }, + fromMe: { type: 'boolean' }, + remoteJid: { type: 'string', minLength: 1 }, + participant: { type: 'string', minLength: 1 }, + }, + required: ['id', 'fromMe', 'remoteJid'], + }, + messageTimestamp: { type: 'integer', minimum: 1 }, + }, + required: ['key', 'messageTimestamp'], + }, }, required: ['readMessages'], + anyOf: [{ properties: { readMessages: { minItems: 1 } } }, { required: ['lastMessage'] }], }; export const archiveChatSchema: JSONSchema7 = { @@ -109,7 +131,7 @@ export const markChatUnreadSchema: JSONSchema7 = { required: ['id', 'fromMe', 'remoteJid'], ...isNotEmpty('id', 'remoteJid'), }, - messageTimestamp: { type: 'integer', minLength: 1 }, + messageTimestamp: { type: 'integer', minimum: 1 }, }, required: ['key'], ...isNotEmpty('messageTimestamp'), diff --git a/src/validate/group.schema.ts b/src/validate/group.schema.ts index 8da188d87f..75db868953 100644 --- a/src/validate/group.schema.ts +++ b/src/validate/group.schema.ts @@ -159,6 +159,45 @@ export const toggleEphemeralSchema: JSONSchema7 = { ...isNotEmpty('groupJid', 'expiration'), }; +export const memberAddModeSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + groupJid: { type: 'string' }, + mode: { type: 'string', enum: ['admin_add', 'all_member_add'] }, + }, + required: ['groupJid', 'mode'], + ...isNotEmpty('groupJid', 'mode'), +}; + +export const joinApprovalModeSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + groupJid: { type: 'string' }, + mode: { type: 'string', enum: ['on', 'off'] }, + }, + required: ['groupJid', 'mode'], + ...isNotEmpty('groupJid', 'mode'), +}; + +export const updateParticipantRequestSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + groupJid: { type: 'string' }, + action: { type: 'string', enum: ['approve', 'reject'] }, + participants: { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { type: 'string' }, + }, + }, + required: ['groupJid', 'action', 'participants'], + ...isNotEmpty('groupJid', 'action'), +}; + export const updateGroupPictureSchema: JSONSchema7 = { $id: v4(), type: 'object', diff --git a/src/validate/message.schema.ts b/src/validate/message.schema.ts index d514c6199e..fcb138e4d0 100644 --- a/src/validate/message.schema.ts +++ b/src/validate/message.schema.ts @@ -65,6 +65,17 @@ export const offerCallSchema: JSONSchema7 = { required: ['number', 'callDuration'], }; +export const forwardMessageSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + number: { ...numberDefinition }, + messageId: { type: 'string' }, + }, + required: ['number', 'messageId'], + ...isNotEmpty('number', 'messageId'), +}; + export const textMessageSchema: JSONSchema7 = { $id: v4(), type: 'object', diff --git a/test/app-state-self-heal.test.ts b/test/app-state-self-heal.test.ts new file mode 100644 index 0000000000..06a12e7f8d --- /dev/null +++ b/test/app-state-self-heal.test.ts @@ -0,0 +1,420 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { EventEmitter } from 'node:events'; + +import { + AppStateSelfHealer, + createSocketHealTarget, + LTHASH_PATCH_FAILURE_MSG, + parseLtHashPatchFailure, + runAsHealReplay, + suppressHealReplay, + watchAppStateFailures, + type AppStateCollection, + type AppStateHealTarget, +} from '../src/api/integrations/channel/whatsapp/app-state-self-heal'; + +function fakeTarget( + opts: { + open?: boolean; + failResync?: boolean; + storeNothing?: boolean; + versions?: Record; + onResync?: () => void; + } = {}, +) { + const calls: string[] = []; + const versions: Record = { regular_low: 108, ...(opts.versions ?? {}) }; + const target: AppStateHealTarget & { open: boolean } = { + open: opts.open ?? true, + isOpen() { + return this.open; + }, + async readVersion(name) { + return versions[name]; + }, + async readState(name) { + return versions[name] === undefined ? undefined : { version: versions[name] }; + }, + async clearVersion(name) { + calls.push(`clear:${name}`); + versions[name] = undefined; // keys.set(null) + }, + async restoreIfEmpty(name, state) { + if (versions[name] !== undefined) return false; + calls.push(`restore:${name}:v${state.version}`); + versions[name] = state.version; + return true; + }, + async resync(name) { + calls.push(`resync:${name}`); + opts.onResync?.(); + if (opts.failResync) throw new Error('boom'); + if (!opts.storeNothing) versions[name] = 215; // snapshot restored + }, + }; + return { target, calls, versions }; +} + +function fakeLog() { + const lines: string[] = []; + return { lines, log: { warn: (m: string) => lines.push(`WARN ${m}`), error: (m: string) => lines.push(`ERROR ${m}`) } }; +} + +function healer(target: AppStateHealTarget | undefined, extra: { now?: () => number; instanceName?: string } = {}) { + const { lines, log } = fakeLog(); + const h = new AppStateSelfHealer({ + instanceName: extra.instanceName ?? 'Leandro', + getTarget: () => target, + log, + now: extra.now, + }); + return { h, lines }; +} + +test('only the patch mismatch warning is recognised (not the snapshot variant, not other collections)', () => { + assert.deepEqual(parseLtHashPatchFailure([{ name: 'regular_low', version: 105 }, LTHASH_PATCH_FAILURE_MSG]), { + name: 'regular_low', + version: 105, + }); + assert.equal( + parseLtHashPatchFailure([ + { name: 'regular_low', version: 1 }, + 'LTHash verification failed on snapshot, continuing with partial state', + ]), + null, + ); + assert.equal(parseLtHashPatchFailure([{ name: 'bogus', version: 1 }, LTHASH_PATCH_FAILURE_MSG]), null); + assert.equal(parseLtHashPatchFailure([LTHASH_PATCH_FAILURE_MSG]), null); +}); + +test('logger proxy reports the warning, still forwards it, and covers child loggers and silenced levels', () => { + const forwarded: unknown[][] = []; + const base = { + level: 'error', + warn: (...args: unknown[]) => forwarded.push(args), + info: () => undefined, + child() { + return { warn: (...args: unknown[]) => forwarded.push(['child', ...args]) }; + }, + }; + const seen: unknown[] = []; + const logger = watchAppStateFailures(base, (f) => seen.push(f)); + + logger.warn({ name: 'regular_low', version: 106 }, LTHASH_PATCH_FAILURE_MSG); + logger.warn('unrelated'); + logger.child().warn({ name: 'regular_high', version: 7 }, LTHASH_PATCH_FAILURE_MSG); + + assert.deepEqual(seen, [ + { name: 'regular_low', version: 106 }, + { name: 'regular_high', version: 7 }, + ]); + assert.equal(forwarded.length, 3); + assert.equal(logger.level, 'error'); +}); + +test('a failing callback never breaks logging', () => { + const forwarded: unknown[][] = []; + const logger = watchAppStateFailures({ warn: (...a: unknown[]) => forwarded.push(a) }, () => { + throw new Error('x'); + }); + logger.warn({ name: 'regular_low', version: 1 }, LTHASH_PATCH_FAILURE_MSG); + assert.equal(forwarded.length, 1); +}); + +test('failure clears the stored version, resyncs that collection only, and logs old version and outcome', async () => { + const { target, calls } = fakeTarget(); + const { h, lines } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + + assert.deepEqual(calls, ['clear:regular_low', 'resync:regular_low']); + assert.match(lines[0], /^WARN app-state self-heal: Leandro regular_low v108 → snapshot$/); + assert.match(lines[1], /^WARN app-state self-heal: Leandro regular_low OK \(snapshot restored at v215\)$/); + assert.deepEqual(h.pendingCollections, []); +}); + +test('report itself never touches the store (it runs inside Baileys\' sync transaction)', () => { + const { target, calls } = fakeTarget(); + const { h } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + assert.deepEqual(calls, []); + assert.deepEqual(h.pendingCollections, ['regular_low']); +}); + +test('nothing runs while the socket is not open; the heal stays queued and runs once it opens', async () => { + const { target, calls } = fakeTarget({ open: false }); + const { h } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.deepEqual(calls, []); + assert.deepEqual(h.pendingCollections, ['regular_low']); + + target.open = true; + await h.flush(); + assert.deepEqual(calls, ['clear:regular_low', 'resync:regular_low']); +}); + +test('no target (no socket yet) is a no-op', async () => { + const { h } = healer(undefined); + h.report({ name: 'regular_low', version: 1 }); + await h.flush(); + assert.deepEqual(h.pendingCollections, ['regular_low']); +}); + +test('cooldown suppresses a second attempt for the same collection, other collections are independent', async () => { + const { target, calls } = fakeTarget({ versions: { regular_high: 3 } }); + let now = 1_000_000; + const { h, lines } = healer(target, { now: () => now }); + + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.equal(calls.length, 2); + + now += 60_000; // 1 min later: still inside the 10 min cooldown + h.report({ name: 'regular_low', version: 109 }); + await h.flush(); + assert.equal(calls.length, 2); + assert.ok(lines.some((l) => /regular_low skipped \(cooldown/.test(l))); + + h.report({ name: 'regular_high', version: 3 }); + await h.flush(); + assert.deepEqual(calls.slice(2), ['clear:regular_high', 'resync:regular_high']); + + now += 11 * 60_000; // past the cooldown + h.report({ name: 'regular_low', version: 120 }); + await h.flush(); + assert.deepEqual(calls.slice(4), ['clear:regular_low', 'resync:regular_low']); +}); + +test('at most 3 attempts per hour per collection', async () => { + const { target, calls } = fakeTarget(); + let now = 5_000_000; + const { h, lines } = healer(target, { now: () => now }); + + for (let i = 0; i < 3; i++) { + h.report({ name: 'regular_low', version: i }); + await h.flush(); + now += 11 * 60_000; + } + assert.equal(calls.filter((c) => c.startsWith('resync')).length, 3); + + h.report({ name: 'regular_low', version: 99 }); // 4th within the hour + await h.flush(); + assert.equal(calls.filter((c) => c.startsWith('resync')).length, 3); + assert.ok(lines.some((l) => /skipped \(3 attempts in the last hour\)/.test(l))); + + now += 60 * 60_000; // window slides + h.report({ name: 'regular_low', version: 100 }); + await h.flush(); + assert.equal(calls.filter((c) => c.startsWith('resync')).length, 4); +}); + +test('a failing resync is logged as an error, not thrown, and still counts as an attempt', async () => { + const { target, calls, versions } = fakeTarget({ failResync: true }); + let now = 1; + const { h, lines } = healer(target, { now: () => now }); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.ok( + lines.some((l) => + /^ERROR app-state self-heal: Leandro regular_low FAILED \(boom\); previous state v108 restored$/.test(l), + ), + ); + // Never left cleared: an ordinary resync from v0 would replay the whole snapshot unguarded. + assert.equal(versions.regular_low, 108); + + now += 1000; + h.report({ name: 'regular_low', version: 109 }); + await h.flush(); + assert.equal(calls.filter((c) => c.startsWith('resync')).length, 1); // cooldown +}); + +test('a resync that stores nothing (e.g. collection parked on a missing key) restores the previous state', async () => { + const { target, calls, versions } = fakeTarget({ storeNothing: true }); + const { h, lines } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.deepEqual(calls, ['clear:regular_low', 'resync:regular_low', 'restore:regular_low:v108']); + assert.equal(versions.regular_low, 108); + assert.ok(lines.some((l) => /FAILED \(no state stored after resync\); previous state v108 restored$/.test(l))); +}); + +test('the previous state is not restored over a state another resync stored meanwhile', async () => { + const { target, calls, versions } = fakeTarget({ failResync: true }); + target.resync = async (name) => { + calls.push(`resync:${name}`); + versions[name] = 300; // an ordinary resync won the mutex and stored a fresh snapshot + throw new Error('timed out'); + }; + const { h, lines } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.equal(versions.regular_low, 300); + assert.ok(!calls.some((c) => c.startsWith('restore'))); + assert.ok(lines.some((l) => /FAILED \(timed out\)$/.test(l))); +}); + +test('replay guard: chat/message events emitted inside a heal resync are dropped, live ones pass', async () => { + const delivered: string[] = []; + const ev = suppressHealReplay({ + emit(event: string, data: unknown) { + delivered.push(`${event}:${JSON.stringify(data)}`); + return true; + }, + }); + suppressHealReplay(ev); // idempotent: a second install must not double-wrap + + let timerEmit: Promise | undefined; + const suppressed = await runAsHealReplay(async () => { + await Promise.resolve(); + ev.emit('chats.update', [{ id: 'g@g.us', unreadCount: 0 }]); // stale read marker + ev.emit('chats.delete', ['old@s.whatsapp.net']); // chat deleted months ago + ev.emit('messages.delete', { keys: [] }); + ev.emit('creds.update', { me: { name: 'x' } }); // not a chat/message replay + // A timer started inside (Baileys' buffer flush) runs after the heal: not suppressed. + timerEmit = new Promise((resolve) => + setTimeout(() => { + ev.emit('chats.update', [{ id: 'after@s.whatsapp.net', unreadCount: 1 }]); + resolve(); + }, 5), + ); + }); + ev.emit('chats.update', [{ id: 'live@s.whatsapp.net', unreadCount: 0 }]); // outside the heal + await timerEmit; + + assert.equal(suppressed, 3); + assert.deepEqual(delivered, [ + 'creds.update:{"me":{"name":"x"}}', + 'chats.update:[{"id":"live@s.whatsapp.net","unreadCount":0}]', + 'chats.update:[{"id":"after@s.whatsapp.net","unreadCount":1}]', + ]); +}); + +test('replay guard on a real EventEmitter keeps `this` and listeners working', async () => { + const emitter = new EventEmitter(); + const got: string[] = []; + emitter.on('chats.update', (d) => got.push(JSON.stringify(d))); + suppressHealReplay(emitter); + await runAsHealReplay(async () => { + emitter.emit('chats.update', [{ id: 'replayed' }]); + }); + emitter.emit('chats.update', [{ id: 'live' }]); + assert.deepEqual(got, ['[{"id":"live"}]']); +}); + +test('heal: replayed events of the snapshot are suppressed and counted in the log', async () => { + const delivered: string[] = []; + const ev = suppressHealReplay({ emit: (event: string) => delivered.push(event) > 0 }); + const { target } = fakeTarget({ + onResync: () => { + ev.emit('chats.update', [{ id: 'a', unreadCount: 0 }]); + ev.emit('chats.update', [{ id: 'b', unreadCount: -1 }]); + }, + }); + const { h, lines } = healer(target); + h.report({ name: 'regular_low', version: 108 }); + await h.flush(); + assert.deepEqual(delivered, []); + assert.ok(lines.some((l) => /regular_low suppressed 2 replayed chat\/message events$/.test(l))); + assert.ok(lines.some((l) => /regular_low OK \(snapshot restored at v215\)$/.test(l))); +}); + +test('healers of two instances are fully independent', async () => { + const a = fakeTarget(); + const b = fakeTarget(); + const first = healer(a.target, { instanceName: 'Leandro' }); + const second = healer(b.target, { instanceName: 'Outra' }); + + first.h.report({ name: 'regular_low', version: 108 }); + await first.h.flush(); + await second.h.flush(); + + assert.deepEqual(a.calls, ['clear:regular_low', 'resync:regular_low']); + assert.deepEqual(b.calls, []); + assert.equal(second.lines.length, 0); +}); + +test('proxy + healer: a Baileys warn on the wrapped logger ends in a snapshot resync', async () => { + const { target, calls } = fakeTarget(); + const { h } = healer(target); + const logger = watchAppStateFailures({ warn: () => undefined }, (f) => h.report(f)); + logger.warn({ name: 'regular_low' satisfies AppStateCollection, version: 108 }, LTHASH_PATCH_FAILURE_MSG); + await h.flush(); + assert.deepEqual(calls, ['clear:regular_low', 'resync:regular_low']); +}); + +test('socket target: version is cleared with null through the key store inside a transaction, then resync([name], false)', async () => { + const events: string[] = []; + let inTx = false; + const socket = { + authState: { + creds: { me: { id: '5511999999999:12@s.whatsapp.net' } }, + keys: { + async get() { + return { regular_low: { version: 109 } }; + }, + async set(data: unknown) { + events.push(`set:${JSON.stringify(data)}:tx=${inTx}`); + }, + async transaction(work: () => Promise, key: string) { + events.push(`tx:${key}`); + inTx = true; + try { + return await work(); + } finally { + inTx = false; + } + }, + }, + }, + async resyncAppState(collections: unknown, initial: boolean) { + events.push(`resync:${JSON.stringify(collections)}:${initial}`); + }, + }; + const target = createSocketHealTarget(socket as never, () => true); + assert.equal(await target.readVersion('regular_low'), 109); + await target.clearVersion('regular_low'); + await target.resync('regular_low'); + assert.deepEqual(events, [ + 'tx:5511999999999:12@s.whatsapp.net', + 'set:{"app-state-sync-version":{"regular_low":null}}:tx=true', + 'resync:["regular_low"]:false', + ]); +}); + +test('socket target: restoreIfEmpty writes back inside the same-key transaction only when nothing is stored', async () => { + const store: Record = { regular_low: { version: 109, hash: 'h' } }; + const events: string[] = []; + const socket = { + authState: { + creds: { me: { id: '5511999999999:12@s.whatsapp.net' } }, + keys: { + async get(_type: string, ids: string[]) { + return Object.fromEntries(ids.map((id) => [id, store[id] ?? null])); + }, + async set(data: { 'app-state-sync-version': Record }) { + Object.assign(store, data['app-state-sync-version']); + events.push(`set:${JSON.stringify(data)}`); + }, + async transaction(work: () => Promise, key: string) { + events.push(`tx:${key}`); + return work(); + }, + }, + }, + async resyncAppState() {}, + }; + const target = createSocketHealTarget(socket as never, () => true); + const previous = await target.readState('regular_low'); + assert.deepEqual(previous, { version: 109, hash: 'h' }); + + assert.equal(await target.restoreIfEmpty('regular_low', { version: 1 }), false); // something stored: untouched + assert.deepEqual(store.regular_low, { version: 109, hash: 'h' }); + + await target.clearVersion('regular_low'); + assert.equal(await target.readState('regular_low'), undefined); + assert.equal(await target.restoreIfEmpty('regular_low', previous!), true); + assert.deepEqual(store.regular_low, { version: 109, hash: 'h' }); + assert.ok(events.every((e) => !e.startsWith('tx:') || e === 'tx:5511999999999:12@s.whatsapp.net')); +}); diff --git a/test/auth-state-key.test.ts b/test/auth-state-key.test.ts new file mode 100644 index 0000000000..ed2fd88bf9 --- /dev/null +++ b/test/auth-state-key.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +test("stored app-state keys are revived as bytes, not as their base64 text", async () => { + const { proto } = await import("baileys"); + const stored = JSON.parse(JSON.stringify({ keyData: Buffer.alloc(32, 7).toString("base64"), timestamp: "1791458000000" })); + const keyData = proto.Message.AppStateSyncKeyData.fromObject(stored).keyData as Uint8Array; + assert.equal(keyData.length, 32); + assert.equal(Buffer.from(keyData).equals(Buffer.alloc(32, 7)), true); + // create() keeps the 44-char base64 string: HKDF over it derives the wrong keys. + assert.equal(typeof proto.Message.AppStateSyncKeyData.create(stored).keyData, "string"); +}); diff --git a/test/numbers-lid.test.ts b/test/numbers-lid.test.ts new file mode 100644 index 0000000000..8ace3d525d --- /dev/null +++ b/test/numbers-lid.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Validator } from 'jsonschema'; +import { + attachLids, + resolveLidsForConfirmedJids, + WHATSAPP_NUMBERS_LID_LIMIT, +} from '../src/api/integrations/channel/whatsapp/numbers-lid'; +import { whatsappNumberSchema } from '../src/validate/chat.schema'; + +const pnJid = (n: string) => `${n}@s.whatsapp.net`; + +/** Mimics lidMapping.getLIDsForPNs: returns only the PNs it knows, in the {lid, pn} pair shape. */ +function mockLookup(known: Record) { + const calls: string[][] = []; + const lookup = async (pns: string[]) => { + calls.push(pns); + const pairs = pns.filter((pn) => known[pn]).map((pn) => ({ pn, lid: known[pn] })); + return pairs.length ? pairs : null; + }; + return { lookup, calls }; +} + +test('legacy body (no includeLid) still passes the schema', () => { + const v = new Validator(); + assert.equal(v.validate({ numbers: ['5511999999999'] }, whatsappNumberSchema).valid, true); +}); + +test('schema: includeLid must be a boolean', () => { + const v = new Validator(); + assert.equal(v.validate({ numbers: ['5511999999999'], includeLid: true }, whatsappNumberSchema).valid, true); + assert.equal(v.validate({ numbers: ['5511999999999'], includeLid: 'yes' }, whatsappNumberSchema).valid, false); +}); + +test('adds lid from the mapping only for exists=true, in ONE batched call', async () => { + const { lookup, calls } = mockLookup({ [pnJid('5511999999999')]: '111222333444@lid' }); + const items = [ + { jid: pnJid('5511999999999'), exists: true, number: '5511999999999', name: 'Ana' }, + { jid: pnJid('5511888888888'), exists: true, number: '5511888888888' }, + { jid: pnJid('5511777777777'), exists: false, number: '5511777777777' }, + { jid: '120363000000000000@g.us', exists: true, number: '120363000000000000@g.us' }, + ]; + const out = await attachLids(items, lookup); + assert.deepEqual( + out.map((o) => o.lid), + ['111222333444@lid', null, null, null], + ); + assert.equal(out[0].name, 'Ana'); + assert.equal(calls.length, 1, 'a single batched lookup'); + assert.deepEqual(calls[0], [pnJid('5511999999999'), pnJid('5511888888888')]); +}); + +test('lid corresponds to the jid WhatsApp confirmed (9th digit), not to the typed number', async () => { + // typed 5511999999999 (with 9) but WhatsApp confirmed the 12-digit jid + const confirmed = pnJid('551199999999'); + const { lookup, calls } = mockLookup({ [confirmed]: '999888777666@lid', [pnJid('5511999999999')]: '000@lid' }); + const out = await attachLids([{ jid: confirmed, exists: true, number: '5511999999999' }], lookup); + assert.deepEqual(calls[0], [confirmed]); + assert.equal(out[0].lid, '999888777666@lid'); +}); + +test('device suffix on the returned lid is stripped; hosted/malformed lids are ignored', async () => { + const lookup = async () => [ + { pn: pnJid('5511999999999'), lid: '111222333444:7@lid' }, + { pn: pnJid('5511888888888'), lid: '555666777888@hosted.lid' }, + { pn: pnJid('5511777777777'), lid: 'garbage' }, + ]; + const out = await attachLids( + ['5511999999999', '5511888888888', '5511777777777'].map((n) => ({ jid: pnJid(n), exists: true, number: n })), + lookup, + ); + assert.deepEqual( + out.map((o) => o.lid), + ['111222333444@lid', null, null], + ); +}); + +test('lookup throws: lid null for everyone, no throw, WARN logged', async () => { + const warnings: string[] = []; + const out = await attachLids( + [{ jid: pnJid('5511999999999'), exists: true, number: '5511999999999' }], + async () => { + throw new Error('usync timeout'); + }, + { warn: (m) => warnings.push(m) }, + ); + assert.equal(out[0].lid, null); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /usync timeout/); +}); + +test('lookup returns null (no pairs): lid null', async () => { + const out = await attachLids([{ jid: pnJid('5511999999999'), exists: true, number: 'x' }], async () => null); + assert.equal(out[0].lid, null); +}); + +test('cap: only the first N unique numbers are looked up, the rest get null + a WARN', async () => { + const known: Record = {}; + const items = Array.from({ length: WHATSAPP_NUMBERS_LID_LIMIT + 5 }, (_, i) => { + const n = `55119${String(10000000 + i)}`; + known[pnJid(n)] = `${900000000 + i}@lid`; + return { jid: pnJid(n), exists: true, number: n }; + }); + const { lookup, calls } = mockLookup(known); + const warnings: string[] = []; + const out = await attachLids(items, lookup, { warn: (m) => warnings.push(m) }); + assert.equal(calls.length, 1); + assert.equal(calls[0].length, WHATSAPP_NUMBERS_LID_LIMIT); + assert.equal(out.filter((o) => o.lid).length, WHATSAPP_NUMBERS_LID_LIMIT); + assert.equal(out[WHATSAPP_NUMBERS_LID_LIMIT - 1].lid, `${900000000 + WHATSAPP_NUMBERS_LID_LIMIT - 1}@lid`); + assert.equal(out[WHATSAPP_NUMBERS_LID_LIMIT].lid, null); + assert.equal(warnings.length, 1); +}); + +test('duplicate confirmed jids are looked up once and both items get the lid', async () => { + const jid = pnJid('5511999999999'); + const { lookup, calls } = mockLookup({ [jid]: '111222333444@lid' }); + const out = await attachLids( + [ + { jid, exists: true, number: '5511999999999' }, + { jid, exists: true, number: '551199999999' }, + ], + lookup, + ); + assert.deepEqual(calls[0], [jid]); + assert.deepEqual( + out.map((o) => o.lid), + ['111222333444@lid', '111222333444@lid'], + ); +}); + +test('already-LID jid (cache hit) is returned as is, without a lookup', async () => { + const { lookup, calls } = mockLookup({}); + const map = await resolveLidsForConfirmedJids([{ jid: '111222333444@lid', exists: true }], lookup); + assert.equal(calls.length, 0); + assert.equal(map.get('111222333444@lid'), '111222333444@lid'); +}); + +test('nothing to resolve: no lookup call at all', async () => { + const { lookup, calls } = mockLookup({}); + await attachLids([{ jid: pnJid('5511999999999'), exists: false, number: 'x' }], lookup); + assert.equal(calls.length, 0); +}); diff --git a/test/read-state.test.ts b/test/read-state.test.ts new file mode 100644 index 0000000000..5eb5528aa7 --- /dev/null +++ b/test/read-state.test.ts @@ -0,0 +1,386 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { ReadMessageDto } from '../src/api/dto/chat.dto'; +import { + buildGroupReadSelfPayload, + chatReadStateUpdates, + collectGroupReadSelf, + normalizeAccountJid, + ownAccountJids, + pickLatestMessageId, + fillRemoteJidAlt, + ReadStateValidationError, + readRequestToChatAddressing, + resolveReadStateLids, + toChatAddressingJid, + syncMessageReadState, +} from '../src/api/integrations/channel/whatsapp/read-state'; +import { readMessageSchema } from '../src/validate/chat.schema'; +import { Validator } from 'jsonschema'; + +const jid = '5511999999999@s.whatsapp.net'; +const otherJid = '5511888888888@s.whatsapp.net'; +const lastMessage = { key: { remoteJid: jid, fromMe: true, id: 'outbound-1' }, messageTimestamp: 1_790_000_000 }; +const receipt = { remoteJid: jid, fromMe: false, id: 'inbound-1' }; + +function mockClient(options: { failModify?: boolean; failReceipts?: boolean } = {}) { + const calls: Array<{ command: string; payload: unknown; jid?: string }> = []; + const client = { + async chatModify(payload: unknown, chatJid: string) { + calls.push({ command: 'chatModify', payload, jid: chatJid }); + if (options.failModify) throw new Error('modify failed'); + }, + async readMessages(keys: unknown) { + calls.push({ command: 'readMessages', payload: keys }); + if (options.failReceipts) throw new Error('receipts failed'); + }, + } as Parameters[0]; + return { client, calls }; +} + +test('legacy readMessages sends receipts without modifying chat state', async () => { + const { client, calls } = mockClient(); + const result = await syncMessageReadState(client, { readMessages: [receipt] }); + assert.deepEqual(result, { message: 'Read messages', read: 'success' }); + assert.deepEqual(calls, [{ command: 'readMessages', payload: [receipt] }]); +}); + +test('lastMessage synchronizes linked-device read state and sends inbound receipts', async () => { + const { client, calls } = mockClient(); + await syncMessageReadState(client, { readMessages: [receipt], lastMessage }); + assert.deepEqual(calls, [ + { command: 'chatModify', payload: { markRead: true, lastMessages: [lastMessage] }, jid }, + { command: 'readMessages', payload: [receipt] }, + ]); +}); + +test('outbound lastMessage can clear a manually unread chat without inbound receipts', async () => { + const { client, calls } = mockClient(); + await syncMessageReadState(client, { readMessages: [], lastMessage }); + assert.deepEqual(calls, [{ command: 'chatModify', payload: { markRead: true, lastMessages: [lastMessage] }, jid }]); +}); + +test('mixed chats and missing timestamps fail before SDK effects', async () => { + const { client, calls } = mockClient(); + await assert.rejects( + syncMessageReadState(client, { readMessages: [{ ...receipt, remoteJid: otherJid }], lastMessage }), + ReadStateValidationError, + ); + await assert.rejects( + syncMessageReadState(client, { readMessages: [receipt], lastMessage: { key: lastMessage.key } } as ReadMessageDto), + ReadStateValidationError, + ); + assert.deepEqual(calls, []); +}); + +test('request schema requires an epoch-seconds timestamp and allows empty receipts only with lastMessage', () => { + const validator = new Validator(); + const valid = (value: unknown) => validator.validate(value, readMessageSchema).valid; + assert.equal(valid({ readMessages: [receipt] }), true); + assert.equal(valid({ readMessages: [], lastMessage }), true); + assert.equal(valid({ readMessages: [] }), false); + assert.equal(valid({ readMessages: [receipt], lastMessage: { key: lastMessage.key } }), false); + assert.equal(valid({ readMessages: [receipt], lastMessage: { ...lastMessage, messageTimestamp: 0 } }), false); +}); + +test('LID chats are accepted by the same JID suffix contract', async () => { + const lid = '123456789@lid'; + const { client, calls } = mockClient(); + await syncMessageReadState(client, { + readMessages: [{ remoteJid: lid, fromMe: false, id: 'lid-inbound' }], + lastMessage: { key: { remoteJid: lid, fromMe: false, id: 'lid-inbound' }, messageTimestamp: 1_790_000_001 }, + }); + assert.equal(calls[0].command, 'chatModify'); + assert.equal(calls[0].jid, lid); + assert.equal(calls[1].command, 'readMessages'); +}); + +test('partial SDK failure never reports success', async () => { + const modify = mockClient({ failModify: true }); + await assert.rejects(syncMessageReadState(modify.client, { readMessages: [receipt], lastMessage }), /modify failed/); + assert.deepEqual(modify.calls.map((call) => call.command), ['chatModify']); + + const receipts = mockClient({ failReceipts: true }); + await assert.rejects(syncMessageReadState(receipts.client, { readMessages: [receipt], lastMessage }), /receipts failed/); + assert.deepEqual(receipts.calls.map((call) => call.command), ['chatModify', 'readMessages']); +}); + +test('chats.update retains zero, manual-unread marker, and positive delta with observation time', () => { + const observedAt = '2026-09-28T12:34:56.000Z'; + const updates = chatReadStateUpdates( + [ + { id: jid, unreadCount: 0 }, + { id: otherJid, unreadCount: -1 }, + { id: '123@g.us', unreadCount: 3 }, + { id: '456@g.us', unreadCount: 1.5 }, + ], + 'instance-1', + observedAt, + ); + assert.deepEqual(updates, [ + { remoteJid: jid, instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: 0 }, + { remoteJid: otherJid, instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: -1 }, + { remoteJid: '123@g.us', instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: 3 }, + { remoteJid: '456@g.us', instanceId: 'instance-1', readStateObservedAt: observedAt }, + ]); +}); + +test('chats.update keyed by LID is re-keyed to the phone JID, keeping the LID', async () => { + const observedAt = '2026-10-08T12:00:00.000Z'; + const resolved = await resolveReadStateLids( + chatReadStateUpdates( + [ + { id: '123456789012345@lid', unreadCount: 0 }, + { id: '999999999999999@lid', unreadCount: -1 }, + { id: '888888888888888@lid', unreadCount: 0 }, + { id: jid, unreadCount: 0 }, + ], + 'instance-1', + observedAt, + ), + async (lid) => { + if (lid === '123456789012345@lid') return '5548991554955:12@s.whatsapp.net'; + if (lid === '888888888888888@lid') throw new Error('mapping store down'); + return null; + }, + ); + assert.deepEqual(resolved, [ + { + remoteJid: '5548991554955@s.whatsapp.net', + lid: '123456789012345@lid', + instanceId: 'instance-1', + readStateObservedAt: observedAt, + unreadCount: 0, + }, + { remoteJid: '999999999999999@lid', instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: -1 }, + { remoteJid: '888888888888888@lid', instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: 0 }, + { remoteJid: jid, instanceId: 'instance-1', readStateObservedAt: observedAt, unreadCount: 0 }, + ]); +}); + +test('commands from webhook consumers are re-addressed to the LID the phone uses', async () => { + const resolveLid = async (pn: string) => (pn === jid ? '55761694654630:3@lid' : null); + assert.equal(await toChatAddressingJid(jid, resolveLid), '55761694654630@lid'); + assert.equal(await toChatAddressingJid(otherJid, resolveLid), otherJid); + assert.equal(await toChatAddressingJid('123@g.us', resolveLid), '123@g.us'); + assert.equal(await toChatAddressingJid(jid, async () => { throw new Error('down'); }), jid); + + const addressed = await readRequestToChatAddressing( + { readMessages: [receipt], lastMessage } as unknown as ReadMessageDto, + resolveLid, + ); + assert.equal(addressed.readMessages[0].remoteJid, '55761694654630@lid'); + assert.equal(addressed.lastMessage?.key.remoteJid, '55761694654630@lid'); + assert.equal(addressed.lastMessage?.key.id, 'outbound-1'); + const { client, calls } = mockClient(); + await syncMessageReadState(client, addressed); + assert.equal(calls[0].jid, '55761694654630@lid'); +}); + +test('LID-only message keys get the phone JID from the LID mapping', async () => { + const resolve = async (lid: string) => (lid === '202860566425607@lid' ? '5511964242104:0@s.whatsapp.net' : null); + const known = { remoteJid: '202860566425607@lid' } as { remoteJid: string; remoteJidAlt?: string }; + await fillRemoteJidAlt(known, resolve); + assert.equal(known.remoteJidAlt, '5511964242104@s.whatsapp.net'); + + const unknown = { remoteJid: '999999999999999@lid' } as { remoteJid: string; remoteJidAlt?: string }; + await fillRemoteJidAlt(unknown, resolve); + assert.equal(unknown.remoteJidAlt, undefined); + + const already = { remoteJid: '202860566425607@lid', remoteJidAlt: '5500000000000@s.whatsapp.net' }; + await fillRemoteJidAlt(already, resolve); + assert.equal(already.remoteJidAlt, '5500000000000@s.whatsapp.net'); + + const phone = { remoteJid: jid } as { remoteJid: string; remoteJidAlt?: string }; + await fillRemoteJidAlt(phone, resolve); + assert.equal(phone.remoteJidAlt, undefined); + + const failing = { remoteJid: '202860566425607@lid' } as { remoteJid: string; remoteJidAlt?: string }; + await fillRemoteJidAlt(failing, async () => { throw new Error('down'); }); + assert.equal(failing.remoteJidAlt, undefined); +}); + +const groupJid = '120363295648424210@g.us'; +const senderJid = '5511977777777@s.whatsapp.net'; +const groupReceipt = { remoteJid: groupJid, fromMe: false, id: 'group-inbound-1', participant: senderJid }; +const groupLastMessage = { key: groupReceipt, messageTimestamp: 1_790_000_100 }; + +test('group receipts carry the participant through to the SDK', async () => { + const { client, calls } = mockClient(); + await syncMessageReadState(client, { readMessages: [groupReceipt], lastMessage: groupLastMessage }); + assert.deepEqual(calls[0], { command: 'chatModify', payload: { markRead: true, lastMessages: [groupLastMessage] }, jid: groupJid }); + assert.deepEqual(calls[1], { command: 'readMessages', payload: [groupReceipt] }); +}); + +test('a group key without participant fails before any SDK effect', async () => { + const { client, calls } = mockClient(); + const { participant: _ignored, ...noParticipant } = groupReceipt; + await assert.rejects( + syncMessageReadState(client, { readMessages: [noParticipant], lastMessage: groupLastMessage }), + ReadStateValidationError, + ); + await assert.rejects( + syncMessageReadState(client, { + readMessages: [groupReceipt], + lastMessage: { key: noParticipant, messageTimestamp: groupLastMessage.messageTimestamp }, + }), + ReadStateValidationError, + ); + await assert.rejects( + syncMessageReadState(client, { + readMessages: [{ ...groupReceipt, participant: ' ' }], + lastMessage: groupLastMessage, + }), + ReadStateValidationError, + ); + assert.deepEqual(calls, []); +}); + +test('our own group message needs no participant', async () => { + const { client, calls } = mockClient(); + const mine = { key: { remoteJid: groupJid, fromMe: true, id: 'group-outbound-1' }, messageTimestamp: 1_790_000_101 }; + await syncMessageReadState(client, { readMessages: [], lastMessage: mine }); + assert.deepEqual(calls, [{ command: 'chatModify', payload: { markRead: true, lastMessages: [mine] }, jid: groupJid }]); +}); + +test('group addressing is left alone: only phone JIDs are mapped to LID', async () => { + const addressed = await readRequestToChatAddressing( + { readMessages: [groupReceipt], lastMessage: groupLastMessage }, + async () => '999999999999@lid', + ); + assert.equal(addressed.readMessages[0].remoteJid, groupJid); + assert.equal(addressed.readMessages[0].participant, senderJid); + assert.equal(addressed.lastMessage?.key.remoteJid, groupJid); +}); + +test('request schema accepts the participant on both keys', () => { + const validator = new Validator(); + const valid = (value: unknown) => validator.validate(value, readMessageSchema).valid; + assert.equal(valid({ readMessages: [groupReceipt], lastMessage: groupLastMessage }), true); + assert.equal(valid({ readMessages: [{ ...groupReceipt, participant: '' }] }), false); +}); + +test('a legacy receipts-only group request is not forced to carry the participant', async () => { + const { client, calls } = mockClient(); + const { participant: _ignored, ...noParticipant } = groupReceipt; + await syncMessageReadState(client, { readMessages: [noParticipant] }); + assert.deepEqual(calls, [{ command: 'readMessages', payload: [noParticipant] }]); +}); + +test('participant is preserved for group read receipts and omitted when absent', async () => { + const groupJid = '120363000000000000@g.us'; + const { client, calls } = mockClient(); + await syncMessageReadState(client, { + readMessages: [ + { remoteJid: groupJid, fromMe: false, id: 'g1', participant: '5511888888888@s.whatsapp.net' }, + { remoteJid: groupJid, fromMe: false, id: 'g2' }, + ], + }); + assert.deepEqual(calls, [ + { + command: 'readMessages', + payload: [ + { remoteJid: groupJid, fromMe: false, id: 'g1', participant: '5511888888888@s.whatsapp.net' }, + { remoteJid: groupJid, fromMe: false, id: 'g2' }, + ], + }, + ]); + const schema = new Validator().validate( + { readMessages: [{ remoteJid: groupJid, fromMe: false, id: 'g1', participant: 'x@lid' }] }, + readMessageSchema, + ); + assert.equal(schema.valid, true); +}); + +const group = '120363000000000000@g.us'; +const own = ownAccountJids({ id: '5511999999999:12@s.whatsapp.net', lid: '202860566425607:12@lid' }); +const receiptFor = (id: string, userJid: string, readTimestamp: unknown = 1_790_000_100, remoteJid = group) => ({ + // Shape emitted by Baileys handleReceipt for group receipts: fromMe is always true, participant = reader. + key: { remoteJid, id, fromMe: true, participant: userJid }, + receipt: { userJid, readTimestamp }, +}); + +test('own account is normalised across device suffix, c.us and LID addressing', () => { + assert.equal(normalizeAccountJid('5511999999999:12@s.whatsapp.net'), '5511999999999@s.whatsapp.net'); + assert.equal(normalizeAccountJid('5511999999999@c.us'), '5511999999999@s.whatsapp.net'); + assert.equal(normalizeAccountJid('202860566425607:3@lid'), '202860566425607@lid'); + assert.equal(normalizeAccountJid('nonsense'), null); + assert.deepEqual([...own].sort(), ['202860566425607@lid', '5511999999999@s.whatsapp.net']); +}); + +test('group read-self receipts (phone JID or LID) are collected once per group, newest timestamp wins', () => { + const result = collectGroupReadSelf( + [ + receiptFor('m1', '5511999999999@s.whatsapp.net', 1_790_000_100), + receiptFor('m2', '202860566425607@lid', 1_790_000_200), + receiptFor('m2', '5511999999999@s.whatsapp.net', 1_790_000_150), + receiptFor('o1', '5511999999999@s.whatsapp.net', 1_790_000_300, '120363111111111111@g.us'), + ], + own, + ); + assert.deepEqual(result, [ + { remoteJid: group, ids: ['m1', 'm2'], readTimestamp: 1_790_000_200 }, + { remoteJid: '120363111111111111@g.us', ids: ['o1'], readTimestamp: 1_790_000_300 }, + ]); +}); + +test('a receipt from another participant (someone read OUR message) is never forwarded as our read', () => { + const others = [ + receiptFor('m1', '5511888888888@s.whatsapp.net'), + receiptFor('m2', '999999999999999@lid'), + receiptFor('m3', '5511999999998@s.whatsapp.net'), // similar number, different user + ]; + assert.deepEqual(collectGroupReadSelf(others, own), []); +}); + +test('non-group chats, delivery-only receipts and malformed updates are ignored', () => { + const me = '5511999999999@s.whatsapp.net'; + assert.deepEqual( + collectGroupReadSelf( + [ + receiptFor('d1', me, 1_790_000_100, '5511888888888@s.whatsapp.net'), + receiptFor('d2', me, null), + receiptFor('d3', me, 'soon'), + { key: { remoteJid: group }, receipt: { userJid: me, readTimestamp: 1 } }, + { key: null, receipt: null }, + ], + own, + ), + [], + ); +}); + +test('groupsIgnore disables the forward; unknown own account disables it too', () => { + const updates = [receiptFor('m1', '5511999999999@s.whatsapp.net')]; + assert.equal(collectGroupReadSelf(updates, own, { groupsIgnore: true }).length, 0); + assert.equal(collectGroupReadSelf(updates, own, { groupsIgnore: false }).length, 1); + assert.equal(collectGroupReadSelf(updates, ownAccountJids(undefined)).length, 0); +}); + +test('latest message id: newest stored timestamp wins, falls back to the last id on missing data or errors', async () => { + assert.equal(await pickLatestMessageId(['a']), 'a'); + assert.equal(await pickLatestMessageId(['a', 'b', 'c']), 'c'); + assert.equal(await pickLatestMessageId(['a', 'b', 'c'], async () => ({ a: 30, b: 50, c: 10 })), 'b'); + assert.equal(await pickLatestMessageId(['a', 'b', 'c'], async () => ({ a: null })), 'c'); + assert.equal(await pickLatestMessageId(['a', 'b'], async () => { throw new Error('db down'); }), 'b'); +}); + +test('group read-self payload has the messages.update status shape the CRM parser consumes', () => { + const payload = buildGroupReadSelfPayload({ + keyId: 'm2', + remoteJid: group, + instanceId: 'inst-1', + readTimestamp: 1_790_000_200, + }); + assert.deepEqual(JSON.parse(JSON.stringify(payload)), { + keyId: 'm2', + remoteJid: group, + fromMe: false, + status: 'READ', + instanceId: 'inst-1', + readTimestamp: 1_790_000_200, + }); + // parseEvolutionEvents('messages.update') needs: id (keyId), status READ, remoteJid, fromMe=false. + assert.equal(payload.fromMe, false); + assert.equal(payload.status, 'READ'); +}); diff --git a/test/status-ack.test.ts b/test/status-ack.test.ts new file mode 100644 index 0000000000..6307559c30 --- /dev/null +++ b/test/status-ack.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { + attachStatusAck, + buildStatusAckStanza, + type StatusAckNode, + type StatusAckStanza, +} from '../src/api/integrations/channel/whatsapp/status-ack'; + +const statusNode: StatusAckNode = { + tag: 'status', + attrs: { id: '3A3ED33A9E8247EFA6B71DC531204430', from: '5511999999999@s.whatsapp.net', type: 'media', offline: '0' }, +}; + +function fakeSocket(sendNode: (s: StatusAckStanza) => Promise = async () => undefined) { + const ws = new EventEmitter(); + const sent: StatusAckStanza[] = []; + return { + ws, + sent, + socket: { + ws: { on: (event: string, listener: (node: StatusAckNode) => void) => ws.on(event, listener) }, + sendNode: async (s: StatusAckStanza) => { + sent.push(s); + return sendNode(s); + }, + }, + }; +} + +test('builds the ack the server asked for: class=status, same id and type, no error attribute', () => { + assert.deepEqual(buildStatusAckStanza(statusNode), { + tag: 'ack', + attrs: { + id: '3A3ED33A9E8247EFA6B71DC531204430', + to: '5511999999999@s.whatsapp.net', + class: 'status', + type: 'media', + }, + }); +}); + +test('keeps participant and recipient when present, and omits type when absent', () => { + const stanza = buildStatusAckStanza({ + tag: 'status', + attrs: { id: 'X1', from: 'status@broadcast', participant: '1@lid', recipient: '2@s.whatsapp.net' }, + }); + assert.deepEqual(stanza?.attrs, { + id: 'X1', + to: 'status@broadcast', + class: 'status', + participant: '1@lid', + recipient: '2@s.whatsapp.net', + }); + assert.equal('error' in (stanza?.attrs ?? {}), false); +}); + +test('a node without id or from cannot be acked', () => { + assert.equal(buildStatusAckStanza({ tag: 'status', attrs: { from: 'a@s.whatsapp.net' } }), null); + assert.equal(buildStatusAckStanza({ tag: 'status', attrs: { id: 'X' } }), null); +}); + +test('CB:status is acked, live and offline; other events are not touched', () => { + const { ws, sent, socket } = fakeSocket(); + attachStatusAck(socket, () => assert.fail('no error expected')); + + ws.emit('CB:status', statusNode); + ws.emit('CB:status', { ...statusNode, attrs: { ...statusNode.attrs, id: 'OFF1', offline: 'true', type: 'text' } }); + ws.emit('CB:message', statusNode); + + assert.deepEqual( + sent.map((s) => [s.attrs.id, s.attrs.class, s.attrs.type]), + [ + ['3A3ED33A9E8247EFA6B71DC531204430', 'status', 'media'], + ['OFF1', 'status', 'text'], + ], + ); +}); + +test('a failing send is reported and never throws out of the listener', async () => { + const boom = new Error('socket closed'); + const { ws, socket } = fakeSocket(async () => { + throw boom; + }); + const errors: unknown[] = []; + attachStatusAck(socket, (e) => errors.push(e)); + + assert.doesNotThrow(() => ws.emit('CB:status', statusNode)); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(errors, [boom]); +}); + +test('a sendNode that throws synchronously (Baileys encodes before the promise) is reported too', () => { + const ws = new EventEmitter(); + const boom = new Error('encode failed'); + const errors: unknown[] = []; + const socket = { + ws, + sendNode: (() => { + throw boom; + }) as (s: StatusAckStanza) => Promise, + }; + assert.equal(attachStatusAck(socket, (e) => errors.push(e)), true); + + assert.doesNotThrow(() => ws.emit('CB:status', statusNode)); + assert.deepEqual(errors, [boom]); +}); + +test('does not register a second ack when Baileys already listens to CB:status (no double ack)', () => { + const ws = new EventEmitter(); + const sent: StatusAckStanza[] = []; + ws.on('CB:status', () => undefined); // what a Baileys release with #2866 registers inside makeWASocket + const socket = { ws, sendNode: async (s: StatusAckStanza) => sent.push(s) }; + + assert.equal(attachStatusAck(socket, () => assert.fail('no error expected')), false); + ws.emit('CB:status', statusNode); + assert.equal(ws.listenerCount('CB:status'), 1); + assert.deepEqual(sent, []); +});