diff --git a/.env.example b/.env.example index 73a3b40d3..f4a99e8b3 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,13 @@ DEL_INSTANCE=false # Provider: postgresql | mysql | psql_bouncer DATABASE_PROVIDER=postgresql -DATABASE_CONNECTION_URI='postgresql://user:pass@postgres:5432/evolution_db?schema=evolution_api' +DATABASE_CONNECTION_URI='postgresql://user:pass@evolution-postgres:5432/evolution_db?schema=evolution_api' + +# Postgres container settings (used by docker-compose) +POSTGRES_DATABASE=evolution_db +POSTGRES_USERNAME=user +POSTGRES_PASSWORD=pass + # Client name for the database connection # It is used to separate an API installation from another that uses the same database. DATABASE_CONNECTION_CLIENT_NAME=evolution_exchange diff --git a/Dockerfile b/Dockerfile index 24c4e3bc7..747e7d730 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,9 +12,12 @@ WORKDIR /evolution COPY ./package*.json ./ COPY ./tsconfig.json ./ COPY ./tsup.config.ts ./ +COPY ./patches ./patches RUN npm ci --silent +RUN npx patch-package + COPY ./src ./src COPY ./public ./public COPY ./prisma ./prisma @@ -28,7 +31,7 @@ RUN chmod +x ./Docker/scripts/* && dos2unix ./Docker/scripts/* RUN ./Docker/scripts/generate_database.sh -RUN npm run build +RUN NODE_OPTIONS="--max-old-space-size=2048" npm run build FROM node:24-alpine AS final diff --git a/docker-compose.yaml b/docker-compose.yaml index e0edee656..a2f526695 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,7 +14,6 @@ services: - evolution_instances:/evolution/instances networks: - evolution-net - - dokploy-network env_file: - .env expose: @@ -26,6 +25,8 @@ services: restart: always ports: - "3000:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/nginx.conf:ro networks: - evolution-net @@ -41,9 +42,6 @@ services: evolution-net: aliases: - evolution-redis - dokploy-network: - aliases: - - evolution-redis expose: - "6379" @@ -67,7 +65,6 @@ services: - postgres_data:/var/lib/postgresql/data networks: - evolution-net - - dokploy-network expose: - "5432" @@ -79,6 +76,4 @@ volumes: networks: evolution-net: name: evolution-net - driver: bridge - dokploy-network: - external: true \ No newline at end of file + driver: bridge \ No newline at end of file diff --git a/env.example b/env.example index 5fe448b82..d6509d546 100644 --- a/env.example +++ b/env.example @@ -166,6 +166,7 @@ SQS_ACCESS_KEY_ID= SQS_SECRET_ACCESS_KEY= SQS_ACCOUNT_ID= SQS_REGION= +SQS_BASE_URL= SQS_MAX_PAYLOAD_SIZE=1048576 # =========================================== diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 000000000..7f4036213 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,51 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied expired no-cache no-store private auth; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + + # Handle client routing + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Cache HTML files for a shorter period + location ~* \.html$ { + expires 1h; + add_header Cache-Control "public"; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} diff --git a/package-lock.json b/package-lock.json index c45e8fef3..8c65324e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,6 +94,7 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "husky": "^9.1.7", "lint-staged": "^16.1.6", + "patch-package": "^8.0.1", "prettier": "^3.4.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.20.5", @@ -2907,6 +2908,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2928,6 +2930,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2940,6 +2943,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2955,6 +2959,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.204.0.tgz", "integrity": "sha512-vV5+WSxktzoMP8JoYWKeopChy6G3HKk4UQ2hESCRDUUTZqQ3+nM3u8noVG0LmNfRWwcFBnbZ71GKC7vaYYdJ1g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.204.0", "import-in-the-middle": "^1.8.1", @@ -3362,6 +3367,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3378,6 +3384,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -3395,6 +3402,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -3643,6 +3651,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -4933,6 +4942,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5108,6 +5118,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -5365,6 +5376,13 @@ "url": "https://github.com/sponsors/eshaz" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/@zxing/text-encoding": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", @@ -5411,6 +5429,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5758,6 +5777,7 @@ "resolved": "https://registry.npmjs.org/audio-decode/-/audio-decode-2.2.3.tgz", "integrity": "sha512-Z0lHvMayR/Pad9+O9ddzaBJE0DrhZkQlStrC1RwcAHF3AhQAsdwKHeLGK8fYKyp2DDU6xHxzGb4CLMui12yVrg==", "license": "MIT", + "peer": true, "dependencies": { "@wasm-audio-decoders/flac": "^0.2.4", "@wasm-audio-decoders/ogg-vorbis": "^0.1.15", @@ -6295,6 +6315,22 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -6746,6 +6782,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -7636,6 +7673,7 @@ "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7706,6 +7744,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7762,6 +7801,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8368,6 +8408,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8736,6 +8777,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/findup-sync": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz", @@ -9994,6 +10045,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -10337,6 +10404,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -10355,6 +10435,7 @@ "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz", "integrity": "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", @@ -10459,6 +10540,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -10492,6 +10593,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -10585,10 +10696,21 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.4.tgz", "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10680,6 +10802,7 @@ "resolved": "https://registry.npmjs.org/link-preview-js/-/link-preview-js-3.2.0.tgz", "integrity": "sha512-FvrLltjOPGbTzt+RugbzM7g8XuUNLPO2U/INSLczrYdAA32E7nZVUrVL1gr61DGOArGJA2QkPGMEvNMLLsXREA==", "license": "MIT", + "peer": true, "dependencies": { "cheerio": "1.0.0-rc.11", "url": "0.11.0" @@ -12126,6 +12249,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openai": { "version": "4.104.0", "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", @@ -12528,6 +12668,137 @@ "node": ">= 0.8" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/patch-package/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/patch-package/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/patch-package/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/path-exists": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", @@ -12600,6 +12871,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -12909,6 +13181,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12938,6 +13211,7 @@ "integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.19.0", "@prisma/engines": "6.19.0" @@ -14029,6 +14303,7 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -14194,6 +14469,16 @@ "url": "https://github.com/sponsors/eshaz" } }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -14871,6 +15156,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15059,6 +15345,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -15707,6 +15994,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16222,6 +16510,7 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "devOptional": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 56e32fcc8..b7bc159e6 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "tsc --noEmit && tsup", "start": "tsx ./src/main.ts", - "start:prod": "node dist/main", + "start:prod": "node --network-family-autoselection-attempt-timeout=1000 dist/main", "dev:server": "tsx watch ./src/main.ts", "test": "tsx watch ./test/all.test.ts", "lint": "eslint --fix --ext .ts src", @@ -20,6 +20,7 @@ "db:studio": "node runWithProvider.js \"npx prisma studio --schema ./prisma/DATABASE_PROVIDER-schema.prisma\"", "db:migrate:dev": "node runWithProvider.js \"rm -rf ./prisma/migrations && cp -r ./prisma/DATABASE_PROVIDER-migrations ./prisma/migrations && npx prisma migrate dev --schema ./prisma/DATABASE_PROVIDER-schema.prisma && cp -r ./prisma/migrations/* ./prisma/DATABASE_PROVIDER-migrations\"", "db:migrate:dev:win": "node runWithProvider.js \"xcopy /E /I prisma\\DATABASE_PROVIDER-migrations prisma\\migrations && npx prisma migrate dev --schema prisma\\DATABASE_PROVIDER-schema.prisma\"", + "postinstall": "patch-package", "prepare": "husky" }, "repository": { @@ -150,6 +151,7 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "husky": "^9.1.7", "lint-staged": "^16.1.6", + "patch-package": "^8.0.1", "prettier": "^3.4.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.20.5", diff --git a/patches/baileys+7.0.0-rc.6.patch b/patches/baileys+7.0.0-rc.6.patch new file mode 100644 index 000000000..e01f5cac3 --- /dev/null +++ b/patches/baileys+7.0.0-rc.6.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/baileys/lib/Utils/messages.js b/node_modules/baileys/lib/Utils/messages.js +index 17b05b8..782efb4 100644 +--- a/node_modules/baileys/lib/Utils/messages.js ++++ b/node_modules/baileys/lib/Utils/messages.js +@@ -132,7 +132,7 @@ export const prepareWAMessageMedia = async (message, options) => { + } + const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'; + const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') && typeof uploadData['jpegThumbnail'] === 'undefined'; +- const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true; ++ const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true && typeof uploadData.waveform === 'undefined'; + const requiresAudioBackground = options.backgroundColor && mediaType === 'audio' && uploadData.ptt === true; + const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation; + const { mediaKey, encFilePath, originalFilePath, fileEncSha256, fileSha256, fileLength } = await encryptedStream(uploadData.media, options.mediaTypeOverride || mediaType, { diff --git a/prisma/mysql-migrations/20250918183910_add_kafka_integration/migration.sql b/prisma/mysql-migrations/20250918183910_add_kafka_integration/migration.sql index c3a089bd0..7db06e1ae 100644 --- a/prisma/mysql-migrations/20250918183910_add_kafka_integration/migration.sql +++ b/prisma/mysql-migrations/20250918183910_add_kafka_integration/migration.sql @@ -131,8 +131,7 @@ ALTER TABLE `IntegrationSession` MODIFY `createdAt` TIMESTAMP NULL DEFAULT CURRE MODIFY `updatedAt` TIMESTAMP NOT NULL; -- AlterTable -ALTER TABLE `IsOnWhatsapp` DROP COLUMN `lid`, - MODIFY `createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, +ALTER TABLE `IsOnWhatsapp` MODIFY `createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, MODIFY `updatedAt` TIMESTAMP NOT NULL; -- AlterTable diff --git a/prisma/mysql-migrations/20251216143054_increase_token_length/migration.sql b/prisma/mysql-migrations/20251216143054_increase_token_length/migration.sql new file mode 100644 index 000000000..7c7d06dce --- /dev/null +++ b/prisma/mysql-migrations/20251216143054_increase_token_length/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE `Instance` MODIFY `token` VARCHAR(500); + diff --git a/prisma/mysql-migrations/20251223093839_re_add_lid_to_is_onwhatsapp/migration.sql b/prisma/mysql-migrations/20251223093839_re_add_lid_to_is_onwhatsapp/migration.sql new file mode 100644 index 000000000..1a6046d67 --- /dev/null +++ b/prisma/mysql-migrations/20251223093839_re_add_lid_to_is_onwhatsapp/migration.sql @@ -0,0 +1,21 @@ +-- Re-add lid column that was incorrectly dropped by previous migration +-- This migration ensures backward compatibility for existing installations + +-- Check if column exists before adding +SET @dbname = DATABASE(); +SET @tablename = 'IsOnWhatsapp'; +SET @columnname = 'lid'; +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (table_name = @tablename) + AND (table_schema = @dbname) + AND (column_name = @columnname) + ) > 0, + 'SELECT 1', + CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` VARCHAR(100);') +)); +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; diff --git a/prisma/mysql-schema.prisma b/prisma/mysql-schema.prisma index 71b5a743f..9e9afad4c 100644 --- a/prisma/mysql-schema.prisma +++ b/prisma/mysql-schema.prisma @@ -70,7 +70,7 @@ model Instance { integration String? @db.VarChar(100) number String? @db.VarChar(100) businessId String? @db.VarChar(100) - token String? @db.VarChar(255) + token String? @db.VarChar(500) clientName String? @db.VarChar(100) disconnectionReasonCode Int? @db.Int disconnectionObject Json? @db.Json @@ -655,6 +655,7 @@ model IsOnWhatsapp { id String @id @default(cuid()) remoteJid String @unique @db.VarChar(100) jidOptions String + lid String? @db.VarChar(100) createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP")) @db.Timestamp updatedAt DateTime @updatedAt @db.Timestamp } diff --git a/prisma/postgresql-migrations/20251216143054_increase_token_length/migration.sql b/prisma/postgresql-migrations/20251216143054_increase_token_length/migration.sql new file mode 100644 index 000000000..dc6c699de --- /dev/null +++ b/prisma/postgresql-migrations/20251216143054_increase_token_length/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Instance" ALTER COLUMN "token" TYPE VARCHAR(500); + diff --git a/prisma/postgresql-schema.prisma b/prisma/postgresql-schema.prisma index 6b98f88da..51b3c97c8 100644 --- a/prisma/postgresql-schema.prisma +++ b/prisma/postgresql-schema.prisma @@ -70,7 +70,7 @@ model Instance { integration String? @db.VarChar(100) number String? @db.VarChar(100) businessId String? @db.VarChar(100) - token String? @db.VarChar(255) + token String? @db.VarChar(500) clientName String? @db.VarChar(100) disconnectionReasonCode Int? @db.Integer disconnectionObject Json? @db.JsonB diff --git a/prisma/psql_bouncer-schema.prisma b/prisma/psql_bouncer-schema.prisma index a3f4dbe90..52e9eb784 100644 --- a/prisma/psql_bouncer-schema.prisma +++ b/prisma/psql_bouncer-schema.prisma @@ -71,7 +71,7 @@ model Instance { integration String? @db.VarChar(100) number String? @db.VarChar(100) businessId String? @db.VarChar(100) - token String? @db.VarChar(255) + token String? @db.VarChar(500) clientName String? @db.VarChar(100) disconnectionReasonCode Int? @db.Integer disconnectionObject Json? @db.JsonB diff --git a/src/api/controllers/chat.controller.ts b/src/api/controllers/chat.controller.ts index 22e90b9fa..fa5ea5fbb 100644 --- a/src/api/controllers/chat.controller.ts +++ b/src/api/controllers/chat.controller.ts @@ -1,6 +1,7 @@ import { ArchiveChatDto, BlockUserDto, + DecryptPollVoteDto, DeleteMessage, getBase64FromMediaMessageDto, MarkChatUnreadDto, @@ -113,4 +114,16 @@ export class ChatController { public async blockUser({ instanceName }: InstanceDto, data: BlockUserDto) { return await this.waMonitor.waInstances[instanceName].blockUser(data); } + + public async decryptPollVote({ instanceName }: InstanceDto, data: DecryptPollVoteDto) { + const pollCreationMessageKey = { + id: data.message.key.id, + remoteJid: data.remoteJid, + }; + return await this.waMonitor.waInstances[instanceName].baileysDecryptPollVote(pollCreationMessageKey); + } + + public async fetchChannels({ instanceName }: InstanceDto, query: Query) { + return await this.waMonitor.waInstances[instanceName].fetchChannels(query); + } } diff --git a/src/api/dto/chat.dto.ts b/src/api/dto/chat.dto.ts index b11f32b05..aeaab6f84 100644 --- a/src/api/dto/chat.dto.ts +++ b/src/api/dto/chat.dto.ts @@ -127,3 +127,12 @@ export class BlockUserDto { number: string; status: 'block' | 'unblock'; } + +export class DecryptPollVoteDto { + message: { + key: { + id: string; + }; + }; + remoteJid: string; +} diff --git a/src/api/dto/sendMessage.dto.ts b/src/api/dto/sendMessage.dto.ts index ba9ecf527..797ca111b 100644 --- a/src/api/dto/sendMessage.dto.ts +++ b/src/api/dto/sendMessage.dto.ts @@ -14,6 +14,7 @@ export class Options { mentionsEveryOne?: boolean; mentioned?: string[]; webhookUrl?: string; + messageId?: string; } export class MediaMessage { @@ -45,6 +46,7 @@ export class Metadata { mentioned?: string[]; encoding?: boolean; notConvertSticker?: boolean; + messageId?: string; } export class SendTextDto extends Metadata { diff --git a/src/api/integrations/channel/evolution/evolution.channel.service.ts b/src/api/integrations/channel/evolution/evolution.channel.service.ts index 87bea08e6..76a154fb1 100644 --- a/src/api/integrations/channel/evolution/evolution.channel.service.ts +++ b/src/api/integrations/channel/evolution/evolution.channel.service.ts @@ -318,7 +318,7 @@ export class EvolutionStartupService extends ChannelStartupService { let audioFile; - const messageId = v4(); + const messageId = options?.messageId ?? v4(); let messageRaw: any; @@ -548,6 +548,7 @@ export class EvolutionStartupService extends ChannelStartupService { linkPreview: data?.linkPreview, mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, + messageId: data?.messageId, }, null, isIntegration, @@ -613,6 +614,7 @@ export class EvolutionStartupService extends ChannelStartupService { linkPreview: data?.linkPreview, mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, + messageId: data?.messageId, }, file, isIntegration, @@ -711,6 +713,7 @@ export class EvolutionStartupService extends ChannelStartupService { linkPreview: data?.linkPreview, mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, + messageId: data?.messageId, }, file, isIntegration, @@ -736,6 +739,7 @@ export class EvolutionStartupService extends ChannelStartupService { quoted: data?.quoted, mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, + messageId: data?.messageId, }, null, isIntegration, diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index 1e4808c15..1f820d0e5 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -668,15 +668,7 @@ export class BusinessStartupService extends ChannelStartupService { sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`); - this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw); - - await chatbotController.emit({ - instance: { instanceName: this.instance.name, instanceId: this.instanceId }, - remoteJid: messageRaw.key.remoteJid, - msg: messageRaw, - pushName: messageRaw.pushName, - }); - + // Normalized order: Chatwoot first, then bot (consistent with Baileys channel) if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { const chatwootSentMessage = await this.chatwootService.eventWhatsapp( Events.MESSAGES_UPSERT, @@ -684,13 +676,22 @@ export class BusinessStartupService extends ChannelStartupService { messageRaw, ); - if (chatwootSentMessage?.id) { + if (chatwootSentMessage) { messageRaw.chatwootMessageId = chatwootSentMessage.id; - messageRaw.chatwootInboxId = chatwootSentMessage.id; - messageRaw.chatwootConversationId = chatwootSentMessage.id; + messageRaw.chatwootInboxId = chatwootSentMessage.inbox_id; + messageRaw.chatwootConversationId = chatwootSentMessage.conversation_id; } } + this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw); + + await chatbotController.emit({ + instance: { instanceName: this.instance.name, instanceId: this.instanceId }, + remoteJid: messageRaw.key.remoteJid, + msg: messageRaw, + pushName: messageRaw.pushName, + }); + if (!this.isMediaMessage(message) && message.type !== 'sticker') { await this.prismaRepository.message.create({ data: messageRaw, diff --git a/src/api/integrations/channel/whatsapp/baileys.controller.ts b/src/api/integrations/channel/whatsapp/baileys.controller.ts index ee547338d..5a6e121dd 100644 --- a/src/api/integrations/channel/whatsapp/baileys.controller.ts +++ b/src/api/integrations/channel/whatsapp/baileys.controller.ts @@ -10,6 +10,12 @@ export class BaileysController { return instance.baileysOnWhatsapp(body?.jid); } + public async generateMessageID({ instanceName }: InstanceDto) { + const instance = this.waMonitor.waInstances[instanceName]; + + return instance.generateMessageID(); + } + public async profilePictureUrl({ instanceName }: InstanceDto, body: any) { const instance = this.waMonitor.waInstances[instanceName]; diff --git a/src/api/integrations/channel/whatsapp/baileys.router.ts b/src/api/integrations/channel/whatsapp/baileys.router.ts index 04a1d565f..354995e04 100644 --- a/src/api/integrations/channel/whatsapp/baileys.router.ts +++ b/src/api/integrations/channel/whatsapp/baileys.router.ts @@ -19,6 +19,16 @@ export class BaileysRouter extends RouterBroker { res.status(HttpStatus.OK).json(response); }) + .get(this.routerPath('generateMessageID'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: instanceSchema, + ClassRef: InstanceDto, + execute: (instance) => baileysController.generateMessageID(instance), + }); + + res.status(HttpStatus.OK).json(response); + }) .post(this.routerPath('profilePictureUrl'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc..aec1806fb 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -67,7 +67,6 @@ import { Chatwoot, ConfigService, configService, - ConfigSessionPhone, Database, Log, Openai, @@ -90,6 +89,7 @@ import useMultiFileAuthStatePrisma from '@utils/use-multi-file-auth-state-prisma import { AuthStateProvider } from '@utils/use-multi-file-auth-state-provider-files'; import { useMultiFileAuthStateRedisDb } from '@utils/use-multi-file-auth-state-redis-db'; import axios from 'axios'; +import audioDecode from 'audio-decode'; import makeWASocket, { AnyMessageContent, BufferedEventData, @@ -104,6 +104,7 @@ import makeWASocket, { DisconnectReason, downloadContentFromMessage, downloadMediaMessage, + generateMessageIDV2, generateWAMessageFromContent, getAggregateVotesInPollMessage, GetCatalogOptions, @@ -124,7 +125,6 @@ import makeWASocket, { Product, proto, UserFacingSocketConfig, - WABrowserDescription, WAMediaUpload, WAMessage, WAMessageKey, @@ -139,11 +139,11 @@ import { createHash } from 'crypto'; import EventEmitter2 from 'eventemitter2'; import ffmpeg from 'fluent-ffmpeg'; import FormData from 'form-data'; +import { getLinkPreview } from 'link-preview-js'; import Long from 'long'; import mimeTypes from 'mime-types'; import NodeCache from 'node-cache'; import cron from 'node-cron'; -import { release } from 'os'; import { join } from 'path'; import P from 'pino'; import qrcode, { QRCodeToDataURLOptions } from 'qrcode'; @@ -249,9 +249,16 @@ export class BaileysStartupService extends ChannelStartupService { private readonly msgRetryCounterCache: CacheStore = new NodeCache(); private readonly userDevicesCache: CacheStore = new NodeCache({ stdTTL: 300000, useClones: false }); private endSession = false; + private isDeleting = false; // Flag to prevent reconnection during deletion private logBaileys = this.configService.get('LOG').BAILEYS; private eventProcessingQueue: Promise = Promise.resolve(); + // Cumulative history sync counters (reset on new sync or completion) + private historySyncMessageCount = 0; + private historySyncChatCount = 0; + private historySyncContactCount = 0; + private historySyncLastProgress = -1; + // Cache TTL constants (in seconds) private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing private readonly UPDATE_CACHE_TTL_SECONDS = 30 * 60; // 30 minutes - avoid duplicate status updates @@ -265,10 +272,27 @@ export class BaileysStartupService extends ChannelStartupService { } public async logoutInstance() { + // Mark instance as deleting to prevent reconnection attempts + this.isDeleting = true; + this.endSession = true; + this.messageProcessor.onDestroy(); - await this.client?.logout('Log out instance: ' + this.instanceName); - this.client?.ws?.close(); + if (this.client) { + try { + await this.client.logout('Log out instance: ' + this.instanceName); + } catch (error) { + this.logger.error({ message: 'Error during logout', error }); + } + + // Improved socket cleanup + try { + this.client.ws?.close(); + this.client.end(new Error('Instance logout')); + } catch (error) { + this.logger.error({ message: 'Error during socket cleanup', error }); + } + } const db = this.configService.get('DATABASE'); const cache = this.configService.get('CACHE'); @@ -332,6 +356,18 @@ export class BaileysStartupService extends ChannelStartupService { } private async connectionUpdate({ qr, connection, lastDisconnect }: Partial) { + // Enhanced logging for connection updates + const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; + this.logger.info({ + message: 'Connection update received', + connection, + hasQr: !!qr, + statusCode, + instanceName: this.instance.name, + isDeleting: this.isDeleting, + endSession: this.endSession, + }); + if (qr) { if (this.instance.qrcode.count === this.configService.get('QRCODE').LIMIT) { this.sendDataWebhook(Events.QRCODE_UPDATED, { @@ -406,7 +442,7 @@ export class BaileysStartupService extends ChannelStartupService { qrcodeTerminal.generate(qr, { small: true }, (qrcode) => this.logger.log( `\n{ instance: ${this.instance.name} pairingCode: ${this.instance.qrcode.pairingCode}, qrcodeCount: ${this.instance.qrcode.count} }\n` + - qrcode, + qrcode, ), ); @@ -424,12 +460,43 @@ export class BaileysStartupService extends ChannelStartupService { } if (connection === 'close') { + // Check if instance is being deleted or session is ending + if (this.isDeleting || this.endSession) { + this.logger.info('Instance is being deleted/ended, skipping reconnection attempt'); + return; + } + const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; + + // FIX: Do not reconnect if it's the initial connection (waiting for QR code) + // This prevents infinite loop that blocks QR code generation + const isInitialConnection = !this.instance.wuid && (this.instance.qrcode?.count ?? 0) === 0; + + if (isInitialConnection) { + this.logger.info('Initial connection closed, waiting for QR code generation...'); + return; + } + const shouldReconnect = !codesToNotReconnect.includes(statusCode); + + this.logger.info({ + message: 'Connection closed, evaluating reconnection', + statusCode, + shouldReconnect, + instanceName: this.instance.name, + }); + if (shouldReconnect) { - await this.connectToWhatsapp(this.phoneNumber); + // Add 3 second delay before reconnection to prevent rapid reconnection loops + this.logger.info('Reconnecting in 3 seconds...'); + setTimeout(async () => { + await this.connectToWhatsapp(this.phoneNumber); + }, 3000); } else { + this.logger.info( + `Skipping reconnection for status code ${statusCode} (code is in codesToNotReconnect list)`, + ); this.sendDataWebhook(Events.STATUS_INSTANCE, { instance: this.instance.name, status: 'closed', @@ -522,12 +589,27 @@ export class BaileysStartupService extends ChannelStartupService { private async getMessage(key: proto.IMessageKey, full = false) { try { - // Use raw SQL to avoid JSON path issues - const webMessageInfo = (await this.prismaRepository.$queryRaw` - SELECT * FROM "Message" - WHERE "instanceId" = ${this.instanceId} - AND "key"->>'id' = ${key.id} - `) as proto.IWebMessageInfo[]; + const provider = this.configService.get('DATABASE').PROVIDER; + + let webMessageInfo: proto.IWebMessageInfo[]; + + if (provider === 'mysql') { + // MySQL version + webMessageInfo = (await this.prismaRepository.$queryRaw` + SELECT * FROM Message + WHERE instanceId = ${this.instanceId} + AND JSON_UNQUOTE(JSON_EXTRACT(\`key\`, '$.id')) = ${key.id} + LIMIT 1 + `) as proto.IWebMessageInfo[]; + } else { + // PostgreSQL version + webMessageInfo = (await this.prismaRepository.$queryRaw` + SELECT * FROM "Message" + WHERE "instanceId" = ${this.instanceId} + AND "key"->>'id' = ${key.id} + LIMIT 1 + `) as proto.IWebMessageInfo[]; + } if (full) { return webMessageInfo[0]; @@ -576,33 +658,29 @@ export class BaileysStartupService extends ChannelStartupService { private async createClient(number?: string): Promise { this.instance.authState = await this.defineAuthState(); - const session = this.configService.get('CONFIG_SESSION_PHONE'); - - let browserOptions = {}; - - if (number || this.phoneNumber) { + if (number) { this.phoneNumber = number; - this.logger.info(`Phone number: ${number}`); - } else { - const browser: WABrowserDescription = [session.CLIENT, session.NAME, release()]; - browserOptions = { browser }; - - this.logger.info(`Browser: ${browser}`); } - const baileysVersion = await fetchLatestWaWebVersion({}); + // Fetch latest WhatsApp Web version automatically + const baileysVersion = await fetchLatestWaWebVersion({}, this.cache); const version = baileysVersion.version; - const log = `Baileys version: ${version.join('.')}`; + const log = `Baileys version: ${version.join('.')}`; this.logger.info(log); + const error = baileysVersion?.error ?? null; + if (error) { + this.logger.error(`Fetch latest WaWeb version error: ${JSON.stringify({ error })}`); + } + this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`); let options; if (this.localProxy?.enabled) { - this.logger.info('Proxy enabled: ' + this.localProxy?.host); + this.logger.verbose('Proxy enabled'); if (this.localProxy?.host?.includes('proxyscrape')) { try { @@ -611,9 +689,10 @@ export class BaileysStartupService extends ChannelStartupService { const proxyUrls = text.split('\r\n'); const rand = Math.floor(Math.random() * Math.floor(proxyUrls.length)); const proxyUrl = 'http://' + proxyUrls[rand]; + this.logger.info('Proxy url: ' + proxyUrl); options = { agent: makeProxyAgent(proxyUrl), fetchAgent: makeProxyAgentUndici(proxyUrl) }; - } catch { - this.localProxy.enabled = false; + } catch (error) { + this.logger.error(error); } } else { options = { @@ -647,7 +726,7 @@ export class BaileysStartupService extends ChannelStartupService { msgRetryCounterCache: this.msgRetryCounterCache, generateHighQualityLinkPreview: true, getMessage: async (key) => (await this.getMessage(key)) as Promise, - ...browserOptions, + // Removido browserOptions para usar Multi-Device nativo (não WebClient) markOnlineOnConnect: this.localSettings.alwaysOnline, retryRequestDelayMs: 350, maxMsgRetryCount: 4, @@ -940,6 +1019,14 @@ export class BaileysStartupService extends ChannelStartupService { syncType?: proto.HistorySync.HistorySyncType; }) => { try { + // Reset counters when a new sync starts (progress resets or decreases) + if (progress <= this.historySyncLastProgress) { + this.historySyncMessageCount = 0; + this.historySyncChatCount = 0; + this.historySyncContactCount = 0; + } + this.historySyncLastProgress = progress ?? -1; + if (syncType === proto.HistorySync.HistorySyncType.ON_DEMAND) { console.log('received on-demand history sync, messages=', messages); } @@ -967,14 +1054,29 @@ export class BaileysStartupService extends ChannelStartupService { } const contactsMap = new Map(); + const contactsMapLidJid = new Map(); for (const contact of contacts) { + let jid = null; + + if (contact?.id?.search('@lid') !== -1) { + if (contact.phoneNumber) { + jid = contact.phoneNumber; + } + } + + if (!jid) { + jid = contact?.id; + } + if (contact.id && (contact.notify || contact.name)) { - contactsMap.set(contact.id, { name: contact.name ?? contact.notify, jid: contact.id }); + contactsMap.set(contact.id, { name: contact.name ?? contact.notify, jid }); } + + contactsMapLidJid.set(contact.id, { jid }); } - const chatsRaw: { remoteJid: string; instanceId: string; name?: string }[] = []; + const chatsRaw: { remoteJid: string; remoteLid: string; instanceId: string; name?: string }[] = []; const chatsRepository = new Set( (await this.prismaRepository.chat.findMany({ where: { instanceId: this.instanceId } })).map( (chat) => chat.remoteJid, @@ -986,29 +1088,57 @@ export class BaileysStartupService extends ChannelStartupService { continue; } - chatsRaw.push({ remoteJid: chat.id, instanceId: this.instanceId, name: chat.name }); - } + let remoteJid = null; + let remoteLid = null; - this.sendDataWebhook(Events.CHATS_SET, chatsRaw); + if (chat.id.search('@lid') !== -1) { + const contact = contactsMapLidJid.get(chat.id); + + remoteLid = chat.id; + + if (contact && contact.jid) { + remoteJid = contact.jid; + } + } + + if (!remoteLid && chat.accountLid && chat.accountLid.search('@lid') !== -1) { + remoteLid = chat.accountLid; + } + + if (!remoteJid) { + remoteJid = chat.id; + } + + chatsRaw.push({ remoteJid, remoteLid, instanceId: this.instanceId, name: chat.name }); + } if (this.configService.get('DATABASE').SAVE_DATA.HISTORIC) { - await this.prismaRepository.chat.createMany({ data: chatsRaw, skipDuplicates: true }); + const chatsToCreateMany = JSON.parse(JSON.stringify(chatsRaw)).map((chat) => { + delete chat.remoteLid; + return chat; + }); + + await this.prismaRepository.chat.createMany({ data: chatsToCreateMany, skipDuplicates: true }); } + this.historySyncChatCount += chatsRaw.length; + + this.sendDataWebhook(Events.CHATS_SET, chatsRaw); + const messagesRaw: any[] = []; const messagesRepository: Set = new Set( chatwootImport.getRepositoryMessagesCache(instance) ?? - ( - await this.prismaRepository.message.findMany({ - select: { key: true }, - where: { instanceId: this.instanceId }, - }) - ).map((message) => { - const key = message.key as { id: string }; + ( + await this.prismaRepository.message.findMany({ + select: { key: true }, + where: { instanceId: this.instanceId }, + }) + ).map((message) => { + const key = message.key as { id: string }; - return key.id; - }), + return key.id; + }), ); if (chatwootImport.getRepositoryMessagesCache(instance) === null) { @@ -1046,15 +1176,17 @@ export class BaileysStartupService extends ChannelStartupService { messagesRaw.push(this.prepareMessage(m)); } - this.sendDataWebhook(Events.MESSAGES_SET, [...messagesRaw], true, undefined, { - isLatest, - progress, - }); + this.historySyncMessageCount += messagesRaw.length; if (this.configService.get('DATABASE').SAVE_DATA.HISTORIC) { await this.prismaRepository.message.createMany({ data: messagesRaw, skipDuplicates: true }); } + this.sendDataWebhook(Events.MESSAGES_SET, [...messagesRaw], true, undefined, { + isLatest, + progress, + }); + if ( this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled && @@ -1067,10 +1199,26 @@ export class BaileysStartupService extends ChannelStartupService { ); } + const filteredContacts = contacts.filter((c) => !!c.notify || !!c.name); + this.historySyncContactCount += filteredContacts.length; + await this.contactHandle['contacts.upsert']( - contacts.filter((c) => !!c.notify || !!c.name).map((c) => ({ id: c.id, name: c.name ?? c.notify })), + filteredContacts.map((c) => ({ id: c.id, name: c.name ?? c.notify })), ); + if (progress === 100) { + this.sendDataWebhook(Events.MESSAGING_HISTORY_SET, { + messageCount: this.historySyncMessageCount, + chatCount: this.historySyncChatCount, + contactCount: this.historySyncContactCount, + }); + + this.historySyncMessageCount = 0; + this.historySyncChatCount = 0; + this.historySyncContactCount = 0; + this.historySyncLastProgress = -1; + } + contacts = undefined; messages = undefined; chats = undefined; @@ -1201,10 +1349,10 @@ export class BaileysStartupService extends ChannelStartupService { } } - const messageRaw = this.prepareMessage(received); + const messageRaw = this.prepareMessage(received) as any; if (messageRaw.messageType === 'pollUpdateMessage') { - const pollCreationKey = messageRaw.message.pollUpdateMessage.pollCreationMessageKey; + const pollCreationKey = (messageRaw.message as any).pollUpdateMessage.pollCreationMessageKey; const pollMessage = (await this.getMessage(pollCreationKey, true)) as proto.IWebMessageInfo; const pollMessageSecret = (await this.getMessage(pollCreationKey)) as any; @@ -1213,7 +1361,7 @@ export class BaileysStartupService extends ChannelStartupService { (pollMessage.message as any).pollCreationMessage?.options || (pollMessage.message as any).pollCreationMessageV3?.options || []; - const pollVote = messageRaw.message.pollUpdateMessage.vote; + const pollVote = (messageRaw.message as any).pollUpdateMessage.vote; const voterJid = received.key.fromMe ? this.instance.wuid @@ -1293,14 +1441,14 @@ export class BaileysStartupService extends ChannelStartupService { }) .map((option) => option.optionName); - messageRaw.message.pollUpdateMessage.vote.selectedOptions = selectedOptionNames; + (messageRaw.message as any).pollUpdateMessage.vote.selectedOptions = selectedOptionNames; const pollUpdates = pollOptions.map((option) => ({ name: option.optionName, voters: selectedOptionNames.includes(option.optionName) ? [successfulVoterJid] : [], })); - messageRaw.pollUpdates = pollUpdates; + (messageRaw as any).pollUpdates = pollUpdates; } } @@ -1348,13 +1496,14 @@ export class BaileysStartupService extends ChannelStartupService { }); if (openAiDefaultSettings && openAiDefaultSettings.openaiCredsId && openAiDefaultSettings.speechToText) { - messageRaw.message.speechToText = `[audio] ${await this.openaiService.speechToText(received, this)}`; + (messageRaw.message as any).speechToText = + `[audio] ${await this.openaiService.speechToText(received, this)}`; } } if (this.configService.get('DATABASE').SAVE_DATA.NEW_MESSAGE) { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { pollUpdates, ...messageData } = messageRaw; + const { pollUpdates, ...messageData } = messageRaw as any; const msg = await this.prismaRepository.message.create({ data: messageData }); const { remoteJid } = received.key; @@ -1430,7 +1579,7 @@ export class BaileysStartupService extends ChannelStartupService { const mediaUrl = await s3Service.getObjectUrl(fullName); - messageRaw.message.mediaUrl = mediaUrl; + (messageRaw.message as any).mediaUrl = mediaUrl; await this.prismaRepository.message.update({ where: { id: msg.id }, data: messageRaw }); } @@ -1452,7 +1601,7 @@ export class BaileysStartupService extends ChannelStartupService { ); if (buffer) { - messageRaw.message.base64 = buffer.toString('base64'); + (messageRaw.message as any).base64 = buffer.toString('base64'); } else { // retry to download media const buffer = await downloadMediaMessage( @@ -1463,7 +1612,7 @@ export class BaileysStartupService extends ChannelStartupService { ); if (buffer) { - messageRaw.message.base64 = buffer.toString('base64'); + (messageRaw.message as any).base64 = buffer.toString('base64'); } } } catch (error) { @@ -1475,8 +1624,14 @@ export class BaileysStartupService extends ChannelStartupService { this.logger.verbose(messageRaw); sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`); + if (messageRaw.key.remoteJid?.includes('@lid') && messageRaw.key.remoteJidAlt) { + const lid = messageRaw.key.remoteJid; + messageRaw.key.remoteJid = messageRaw.key.remoteJidAlt; + messageRaw.key.remoteJidAlt = lid; + + messageRaw.key.addressingMode = 'pn'; } console.log(messageRaw); @@ -1484,7 +1639,7 @@ export class BaileysStartupService extends ChannelStartupService { await chatbotController.emit({ instance: { instanceName: this.instance.name, instanceId: this.instanceId }, - remoteJid: messageRaw.key.remoteJid, + remoteJid: (messageRaw.key as any).remoteJid, msg: messageRaw, pushName: messageRaw.pushName, }); @@ -1513,9 +1668,11 @@ export class BaileysStartupService extends ChannelStartupService { await saveOnWhatsappCache([ { remoteJid: - messageRaw.key.addressingMode === 'lid' ? messageRaw.key.remoteJidAlt : messageRaw.key.remoteJid, - remoteJidAlt: messageRaw.key.remoteJidAlt, - lid: messageRaw.key.addressingMode === 'lid' ? 'lid' : null, + (messageRaw.key as any).addressingMode === 'lid' + ? (messageRaw.key as any).remoteJidAlt + : (messageRaw.key as any).remoteJid, + remoteJidAlt: (messageRaw.key as any).remoteJidAlt, + lid: (messageRaw.key as any).addressingMode === 'lid' ? 'lid' : null, }, ]); } @@ -1561,7 +1718,18 @@ export class BaileysStartupService extends ChannelStartupService { const readChatToUpdate: Record = {}; // {remoteJid: true} for await (const { key, update } of args) { - if (settings?.groupsIgnore && key.remoteJid?.includes('@g.us')) { + const keyAny = key as any; + if (keyAny.remoteJid) { + keyAny.remoteJid = keyAny.remoteJid.replace(/:.*$/, ''); + } + if (keyAny.participant) { + keyAny.participant = keyAny.participant.replace(/:.*$/, ''); + } + + const normalizedRemoteJid = keyAny.remoteJid; + const normalizedParticipant = keyAny.participant; + + if (settings?.groupsIgnore && normalizedRemoteJid?.includes('@g.us')) { continue; } @@ -1612,9 +1780,9 @@ export class BaileysStartupService extends ChannelStartupService { const message: any = { keyId: key.id, - remoteJid: key?.remoteJid, + remoteJid: normalizedRemoteJid, fromMe: key.fromMe, - participant: key?.participant, + participant: normalizedParticipant, status: status[update.status] ?? 'SERVER_ACK', pollUpdates, instanceId: this.instanceId, @@ -1636,19 +1804,45 @@ export class BaileysStartupService extends ChannelStartupService { } const searchId = originalMessageId || key.id; - - const messages = (await this.prismaRepository.$queryRaw` - SELECT * FROM "Message" - WHERE "instanceId" = ${this.instanceId} - AND "key"->>'id' = ${searchId} - LIMIT 1 - `) as any[]; + const dbProvider = this.configService.get('DATABASE').PROVIDER; + + let messages: any[]; + if (dbProvider === 'mysql') { + messages = (await this.prismaRepository.$queryRaw` + SELECT * FROM Message + WHERE instanceId = ${this.instanceId} + AND JSON_UNQUOTE(JSON_EXTRACT(\`key\`, '$.id')) = ${searchId} + LIMIT 1 + `) as any[]; + } else { + messages = (await this.prismaRepository.$queryRaw` + SELECT * FROM "Message" + WHERE "instanceId" = ${this.instanceId} + AND "key"->>'id' = ${searchId} + LIMIT 1 + `) as any[]; + } findMessage = messages[0] || null; if (!findMessage?.id) { - this.logger.warn(`Original message not found for update. Skipping. Key: ${JSON.stringify(key)}`); + this.logger.verbose( + `Original message not found for update after ${maxRetries} retries. Skipping. This is expected for protocol messages or ephemeral events not saved to the database. Key: ${JSON.stringify(key)}`, + ); continue; } + + // Sync the incoming key.remoteJid with the stored one. + // This mutation is safe and necessary because Baileys events might use LIDs while we store Phone JIDs (or vice versa). + // Normalizing ensuring downstream logic uses the identifier that exists in our database. + if (findMessage?.key?.remoteJid && key.remoteJid !== findMessage.key.remoteJid) { + key.remoteJid = findMessage.key.remoteJid; + } + if (findMessage?.key?.remoteJid && findMessage.key.remoteJid !== key.remoteJid) { + this.logger.verbose( + `Updating key.remoteJid from ${key.remoteJid} to ${findMessage.key.remoteJid} based on stored message`, + ); + key.remoteJid = findMessage.key.remoteJid; + } message.messageId = findMessage.id; } @@ -2126,6 +2320,51 @@ export class BaileysStartupService extends ChannelStartupService { return error; } } + public generateMessageID() { + return { + id: generateMessageIDV2(this.client.user?.id), + }; + } + + private async generateLinkPreview(text: string) { + try { + const linkRegex = /https?:\/\/[^\s]+/; + const match = text.match(linkRegex); + + if (!match) return undefined; + + // Trim common trailing punctuation that may follow URLs in natural text + const url = match[0].replace(/[.,);\]]+$/u, ''); + if (!url) return undefined; + + const previewData = await getLinkPreview(url, { + imagesPropertyType: 'og', // fetches only open-graph images + headers: { + 'user-agent': 'googlebot', // fetches with googlebot to prevent login pages + }, + }) as any; + + if (!previewData || !previewData.title) return undefined; + + const image = previewData.images && previewData.images.length > 0 ? previewData.images[0] : undefined; + + return { + externalAdReply: { + title: previewData.title, + body: previewData.description, + mediaType: 2, // 2 for video/image preview, though usually 1 is for thumbnail + thumbnailUrl: image, + sourceUrl: url, + mediaUrl: url, + renderLargerThumbnail: true + // showAdAttribution: true // Removed to prevent "Sent via ad" label + } + }; + } catch (error) { + this.logger.error(`Error generating link preview: ${error}`); + return undefined; + } + } private async sendMessage( sender: string, @@ -2338,7 +2577,12 @@ export class BaileysStartupService extends ChannelStartupService { } } - const linkPreview = options?.linkPreview != false ? undefined : false; + const linkPreview = options?.linkPreview === false ? false : undefined; + + let previewContext: any = undefined; + if (linkPreview !== false && (message as any)?.conversation) { + previewContext = await this.generateLinkPreview((message as any).conversation); + } let quoted: WAMessage; @@ -2390,8 +2634,9 @@ export class BaileysStartupService extends ChannelStartupService { mentions, linkPreview, quoted, - null, + options?.messageId ?? null, group?.ephemeralDuration, + previewContext, // group?.participants, ); } else { @@ -2405,6 +2650,7 @@ export class BaileysStartupService extends ChannelStartupService { unsigned: false, }, disappearingMode: { initiator: 0 }, + ...previewContext, }; messageSent = await this.sendMessage( sender, @@ -2412,7 +2658,7 @@ export class BaileysStartupService extends ChannelStartupService { mentions, linkPreview, quoted, - null, + options?.messageId ?? null, undefined, contextInfo, ); @@ -2422,7 +2668,7 @@ export class BaileysStartupService extends ChannelStartupService { messageSent.messageTimestamp = messageSent.messageTimestamp?.toNumber(); } - const messageRaw = this.prepareMessage(messageSent); + const messageRaw = this.prepareMessage(messageSent) as any; const isMedia = messageSent?.message?.imageMessage || @@ -2444,14 +2690,15 @@ export class BaileysStartupService extends ChannelStartupService { ); } - if (this.configService.get('OPENAI').ENABLED && messageRaw?.message?.audioMessage) { + if (this.configService.get('OPENAI').ENABLED && (messageRaw as any)?.message?.audioMessage) { const openAiDefaultSettings = await this.prismaRepository.openaiSetting.findFirst({ where: { instanceId: this.instanceId }, include: { OpenaiCreds: true }, }); if (openAiDefaultSettings && openAiDefaultSettings.openaiCredsId && openAiDefaultSettings.speechToText) { - messageRaw.message.speechToText = `[audio] ${await this.openaiService.speechToText(messageRaw, this)}`; + (messageRaw.message as any).speechToText = + `[audio] ${await this.openaiService.speechToText(messageRaw, this)}`; } } @@ -2643,6 +2890,7 @@ export class BaileysStartupService extends ChannelStartupService { linkPreview: data?.linkPreview, mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, + messageId: data?.messageId, }, isIntegration, ); @@ -2659,6 +2907,7 @@ export class BaileysStartupService extends ChannelStartupService { linkPreview: data?.linkPreview, mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, + messageId: data?.messageId, }, ); } @@ -2972,6 +3221,7 @@ export class BaileysStartupService extends ChannelStartupService { quoted: data?.quoted, mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, + messageId: data?.messageId, }, ); @@ -2994,6 +3244,7 @@ export class BaileysStartupService extends ChannelStartupService { quoted: data?.quoted, mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, + messageId: data?.messageId, }, isIntegration, ); @@ -3010,6 +3261,7 @@ export class BaileysStartupService extends ChannelStartupService { quoted: data?.quoted, mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, + messageId: data?.messageId, }; if (file) mediaData.media = file.buffer.toString('base64'); @@ -3025,6 +3277,7 @@ export class BaileysStartupService extends ChannelStartupService { quoted: data?.quoted, mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, + messageId: data?.messageId, }, isIntegration, ); @@ -3171,7 +3424,7 @@ export class BaileysStartupService extends ChannelStartupService { .noVideo() .audioCodec('libopus') .addOutputOptions('-avoid_negative_ts make_zero') - .audioBitrate('128k') + .audioBitrate('48k') .audioFrequency(48000) .audioChannels(1) .outputOptions([ @@ -3203,6 +3456,58 @@ export class BaileysStartupService extends ChannelStartupService { } } + private async getAudioMetadata(audioBuffer: Buffer): Promise<{ seconds: number; waveform: Uint8Array }> { + try { + this.logger.debug('Decoding audio buffer for metadata extraction...'); + const audioData = await audioDecode(audioBuffer); + + // Extract duration + const seconds = Math.ceil(audioData.duration); + this.logger.debug(`Audio duration: ${seconds} seconds`); + + // Generate waveform + const samples = audioData.getChannelData(0); + const waveformLength = 64; + const samplesPerWaveform = Math.max(1, Math.floor(samples.length / waveformLength)); + + // First pass: calculate raw averages + const rawValues: number[] = []; + for (let i = 0; i < waveformLength; i++) { + const start = i * samplesPerWaveform; + const end = start + samplesPerWaveform; + let sum = 0; + for (let j = start; j < end && j < samples.length; j++) { + sum += Math.abs(samples[j]); + } + const avg = sum / samplesPerWaveform; + rawValues.push(avg); + } + + // Find max value for normalization + const maxValue = Math.max(...rawValues); + + // Second pass: normalize to 0-100 range + const waveform = new Uint8Array(waveformLength); + if (maxValue > 0) { + for (let i = 0; i < waveformLength; i++) { + const normalized = Math.floor((rawValues[i] / maxValue) * 100); + waveform[i] = rawValues[i] > 0 ? Math.max(5, Math.min(100, normalized)) : 0; + } + } else { + waveform.fill(50); + } + + this.logger.debug(`Generated waveform with ${waveform.length} values`); + + return { seconds, waveform }; + } catch (error) { + this.logger.warn(`Failed to extract audio metadata: ${error.message}, using defaults`); + const defaultWaveform = new Uint8Array(64); + defaultWaveform.fill(50); + return { seconds: 1, waveform: defaultWaveform }; + } + } + public async audioWhatsapp(data: SendAudioDto, file?: any, isIntegration = false) { const mediaData: SendAudioDto = { ...data }; @@ -3221,9 +3526,13 @@ export class BaileysStartupService extends ChannelStartupService { const convert = await this.processAudio(mediaData.audio); if (Buffer.isBuffer(convert)) { + const { seconds, waveform } = await this.getAudioMetadata(convert); + + const messageContent = { audio: convert, ptt: true, mimetype: 'audio/ogg; codecs=opus', seconds, waveform }; + const result = this.sendMessageWithTyping( data.number, - { audio: convert, ptt: true, mimetype: 'audio/ogg; codecs=opus' }, + messageContent as any, { presence: 'recording', delay: data?.delay }, isIntegration, ); @@ -3234,12 +3543,21 @@ export class BaileysStartupService extends ChannelStartupService { } } + const audioBuffer = isURL(data.audio) ? { url: data.audio } : Buffer.from(data.audio, 'base64'); + let metadata: { seconds: number; waveform: Uint8Array } | undefined; + + // Only generate waveform for buffers, not URLs + if (Buffer.isBuffer(audioBuffer)) { + metadata = await this.getAudioMetadata(audioBuffer); + } + return await this.sendMessageWithTyping( data.number, { - audio: isURL(data.audio) ? { url: data.audio } : Buffer.from(data.audio, 'base64'), + audio: audioBuffer, ptt: true, mimetype: 'audio/ogg; codecs=opus', + ...(metadata && { seconds: metadata.seconds, waveform: metadata.waveform }), }, { presence: 'recording', delay: data?.delay }, isIntegration, @@ -3311,30 +3629,34 @@ export class BaileysStartupService extends ChannelStartupService { ]); public async buttonMessage(data: SendButtonsDto) { - if (data.buttons.length === 0) { + if (!data.buttons || data.buttons.length === 0) { throw new BadRequestException('At least one button is required'); } const hasReplyButtons = data.buttons.some((btn) => btn.type === 'reply'); - const hasPixButton = data.buttons.some((btn) => btn.type === 'pix'); + const hasCTAButtons = data.buttons.some((btn) => btn.type === 'url' || btn.type === 'call' || btn.type === 'copy'); - const hasOtherButtons = data.buttons.some((btn) => btn.type !== 'reply' && btn.type !== 'pix'); + /* ========================= + * REGRAS DE VALIDAÇÃO + * ========================= */ + // Reply if (hasReplyButtons) { if (data.buttons.length > 3) { throw new BadRequestException('Maximum of 3 reply buttons allowed'); } - if (hasOtherButtons) { - throw new BadRequestException('Reply buttons cannot be mixed with other button types'); + if (hasCTAButtons || hasPixButton) { + throw new BadRequestException('Reply buttons cannot be mixed with CTA or PIX buttons'); } } + // PIX if (hasPixButton) { if (data.buttons.length > 1) { throw new BadRequestException('Only one PIX button is allowed'); } - if (hasOtherButtons) { + if (hasReplyButtons || hasCTAButtons) { throw new BadRequestException('PIX button cannot be mixed with other button types'); } @@ -3343,8 +3665,16 @@ export class BaileysStartupService extends ChannelStartupService { message: { interactiveMessage: { nativeFlowMessage: { - buttons: [{ name: this.mapType.get('pix'), buttonParamsJson: this.toJSONString(data.buttons[0]) }], - messageParamsJson: JSON.stringify({ from: 'api', templateId: v4() }), + buttons: [ + { + name: this.mapType.get('pix'), + buttonParamsJson: this.toJSONString(data.buttons[0]), + }, + ], + messageParamsJson: JSON.stringify({ + from: 'api', + templateId: v4(), + }), }, }, }, @@ -3360,15 +3690,36 @@ export class BaileysStartupService extends ChannelStartupService { }); } - const generate = await (async () => { - if (data?.thumbnailUrl) { - return await this.prepareMediaMessage({ mediatype: 'image', media: data.thumbnailUrl }); + // CTA (url / call / copy) + if (hasCTAButtons) { + if (data.buttons.length > 2) { + throw new BadRequestException('Maximum of 2 CTA buttons allowed'); + } + if (hasReplyButtons) { + throw new BadRequestException('CTA buttons cannot be mixed with reply buttons'); } - })(); + } - const buttons = data.buttons.map((value) => { - return { name: this.mapType.get(value.type), buttonParamsJson: this.toJSONString(value) }; - }); + /* ========================= + * HEADER (opcional) + * ========================= */ + + const generatedMedia = data?.thumbnailUrl + ? await this.prepareMediaMessage({ mediatype: 'image', media: data.thumbnailUrl }) + : null; + + /* ========================= + * BOTÕES + * ========================= */ + + const buttons = data.buttons.map((btn) => ({ + name: this.mapType.get(btn.type), + buttonParamsJson: this.toJSONString(btn), + })); + + /* ========================= + * MENSAGEM FINAL + * ========================= */ const message: proto.IMessage = { viewOnceMessage: { @@ -3376,27 +3727,26 @@ export class BaileysStartupService extends ChannelStartupService { interactiveMessage: { body: { text: (() => { - let t = '*' + data.title + '*'; + let text = `*${data.title}*`; if (data?.description) { - t += '\n\n'; - t += data.description; - t += '\n'; + text += `\n\n${data.description}`; } - return t; + return text; })(), }, - footer: { text: data?.footer }, - header: (() => { - if (generate?.message?.imageMessage) { - return { - hasMediaAttachment: !!generate.message.imageMessage, - imageMessage: generate.message.imageMessage, - }; - } - })(), + footer: data?.footer ? { text: data.footer } : undefined, + header: generatedMedia?.message?.imageMessage + ? { + hasMediaAttachment: true, + imageMessage: generatedMedia.message.imageMessage, + } + : undefined, nativeFlowMessage: { - buttons: buttons, - messageParamsJson: JSON.stringify({ from: 'api', templateId: v4() }), + buttons, + messageParamsJson: JSON.stringify({ + from: 'api', + templateId: v4(), + }), }, }, }, @@ -3511,9 +3861,24 @@ export class BaileysStartupService extends ChannelStartupService { users: { number: string; jid: string; name?: string }[]; } = { groups: [], broadcast: [], users: [] }; + const onWhatsapp: OnWhatsAppDto[] = []; + data.numbers.forEach((number) => { const jid = createJid(number); + if (isJidNewsletter(jid)) { + onWhatsapp.push( + new OnWhatsAppDto( + jid, + true, // Newsletters are always valid + number, + undefined, // Can be fetched later if needed + 'newsletter', // Indicate it's a newsletter type + ), + ); + return; + } + if (isJidGroup(jid)) { jids.groups.push({ number, jid }); } else if (jid === 'status@broadcast') { @@ -3523,8 +3888,6 @@ export class BaileysStartupService extends ChannelStartupService { } }); - const onWhatsapp: OnWhatsAppDto[] = []; - // BROADCAST onWhatsapp.push(...jids.broadcast.map(({ jid, number }) => new OnWhatsAppDto(jid, false, number))); @@ -4649,26 +5012,28 @@ export class BaileysStartupService extends ChannelStartupService { return obj; } - private prepareMessage(message: proto.IWebMessageInfo): any { - const contentType = getContentType(message.message); - const contentMsg = message?.message[contentType] as any; - - const messageRaw = { - key: message.key, // Save key exactly as it comes from Baileys + private prepareMessage(message: WAMessage): Message { + const keyAny = message.key as any; + const messageRaw: any = { + key: { + ...message.key, + remoteJid: keyAny.remoteJid?.replace(/:.*$/, ''), + participant: keyAny.participant?.replace(/:.*$/, ''), + }, pushName: message.pushName || (message.key.fromMe ? 'Você' : message?.participant || (message.key?.participant ? message.key.participant.split('@')[0] : null)), - status: status[message.status], message: this.deserializeMessageBuffers({ ...message.message }), - contextInfo: this.deserializeMessageBuffers(contentMsg?.contextInfo), - messageType: contentType || 'unknown', + messageType: getContentType(message.message), messageTimestamp: Long.isLong(message.messageTimestamp) ? message.messageTimestamp.toNumber() : (message.messageTimestamp as number), + source: getDevice(keyAny.id), instanceId: this.instanceId, - source: getDevice(message.key.id), + status: status[message.status], + contextInfo: this.deserializeMessageBuffers(message.message?.messageContextInfo), }; if (!messageRaw.status && message.key.fromMe === false) { @@ -4700,6 +5065,10 @@ export class BaileysStartupService extends ChannelStartupService { } } + if (isJidNewsletter(message.key.remoteJid) && message.key.fromMe) { + messageRaw.status = status[3]; // DELIVERED MESSAGE TO NEWSLETTER CHANNEL + } + return messageRaw; } @@ -4734,16 +5103,32 @@ export class BaileysStartupService extends ChannelStartupService { private async updateMessagesReadedByTimestamp(remoteJid: string, timestamp?: number): Promise { if (timestamp === undefined || timestamp === null) return 0; - // Use raw SQL to avoid JSON path issues - const result = await this.prismaRepository.$executeRaw` - UPDATE "Message" - SET "status" = ${status[4]} - WHERE "instanceId" = ${this.instanceId} - AND "key"->>'remoteJid' = ${remoteJid} - AND ("key"->>'fromMe')::boolean = false - AND "messageTimestamp" <= ${timestamp} - AND ("status" IS NULL OR "status" = ${status[3]}) - `; + const provider = this.configService.get('DATABASE').PROVIDER; + let result: number; + + if (provider === 'mysql') { + // MySQL version + result = await this.prismaRepository.$executeRaw` + UPDATE Message + SET status = ${status[4]} + WHERE instanceId = ${this.instanceId} + AND JSON_UNQUOTE(JSON_EXTRACT(\`key\`, '$.remoteJid')) = ${remoteJid} + AND JSON_UNQUOTE(JSON_EXTRACT(\`key\`, '$.fromMe')) = 'false' + AND messageTimestamp <= ${timestamp} + AND (status IS NULL OR status = ${status[3]}) + `; + } else { + // PostgreSQL version + result = await this.prismaRepository.$executeRaw` + UPDATE "Message" + SET "status" = ${status[4]} + WHERE "instanceId" = ${this.instanceId} + AND "key"->>'remoteJid' = ${remoteJid} + AND ("key"->>'fromMe')::boolean = false + AND "messageTimestamp" <= ${timestamp} + AND ("status" IS NULL OR "status" = ${status[3]}) + `; + } if (result) { if (result > 0) { @@ -4757,16 +5142,33 @@ export class BaileysStartupService extends ChannelStartupService { } private async updateChatUnreadMessages(remoteJid: string): Promise { - const [chat, unreadMessages] = await Promise.all([ - this.prismaRepository.chat.findFirst({ where: { remoteJid } }), - // Use raw SQL to avoid JSON path issues - this.prismaRepository.$queryRaw` + const provider = this.configService.get('DATABASE').PROVIDER; + + let unreadMessagesPromise: Promise; + + if (provider === 'mysql') { + // MySQL version + unreadMessagesPromise = this.prismaRepository.$queryRaw` + SELECT COUNT(*) as count FROM Message + WHERE instanceId = ${this.instanceId} + AND JSON_UNQUOTE(JSON_EXTRACT(\`key\`, '$.remoteJid')) = ${remoteJid} + AND JSON_UNQUOTE(JSON_EXTRACT(\`key\`, '$.fromMe')) = 'false' + AND status = ${status[3]} + `.then((result: any[]) => Number(result[0]?.count) || 0); + } else { + // PostgreSQL version + unreadMessagesPromise = this.prismaRepository.$queryRaw` SELECT COUNT(*)::int as count FROM "Message" WHERE "instanceId" = ${this.instanceId} AND "key"->>'remoteJid' = ${remoteJid} AND ("key"->>'fromMe')::boolean = false AND "status" = ${status[3]} - `.then((result: any[]) => result[0]?.count || 0), + `.then((result: any[]) => result[0]?.count || 0); + } + + const [chat, unreadMessages] = await Promise.all([ + this.prismaRepository.chat.findFirst({ where: { remoteJid } }), + unreadMessagesPromise, ]); if (chat && chat.unreadMessages !== unreadMessages) { @@ -4778,50 +5180,95 @@ export class BaileysStartupService extends ChannelStartupService { private async addLabel(labelId: string, instanceId: string, chatId: string) { const id = cuid(); - - await this.prismaRepository.$executeRawUnsafe( - `INSERT INTO "Chat" ("id", "instanceId", "remoteJid", "labels", "createdAt", "updatedAt") - VALUES ($4, $2, $3, to_jsonb(ARRAY[$1]::text[]), NOW(), NOW()) ON CONFLICT ("instanceId", "remoteJid") - DO - UPDATE - SET "labels" = ( - SELECT to_jsonb(array_agg(DISTINCT elem)) - FROM ( - SELECT jsonb_array_elements_text("Chat"."labels") AS elem - UNION - SELECT $1::text AS elem - ) sub - ), - "updatedAt" = NOW();`, - labelId, - instanceId, - chatId, - id, - ); + const provider = this.configService.get('DATABASE').PROVIDER; + + if (provider === 'mysql') { + // MySQL version - use INSERT ... ON DUPLICATE KEY UPDATE + await this.prismaRepository.$executeRawUnsafe( + `INSERT INTO Chat (id, instanceId, remoteJid, labels, createdAt, updatedAt) + VALUES (?, ?, ?, JSON_ARRAY(?), NOW(), NOW()) + ON DUPLICATE KEY UPDATE + labels = JSON_ARRAY_APPEND( + COALESCE(labels, JSON_ARRAY()), + '$', + ? + ), + updatedAt = NOW()`, + id, + instanceId, + chatId, + labelId, + labelId, + ); + } else { + // PostgreSQL version + await this.prismaRepository.$executeRawUnsafe( + `INSERT INTO "Chat" ("id", "instanceId", "remoteJid", "labels", "createdAt", "updatedAt") + VALUES ($4, $2, $3, to_jsonb(ARRAY[$1]::text[]), NOW(), NOW()) ON CONFLICT ("instanceId", "remoteJid") + DO + UPDATE + SET "labels" = ( + SELECT to_jsonb(array_agg(DISTINCT elem)) + FROM ( + SELECT jsonb_array_elements_text("Chat"."labels") AS elem + UNION + SELECT $1::text AS elem + ) sub + ), + "updatedAt" = NOW();`, + labelId, + instanceId, + chatId, + id, + ); + } } private async removeLabel(labelId: string, instanceId: string, chatId: string) { const id = cuid(); - - await this.prismaRepository.$executeRawUnsafe( - `INSERT INTO "Chat" ("id", "instanceId", "remoteJid", "labels", "createdAt", "updatedAt") - VALUES ($4, $2, $3, '[]'::jsonb, NOW(), NOW()) ON CONFLICT ("instanceId", "remoteJid") - DO - UPDATE - SET "labels" = COALESCE ( - ( - SELECT jsonb_agg(elem) - FROM jsonb_array_elements_text("Chat"."labels") AS elem - WHERE elem <> $1 - ), - '[]'::jsonb - ), - "updatedAt" = NOW();`, - labelId, - instanceId, - chatId, - id, - ); + const provider = this.configService.get('DATABASE').PROVIDER; + + if (provider === 'mysql') { + // MySQL version - use INSERT ... ON DUPLICATE KEY UPDATE + await this.prismaRepository.$executeRawUnsafe( + `INSERT INTO Chat (id, instanceId, remoteJid, labels, createdAt, updatedAt) + VALUES (?, ?, ?, JSON_ARRAY(), NOW(), NOW()) + ON DUPLICATE KEY UPDATE + labels = COALESCE( + JSON_REMOVE( + labels, + JSON_UNQUOTE(JSON_SEARCH(labels, 'one', ?)) + ), + JSON_ARRAY() + ), + updatedAt = NOW()`, + id, + instanceId, + chatId, + labelId, + ); + } else { + // PostgreSQL version + await this.prismaRepository.$executeRawUnsafe( + `INSERT INTO "Chat" ("id", "instanceId", "remoteJid", "labels", "createdAt", "updatedAt") + VALUES ($4, $2, $3, '[]'::jsonb, NOW(), NOW()) ON CONFLICT ("instanceId", "remoteJid") + DO + UPDATE + SET "labels" = COALESCE ( + ( + SELECT jsonb_agg(elem) + FROM jsonb_array_elements_text("Chat"."labels") AS elem + WHERE elem <> $1 + ), + '[]'::jsonb + ), + "updatedAt" = NOW();`, + labelId, + instanceId, + chatId, + id, + ); + } } public async baileysOnWhatsapp(jid: string) { @@ -5119,4 +5566,299 @@ export class BaileysStartupService extends ChannelStartupService { }, }; } + + public async baileysDecryptPollVote(pollCreationMessageKey: proto.IMessageKey) { + try { + this.logger.verbose('Starting poll vote decryption process'); + + // Buscar a mensagem de criação da enquete + const pollCreationMessage = (await this.getMessage(pollCreationMessageKey, true)) as proto.IWebMessageInfo; + + if (!pollCreationMessage) { + throw new NotFoundException('Poll creation message not found'); + } + + // Extrair opções da enquete + const pollOptions = + (pollCreationMessage.message as any)?.pollCreationMessage?.options || + (pollCreationMessage.message as any)?.pollCreationMessageV3?.options || + []; + + if (!pollOptions || pollOptions.length === 0) { + throw new NotFoundException('Poll options not found'); + } + + // Recuperar chave de criptografia + const pollMessageSecret = (await this.getMessage(pollCreationMessageKey)) as any; + let pollEncKey = pollMessageSecret?.messageContextInfo?.messageSecret; + + if (!pollEncKey) { + throw new NotFoundException('Poll encryption key not found'); + } + + // Normalizar chave de criptografia + if (typeof pollEncKey === 'string') { + pollEncKey = Buffer.from(pollEncKey, 'base64'); + } else if (pollEncKey?.type === 'Buffer' && Array.isArray(pollEncKey.data)) { + pollEncKey = Buffer.from(pollEncKey.data); + } + + if (Buffer.isBuffer(pollEncKey) && pollEncKey.length === 44) { + pollEncKey = Buffer.from(pollEncKey.toString('utf8'), 'base64'); + } + + // Buscar todas as mensagens de atualização de votos + const allPollUpdateMessages = await this.prismaRepository.message.findMany({ + where: { + instanceId: this.instanceId, + messageType: 'pollUpdateMessage', + }, + select: { + id: true, + key: true, + message: true, + messageTimestamp: true, + }, + }); + + this.logger.verbose(`Found ${allPollUpdateMessages.length} pollUpdateMessage messages in database`); + + // Filtrar apenas mensagens relacionadas a esta enquete específica + const pollUpdateMessages = allPollUpdateMessages.filter((msg) => { + const pollUpdate = (msg.message as any)?.pollUpdateMessage; + if (!pollUpdate) return false; + + const creationKey = pollUpdate.pollCreationMessageKey; + if (!creationKey) return false; + + return ( + creationKey.id === pollCreationMessageKey.id && + jidNormalizedUser(creationKey.remoteJid || '') === jidNormalizedUser(pollCreationMessageKey.remoteJid || '') + ); + }); + + this.logger.verbose(`Filtered to ${pollUpdateMessages.length} matching poll update messages`); + + // Preparar candidatos de JID para descriptografia + const creatorCandidates = [ + this.instance.wuid, + this.client.user?.lid, + pollCreationMessage.key.participant, + (pollCreationMessage.key as any).participantAlt, + pollCreationMessage.key.remoteJid, + (pollCreationMessage.key as any).remoteJidAlt, + ].filter(Boolean); + + const uniqueCreators = [...new Set(creatorCandidates.map((id) => jidNormalizedUser(id)))]; + + // Processar votos + const votesByUser = new Map(); + + this.logger.verbose(`Processing ${pollUpdateMessages.length} poll update messages for decryption`); + + for (const pollUpdateMsg of pollUpdateMessages) { + const pollVote = (pollUpdateMsg.message as any)?.pollUpdateMessage?.vote; + if (!pollVote) continue; + + const key = pollUpdateMsg.key as any; + const voterCandidates = [ + this.instance.wuid, + this.client.user?.lid, + key.participant, + key.participantAlt, + key.remoteJidAlt, + key.remoteJid, + ].filter(Boolean); + + const uniqueVoters = [...new Set(voterCandidates.map((id) => jidNormalizedUser(id)))]; + + let selectedOptionNames: string[] = []; + let successfulVoterJid: string | undefined; + + // Verificar se o voto já está descriptografado + if (pollVote.selectedOptions && Array.isArray(pollVote.selectedOptions)) { + const selectedOptions = pollVote.selectedOptions; + this.logger.verbose('Vote already has selectedOptions, checking format'); + + // Verificar se são strings (já descriptografado) ou buffers (precisa descriptografar) + if (selectedOptions.length > 0 && typeof selectedOptions[0] === 'string') { + // Já está descriptografado como nomes de opções + selectedOptionNames = selectedOptions; + successfulVoterJid = uniqueVoters[0]; + this.logger.verbose( + `Using already decrypted vote: voter=${successfulVoterJid}, options=${selectedOptionNames.join(',')}`, + ); + } else { + // Está como hash, precisa converter para nomes + selectedOptionNames = pollOptions + .filter((option: any) => { + const hash = createHash('sha256').update(option.optionName).digest(); + return selectedOptions.some((selected: any) => { + if (Buffer.isBuffer(selected)) { + return Buffer.compare(selected, hash) === 0; + } + return false; + }); + }) + .map((option: any) => option.optionName); + successfulVoterJid = uniqueVoters[0]; + } + } else if (pollVote.encPayload && pollEncKey) { + // Tentar descriptografar + let decryptedVote: any = null; + + for (const creator of uniqueCreators) { + for (const voter of uniqueVoters) { + try { + decryptedVote = decryptPollVote(pollVote, { + pollCreatorJid: creator, + pollMsgId: pollCreationMessage.key.id, + pollEncKey, + voterJid: voter, + } as any); + + if (decryptedVote) { + successfulVoterJid = voter; + break; + } + } catch { + // Continue tentando outras combinações + } + } + if (decryptedVote) break; + } + + if (decryptedVote && decryptedVote.selectedOptions) { + // Converter hashes para nomes de opções + selectedOptionNames = pollOptions + .filter((option: any) => { + const hash = createHash('sha256').update(option.optionName).digest(); + return decryptedVote.selectedOptions.some((selected: any) => { + if (Buffer.isBuffer(selected)) { + return Buffer.compare(selected, hash) === 0; + } + return false; + }); + }) + .map((option: any) => option.optionName); + + this.logger.verbose( + `Successfully decrypted vote for voter: ${successfulVoterJid}, creator: ${uniqueCreators[0]}`, + ); + } else { + this.logger.warn(`Failed to decrypt vote. Last error: Could not decrypt with any combination`); + continue; + } + } else { + this.logger.warn('Vote has no encPayload and no selectedOptions, skipping'); + continue; + } + + if (selectedOptionNames.length > 0 && successfulVoterJid) { + const normalizedVoterJid = jidNormalizedUser(successfulVoterJid); + const existingVote = votesByUser.get(normalizedVoterJid); + + // Manter apenas o voto mais recente de cada usuário + if (!existingVote || pollUpdateMsg.messageTimestamp > existingVote.timestamp) { + votesByUser.set(normalizedVoterJid, { + timestamp: pollUpdateMsg.messageTimestamp, + selectedOptions: selectedOptionNames, + voterJid: successfulVoterJid, + }); + } + } + } + + // Agrupar votos por opção + const results: Record = {}; + + // Inicializar todas as opções com zero votos + pollOptions.forEach((option: any) => { + results[option.optionName] = { + votes: 0, + voters: [], + }; + }); + + // Agregar votos + votesByUser.forEach((voteData) => { + voteData.selectedOptions.forEach((optionName) => { + if (results[optionName]) { + results[optionName].votes++; + if (!results[optionName].voters.includes(voteData.voterJid)) { + results[optionName].voters.push(voteData.voterJid); + } + } + }); + }); + + // Obter nome da enquete + const pollName = + (pollCreationMessage.message as any)?.pollCreationMessage?.name || + (pollCreationMessage.message as any)?.pollCreationMessageV3?.name || + 'Enquete sem nome'; + + // Calcular total de votos únicos + const totalVotes = votesByUser.size; + + return { + poll: { + name: pollName, + totalVotes, + results, + }, + }; + } catch (error) { + this.logger.error(`Error decrypting poll votes: ${error}`); + throw new InternalServerErrorException('Error decrypting poll votes', error.toString()); + } + } + + public async fetchChannels(query: Query) { + const page = Number((query as any)?.page ?? 1); + const limit = Number((query as any)?.limit ?? (query as any)?.rows ?? 50); + const skip = (page - 1) * limit; + + const messages = await this.prismaRepository.message.findMany({ + where: { + instanceId: this.instanceId, + AND: [{ key: { path: ['remoteJid'], not: null } }], + }, + orderBy: { messageTimestamp: 'desc' }, + select: { + key: true, + messageTimestamp: true, + }, + }); + + const channelMap = new Map(); + + for (const msg of messages) { + const key = msg.key as any; + const remoteJid = key?.remoteJid as string | undefined; + if (!remoteJid || !isJidNewsletter(remoteJid)) continue; + + if (!channelMap.has(remoteJid)) { + channelMap.set(remoteJid, { + remoteJid, + pushName: undefined, // Push name is never stored for channels, so we set it as undefined + lastMessageTimestamp: msg.messageTimestamp, + }); + } + } + + const allChannels = Array.from(channelMap.values()); + + const total = allChannels.length; + const pages = Math.ceil(total / limit); + const records = allChannels.slice(skip, skip + limit); + + return { + total, + pages, + currentPage: page, + limit, + records, + }; + } } diff --git a/src/api/integrations/chatbot/base-chatbot.controller.ts b/src/api/integrations/chatbot/base-chatbot.controller.ts index a5b83e257..4108785e4 100644 --- a/src/api/integrations/chatbot/base-chatbot.controller.ts +++ b/src/api/integrations/chatbot/base-chatbot.controller.ts @@ -797,7 +797,7 @@ export abstract class BaseChatbotController = new Map(); + private readonly ORDER_CACHE_TTL_MS = 30000; // 30 segundos + + // Cache para mapeamento LID → Número Normal (resolve problema de @lid) + private lidToPhoneMap: Map = new Map(); + private readonly LID_CACHE_TTL_MS = 3600000; // 1 hora + constructor( private readonly waMonitor: WAMonitoringService, private readonly configService: ConfigService, @@ -632,10 +640,32 @@ export class ChatwootService { public async createConversation(instance: InstanceDto, body: any) { const isLid = body.key.addressingMode === 'lid'; const isGroup = body.key.remoteJid.endsWith('@g.us'); - const phoneNumber = isLid && !isGroup ? body.key.remoteJidAlt : body.key.remoteJid; - const { remoteJid } = body.key; - const cacheKey = `${instance.instanceName}:createConversation-${remoteJid}`; - const lockKey = `${instance.instanceName}:lock:createConversation-${remoteJid}`; + let phoneNumber = isLid && !isGroup ? body.key.remoteJidAlt : body.key.remoteJid; + let { remoteJid } = body.key; + + // CORREÇÃO LID: Resolve LID para número normal antes de processar + if (isLid && !isGroup) { + const resolvedPhone = await this.resolveLidToPhone(instance, body.key); + + if (resolvedPhone && resolvedPhone !== remoteJid) { + this.logger.verbose(`LID detected and resolved: ${remoteJid} → ${resolvedPhone}`); + phoneNumber = resolvedPhone; + + // Salva mapeamento se temos remoteJidAlt + if (body.key.remoteJidAlt) { + this.saveLidMapping(remoteJid, body.key.remoteJidAlt); + } + } else if (body.key.remoteJidAlt) { + // Se não resolveu mas tem remoteJidAlt, usa ele + phoneNumber = body.key.remoteJidAlt; + this.saveLidMapping(remoteJid, body.key.remoteJidAlt); + this.logger.verbose(`Using remoteJidAlt for LID: ${remoteJid} → ${phoneNumber}`); + } + } + + // Usa phoneNumber como base para cache (não o LID) + const cacheKey = `${instance.instanceName}:createConversation-${phoneNumber}`; + const lockKey = `${instance.instanceName}:lock:createConversation-${phoneNumber}`; const maxWaitTime = 5000; // 5 seconds const client = await this.clientCw(instance); if (!client) return null; @@ -943,20 +973,39 @@ export class ChatwootService { const sourceReplyId = quotedMsg?.chatwootMessageId || null; + // Filtra valores null/undefined do content_attributes para evitar erro 406 + const filteredReplyToIds = Object.fromEntries( + Object.entries(replyToIds).filter(([_, value]) => value != null) + ); + + // Monta o objeto data, incluindo content_attributes apenas se houver dados válidos + const messageData: any = { + content: content, + message_type: messageType, + content_type: 'text', // Explicitamente define como texto para Chatwoot 4.x + attachments: attachments, + private: privateMessage || false, + }; + + // Adiciona source_id apenas se existir + if (sourceId) { + messageData.source_id = sourceId; + } + + // Adiciona content_attributes apenas se houver dados válidos + if (Object.keys(filteredReplyToIds).length > 0) { + messageData.content_attributes = filteredReplyToIds; + } + + // Adiciona source_reply_id apenas se existir + if (sourceReplyId) { + messageData.source_reply_id = sourceReplyId.toString(); + } + const message = await client.messages.create({ accountId: this.provider.accountId, conversationId: conversationId, - data: { - content: content, - message_type: messageType, - attachments: attachments, - private: privateMessage || false, - source_id: sourceId, - content_attributes: { - ...replyToIds, - }, - source_reply_id: sourceReplyId ? sourceReplyId.toString() : null, - }, + data: messageData, }); if (!message) { @@ -1082,11 +1131,14 @@ export class ChatwootService { if (messageBody && instance) { const replyToIds = await this.getReplyToIds(messageBody, instance); - if (replyToIds.in_reply_to || replyToIds.in_reply_to_external_id) { - const content = JSON.stringify({ - ...replyToIds, - }); - data.append('content_attributes', content); + // Filtra valores null/undefined antes de enviar + const filteredReplyToIds = Object.fromEntries( + Object.entries(replyToIds).filter(([_, value]) => value != null) + ); + + if (Object.keys(filteredReplyToIds).length > 0) { + const contentAttrs = JSON.stringify(filteredReplyToIds); + data.append('content_attributes', contentAttrs); } } @@ -1617,18 +1669,36 @@ export class ChatwootService { return; } - // Use raw SQL to avoid JSON path issues - const result = await this.prismaRepository.$executeRaw` - UPDATE "Message" - SET - "chatwootMessageId" = ${chatwootMessageIds.messageId}, - "chatwootConversationId" = ${chatwootMessageIds.conversationId}, - "chatwootInboxId" = ${chatwootMessageIds.inboxId}, - "chatwootContactInboxSourceId" = ${chatwootMessageIds.contactInboxSourceId}, - "chatwootIsRead" = ${chatwootMessageIds.isRead || false} - WHERE "instanceId" = ${instance.instanceId} - AND "key"->>'id' = ${key.id} - `; + const provider = this.configService.get('DATABASE').PROVIDER; + let result: number; + + if (provider === 'mysql') { + // MySQL version + result = await this.prismaRepository.$executeRaw` + UPDATE Message + SET + chatwootMessageId = ${chatwootMessageIds.messageId}, + chatwootConversationId = ${chatwootMessageIds.conversationId}, + chatwootInboxId = ${chatwootMessageIds.inboxId}, + chatwootContactInboxSourceId = ${chatwootMessageIds.contactInboxSourceId}, + chatwootIsRead = ${chatwootMessageIds.isRead || false} + WHERE instanceId = ${instance.instanceId} + AND JSON_UNQUOTE(JSON_EXTRACT(\`key\`, '$.id')) = ${key.id} + `; + } else { + // PostgreSQL version + result = await this.prismaRepository.$executeRaw` + UPDATE "Message" + SET + "chatwootMessageId" = ${chatwootMessageIds.messageId}, + "chatwootConversationId" = ${chatwootMessageIds.conversationId}, + "chatwootInboxId" = ${chatwootMessageIds.inboxId}, + "chatwootContactInboxSourceId" = ${chatwootMessageIds.contactInboxSourceId}, + "chatwootIsRead" = ${chatwootMessageIds.isRead || false} + WHERE "instanceId" = ${instance.instanceId} + AND "key"->>'id' = ${key.id} + `; + } this.logger.verbose(`Update result: ${result} rows affected`); @@ -1642,15 +1712,28 @@ export class ChatwootService { } private async getMessageByKeyId(instance: InstanceDto, keyId: string): Promise { - // Use raw SQL query to avoid JSON path issues with Prisma - const messages = await this.prismaRepository.$queryRaw` - SELECT * FROM "Message" - WHERE "instanceId" = ${instance.instanceId} - AND "key"->>'id' = ${keyId} - LIMIT 1 - `; - - return (messages as MessageModel[])[0] || null; + const provider = this.configService.get('DATABASE').PROVIDER; + let messages: MessageModel[]; + + if (provider === 'mysql') { + // MySQL version + messages = await this.prismaRepository.$queryRaw` + SELECT * FROM Message + WHERE instanceId = ${instance.instanceId} + AND JSON_UNQUOTE(JSON_EXTRACT(\`key\`, '$.id')) = ${keyId} + LIMIT 1 + `; + } else { + // PostgreSQL version + messages = await this.prismaRepository.$queryRaw` + SELECT * FROM "Message" + WHERE "instanceId" = ${instance.instanceId} + AND "key"->>'id' = ${keyId} + LIMIT 1 + `; + } + + return messages[0] || null; } private async getReplyToIds( @@ -1758,41 +1841,127 @@ export class ChatwootService { } private getTypeMessage(msg: any) { - const types = { - conversation: msg.conversation, - imageMessage: msg.imageMessage?.caption, - videoMessage: msg.videoMessage?.caption, - extendedTextMessage: msg.extendedTextMessage?.text, - messageContextInfo: msg.messageContextInfo?.stanzaId, - stickerMessage: undefined, - documentMessage: msg.documentMessage?.caption, - documentWithCaptionMessage: msg.documentWithCaptionMessage?.message?.documentMessage?.caption, - audioMessage: msg.audioMessage ? (msg.audioMessage.caption ?? '') : undefined, - contactMessage: msg.contactMessage?.vcard, - contactsArrayMessage: msg.contactsArrayMessage, - locationMessage: msg.locationMessage, - liveLocationMessage: msg.liveLocationMessage, - listMessage: msg.listMessage, - listResponseMessage: msg.listResponseMessage, - viewOnceMessageV2: - msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url || - msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url || - msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url, - }; - - return types; - } + const types = { + conversation: msg.conversation, + imageMessage: msg.imageMessage?.caption, + videoMessage: msg.videoMessage?.caption, + extendedTextMessage: msg.extendedTextMessage?.text, + messageContextInfo: msg.messageContextInfo?.stanzaId, + stickerMessage: undefined, + documentMessage: msg.documentMessage?.caption, + documentWithCaptionMessage: msg.documentWithCaptionMessage?.message?.documentMessage?.caption, + audioMessage: msg.audioMessage ? (msg.audioMessage.caption ?? '') : undefined, + contactMessage: msg.contactMessage?.vcard, + contactsArrayMessage: msg.contactsArrayMessage, + locationMessage: msg.locationMessage, + liveLocationMessage: msg.liveLocationMessage, + listMessage: msg.listMessage, + listResponseMessage: msg.listResponseMessage, + orderMessage: msg.orderMessage, + quotedProductMessage: msg.contextInfo?.quotedMessage?.productMessage, + viewOnceMessageV2: + msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url || + msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url || + msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url, + }; + + return types; +} private getMessageContent(types: any) { const typeKey = Object.keys(types).find((key) => types[key] !== undefined); let result = typeKey ? types[typeKey] : undefined; - // Remove externalAdReplyBody| in Chatwoot (Already Have) + // Remove externalAdReplyBody| in Chatwoot if (result && typeof result === 'string' && result.includes('externalAdReplyBody|')) { result = result.split('externalAdReplyBody|').filter(Boolean).join(''); } + // Tratamento de Pedidos do Catálogo (WhatsApp Business Catalog) + if (typeKey === 'orderMessage' && result.orderId) { + const now = Date.now(); + // Limpa entradas antigas do cache + this.processedOrderIds.forEach((timestamp, id) => { + if (now - timestamp > this.ORDER_CACHE_TTL_MS) { + this.processedOrderIds.delete(id); + } + }); + // Verifica se já processou este orderId + if (this.processedOrderIds.has(result.orderId)) { + return undefined; // Ignora duplicado + } + this.processedOrderIds.set(result.orderId, now); + } + // Tratamento de Produto citado (WhatsApp Desktop) +if (typeKey === 'quotedProductMessage' && result?.product) { + const product = result.product; + + // Extrai preço + let rawPrice = 0; + const amount = product.priceAmount1000; + + if (Long.isLong(amount)) { + rawPrice = amount.toNumber(); + } else if (amount && typeof amount === 'object' && 'low' in amount) { + rawPrice = Long.fromValue(amount).toNumber(); + } else if (typeof amount === 'number') { + rawPrice = amount; + } + + const price = (rawPrice / 1000).toLocaleString('pt-BR', { + style: 'currency', + currency: product.currencyCode || 'BRL', + }); + + const productTitle = product.title || 'Produto do catálogo'; + const productId = product.productId || 'N/A'; + + return ( + `🛒 *PRODUTO DO CATÁLOGO (Desktop)*\n` + + `━━━━━━━━━━━━━━━━━━━━━\n` + + `📦 *Produto:* ${productTitle}\n` + + `💰 *Preço:* ${price}\n` + + `🆔 *Código:* ${productId}\n` + + `━━━━━━━━━━━━━━━━━━━━━\n` + + `_Cliente perguntou: "${types.conversation || 'Me envia este produto?'}"_` + ); +} + if (typeKey === 'orderMessage') { + // Extrai o valor - pode ser Long, objeto {low, high}, ou número direto + let rawPrice = 0; + const amount = result.totalAmount1000; + + if (Long.isLong(amount)) { + rawPrice = amount.toNumber(); + } else if (amount && typeof amount === 'object' && 'low' in amount) { + // Formato {low: number, high: number, unsigned: boolean} + rawPrice = Long.fromValue(amount).toNumber(); + } else if (typeof amount === 'number') { + rawPrice = amount; + } + + const price = (rawPrice / 1000).toLocaleString('pt-BR', { + style: 'currency', + currency: result.totalCurrencyCode || 'BRL', + }); + + const itemCount = result.itemCount || 1; + const orderTitle = result.orderTitle || 'Produto do catálogo'; + const orderId = result.orderId || 'N/A'; + + return ( + `🛒 *NOVO PEDIDO NO CATÁLOGO*\n` + + `━━━━━━━━━━━━━━━━━━━━━\n` + + `📦 *Produto:* ${orderTitle}\n` + + `📊 *Quantidade:* ${itemCount}\n` + + `💰 *Total:* ${price}\n` + + `🆔 *Pedido:* #${orderId}\n` + + `━━━━━━━━━━━━━━━━━━━━━\n` + + `_Responda para atender este pedido!_` + ); + } + if (typeKey === 'locationMessage' || typeKey === 'liveLocationMessage') { const latitude = result.degreesLatitude; const longitude = result.degreesLongitude; @@ -1993,6 +2162,29 @@ export class ChatwootService { } } + // CORREÇÃO LID: Resolve LID para número normal antes de processar evento + if (body?.key?.remoteJid && body.key.remoteJid.includes('@lid') && !body.key.remoteJid.endsWith('@g.us')) { + const originalJid = body.key.remoteJid; + const resolvedPhone = await this.resolveLidToPhone(instance, body.key); + + if (resolvedPhone && resolvedPhone !== originalJid) { + this.logger.verbose(`Event LID resolved: ${originalJid} → ${resolvedPhone}`); + body.key.remoteJid = resolvedPhone; + + // Salva mapeamento se temos remoteJidAlt + if (body.key.remoteJidAlt) { + this.saveLidMapping(originalJid, body.key.remoteJidAlt); + } + } else if (body.key.remoteJidAlt && !body.key.remoteJidAlt.includes('@lid')) { + // Se não resolveu mas tem remoteJidAlt válido, usa ele + this.logger.verbose(`Using remoteJidAlt for event: ${originalJid} → ${body.key.remoteJidAlt}`); + body.key.remoteJid = body.key.remoteJidAlt; + this.saveLidMapping(originalJid, body.key.remoteJidAlt); + } else { + this.logger.warn(`Could not resolve LID for event, keeping original: ${originalJid}`); + } + } + if (event === 'messages.upsert' || event === 'send.message') { this.logger.info(`[${event}] New message received - Instance: ${JSON.stringify(body, null, 2)}`); if (body.key.remoteJid === 'status@broadcast') { @@ -2537,6 +2729,82 @@ export class ChatwootService { return remoteJid.replace(/:\d+/, '').split('@')[0]; } + /** + * Limpa entradas antigas do cache de mapeamento LID + */ + private cleanLidCache() { + const now = Date.now(); + this.lidToPhoneMap.forEach((value, lid) => { + if (now - value.timestamp > this.LID_CACHE_TTL_MS) { + this.lidToPhoneMap.delete(lid); + } + }); + } + + /** + * Salva mapeamento LID → Número Normal + */ + private saveLidMapping(lid: string, phoneNumber: string) { + if (!lid || !phoneNumber || !lid.includes('@lid')) { + return; + } + + this.cleanLidCache(); + this.lidToPhoneMap.set(lid, { + phone: phoneNumber, + timestamp: Date.now(), + }); + + this.logger.verbose(`LID mapping saved: ${lid} → ${phoneNumber}`); + } + + /** + * Resolve LID para Número Normal + * Retorna o número normal se encontrado, ou o LID original se não encontrado + */ + private async resolveLidToPhone(instance: InstanceDto, messageKey: any): Promise { + const { remoteJid, remoteJidAlt } = messageKey; + + // Se não for LID, retorna o próprio remoteJid + if (!remoteJid || !remoteJid.includes('@lid')) { + return remoteJid; + } + + // 1. Tenta buscar no cache + const cached = this.lidToPhoneMap.get(remoteJid); + if (cached) { + this.logger.verbose(`LID resolved from cache: ${remoteJid} → ${cached.phone}`); + return cached.phone; + } + + // 2. Se tem remoteJidAlt (número alternativo), usa ele e salva no cache + if (remoteJidAlt && !remoteJidAlt.includes('@lid')) { + this.saveLidMapping(remoteJid, remoteJidAlt); + this.logger.verbose(`LID resolved from remoteJidAlt: ${remoteJid} → ${remoteJidAlt}`); + return remoteJidAlt; + } + + // 3. Tenta buscar no banco de dados do Chatwoot + try { + const lidIdentifier = this.normalizeJidIdentifier(remoteJid); + const contact = await this.findContactByIdentifier(instance, lidIdentifier); + + if (contact && contact.phone_number) { + // Converte +554498860240 → 554498860240@s.whatsapp.net + const phoneNumber = contact.phone_number.replace('+', '') + '@s.whatsapp.net'; + this.saveLidMapping(remoteJid, phoneNumber); + this.logger.verbose(`LID resolved from database: ${remoteJid} → ${phoneNumber}`); + return phoneNumber; + } + } catch (error) { + this.logger.warn(`Error resolving LID from database: ${error}`); + } + + // 4. Se não encontrou, retorna null (será necessário criar novo contato) + this.logger.warn(`Could not resolve LID: ${remoteJid}`); + return null; + } + public startImportHistoryMessages(instance: InstanceDto) { if (!this.isImportHistoryAvailable()) { return; diff --git a/src/api/integrations/chatbot/typebot/services/typebot.service.ts b/src/api/integrations/chatbot/typebot/services/typebot.service.ts index 03712bfdb..79f3180b8 100644 --- a/src/api/integrations/chatbot/typebot/services/typebot.service.ts +++ b/src/api/integrations/chatbot/typebot/services/typebot.service.ts @@ -368,6 +368,60 @@ export class TypebotService extends BaseChatbotService { sendTelemetry('/message/sendWhatsAppAudio'); } + if (message.type === 'file' || message.type === 'embed') { + const content = message.content as { url?: string; name?: string } | undefined; + if (!content?.url) { + sendTelemetry('/message/sendMediaMissingUrl'); + return; + } + + const mediaUrl = content.url; + const mediaType = this.getMediaType(mediaUrl); + + let fileName = content.name; + if (!fileName) { + try { + const urlObj = new URL(mediaUrl); + const path = urlObj.pathname || ''; + const candidate = path.split('/').pop() || ''; + if (candidate && candidate.includes('.')) { + fileName = candidate; + } + } catch { + // Ignore URL parsing failures + } + + if (!fileName) { + fileName = mediaType && mediaType !== 'document' ? `media.${mediaType}` : 'attachment'; + } + } + + if (mediaType === 'audio') { + await instance.audioWhatsapp( + { + number: session.remoteJid, + delay: settings?.delayMessage || 1000, + encoding: true, + audio: mediaUrl, + }, + false, + ); + } else { + await instance.mediaMessage( + { + number: session.remoteJid, + delay: settings?.delayMessage || 1000, + mediatype: mediaType || 'document', + media: mediaUrl, + fileName, + }, + null, + false, + ); + } + sendTelemetry('/message/sendMedia'); + } + const wait = findItemAndGetSecondsToWait(clientSideActions, message.id); if (wait) { diff --git a/src/api/integrations/event/event.controller.ts b/src/api/integrations/event/event.controller.ts index 39b52184b..63061ea10 100644 --- a/src/api/integrations/event/event.controller.ts +++ b/src/api/integrations/event/event.controller.ts @@ -162,6 +162,7 @@ export class EventController { 'CALL', 'TYPEBOT_START', 'TYPEBOT_CHANGE_STATUS', + 'MESSAGING_HISTORY_SET', 'REMOVE_INSTANCE', 'LOGOUT_INSTANCE', 'INSTANCE_CREATE', diff --git a/src/api/integrations/event/sqs/sqs.controller.ts b/src/api/integrations/event/sqs/sqs.controller.ts index 2b0398ef2..d18677c4f 100644 --- a/src/api/integrations/event/sqs/sqs.controller.ts +++ b/src/api/integrations/event/sqs/sqs.controller.ts @@ -126,7 +126,9 @@ export class SqsController extends EventController implements EventControllerInt ? 'singlequeue' : `${event.replace('.', '_').toLowerCase()}`; const queueName = `${prefixName}_${eventFormatted}.fifo`; - const sqsUrl = `https://sqs.${sqsConfig.REGION}.amazonaws.com/${sqsConfig.ACCOUNT_ID}/${queueName}`; + const rawBaseUrl = sqsConfig.BASE_URL || `https://sqs.${sqsConfig.REGION}.amazonaws.com`; + const baseUrl = rawBaseUrl.replace(/\/+$/, ''); + const sqsUrl = `${baseUrl}/${sqsConfig.ACCOUNT_ID}/${queueName}`; const message = { ...(extra ?? {}), diff --git a/src/api/integrations/event/webhook/webhook.controller.ts b/src/api/integrations/event/webhook/webhook.controller.ts index 7f1dd8dc0..5357554f9 100644 --- a/src/api/integrations/event/webhook/webhook.controller.ts +++ b/src/api/integrations/event/webhook/webhook.controller.ts @@ -124,9 +124,20 @@ export class WebhookController extends EventController implements EventControlle try { if (instance?.enabled && regex.test(instance.url)) { + // Add custom headers for better webhook tracking and debugging + const enhancedHeaders = { + ...webhookHeaders, + 'Content-Type': 'application/json', + 'X-Instance-ID': this.monitor.waInstances[instanceName].instanceId, + 'X-Instance-Name': instanceName, + 'X-Event-Type': event, + 'X-Timestamp': Date.now().toString(), + 'User-Agent': 'EvolutionAPI-Webhook/2.3.7', + }; + const httpService = axios.create({ baseURL, - headers: webhookHeaders as Record | undefined, + headers: enhancedHeaders as Record, timeout: webhookConfig.REQUEST?.TIMEOUT_MS ?? 30000, }); diff --git a/src/api/routes/chat.router.ts b/src/api/routes/chat.router.ts index 158947ed2..28578d9e7 100644 --- a/src/api/routes/chat.router.ts +++ b/src/api/routes/chat.router.ts @@ -2,6 +2,7 @@ import { RouterBroker } from '@api/abstract/abstract.router'; import { ArchiveChatDto, BlockUserDto, + DecryptPollVoteDto, DeleteMessage, getBase64FromMediaMessageDto, MarkChatUnreadDto, @@ -23,6 +24,7 @@ import { archiveChatSchema, blockUserSchema, contactValidateSchema, + decryptPollVoteSchema, deleteMessageSchema, markChatUnreadSchema, messageUpSchema, @@ -281,6 +283,26 @@ export class ChatRouter extends RouterBroker { }); return res.status(HttpStatus.CREATED).json(response); + }) + .post(this.routerPath('getPollVote'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: decryptPollVoteSchema, + ClassRef: DecryptPollVoteDto, + execute: (instance, data) => chatController.decryptPollVote(instance, data), + }); + + return res.status(HttpStatus.OK).json(response); + }) + .post(this.routerPath('findChannels'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: contactValidateSchema, + ClassRef: Query, + execute: (instance, query) => chatController.fetchChannels(instance, query as any), + }); + + return res.status(HttpStatus.OK).json(response); }); } diff --git a/src/api/services/channel.service.ts b/src/api/services/channel.service.ts index 56bec0802..2d7ec8c60 100644 --- a/src/api/services/channel.service.ts +++ b/src/api/services/channel.service.ts @@ -9,7 +9,7 @@ import { TypebotService } from '@api/integrations/chatbot/typebot/services/typeb import { PrismaRepository, Query } from '@api/repository/repository.service'; import { eventManager, waMonitor } from '@api/server.module'; import { Events, wa } from '@api/types/wa.types'; -import { Auth, Chatwoot, ConfigService, HttpServer, Proxy } from '@config/env.config'; +import { Auth, Chatwoot, ConfigService, Database, HttpServer, Proxy } from '@config/env.config'; import { Logger } from '@config/logger.config'; import { NotFoundException } from '@exceptions'; import { Contact, Message, Prisma } from '@prisma/client'; @@ -731,63 +731,127 @@ export class ChannelStartupService { where['remoteJid'] = remoteJid; } - const timestampFilter = - query?.where?.messageTimestamp?.gte && query?.where?.messageTimestamp?.lte - ? Prisma.sql` - AND "Message"."messageTimestamp" >= ${Math.floor(new Date(query.where.messageTimestamp.gte).getTime() / 1000)} - AND "Message"."messageTimestamp" <= ${Math.floor(new Date(query.where.messageTimestamp.lte).getTime() / 1000)}` - : Prisma.sql``; - + const provider = this.configService.get('DATABASE').PROVIDER; const limit = query?.take ? Prisma.sql`LIMIT ${query.take}` : Prisma.sql``; const offset = query?.skip ? Prisma.sql`OFFSET ${query.skip}` : Prisma.sql``; - const results = await this.prismaRepository.$queryRaw` - WITH rankedMessages AS ( - SELECT DISTINCT ON ("Message"."key"->>'remoteJid') - "Contact"."id" as "contactId", - "Message"."key"->>'remoteJid' as "remoteJid", - CASE - WHEN "Message"."key"->>'remoteJid' LIKE '%@g.us' THEN COALESCE("Chat"."name", "Contact"."pushName") - ELSE COALESCE("Contact"."pushName", "Message"."pushName") - END as "pushName", - "Contact"."profilePicUrl", + let results: any[]; + + if (provider === 'mysql') { + // MySQL version + const timestampFilterMysql = + query?.where?.messageTimestamp?.gte && query?.where?.messageTimestamp?.lte + ? Prisma.sql` + AND Message.messageTimestamp >= ${Math.floor(new Date(query.where.messageTimestamp.gte).getTime() / 1000)} + AND Message.messageTimestamp <= ${Math.floor(new Date(query.where.messageTimestamp.lte).getTime() / 1000)}` + : Prisma.sql``; + + results = await this.prismaRepository.$queryRaw` + SELECT + Contact.id as contactId, + JSON_UNQUOTE(JSON_EXTRACT(Message.key, '$.remoteJid')) as remoteJid, + CASE + WHEN JSON_UNQUOTE(JSON_EXTRACT(Message.key, '$.remoteJid')) LIKE '%@g.us' THEN COALESCE(Chat.name, Contact.pushName) + ELSE COALESCE(Contact.pushName, Message.pushName) + END as pushName, + Contact.profilePicUrl, COALESCE( - to_timestamp("Message"."messageTimestamp"::double precision), - "Contact"."updatedAt" - ) as "updatedAt", - "Chat"."name" as "pushName", - "Chat"."createdAt" as "windowStart", - "Chat"."createdAt" + INTERVAL '24 hours' as "windowExpires", - "Chat"."unreadMessages" as "unreadMessages", - CASE WHEN "Chat"."createdAt" + INTERVAL '24 hours' > NOW() THEN true ELSE false END as "windowActive", - "Message"."id" AS "lastMessageId", - "Message"."key" AS "lastMessage_key", + FROM_UNIXTIME(Message.messageTimestamp), + Contact.updatedAt + ) as updatedAt, + Chat.name as chatName, + Chat.createdAt as windowStart, + DATE_ADD(Chat.createdAt, INTERVAL 24 HOUR) as windowExpires, + Chat.unreadMessages as unreadMessages, + CASE WHEN DATE_ADD(Chat.createdAt, INTERVAL 24 HOUR) > NOW() THEN 1 ELSE 0 END as windowActive, + Message.id AS lastMessageId, + Message.key AS lastMessage_key, CASE - WHEN "Message"."key"->>'fromMe' = 'true' THEN 'Você' - ELSE "Message"."pushName" - END AS "lastMessagePushName", - "Message"."participant" AS "lastMessageParticipant", - "Message"."messageType" AS "lastMessageMessageType", - "Message"."message" AS "lastMessageMessage", - "Message"."contextInfo" AS "lastMessageContextInfo", - "Message"."source" AS "lastMessageSource", - "Message"."messageTimestamp" AS "lastMessageMessageTimestamp", - "Message"."instanceId" AS "lastMessageInstanceId", - "Message"."sessionId" AS "lastMessageSessionId", - "Message"."status" AS "lastMessageStatus" - FROM "Message" - LEFT JOIN "Contact" ON "Contact"."remoteJid" = "Message"."key"->>'remoteJid' AND "Contact"."instanceId" = "Message"."instanceId" - LEFT JOIN "Chat" ON "Chat"."remoteJid" = "Message"."key"->>'remoteJid' AND "Chat"."instanceId" = "Message"."instanceId" - WHERE "Message"."instanceId" = ${this.instanceId} - ${remoteJid ? Prisma.sql`AND "Message"."key"->>'remoteJid' = ${remoteJid}` : Prisma.sql``} - ${timestampFilter} - ORDER BY "Message"."key"->>'remoteJid', "Message"."messageTimestamp" DESC - ) - SELECT * FROM rankedMessages - ORDER BY "updatedAt" DESC NULLS LAST - ${limit} - ${offset}; - `; + WHEN JSON_UNQUOTE(JSON_EXTRACT(Message.key, '$.fromMe')) = 'true' THEN 'Você' + ELSE Message.pushName + END AS lastMessagePushName, + Message.participant AS lastMessageParticipant, + Message.messageType AS lastMessageMessageType, + Message.message AS lastMessageMessage, + Message.contextInfo AS lastMessageContextInfo, + Message.source AS lastMessageSource, + Message.messageTimestamp AS lastMessageMessageTimestamp, + Message.instanceId AS lastMessageInstanceId, + Message.sessionId AS lastMessageSessionId, + Message.status AS lastMessageStatus + FROM Message + LEFT JOIN Contact ON Contact.remoteJid = JSON_UNQUOTE(JSON_EXTRACT(Message.key, '$.remoteJid')) AND Contact.instanceId = Message.instanceId + LEFT JOIN Chat ON Chat.remoteJid = JSON_UNQUOTE(JSON_EXTRACT(Message.key, '$.remoteJid')) AND Chat.instanceId = Message.instanceId + WHERE Message.instanceId = ${this.instanceId} + ${remoteJid ? Prisma.sql`AND JSON_UNQUOTE(JSON_EXTRACT(Message.key, '$.remoteJid')) = ${remoteJid}` : Prisma.sql``} + ${timestampFilterMysql} + AND Message.messageTimestamp = ( + SELECT MAX(m2.messageTimestamp) + FROM Message m2 + WHERE JSON_UNQUOTE(JSON_EXTRACT(m2.key, '$.remoteJid')) = JSON_UNQUOTE(JSON_EXTRACT(Message.key, '$.remoteJid')) + AND m2.instanceId = Message.instanceId + ) + ORDER BY updatedAt DESC + ${limit} + ${offset}; + `; + } else { + // PostgreSQL version + const timestampFilter = + query?.where?.messageTimestamp?.gte && query?.where?.messageTimestamp?.lte + ? Prisma.sql` + AND "Message"."messageTimestamp" >= ${Math.floor(new Date(query.where.messageTimestamp.gte).getTime() / 1000)} + AND "Message"."messageTimestamp" <= ${Math.floor(new Date(query.where.messageTimestamp.lte).getTime() / 1000)}` + : Prisma.sql``; + + results = await this.prismaRepository.$queryRaw` + WITH rankedMessages AS ( + SELECT DISTINCT ON ("Message"."key"->>'remoteJid') + "Contact"."id" as "contactId", + "Message"."key"->>'remoteJid' as "remoteJid", + CASE + WHEN "Message"."key"->>'remoteJid' LIKE '%@g.us' THEN COALESCE("Chat"."name", "Contact"."pushName") + ELSE COALESCE("Contact"."pushName", "Message"."pushName") + END as "pushName", + "Contact"."profilePicUrl", + COALESCE( + to_timestamp("Message"."messageTimestamp"::double precision), + "Contact"."updatedAt" + ) as "updatedAt", + "Chat"."name" as "pushName", + "Chat"."createdAt" as "windowStart", + "Chat"."createdAt" + INTERVAL '24 hours' as "windowExpires", + "Chat"."unreadMessages" as "unreadMessages", + CASE WHEN "Chat"."createdAt" + INTERVAL '24 hours' > NOW() THEN true ELSE false END as "windowActive", + "Message"."id" AS "lastMessageId", + "Message"."key" AS "lastMessage_key", + CASE + WHEN "Message"."key"->>'fromMe' = 'true' THEN 'Você' + ELSE "Message"."pushName" + END AS "lastMessagePushName", + "Message"."participant" AS "lastMessageParticipant", + "Message"."messageType" AS "lastMessageMessageType", + "Message"."message" AS "lastMessageMessage", + "Message"."contextInfo" AS "lastMessageContextInfo", + "Message"."source" AS "lastMessageSource", + "Message"."messageTimestamp" AS "lastMessageMessageTimestamp", + "Message"."instanceId" AS "lastMessageInstanceId", + "Message"."sessionId" AS "lastMessageSessionId", + "Message"."status" AS "lastMessageStatus" + FROM "Message" + LEFT JOIN "Contact" ON "Contact"."remoteJid" = "Message"."key"->>'remoteJid' AND "Contact"."instanceId" = "Message"."instanceId" + LEFT JOIN "Chat" ON "Chat"."remoteJid" = "Message"."key"->>'remoteJid' AND "Chat"."instanceId" = "Message"."instanceId" + WHERE "Message"."instanceId" = ${this.instanceId} + ${remoteJid ? Prisma.sql`AND "Message"."key"->>'remoteJid' = ${remoteJid}` : Prisma.sql``} + ${timestampFilter} + ORDER BY "Message"."key"->>'remoteJid', "Message"."messageTimestamp" DESC + ) + SELECT * FROM rankedMessages + ORDER BY "updatedAt" DESC NULLS LAST + ${limit} + ${offset}; + `; + } if (results && isArray(results) && results.length > 0) { const mappedResults = results.map((contact) => { diff --git a/src/config/env.config.ts b/src/config/env.config.ts index 7c4e382e7..d4981d854 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -91,6 +91,7 @@ export type EventsRabbitmq = { CALL: boolean; TYPEBOT_START: boolean; TYPEBOT_CHANGE_STATUS: boolean; + MESSAGING_HISTORY_SET: boolean; }; export type Rabbitmq = { @@ -121,6 +122,7 @@ export type Sqs = { SECRET_ACCESS_KEY: string; ACCOUNT_ID: string; REGION: string; + BASE_URL: string; MAX_PAYLOAD_SIZE: number; EVENTS: { APPLICATION_STARTUP: boolean; @@ -150,6 +152,7 @@ export type Sqs = { SEND_MESSAGE: boolean; TYPEBOT_CHANGE_STATUS: boolean; TYPEBOT_START: boolean; + MESSAGING_HISTORY_SET: boolean; }; }; @@ -223,6 +226,7 @@ export type EventsWebhook = { CALL: boolean; TYPEBOT_START: boolean; TYPEBOT_CHANGE_STATUS: boolean; + MESSAGING_HISTORY_SET: boolean; ERRORS: boolean; ERRORS_WEBHOOK: string; }; @@ -256,6 +260,7 @@ export type EventsPusher = { CALL: boolean; TYPEBOT_START: boolean; TYPEBOT_CHANGE_STATUS: boolean; + MESSAGING_HISTORY_SET: boolean; }; export type ApiKey = { KEY: string }; @@ -313,6 +318,7 @@ export type Webhook = { }; export type Pusher = { ENABLED: boolean; GLOBAL?: GlobalPusher; EVENTS: EventsPusher }; export type ConfigSessionPhone = { CLIENT: string; NAME: string }; +export type Baileys = { VERSION?: string }; export type QrCode = { LIMIT: number; COLOR: string }; export type Typebot = { ENABLED: boolean; API_VERSION: string; SEND_MEDIA_BASE64: boolean }; export type Chatwoot = { @@ -410,6 +416,7 @@ export interface Env { WEBHOOK: Webhook; PUSHER: Pusher; CONFIG_SESSION_PHONE: ConfigSessionPhone; + BAILEYS: Baileys; QRCODE: QrCode; TYPEBOT: Typebot; CHATWOOT: Chatwoot; @@ -537,6 +544,7 @@ export class ConfigService { CALL: process.env?.RABBITMQ_EVENTS_CALL === 'true', TYPEBOT_START: process.env?.RABBITMQ_EVENTS_TYPEBOT_START === 'true', TYPEBOT_CHANGE_STATUS: process.env?.RABBITMQ_EVENTS_TYPEBOT_CHANGE_STATUS === 'true', + MESSAGING_HISTORY_SET: process.env?.RABBITMQ_EVENTS_MESSAGING_HISTORY_SET === 'true', }, }, NATS: { @@ -574,6 +582,7 @@ export class ConfigService { CALL: process.env?.NATS_EVENTS_CALL === 'true', TYPEBOT_START: process.env?.NATS_EVENTS_TYPEBOT_START === 'true', TYPEBOT_CHANGE_STATUS: process.env?.NATS_EVENTS_TYPEBOT_CHANGE_STATUS === 'true', + MESSAGING_HISTORY_SET: process.env?.NATS_EVENTS_MESSAGING_HISTORY_SET === 'true', }, }, SQS: { @@ -585,6 +594,7 @@ export class ConfigService { SECRET_ACCESS_KEY: process.env.SQS_SECRET_ACCESS_KEY || '', ACCOUNT_ID: process.env.SQS_ACCOUNT_ID || '', REGION: process.env.SQS_REGION || '', + BASE_URL: process.env.SQS_BASE_URL || '', MAX_PAYLOAD_SIZE: Number.parseInt(process.env.SQS_MAX_PAYLOAD_SIZE ?? '1048576'), EVENTS: { APPLICATION_STARTUP: process.env?.SQS_GLOBAL_APPLICATION_STARTUP === 'true', @@ -614,6 +624,7 @@ export class ConfigService { SEND_MESSAGE: process.env?.SQS_GLOBAL_SEND_MESSAGE === 'true', TYPEBOT_CHANGE_STATUS: process.env?.SQS_GLOBAL_TYPEBOT_CHANGE_STATUS === 'true', TYPEBOT_START: process.env?.SQS_GLOBAL_TYPEBOT_START === 'true', + MESSAGING_HISTORY_SET: process.env?.SQS_GLOBAL_MESSAGING_HISTORY_SET === 'true', }, }, KAFKA: { @@ -657,6 +668,7 @@ export class ConfigService { CALL: process.env?.KAFKA_EVENTS_CALL === 'true', TYPEBOT_START: process.env?.KAFKA_EVENTS_TYPEBOT_START === 'true', TYPEBOT_CHANGE_STATUS: process.env?.KAFKA_EVENTS_TYPEBOT_CHANGE_STATUS === 'true', + MESSAGING_HISTORY_SET: process.env?.KAFKA_EVENTS_MESSAGING_HISTORY_SET === 'true', }, SASL: process.env?.KAFKA_SASL_ENABLED === 'true' @@ -722,6 +734,7 @@ export class ConfigService { CALL: process.env?.PUSHER_EVENTS_CALL === 'true', TYPEBOT_START: process.env?.PUSHER_EVENTS_TYPEBOT_START === 'true', TYPEBOT_CHANGE_STATUS: process.env?.PUSHER_EVENTS_TYPEBOT_CHANGE_STATUS === 'true', + MESSAGING_HISTORY_SET: process.env?.PUSHER_EVENTS_MESSAGING_HISTORY_SET === 'true', }, }, WA_BUSINESS: { @@ -779,6 +792,7 @@ export class ConfigService { CALL: process.env?.WEBHOOK_EVENTS_CALL === 'true', TYPEBOT_START: process.env?.WEBHOOK_EVENTS_TYPEBOT_START === 'true', TYPEBOT_CHANGE_STATUS: process.env?.WEBHOOK_EVENTS_TYPEBOT_CHANGE_STATUS === 'true', + MESSAGING_HISTORY_SET: process.env?.WEBHOOK_EVENTS_MESSAGING_HISTORY_SET === 'true', ERRORS: process.env?.WEBHOOK_EVENTS_ERRORS === 'true', ERRORS_WEBHOOK: process.env?.WEBHOOK_EVENTS_ERRORS_WEBHOOK || '', }, @@ -800,6 +814,9 @@ export class ConfigService { CLIENT: process.env?.CONFIG_SESSION_PHONE_CLIENT || 'Evolution API', NAME: process.env?.CONFIG_SESSION_PHONE_NAME || 'Chrome', }, + BAILEYS: { + VERSION: process.env?.CONFIG_BAILEYS_VERSION, + }, QRCODE: { LIMIT: Number.parseInt(process.env.QRCODE_LIMIT) || 30, COLOR: process.env.QRCODE_COLOR || '#198754', diff --git a/src/utils/createJid.ts b/src/utils/createJid.ts index a680e821e..23a3afe1f 100644 --- a/src/utils/createJid.ts +++ b/src/utils/createJid.ts @@ -35,7 +35,12 @@ function formatBRNumber(jid: string) { export function createJid(number: string): string { number = number.replace(/:\d+/, ''); - if (number.includes('@g.us') || number.includes('@s.whatsapp.net') || number.includes('@lid')) { + if ( + number.includes('@g.us') || + number.includes('@s.whatsapp.net') || + number.includes('@lid') || + number.includes('@newsletter') + ) { return number; } diff --git a/src/utils/fetchLatestWaWebVersion.ts b/src/utils/fetchLatestWaWebVersion.ts index 6dcfb797e..f973f20f2 100644 --- a/src/utils/fetchLatestWaWebVersion.ts +++ b/src/utils/fetchLatestWaWebVersion.ts @@ -1,7 +1,51 @@ import axios, { AxiosRequestConfig } from 'axios'; import { fetchLatestBaileysVersion, WAVersion } from 'baileys'; -export const fetchLatestWaWebVersion = async (options: AxiosRequestConfig<{}>) => { +import { CacheService } from '../api/services/cache.service'; +import { CacheEngine } from '../cache/cacheengine'; +import { Baileys, configService } from '../config/env.config'; + +// Cache keys +const CACHE_KEY_WHATSAPP_WEB_VERSION = 'whatsapp_web_version'; +const CACHE_KEY_BAILEYS_FALLBACK_VERSION = 'baileys_fallback_version'; + +// Cache TTL (1 hour in seconds) +const CACHE_TTL_SECONDS = 3600; + +const MODULE_NAME = 'whatsapp-version'; + +export const fetchLatestWaWebVersion = async (options: AxiosRequestConfig<{}>, cache?: CacheService) => { + // Check if manual version is set via configuration + const baileysConfig = configService.get('BAILEYS'); + const manualVersion = baileysConfig?.VERSION; + + if (manualVersion) { + const versionParts = manualVersion.split('.').map(Number); + if (versionParts.length === 3 && !versionParts.some(isNaN)) { + return { + version: versionParts as WAVersion, + isLatest: false, + isManual: true, + }; + } + } + + let versionCache = cache || null; + + if (!versionCache) { + // Cache estático para versões do WhatsApp Web e fallback do Baileys (fallback se não for passado via parâmetro) + const cacheEngine = new CacheEngine(configService, MODULE_NAME); + const engine = cacheEngine.getEngine(); + const defaultVersionCache = new CacheService(engine); + versionCache = defaultVersionCache; + } + + // Check cache for WhatsApp Web version + const cachedWaVersion = await versionCache.get(CACHE_KEY_WHATSAPP_WEB_VERSION); + if (cachedWaVersion) { + return cachedWaVersion; + } + try { const { data } = await axios.get('https://web.whatsapp.com/sw.js', { ...options, @@ -12,26 +56,51 @@ export const fetchLatestWaWebVersion = async (options: AxiosRequestConfig<{}>) = const match = data.match(regex); if (!match?.[1]) { - return { + // Check cache for Baileys fallback version + const cachedFallback = await versionCache.get(CACHE_KEY_BAILEYS_FALLBACK_VERSION); + if (cachedFallback) { + return cachedFallback; + } + + // Fetch and cache Baileys fallback version + const fallbackVersion = { version: (await fetchLatestBaileysVersion()).version as WAVersion, isLatest: false, error: { message: 'Could not find client revision in the fetched content', }, }; + + await versionCache.set(CACHE_KEY_BAILEYS_FALLBACK_VERSION, fallbackVersion, CACHE_TTL_SECONDS); + return fallbackVersion; } const clientRevision = match[1]; - return { + const result = { version: [2, 3000, +clientRevision] as WAVersion, isLatest: true, }; + + // Cache the successful result + await versionCache.set(CACHE_KEY_WHATSAPP_WEB_VERSION, result, CACHE_TTL_SECONDS); + + return result; } catch (error) { - return { + // Check cache for Baileys fallback version + const cachedFallback = await versionCache.get(CACHE_KEY_BAILEYS_FALLBACK_VERSION); + if (cachedFallback) { + return cachedFallback; + } + + // Fetch and cache Baileys fallback version + const fallbackVersion = { version: (await fetchLatestBaileysVersion()).version as WAVersion, isLatest: false, error, }; + + await versionCache.set(CACHE_KEY_BAILEYS_FALLBACK_VERSION, fallbackVersion, CACHE_TTL_SECONDS); + return fallbackVersion; } }; diff --git a/src/utils/findBotByTrigger.ts b/src/utils/findBotByTrigger.ts index eea5db823..f151b29b1 100644 --- a/src/utils/findBotByTrigger.ts +++ b/src/utils/findBotByTrigger.ts @@ -1,6 +1,8 @@ import { advancedOperatorsSearch } from './advancedOperatorsSearch'; export const findBotByTrigger = async (botRepository: any, content: string, instanceId: string) => { + const normalizedContent = content?.trim() || ''; + // Check for triggerType 'all' or 'none' (both should match any message) const findTriggerAllOrNone = await botRepository.findFirst({ where: { @@ -16,6 +18,12 @@ export const findBotByTrigger = async (botRepository: any, content: string, inst return findTriggerAllOrNone; } + // If content is empty (null, undefined, whitespace-only, or media-only messages), + // only 'all'/'none' triggers apply — skip keyword/regex matching + if (!normalizedContent) { + return null; + } + const findTriggerAdvanced = await botRepository.findMany({ where: { enabled: true, diff --git a/src/utils/getConversationMessage.ts b/src/utils/getConversationMessage.ts index eca23b454..5c5bcc3cf 100644 --- a/src/utils/getConversationMessage.ts +++ b/src/utils/getConversationMessage.ts @@ -16,7 +16,7 @@ const getTypeMessage = (msg: any) => { conversation: msg?.message?.conversation, extendedTextMessage: msg?.message?.extendedTextMessage?.text, contactMessage: msg?.message?.contactMessage?.displayName, - locationMessage: msg?.message?.locationMessage?.degreesLatitude.toString(), + locationMessage: msg?.message?.locationMessage?.degreesLatitude?.toString(), viewOnceMessageV2: msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url || msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url || @@ -49,9 +49,18 @@ const getTypeMessage = (msg: any) => { : '' }` : undefined, - externalAdReplyBody: msg?.contextInfo?.externalAdReply?.body - ? `externalAdReplyBody|${msg.contextInfo.externalAdReply.body}` - : undefined, + + // --- FIX FACEBOOK ADS START --- + externalAdReplyBody: msg?.message?.extendedTextMessage?.contextInfo?.externalAdReply?.body + ? `externalAdReplyBody|${msg.message.extendedTextMessage.contextInfo.externalAdReply.body}` + : msg?.message?.extendedTextMessage?.contextInfo?.externalAdReply?.title + ? `externalAdReplyBody|${msg.message.extendedTextMessage.contextInfo.externalAdReply.title}` + : msg?.contextInfo?.externalAdReply?.body + ? `externalAdReplyBody|${msg.contextInfo.externalAdReply.body}` + : msg?.contextInfo?.externalAdReply?.title + ? `externalAdReplyBody|${msg.contextInfo.externalAdReply.title}` + : undefined, + // --- FIX FACEBOOK ADS END --- }; const messageType = Object.keys(types).find((key) => types[key] !== undefined) || 'unknown'; @@ -60,7 +69,9 @@ const getTypeMessage = (msg: any) => { }; const getMessageContent = (types: any) => { - const typeKey = Object.keys(types).find((key) => key !== 'externalAdReplyBody' && types[key] !== undefined); + const typeKey = Object.keys(types).find( + (key) => key !== 'externalAdReplyBody' && key !== 'messageType' && types[key] !== undefined, + ); let result = typeKey ? types[typeKey] : undefined; @@ -73,8 +84,7 @@ const getMessageContent = (types: any) => { export const getConversationMessage = (msg: any) => { const types = getTypeMessage(msg); - const messageContent = getMessageContent(types); - return messageContent; + return messageContent ?? ''; }; diff --git a/src/utils/onWhatsappCache.ts b/src/utils/onWhatsappCache.ts index 08de0714e..8d7a2c16a 100644 --- a/src/utils/onWhatsappCache.ts +++ b/src/utils/onWhatsappCache.ts @@ -1,6 +1,7 @@ import { prismaRepository } from '@api/server.module'; import { configService, Database } from '@config/env.config'; import { Logger } from '@config/logger.config'; +import { Prisma } from '@prisma/client'; import dayjs from 'dayjs'; const logger = new Logger('OnWhatsappCache'); @@ -164,9 +165,28 @@ export async function saveOnWhatsappCache(data: ISaveOnWhatsappCacheParams[]) { logger.verbose( `[saveOnWhatsappCache] Register does not exist, creating: remoteJid=${remoteJid}, jidOptions=${dataPayload.jidOptions}, lid=${dataPayload.lid}`, ); - await prismaRepository.isOnWhatsapp.create({ - data: dataPayload, - }); + try { + await prismaRepository.isOnWhatsapp.create({ + data: dataPayload, + }); + } catch (error: any) { + // Check for unique constraint violation (Prisma error code P2002) + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' && + (error.meta?.target as string[])?.includes('remoteJid') + ) { + logger.verbose( + `[saveOnWhatsappCache] Race condition detected for ${remoteJid}, updating existing record instead.`, + ); + await prismaRepository.isOnWhatsapp.update({ + where: { remoteJid: remoteJid }, + data: dataPayload, + }); + } else { + throw error; + } + } } } catch (e) { // Loga o erro mas não para a execução dos outros promises diff --git a/src/validate/instance.schema.ts b/src/validate/instance.schema.ts index a0553b666..16fd4fe80 100644 --- a/src/validate/instance.schema.ts +++ b/src/validate/instance.schema.ts @@ -86,6 +86,7 @@ export const instanceSchema: JSONSchema7 = { 'CALL', 'TYPEBOT_START', 'TYPEBOT_CHANGE_STATUS', + 'MESSAGING_HISTORY_SET', ], }, }, @@ -123,6 +124,7 @@ export const instanceSchema: JSONSchema7 = { 'CALL', 'TYPEBOT_START', 'TYPEBOT_CHANGE_STATUS', + 'MESSAGING_HISTORY_SET', ], }, }, @@ -160,6 +162,7 @@ export const instanceSchema: JSONSchema7 = { 'CALL', 'TYPEBOT_START', 'TYPEBOT_CHANGE_STATUS', + 'MESSAGING_HISTORY_SET', ], }, }, @@ -197,6 +200,7 @@ export const instanceSchema: JSONSchema7 = { 'CALL', 'TYPEBOT_START', 'TYPEBOT_CHANGE_STATUS', + 'MESSAGING_HISTORY_SET', ], }, }, diff --git a/src/validate/message.schema.ts b/src/validate/message.schema.ts index d514c6199..6970fd9b0 100644 --- a/src/validate/message.schema.ts +++ b/src/validate/message.schema.ts @@ -447,3 +447,25 @@ export const buttonsMessageSchema: JSONSchema7 = { }, required: ['number'], }; + +export const decryptPollVoteSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + message: { + type: 'object', + properties: { + key: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + }, + }, + required: ['key'], + }, + remoteJid: { type: 'string' }, + }, + required: ['message', 'remoteJid'], +};