From 60adc79990d5d6d0ade9e4858eb92ec2c87f281b Mon Sep 17 00:00:00 2001 From: Luiz Carlos Ceconi Date: Sat, 19 Sep 2026 10:09:20 -0300 Subject: [PATCH 1/5] fix(chatwoot): make outbound delivery idempotent and stop masking failures as private notes The Chatwoot -> WhatsApp send path in receiveWebhook() had no deduplication: a retried/duplicated Chatwoot message_created webhook would call waInstance.textMessage/sendAttachment a second time for the same message, and a blind unconditional 500ms sleep at the top of the handler masked (without fixing) any race it was meant to avoid, at the cost of latency on every webhook. - Remove the unconditional 500ms sleep. - Add ChatwootDeliveryService.claim(instanceName, chatwootMessageId, operation), an atomic idempotency claim keyed on the tuple the webhook carries, so a duplicate delivery is acknowledged without re-sending to WhatsApp. Backed by a new ICache.setNX (SET NX EX in Redis; synchronous check-and-set in the in-process LocalCache, which is race-free because no await separates the check from the set). CacheService.setNX fails open (claim succeeds) when caching is disabled, so behavior is unchanged for deployments without a cache configured. - Replace onSendMessageError's private-note creation with ChatwootDeliveryService.reportFailure, which calls Chatwoot's existing authenticated message update endpoint (PATCH .../messages/:id with { status: 'failed', external_error }) so the original message becomes retryable instead of spawning a second, unrelated message in the thread. Also introduces vitest (the "test" script previously pointed at a non-existent test/all.test.ts) with 10 passing unit tests covering the new idempotency claim and failure-reporting behavior, plus the underlying cache primitives. Full end-to-end coverage of receiveWebhook() itself is out of scope for this change given the size of that method and the absence of any prior test harness for it. Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 678 +++++++++++++++++- package.json | 7 +- src/api/abstract/abstract.cache.ts | 6 + .../chatwoot-delivery.service.spec.ts | 70 ++ .../services/chatwoot-delivery.service.ts | 65 ++ .../chatwoot/services/chatwoot.service.ts | 65 +- src/api/services/cache.service.spec.ts | 24 + src/api/services/cache.service.ts | 9 + src/cache/localcache.spec.ts | 41 ++ src/cache/localcache.ts | 13 + src/cache/rediscache.ts | 14 + vitest.config.ts | 10 + 12 files changed, 942 insertions(+), 60 deletions(-) create mode 100644 src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.spec.ts create mode 100644 src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.ts create mode 100644 src/api/services/cache.service.spec.ts create mode 100644 src/cache/localcache.spec.ts create mode 100644 vitest.config.ts 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..0bc04bac37 --- /dev/null +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.spec.ts @@ -0,0 +1,70 @@ +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(); + }); + }); +}); 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..6ccd713b1a --- /dev/null +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.ts @@ -0,0 +1,65 @@ +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); + } + } +} diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts index 906fff1881..05a6d86131 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'; @@ -58,6 +59,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 +1279,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) { @@ -1446,8 +1440,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 +1486,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( @@ -1534,7 +1537,7 @@ export class ChatwootService { ); } 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; } 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'], + }, +}); From 25244fd6b10471a8d2d960ce438d1db0b88f9e21 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Ceconi Date: Sat, 19 Sep 2026 10:21:42 -0300 Subject: [PATCH 2/5] fix(chatwoot): resolve quoted replies by external id and register outbound WhatsApp keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After WhatsApp accepts an outbound send, Evolution only recorded the WhatsApp key on its own local DB row (updateChatwootMessageId) — it never told Chatwoot, so Chatwoot's source_id for agent-sent messages stayed empty forever. That broke quoting an outgoing message from the WhatsApp side, since Chatwoot's InReplyToMessageBuilder resolves parents by source_id. - Add ChatwootDeliveryService.registerExternalId: after a successful textMessage/sendAttachment, calls Chatwoot's existing authenticated message update endpoint with { source_id: 'WAID:' + key.id } — matching the same prefix convention already used for inbound messages, so the reply-matching and echo-guard logic stay consistent. Never writes to Chatwoot's database directly. - getQuotedMessage now prefers content_attributes.in_reply_to_external_id (identifies the parent directly, on either side of the conversation) and falls back to Evolution's own chatwootMessageId mapping only when it is absent, per the plan: Evolution's local mapping only ever covers messages Evolution itself sent. Depends on the ChatwootDeliveryService introduced in fix/chatwoot-idempotent-delivery (PR #1) — this branch is stacked on top of it. Companion Chatwoot-side change (same repo family, chatwoot fork): Api::V1::Accounts::Conversations::MessagesController#update now accepts source_id (API inboxes only, unique per inbox_id, validated at the model level with a matching partial unique index), which is what this PR's registerExternalId call relies on. Co-Authored-By: Claude Sonnet 5 --- .../chatwoot-delivery.service.spec.ts | 35 +++++++++++++++++ .../services/chatwoot-delivery.service.ts | 30 +++++++++++++++ .../chatwoot/services/chatwoot.service.ts | 38 +++++++++++++++++++ 3 files changed, 103 insertions(+) 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 index 0bc04bac37..77e27a97b8 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.spec.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.spec.ts @@ -67,4 +67,39 @@ describe('ChatwootDeliveryService', () => { 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 index 6ccd713b1a..d1fd850e9b 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot-delivery.service.ts @@ -62,4 +62,34 @@ export class ChatwootDeliveryService { 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.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts index 05a6d86131..6bdd2e4f51 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts @@ -1501,6 +1501,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 = { @@ -1535,6 +1546,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}`, + ); + } } catch (error) { if (!messageSent && body.conversation?.id) { this.onSendMessageError(instance, body.conversation?.id, body.id, error); @@ -1680,6 +1702,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: { From 457a795c227dddaa34c21e4ef9340ee46e8166a0 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Ceconi Date: Sat, 19 Sep 2026 11:03:50 -0300 Subject: [PATCH 3/5] feat(chatwoot): synchronize WhatsApp reactions without fake messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems this closes: 1. Inbound: a WhatsApp contact's reaction was turned into a plain Chatwoot text message whose content was just the emoji (chatwoot.service.ts, the reactionMessage branch of eventWhatsapp). That inflated unread counts, changed the conversation's last message, could trigger automations, and could never be removed when the contact un-reacted (empty text was silently dropped). 2. Outbound: nothing relayed an agent's reaction (created via Chatwoot's new PUT .../messages/:id/reaction endpoint) to WhatsApp at all. Changes: - receiveWebhook now branches on the three new Chatwoot events (message_reaction_created/updated/deleted) before the existing message-shaped filtering, since their payload has no `conversation` object. handleReactionWebhook only relays actor_type: 'User' (agent) reactions to WhatsApp via waInstance.reactionMessage — actor_type: 'Contact' reactions already came from WhatsApp, so relaying them back would echo. Deletion sends an empty reaction string (Baileys' native "remove reaction" signal). Deduped via the same ChatwootDeliveryService.claim(...) used for the outbound message-send path, keyed by (instance, chatwootMessageId, event). An unresolvable target (unknown source_id, or the target message hasn't finished sending yet) is logged and acknowledged rather than retried. - The reactionMessage branch of eventWhatsapp now calls handleInboundContactReaction, which resolves the target message's chatwootMessageId/chatwootConversationId via the existing getMessageByKeyId lookup and PUTs Chatwoot's reaction endpoint directly (message_type: 'incoming' attributes it to the contact), instead of createMessage(...reactionMessage.text...). An unknown parent is logged and acknowledged, never retried. createMessage is never called on this path. Depends on Chatwoot's new reaction endpoint and the Channel::Api#provider_capability?('reactions') gate (already merged in the chatwoot fork) — Chatwoot only sends these events to inboxes that declared the capability, so no capability check was needed here. 9 new unit tests on ChatwootService's two new methods (constructed with mocked constructor dependencies, matching the existing ChatwootDeliveryService test style since receiveWebhook itself has no test harness). Co-Authored-By: Claude Sonnet 5 --- .../services/chatwoot-reactions.spec.ts | 170 ++++++++++++++++++ .../chatwoot/services/chatwoot.service.ts | 121 ++++++++++--- 2 files changed, 271 insertions(+), 20 deletions(-) create mode 100644 src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts 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..a96796f876 --- /dev/null +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts @@ -0,0 +1,170 @@ +// 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(); + }); + }); + + 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 6bdd2e4f51..8908b91cd2 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts @@ -42,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'); @@ -1309,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' && @@ -1631,6 +1644,60 @@ 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' }; + } + + 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, @@ -1798,6 +1865,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, @@ -2184,26 +2284,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; } From 8c091db7a9b851cd4d8cc2ea33b614c9b0b7a0cb Mon Sep 17 00:00:00 2001 From: Luiz Carlos Ceconi Date: Sat, 19 Sep 2026 15:20:49 -0300 Subject: [PATCH 4/5] ci(docker): publish images to this fork's own Docker Hub namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three inherited Docker publish workflows all targeted evoapicloud/evolution-api — the upstream project's own Docker Hub namespace. This fork has no credentials for (and no rights to push to) that namespace, so every run would fail at push, and in practice none had ever run successfully (zero recorded workflow runs on this fork before this change). - Point all three workflows at ticczaleski/evolution-api instead, reusing the same DOCKER_USERNAME/DOCKER_PASSWORD secret names already configured the same way on the ticczaleski/chatwoot fork. - publish_docker_image_latest.yml (push to main) now mirrors chatwoot's docker-build.yml pattern: concurrency group per ref, a workflow_dispatch custom_tag input, and metadata-action tags for latest + the package.json version + a short commit sha, instead of a single hardcoded "latest" tag. This is CI-only; no application behavior changes. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/publish_docker_image.yml | 2 +- .../publish_docker_image_homolog.yml | 2 +- .../workflows/publish_docker_image_latest.yml | 33 +++++++++++++++++-- 3 files changed, 32 insertions(+), 5 deletions(-) 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 }} From f6ca22dc595efb0a66b4bd0f42e77fc9bf098ab4 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Ceconi Date: Mon, 21 Sep 2026 09:30:19 -0300 Subject: [PATCH 5/5] fix(chatwoot): resolve instanceId before looking up reaction targets The HTTP webhook route builds `instance` from the URL's :instanceName param alone (ChatwootRouter -> RouterBroker#dataValidate never sets instanceId), same as the message_created path which patches it in via `instance.instanceId = waInstance.instanceId` right after resolving the running instance. handleReactionWebhook was missing that same patch, so getMessageByKeyId's `instanceId = ${instance.instanceId}` predicate was always comparing against undefined and silently matched no rows - every agent reaction sent from the dashboard was acknowledged as "could not resolve WhatsApp key" and never relayed to WhatsApp, even though the target message existed in the local Message table. Co-Authored-By: Claude Sonnet 5 --- .../services/chatwoot-reactions.spec.ts | 20 +++++++++++++++++++ .../chatwoot/services/chatwoot.service.ts | 5 +++++ 2 files changed, 25 insertions(+) diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts index a96796f876..2129ecdd59 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot-reactions.spec.ts @@ -115,6 +115,26 @@ describe('ChatwootService reaction bridge', () => { 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', () => { diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts index 8908b91cd2..438e16a844 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts @@ -1664,6 +1664,11 @@ export class ChatwootService { 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) {