diff --git a/.github/workflows/publish_docker_image.yml b/.github/workflows/publish_docker_image.yml index c5a3996edb..cdd0a31cd9 100644 --- a/.github/workflows/publish_docker_image.yml +++ b/.github/workflows/publish_docker_image.yml @@ -22,7 +22,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: evoapicloud/evolution-api + images: ticczaleski/evolution-api tags: type=semver,pattern=v{{version}} - name: Set up QEMU diff --git a/.github/workflows/publish_docker_image_homolog.yml b/.github/workflows/publish_docker_image_homolog.yml index a76e1008be..c6878c7c5f 100644 --- a/.github/workflows/publish_docker_image_homolog.yml +++ b/.github/workflows/publish_docker_image_homolog.yml @@ -22,7 +22,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: evoapicloud/evolution-api + images: ticczaleski/evolution-api tags: homolog - name: Set up QEMU diff --git a/.github/workflows/publish_docker_image_latest.yml b/.github/workflows/publish_docker_image_latest.yml index f73fe80e4e..0c04d7931e 100644 --- a/.github/workflows/publish_docker_image_latest.yml +++ b/.github/workflows/publish_docker_image_latest.yml @@ -4,6 +4,19 @@ on: push: branches: - main + workflow_dispatch: + inputs: + custom_tag: + description: 'Custom tag for the Docker image (e.g. 2.3.7-reactions)' + required: false + type: string + +concurrency: + group: docker-build-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE_NAME: ticczaleski/evolution-api jobs: build_deploy: @@ -11,19 +24,31 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - packages: write + steps: - name: Checkout uses: actions/checkout@v5 with: submodules: recursive + - name: Extract app version + id: app_version + run: | + VERSION=$(jq -r '.version // "0.0.0"' package.json) + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + - name: Docker meta id: meta uses: docker/metadata-action@v5 with: - images: evoapicloud/evolution-api - tags: latest + images: ${{ env.IMAGE_NAME }} + flavor: | + latest=false + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=${{ steps.app_version.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} + type=sha,prefix=sha-,format=short + type=raw,value=${{ inputs.custom_tag }},enable=${{ github.event_name == 'workflow_dispatch' && inputs.custom_tag != '' }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -45,6 +70,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/package-lock.json b/package-lock.json index c45e8fef38..b7748e2438 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,7 +97,9 @@ "prettier": "^3.4.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.20.5", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.0.5" } }, "node_modules/@adiwajshing/keyed-db": { @@ -4803,6 +4805,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/compression": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", @@ -4842,6 +4855,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5314,6 +5334,121 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wasm-audio-decoders/common": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/@wasm-audio-decoders/common/-/common-9.0.7.tgz", @@ -5698,6 +5833,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", @@ -6221,6 +6366,23 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -6241,6 +6403,16 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cheerio": { "version": "1.0.0-rc.11", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.11.tgz", @@ -7008,6 +7180,16 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -7572,6 +7754,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -8296,6 +8485,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -8363,6 +8562,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -9304,6 +9513,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -11204,6 +11420,13 @@ "node": ">=0.10.0" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.2.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", @@ -11778,6 +12001,25 @@ "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" } }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/nats": { "version": "2.29.3", "resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz", @@ -12576,6 +12818,16 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/peek-readable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", @@ -12812,6 +13064,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "devOptional": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -14168,6 +14449,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -14412,6 +14700,16 @@ "node": ">= 12" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split-on-first": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", @@ -14430,6 +14728,13 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -14439,6 +14744,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -14627,6 +14939,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/strnum": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", @@ -14818,6 +15150,13 @@ "readable-stream": "3" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -14878,6 +15217,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -14964,6 +15333,28 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "deprecated": "unmaintained", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -15080,7 +15471,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15097,7 +15487,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15114,7 +15503,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15131,7 +15519,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15148,7 +15535,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15165,7 +15551,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15182,7 +15567,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15199,7 +15583,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15216,7 +15599,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15233,7 +15615,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15250,7 +15631,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15267,7 +15647,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15284,7 +15663,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15301,7 +15679,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15318,7 +15695,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15335,7 +15711,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15352,7 +15727,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15369,7 +15743,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15386,7 +15759,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15403,7 +15775,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15420,7 +15791,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15437,7 +15807,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15454,7 +15823,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15471,7 +15839,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15488,7 +15855,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15505,7 +15871,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15907,6 +16272,248 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", + "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -16064,6 +16671,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/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..0abc8aedb0 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start": "tsx ./src/main.ts", "start:prod": "node dist/main", "dev:server": "tsx watch ./src/main.ts", - "test": "tsx watch ./test/all.test.ts", + "test": "vitest run", + "test:watch": "vitest", "lint": "eslint --fix --ext .ts src", "lint:check": "eslint --ext .ts src", "commit": "cz", @@ -153,6 +154,8 @@ "prettier": "^3.4.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.20.5", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.0.5" } } diff --git a/src/api/abstract/abstract.cache.ts b/src/api/abstract/abstract.cache.ts index 2d93f3232f..de052bdff3 100644 --- a/src/api/abstract/abstract.cache.ts +++ b/src/api/abstract/abstract.cache.ts @@ -5,6 +5,12 @@ export interface ICache { set(key: string, value: any, ttl?: number): void; + /** + * Atomically sets `key` only if it does not already exist. + * Returns true when this call claimed the key, false when another caller already holds it. + */ + setNX(key: string, value: any, ttl?: number): Promise; + hSet(key: string, field: string, value: any): Promise; has(key: string): Promise; diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.spec.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.spec.ts new file mode 100644 index 0000000000..77e27a97b8 --- /dev/null +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.spec.ts @@ -0,0 +1,105 @@ +import { CacheService } from '@api/services/cache.service'; +import { describe, expect, it, vi } from 'vitest'; + +import { ChatwootDeliveryService } from './chatwoot-delivery.service'; + +describe('ChatwootDeliveryService', () => { + describe('claim', () => { + it('claims a (instance, message, operation) tuple only once', async () => { + const cache = { + setNX: vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false), + } as unknown as CacheService; + const delivery = new ChatwootDeliveryService(cache); + + const first = await delivery.claim('instance-1', 42, 'send'); + const second = await delivery.claim('instance-1', 42, 'send'); + + expect(first).toBe(true); + expect(second).toBe(false); + expect(cache.setNX).toHaveBeenCalledTimes(2); + expect(cache.setNX).toHaveBeenNthCalledWith(1, 'chatwoot-outbound-delivery:instance-1:42:send', true, 300); + }); + + it('fails open when there is no stable message id to key on', async () => { + const cache = { setNX: vi.fn() } as unknown as CacheService; + const delivery = new ChatwootDeliveryService(cache); + + const claimed = await delivery.claim('instance-1', undefined as unknown as number, 'send'); + + expect(claimed).toBe(true); + expect(cache.setNX).not.toHaveBeenCalled(); + }); + }); + + describe('reportFailure', () => { + it('updates the original message to failed with the external error, and never creates a message', async () => { + const update = vi.fn().mockResolvedValue({}); + const create = vi.fn(); + const client = { messages: { update, create } } as any; + const delivery = new ChatwootDeliveryService({} as CacheService); + + await delivery.reportFailure(client, 7, 99, 123, 'number not on whatsapp'); + + expect(update).toHaveBeenCalledWith({ + accountId: 7, + conversationId: 99, + messageId: 123, + data: { status: 'failed', external_error: 'number not on whatsapp' }, + }); + expect(create).not.toHaveBeenCalled(); + }); + + it('does nothing when the message id is missing', async () => { + const update = vi.fn(); + const client = { messages: { update } } as any; + const delivery = new ChatwootDeliveryService({} as CacheService); + + await delivery.reportFailure(client, 7, 99, undefined as unknown as number, 'error'); + + expect(update).not.toHaveBeenCalled(); + }); + + it('swallows update errors instead of throwing, so the webhook can still be acknowledged', async () => { + const update = vi.fn().mockRejectedValue(new Error('network blip')); + const client = { messages: { update } } as any; + const delivery = new ChatwootDeliveryService({} as CacheService); + + await expect(delivery.reportFailure(client, 7, 99, 123, 'error')).resolves.toBeUndefined(); + }); + }); + + describe('registerExternalId', () => { + it('sets source_id on the original message via the Application API', async () => { + const update = vi.fn().mockResolvedValue({}); + const client = { messages: { update } } as any; + const delivery = new ChatwootDeliveryService({} as CacheService); + + await delivery.registerExternalId(client, 7, 99, 123, 'WAID:abc123'); + + expect(update).toHaveBeenCalledWith({ + accountId: 7, + conversationId: 99, + messageId: 123, + data: { source_id: 'WAID:abc123' }, + }); + }); + + it('does nothing when the external id is missing', async () => { + const update = vi.fn(); + const client = { messages: { update } } as any; + const delivery = new ChatwootDeliveryService({} as CacheService); + + await delivery.registerExternalId(client, 7, 99, 123, ''); + + expect(update).not.toHaveBeenCalled(); + }); + + it('swallows update errors instead of throwing', async () => { + const update = vi.fn().mockRejectedValue(new Error('network blip')); + const client = { messages: { update } } as any; + const delivery = new ChatwootDeliveryService({} as CacheService); + + await expect(delivery.registerExternalId(client, 7, 99, 123, 'WAID:abc123')).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.ts new file mode 100644 index 0000000000..d1fd850e9b --- /dev/null +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.ts @@ -0,0 +1,95 @@ +import { CacheService } from '@api/services/cache.service'; +import { Logger } from '@config/logger.config'; +import ChatwootClient from '@figuro/chatwoot-sdk'; + +const logger = new Logger('ChatwootDeliveryService'); + +/** + * Guards outbound Chatwoot -> WhatsApp delivery against duplicate webhook deliveries and + * reports permanent send failures back onto the original Chatwoot message instead of a + * private note, so the message stays retryable and unread counts/automations stay untouched. + */ +export class ChatwootDeliveryService { + constructor(private readonly cache: CacheService) {} + + private buildDeliveryKey(instanceName: string, chatwootMessageId: number | string, operation: string): string { + return `chatwoot-outbound-delivery:${instanceName}:${chatwootMessageId}:${operation}`; + } + + /** + * Atomically claims the right to process a given (instance, chatwootMessageId, operation) + * exactly once. A retried/duplicated Chatwoot webhook for the same message resolves to + * `false`, so the caller can acknowledge the webhook without sending to WhatsApp again. + */ + public async claim( + instanceName: string, + chatwootMessageId: number | string, + operation: string, + ttlSeconds = 300, + ): Promise { + if (!chatwootMessageId) { + // Without a stable message id we cannot deduplicate; fail open rather than block sends. + return true; + } + + return this.cache.setNX(this.buildDeliveryKey(instanceName, chatwootMessageId, operation), true, ttlSeconds); + } + + /** + * Marks the original Chatwoot message as failed via the authenticated Application API + * instead of creating a private note. Keeps the message content/attachments intact so the + * agent can retry, and never creates a second, unrelated message. + */ + public async reportFailure( + client: ChatwootClient, + accountId: number, + conversationId: number, + chatwootMessageId: number, + externalError: string, + ): Promise { + if (!client || !accountId || !conversationId || !chatwootMessageId) { + return; + } + + try { + await client.messages.update({ + accountId, + conversationId, + messageId: chatwootMessageId, + data: { status: 'failed', external_error: externalError } as any, + }); + } catch (error) { + logger.error(error); + } + } + + /** + * Registers the raw WhatsApp message key on the original Chatwoot message's source_id via + * the authenticated Application API, once WhatsApp has accepted the send. This is the only + * supported way to set source_id on an outbound message — never write it directly to + * Chatwoot's database. Required for quoted replies to outgoing messages to resolve, since + * Chatwoot's InReplyToMessageBuilder matches parents by source_id. + */ + public async registerExternalId( + client: ChatwootClient, + accountId: number, + conversationId: number, + chatwootMessageId: number, + externalId: string, + ): Promise { + if (!client || !accountId || !conversationId || !chatwootMessageId || !externalId) { + return; + } + + try { + await client.messages.update({ + accountId, + conversationId, + messageId: chatwootMessageId, + data: { source_id: externalId } as any, + }); + } catch (error) { + logger.error(error); + } + } +} diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts new file mode 100644 index 0000000000..2129ecdd59 --- /dev/null +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts @@ -0,0 +1,190 @@ +// eslint-disable-next-line simple-import-sort/imports +import { request as chatwootRequest } from '@figuro/chatwoot-sdk/dist/core/request'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ChatwootService } from './chatwoot.service'; + +vi.mock('@figuro/chatwoot-sdk/dist/core/request', () => ({ + request: vi.fn(), +})); + +const buildService = () => { + const claimedKeys = new Set(); + const cache = { + setNX: vi.fn(async (key: string) => { + if (claimedKeys.has(key)) return false; + claimedKeys.add(key); + return true; + }), + } as any; + const waMonitor = { waInstances: {} } as any; + const configService = {} as any; + const prismaRepository = {} as any; + + const service = new ChatwootService(waMonitor, configService, prismaRepository, cache); + (service as any).provider = { accountId: 42, token: 'token', url: 'https://cw.example' }; + + return { service, waMonitor }; +}; + +describe('ChatwootService reaction bridge', () => { + const instance = { instanceName: 'my-instance' } as any; + + beforeEach(() => { + vi.mocked(chatwootRequest).mockReset(); + vi.mocked(chatwootRequest).mockResolvedValue({}); + }); + + describe('outbound: agent reaction (Chatwoot) -> WhatsApp', () => { + const targetKey = { id: 'WA-KEY-1', remoteJid: '123@s.whatsapp.net', fromMe: false }; + + const buildReactionBody = (overrides: Record = {}) => ({ + id: 555, + event: 'message_reaction_created', + actor_type: 'User', + emoji: '👍', + message_id: 10, + source_id: `WAID:${targetKey.id}`, + ...overrides, + }); + + it('invokes reactionMessage with the target WhatsApp key and emoji', async () => { + const { service, waMonitor } = buildService(); + const reactionMessage = vi.fn().mockResolvedValue({}); + waMonitor.waInstances[instance.instanceName] = { reactionMessage }; + vi.spyOn(service as any, 'getMessageByKeyId').mockResolvedValue({ key: targetKey }); + + await (service as any).handleReactionWebhook(instance, buildReactionBody()); + + expect(reactionMessage).toHaveBeenCalledWith({ key: targetKey, reaction: '👍' }); + }); + + it('sends an empty reaction for message_reaction_deleted', async () => { + const { service, waMonitor } = buildService(); + const reactionMessage = vi.fn().mockResolvedValue({}); + waMonitor.waInstances[instance.instanceName] = { reactionMessage }; + vi.spyOn(service as any, 'getMessageByKeyId').mockResolvedValue({ key: targetKey }); + + await (service as any).handleReactionWebhook( + instance, + buildReactionBody({ event: 'message_reaction_deleted', emoji: '👍' }), + ); + + expect(reactionMessage).toHaveBeenCalledWith({ key: targetKey, reaction: '' }); + }); + + it('does not relay a contact-originated reaction back to WhatsApp (would echo)', async () => { + const { service, waMonitor } = buildService(); + const reactionMessage = vi.fn(); + waMonitor.waInstances[instance.instanceName] = { reactionMessage }; + + await (service as any).handleReactionWebhook(instance, buildReactionBody({ actor_type: 'Contact' })); + + expect(reactionMessage).not.toHaveBeenCalled(); + }); + + it('invokes the provider once for a duplicate delivery id', async () => { + const { service, waMonitor } = buildService(); + const reactionMessage = vi.fn().mockResolvedValue({}); + waMonitor.waInstances[instance.instanceName] = { reactionMessage }; + vi.spyOn(service as any, 'getMessageByKeyId').mockResolvedValue({ key: targetKey }); + + const body = buildReactionBody(); + await (service as any).handleReactionWebhook(instance, body); + await (service as any).handleReactionWebhook(instance, body); + + expect(reactionMessage).toHaveBeenCalledTimes(1); + }); + + it('acknowledges without sending when the target WhatsApp key cannot be resolved', async () => { + const { service, waMonitor } = buildService(); + const reactionMessage = vi.fn(); + waMonitor.waInstances[instance.instanceName] = { reactionMessage }; + vi.spyOn(service as any, 'getMessageByKeyId').mockResolvedValue(null); + + await expect((service as any).handleReactionWebhook(instance, buildReactionBody())).resolves.not.toThrow(); + expect(reactionMessage).not.toHaveBeenCalled(); + }); + + it('acknowledges without sending when the message has no source_id yet', async () => { + const { service, waMonitor } = buildService(); + const reactionMessage = vi.fn(); + waMonitor.waInstances[instance.instanceName] = { reactionMessage }; + + await (service as any).handleReactionWebhook(instance, buildReactionBody({ source_id: undefined })); + + expect(reactionMessage).not.toHaveBeenCalled(); + }); + + // The HTTP webhook route builds `instance` from the URL's :instanceName param alone + // (see ChatwootRouter -> RouterBroker#dataValidate), so instanceId is missing until this + // handler fills it in. Regression for the bug where getMessageByKeyId's `instanceId` + // predicate silently matched nothing because that fill-in step was skipped. + it('resolves instanceId from the running instance before looking up the target message', async () => { + const { service, waMonitor } = buildService(); + const routeInstance = { instanceName: 'my-instance' } as any; + const reactionMessage = vi.fn().mockResolvedValue({}); + waMonitor.waInstances[routeInstance.instanceName] = { reactionMessage, instanceId: 'resolved-instance-id' }; + const getMessageByKeyId = vi.spyOn(service as any, 'getMessageByKeyId').mockResolvedValue({ key: targetKey }); + + await (service as any).handleReactionWebhook(routeInstance, buildReactionBody()); + + expect(routeInstance.instanceId).toBe('resolved-instance-id'); + expect(getMessageByKeyId).toHaveBeenCalledWith( + expect.objectContaining({ instanceId: 'resolved-instance-id' }), + targetKey.id, + ); + }); + }); + + describe('inbound: WhatsApp contact reaction -> Chatwoot', () => { + const reactionMessage = { key: { id: 'WA-KEY-2', remoteJid: '123@s.whatsapp.net', fromMe: false }, text: '👍' }; + + it('calls the Chatwoot reaction endpoint and never creates a message', async () => { + const { service } = buildService(); + vi.spyOn(service as any, 'getMessageByKeyId').mockResolvedValue({ + chatwootMessageId: 77, + chatwootConversationId: 88, + }); + const createMessage = vi.spyOn(service as any, 'createMessage'); + + await (service as any).handleInboundContactReaction(instance, reactionMessage); + + expect(chatwootRequest).toHaveBeenCalledWith( + expect.objectContaining({ basePath: 'https://cw.example' }), + expect.objectContaining({ + method: 'PUT', + url: '/api/v1/accounts/42/conversations/88/messages/77/reaction', + body: { emoji: '👍', message_type: 'incoming' }, + }), + ); + expect(createMessage).not.toHaveBeenCalled(); + }); + + it('sends an empty emoji to remove the reaction when text is empty', async () => { + const { service } = buildService(); + vi.spyOn(service as any, 'getMessageByKeyId').mockResolvedValue({ + chatwootMessageId: 77, + chatwootConversationId: 88, + }); + + await (service as any).handleInboundContactReaction(instance, { ...reactionMessage, text: '' }); + + expect(chatwootRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ body: { emoji: '', message_type: 'incoming' } }), + ); + }); + + it('acknowledges an unknown parent without retrying or creating a message', async () => { + const { service } = buildService(); + vi.spyOn(service as any, 'getMessageByKeyId').mockResolvedValue(null); + const createMessage = vi.spyOn(service as any, 'createMessage'); + + await expect((service as any).handleInboundContactReaction(instance, reactionMessage)).resolves.not.toThrow(); + + expect(chatwootRequest).not.toHaveBeenCalled(); + expect(createMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts index 906fff1881..438e16a844 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts @@ -2,6 +2,7 @@ import { InstanceDto } from '@api/dto/instance.dto'; import { Options, Quoted, SendAudioDto, SendMediaDto, SendTextDto } from '@api/dto/sendMessage.dto'; import { ChatwootDto } from '@api/integrations/chatbot/chatwoot/dto/chatwoot.dto'; import { postgresClient } from '@api/integrations/chatbot/chatwoot/libs/postgres.client'; +import { ChatwootDeliveryService } from '@api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service'; import { chatwootImport } from '@api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper'; import { PrismaRepository } from '@api/repository/repository.service'; import { CacheService } from '@api/services/cache.service'; @@ -41,6 +42,15 @@ interface ChatwootMessage { isRead?: boolean; } +// Chatwoot dispatches these for a reaction created/replaced/removed on an existing message — +// never a chat message in its own right. Chatwoot only sends them to inboxes that declared +// the 'reactions' provider capability, so no capability check is needed on this side. +const REACTION_WEBHOOK_EVENTS = new Set([ + 'message_reaction_created', + 'message_reaction_updated', + 'message_reaction_deleted', +]); + export class ChatwootService { private readonly logger = new Logger('ChatwootService'); @@ -58,6 +68,16 @@ export class ChatwootService { private pgClient = postgresClient.getChatwootConnection(); + private chatwootDeliveryService: ChatwootDeliveryService; + + private get delivery(): ChatwootDeliveryService { + if (!this.chatwootDeliveryService) { + this.chatwootDeliveryService = new ChatwootDeliveryService(this.cache); + } + + return this.chatwootDeliveryService; + } + private async getProvider(instance: InstanceDto): Promise { const cacheKey = `${instance.instanceName}:getProvider`; if (await this.cache.has(cacheKey)) { @@ -1268,46 +1288,29 @@ export class ChatwootService { } } - public async onSendMessageError(instance: InstanceDto, conversation: number, error?: any) { + public async onSendMessageError(instance: InstanceDto, conversation: number, messageId?: number, error?: any) { this.logger.verbose(`onSendMessageError ${JSON.stringify(error)}`); const client = await this.clientCw(instance); - if (!client) { + if (!client || !messageId) { return; } - if (error && error?.status === 400 && error?.message[0]?.exists === false) { - client.messages.create({ - accountId: this.provider.accountId, - conversationId: conversation, - data: { - content: `${i18next.t('cw.message.numbernotinwhatsapp')}`, - message_type: 'outgoing', - private: true, - }, - }); - - return; - } + const externalError = + error && error?.status === 400 && error?.message[0]?.exists === false + ? i18next.t('cw.message.numbernotinwhatsapp') + : i18next.t('cw.message.notsent', { + error: error ? `_${error.toString()}_` : '', + }); - client.messages.create({ - accountId: this.provider.accountId, - conversationId: conversation, - data: { - content: i18next.t('cw.message.notsent', { - error: error ? `_${error.toString()}_` : '', - }), - message_type: 'outgoing', - private: true, - }, - }); + // Mark the original message as failed via the Application API instead of creating a + // private note: keeps it retryable and avoids a second, unrelated message in the thread. + await this.delivery.reportFailure(client, this.provider.accountId, conversation, messageId, externalError); } public async receiveWebhook(instance: InstanceDto, body: any) { try { - await new Promise((resolve) => setTimeout(resolve, 500)); - const client = await this.clientCw(instance); if (!client) { @@ -1315,6 +1318,10 @@ export class ChatwootService { return null; } + if (REACTION_WEBHOOK_EVENTS.has(body.event)) { + return this.handleReactionWebhook(instance, body); + } + if ( this.provider.reopenConversation === false && body.event === 'conversation_status_changed' && @@ -1446,8 +1453,17 @@ export class ChatwootService { return { message: 'bot' }; } + // Chatwoot may redeliver the same message_created webhook (retries, worker restarts). + // Claim this (instance, message, operation) once so a duplicate delivery is + // acknowledged without sending to WhatsApp a second time. + const canProcessDelivery = await this.delivery.claim(instance.instanceName, body.id, 'send'); + if (!canProcessDelivery) { + this.logger.verbose(`Skipping duplicate outbound delivery for chatwoot message ${body.id}`); + return { message: 'bot' }; + } + if (!waInstance && body.conversation?.id) { - this.onSendMessageError(instance, body.conversation?.id, 'Instance not found'); + this.onSendMessageError(instance, body.conversation?.id, body.id, 'Instance not found'); return { message: 'bot' }; } @@ -1483,7 +1499,7 @@ export class ChatwootService { options, ); if (!messageSent && body.conversation?.id) { - this.onSendMessageError(instance, body.conversation?.id); + this.onSendMessageError(instance, body.conversation?.id, body.id); } await this.updateChatwootMessageId( @@ -1498,6 +1514,17 @@ export class ChatwootService { }, instance, ); + + const sentKey = messageSent?.key as WAMessageKey; + if (sentKey?.id && body.conversation?.id) { + await this.delivery.registerExternalId( + client, + this.provider.accountId, + body.conversation.id, + body.id, + `WAID:${sentKey.id}`, + ); + } } } else { const data: SendTextDto = { @@ -1532,9 +1559,20 @@ export class ChatwootService { }, instance, ); + + const sentKey = messageSent?.key as WAMessageKey; + if (sentKey?.id && body.conversation?.id) { + await this.delivery.registerExternalId( + client, + this.provider.accountId, + body.conversation.id, + body.id, + `WAID:${sentKey.id}`, + ); + } } catch (error) { if (!messageSent && body.conversation?.id) { - this.onSendMessageError(instance, body.conversation?.id, error); + this.onSendMessageError(instance, body.conversation?.id, body.id, error); } throw error; } @@ -1606,6 +1644,65 @@ export class ChatwootService { } } + // A reaction created/replaced by an agent in Chatwoot (actor_type: 'User') is relayed to + // WhatsApp via Baileys' native reaction message. A contact-originated reaction + // (actor_type: 'Contact') already came FROM WhatsApp — relaying it back would be a pointless + // echo, so it is acknowledged without any outbound send. + private async handleReactionWebhook(instance: InstanceDto, body: any) { + try { + if (body.actor_type !== 'User') { + return { message: 'bot' }; + } + + const canProcess = await this.delivery.claim(instance.instanceName, body.id, `react:${body.event}`); + if (!canProcess) { + return { message: 'bot' }; + } + + const waInstance = this.waMonitor.waInstances[instance.instanceName]; + if (!waInstance) { + this.logger.warn(`Reaction webhook for chatwoot message ${body.message_id}: instance not found`); + return { message: 'bot' }; + } + // The webhook route only resolves `instanceName` from the URL param — `instanceId` must + // be filled in from the running instance before any lookup keyed on it (mirrors the + // message_created path at the top of receiveWebhook), or getMessageByKeyId's `instanceId` + // predicate silently matches nothing. + instance.instanceId = waInstance.instanceId; + + const rawSourceId: string | undefined = body.source_id; + if (!rawSourceId) { + // The target message has not been registered with a WhatsApp key yet (e.g. it hasn't + // finished sending) — acknowledge without retrying; there is nothing to react to yet. + this.logger.warn(`Reaction webhook for chatwoot message ${body.message_id} has no source_id; skipping`); + return { message: 'bot' }; + } + + const rawKeyId = rawSourceId.startsWith('WAID:') ? rawSourceId.slice(5) : rawSourceId; + const targetMessage = await this.getMessageByKeyId(instance, rawKeyId); + const targetKey = targetMessage?.key as WAMessageKey; + + if (!targetKey?.id) { + // Unknown parent: log and acknowledge rather than retry-storming a message this + // instance never saw (e.g. it predates this instance's history). + this.logger.warn( + `Could not resolve WhatsApp key for chatwoot message ${body.message_id}; acknowledging without retry`, + ); + return { message: 'bot' }; + } + + const reaction = body.event === 'message_reaction_deleted' ? '' : body.emoji || ''; + + await waInstance.reactionMessage({ key: targetKey, reaction }); + + return { message: 'bot' }; + } catch (error) { + this.logger.error(error); + + return { message: 'bot' }; + } + } + private async updateChatwootMessageId( message: MessageModel, chatwootMessageIds: ChatwootMessage, @@ -1677,6 +1774,22 @@ export class ChatwootService { } private async getQuotedMessage(msg: any, instance: InstanceDto): Promise { + // Prefer the WhatsApp-native external id Chatwoot carries on the reply: it identifies the + // parent directly, regardless of which side (agent or contact) originally sent it. Fall + // back to Evolution's own chatwootMessageId mapping only when it is absent, since that + // mapping only ever covers messages Evolution itself sent. + const inReplyToExternalId: string | undefined = msg?.content_attributes?.in_reply_to_external_id; + if (inReplyToExternalId) { + const rawKeyId = inReplyToExternalId.startsWith('WAID:') ? inReplyToExternalId.slice(5) : inReplyToExternalId; + const message = await this.getMessageByKeyId(instance, rawKeyId); + const key = message?.key as WAMessageKey; + const messageContent = message?.message as WAMessageContent; + + if (messageContent && key?.id) { + return { key, message: messageContent }; + } + } + if (msg?.content_attributes?.in_reply_to) { const message = await this.prismaRepository.message.findFirst({ where: { @@ -1757,6 +1870,39 @@ export class ChatwootService { return reactionMessage; } + // A WhatsApp contact's own reaction, relayed to Chatwoot as reaction state on the existing + // message it targets — never as a new chat message. message_type: 'incoming' tells Chatwoot + // to attribute it to the conversation's contact, mirroring how MessageBuilder already + // attributes contact-originated messages regardless of which token authenticates the call. + private async handleInboundContactReaction( + instance: InstanceDto, + reactionMessage: { key: { id: string; fromMe: boolean; remoteJid: string; participant?: string }; text: string }, + ) { + const targetMessage = await this.getMessageByKeyId(instance, reactionMessage.key.id); + + if (!targetMessage?.chatwootMessageId || !targetMessage?.chatwootConversationId) { + // Unknown parent (e.g. it predates this instance's history): log and acknowledge rather + // than retry-storming a message Chatwoot has never seen. + this.logger.warn( + `Received a WhatsApp reaction for an unknown message (key: ${reactionMessage.key.id}); acknowledging without retry`, + ); + return; + } + + try { + await chatwootRequest(this.getClientCwConfig(), { + method: 'PUT', + url: `/api/v1/accounts/${this.provider.accountId}/conversations/${targetMessage.chatwootConversationId}/messages/${targetMessage.chatwootMessageId}/reaction`, + body: { + emoji: reactionMessage.text || '', + message_type: 'incoming', + }, + }); + } catch (error) { + this.logger.error(error); + } + } + private getTypeMessage(msg: any) { const types = { conversation: msg.conversation, @@ -2143,26 +2289,7 @@ export class ChatwootService { } if (reactionMessage) { - if (reactionMessage.text) { - const send = await this.createMessage( - instance, - getConversation, - reactionMessage.text, - messageType, - false, - [], - { - message: { extendedTextMessage: { contextInfo: { stanzaId: reactionMessage.key.id } } }, - }, - 'WAID:' + body.key.id, - quotedMsg, - ); - if (!send) { - this.logger.warn('message not sent'); - return; - } - } - + await this.handleInboundContactReaction(instance, reactionMessage); return; } diff --git a/src/api/services/cache.service.spec.ts b/src/api/services/cache.service.spec.ts new file mode 100644 index 0000000000..f9b10a2d15 --- /dev/null +++ b/src/api/services/cache.service.spec.ts @@ -0,0 +1,24 @@ +import { ICache } from '@api/abstract/abstract.cache'; +import { describe, expect, it, vi } from 'vitest'; + +import { CacheService } from './cache.service'; + +describe('CacheService.setNX', () => { + it('delegates to the underlying cache engine', async () => { + const engine = { setNX: vi.fn().mockResolvedValue(true) } as unknown as ICache; + const cacheService = new CacheService(engine); + + const claimed = await cacheService.setNX('key', true, 30); + + expect(claimed).toBe(true); + expect(engine.setNX).toHaveBeenCalledWith('key', true, 30); + }); + + it('fails open (claim succeeds) when caching is disabled', async () => { + const cacheService = new CacheService(undefined as unknown as ICache); + + const claimed = await cacheService.setNX('key', true, 30); + + expect(claimed).toBe(true); + }); +}); diff --git a/src/api/services/cache.service.ts b/src/api/services/cache.service.ts index e2f96d4b22..fa6049234c 100644 --- a/src/api/services/cache.service.ts +++ b/src/api/services/cache.service.ts @@ -58,6 +58,15 @@ export class CacheService { } } + // When caching is disabled there is no way to deduplicate, so we fail open (claim + // succeeds) rather than silently blocking every call that depends on this claim. + async setNX(key: string, value: any, ttl?: number): Promise { + if (!this.cache) { + return true; + } + return this.cache.setNX(key, value, ttl); + } + async has(key: string) { if (!this.cache) { return; diff --git a/src/cache/localcache.spec.ts b/src/cache/localcache.spec.ts new file mode 100644 index 0000000000..6a85f1e666 --- /dev/null +++ b/src/cache/localcache.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LocalCache } from './localcache'; + +const configService = { + get: vi.fn().mockReturnValue({ LOCAL: { TTL: 60 } }), +} as any; + +describe('LocalCache.setNX', () => { + it('claims a key only once, until it is deleted', async () => { + const cache = new LocalCache(configService, `test-module-${Date.now()}-${Math.random()}`); + + const first = await cache.setNX('lock', true, 60); + const second = await cache.setNX('lock', true, 60); + + expect(first).toBe(true); + expect(second).toBe(false); + }); + + it('allows re-claiming after the key is deleted', async () => { + const cache = new LocalCache(configService, `test-module-${Date.now()}-${Math.random()}`); + + await cache.setNX('lock', true, 60); + await cache.delete('lock'); + + const reclaimed = await cache.setNX('lock', true, 60); + + expect(reclaimed).toBe(true); + }); + + it('keeps claims isolated per module namespace', async () => { + const cacheA = new LocalCache(configService, `module-a-${Date.now()}-${Math.random()}`); + const cacheB = new LocalCache(configService, `module-b-${Date.now()}-${Math.random()}`); + + const claimA = await cacheA.setNX('same-key', true, 60); + const claimB = await cacheB.setNX('same-key', true, 60); + + expect(claimA).toBe(true); + expect(claimB).toBe(true); + }); +}); diff --git a/src/cache/localcache.ts b/src/cache/localcache.ts index f7769e5845..6a9c6a7331 100644 --- a/src/cache/localcache.ts +++ b/src/cache/localcache.ts @@ -28,6 +28,19 @@ export class LocalCache implements ICache { return LocalCache.localCache.has(this.buildKey(key)); } + // No `await` occurs between the has()/set() check-and-act below, so within this single + // JS event-loop turn no other call can interleave and race it — this is atomic in-process. + async setNX(key: string, value: any, ttl?: number): Promise { + const builtKey = this.buildKey(key); + + if (LocalCache.localCache.has(builtKey)) { + return false; + } + + LocalCache.localCache.set(builtKey, value, ttl || this.conf.TTL); + return true; + } + async delete(key: string) { return LocalCache.localCache.del(this.buildKey(key)); } diff --git a/src/cache/rediscache.ts b/src/cache/rediscache.ts index 1ec67e7607..7f7a425f6c 100644 --- a/src/cache/rediscache.ts +++ b/src/cache/rediscache.ts @@ -48,6 +48,20 @@ export class RedisCache implements ICache { } } + async setNX(key: string, value: any, ttl?: number): Promise { + try { + const result = await this.client.set(this.buildKey(key), JSON.stringify(value), { + NX: true, + EX: ttl || this.conf?.TTL, + }); + + return result === 'OK'; + } catch (error) { + this.logger.error(error); + return false; + } + } + async hSet(key: string, field: string, value: any) { try { const json = JSON.stringify(value, BufferJSON.replacer); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000000..7b327a0c98 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: 'node', + include: ['src/**/*.spec.ts'], + }, +});