From 83a269105ee7eb651f7ab1468a418e58a2b74685 Mon Sep 17 00:00:00 2001 From: MarcelaMartinsRap Date: Mon, 24 Aug 2026 11:53:17 -0300 Subject: [PATCH 01/10] refactor(deploy): remove dedicated evolution infrastructure --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 4586e4028a..92f28becd4 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,13 @@ --- +## Meets deployment + +The Meets integration publishes the provider image from `develop`, `homolog`, +and `main`. Infrastructure provisioning is intentionally managed outside this +repository so the provider does not introduce dedicated databases, caches, +proxies, or Kubernetes resources without an approved environment plan. + ## About **Evolution API** is a powerful, production-ready REST API for WhatsApp and multi-channel messaging. Originally focused on WhatsApp, it has grown into a comprehensive platform supporting multiple messaging providers and integrations. From 790f3135bcb978b69f81273d5236f0cfe1a5eeb8 Mon Sep 17 00:00:00 2001 From: MarcelaMartinsRap Date: Mon, 24 Aug 2026 12:00:35 -0300 Subject: [PATCH 02/10] feat(deploy): add minimal shared services chart --- chart/Chart.yaml | 6 ++++ chart/templates/deployment.yaml | 57 +++++++++++++++++++++++++++++++++ chart/templates/service.yaml | 16 +++++++++ chart/values-dev.yaml | 27 ++++++++++++++++ chart/values-hlg.yaml | 27 ++++++++++++++++ chart/values-prod.yaml | 34 ++++++++++++++++++++ chart/values.yaml | 21 ++++++++++++ deploy/README.md | 47 +++++++++++++++++++++++++++ 8 files changed, 235 insertions(+) create mode 100644 chart/Chart.yaml create mode 100644 chart/templates/deployment.yaml create mode 100644 chart/templates/service.yaml create mode 100644 chart/values-dev.yaml create mode 100644 chart/values-hlg.yaml create mode 100644 chart/values-prod.yaml create mode 100644 chart/values.yaml create mode 100644 deploy/README.md diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000000..cc6c0422f7 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: meets-evolution-api +description: Minimal Evolution API deployment for Meets environments +type: application +version: 0.1.0 +appVersion: '2' diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000000..607dbc0686 --- /dev/null +++ b/chart/templates/deployment.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }} + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + containers: + - name: evolution-api + image: '{{ .Values.image.repository }}:{{ .Values.image.tag }}' + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + envFrom: + - secretRef: + name: {{ .Values.secretName }} + env: + - name: ENVIRONMENT + value: {{ .Values.environment | quote }} +{{- with .Values.env }} +{{ toYaml . | nindent 12 }} +{{- end }} + resources: +{{ toYaml .Values.resources | nindent 12 }} + startupProbe: + httpGet: + path: / + port: http + failureThreshold: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + failureThreshold: 6 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 60 + periodSeconds: 30 + failureThreshold: 5 diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000000..a97da79c3d --- /dev/null +++ b/chart/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }} + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http diff --git a/chart/values-dev.yaml b/chart/values-dev.yaml new file mode 100644 index 0000000000..292a534e78 --- /dev/null +++ b/chart/values-dev.yaml @@ -0,0 +1,27 @@ +environment: dev +image: + tag: dev +secretName: evolution-api-dev-secrets +env: + - name: SERVER_TYPE + value: http + - name: SERVER_PORT + value: '8080' + - name: DATABASE_ENABLED + value: 'true' + - name: DATABASE_PROVIDER + value: mysql + - name: DATABASE_CONNECTION_CLIENT_NAME + value: evolution_dev + - name: CACHE_REDIS_ENABLED + value: 'true' + - name: CACHE_REDIS_PREFIX_KEY + value: 'evolution:dev' + - name: CACHE_REDIS_SAVE_INSTANCES + value: 'false' + - name: CACHE_LOCAL_ENABLED + value: 'false' + - name: EVOLUTION_OWNERSHIP_ENABLED + value: 'false' + - name: TELEMETRY_ENABLED + value: 'false' diff --git a/chart/values-hlg.yaml b/chart/values-hlg.yaml new file mode 100644 index 0000000000..7fbc1bcbab --- /dev/null +++ b/chart/values-hlg.yaml @@ -0,0 +1,27 @@ +environment: hlg +image: + tag: hlg +secretName: evolution-api-hlg-secrets +env: + - name: SERVER_TYPE + value: http + - name: SERVER_PORT + value: '8080' + - name: DATABASE_ENABLED + value: 'true' + - name: DATABASE_PROVIDER + value: mysql + - name: DATABASE_CONNECTION_CLIENT_NAME + value: evolution_hlg + - name: CACHE_REDIS_ENABLED + value: 'true' + - name: CACHE_REDIS_PREFIX_KEY + value: 'evolution:hlg' + - name: CACHE_REDIS_SAVE_INSTANCES + value: 'false' + - name: CACHE_LOCAL_ENABLED + value: 'false' + - name: EVOLUTION_OWNERSHIP_ENABLED + value: 'false' + - name: TELEMETRY_ENABLED + value: 'false' diff --git a/chart/values-prod.yaml b/chart/values-prod.yaml new file mode 100644 index 0000000000..1785c6af2d --- /dev/null +++ b/chart/values-prod.yaml @@ -0,0 +1,34 @@ +environment: prod +image: + tag: stable +secretName: evolution-api-prod-secrets +resources: + requests: + cpu: 250m + memory: 768Mi + limits: + cpu: '1' + memory: 1536Mi +env: + - name: SERVER_TYPE + value: http + - name: SERVER_PORT + value: '8080' + - name: DATABASE_ENABLED + value: 'true' + - name: DATABASE_PROVIDER + value: mysql + - name: DATABASE_CONNECTION_CLIENT_NAME + value: evolution_prod + - name: CACHE_REDIS_ENABLED + value: 'true' + - name: CACHE_REDIS_PREFIX_KEY + value: 'evolution:prod' + - name: CACHE_REDIS_SAVE_INSTANCES + value: 'false' + - name: CACHE_LOCAL_ENABLED + value: 'false' + - name: EVOLUTION_OWNERSHIP_ENABLED + value: 'false' + - name: TELEMETRY_ENABLED + value: 'false' diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000000..ee23b84748 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,21 @@ +replicaCount: 1 + +image: + repository: evoapicloud/evolution-api + tag: dev + pullPolicy: Always + +service: + port: 8080 + +secretName: evolution-api-secrets + +resources: + requests: + cpu: 100m + memory: 512Mi + limits: + cpu: 500m + memory: 1Gi + +env: [] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000000..ad3ad95814 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,47 @@ +# Meets deployment + +This chart deploys one internal Evolution provider. It creates only a +`Deployment` and a `ClusterIP` Service. PostgreSQL, MySQL, Redis, volumes, +Ingress, HPA, namespaces and Secrets are intentionally external resources. + +## Prerequisites + +For each environment, Infrastructure must provision a dedicated MySQL database +and user, and grant the existing Redis service access to the `evolution:` +key prefix. Create the Kubernetes Secret from the approved secret manager; do +not commit its values. + +The Secret must contain these keys: + +```text +AUTHENTICATION_API_KEY +DATABASE_CONNECTION_URI +CACHE_REDIS_URI +``` + +For example, after loading the values into a protected local file: + +```bash +kubectl -n dev create secret generic evolution-api-dev-secrets \ + --from-env-file=/secure/path/evolution-dev.env +``` + +The database URI must select MySQL and the environment database, for example +`mysql://:@:3306/evolution_dev`. + +## Deploy + +```bash +helm upgrade --install evolution-api-dev chart/ \ + --namespace dev \ + --values chart/values-dev.yaml \ + --wait --timeout 10m +``` + +Use `values-hlg.yaml` in namespace `hlg` and `values-prod.yaml` in namespace +`prod`. The public gateway remains a separate service; this provider has no +Ingress and is reached through its internal Kubernetes Service. + +Before the first rollout in an environment, run the Prisma migration using the +same Secret-backed variables and then validate QR, reconnect, text, media and +webhook delivery with one WhatsApp account. From 96ce954db8567609ad1bcd4fef915dbb1a9a1c0e Mon Sep 17 00:00:00 2001 From: MarcelaMartinsRap Date: Mon, 24 Aug 2026 12:07:20 -0300 Subject: [PATCH 03/10] feat(deploy): configure evolution ownership nodes --- chart/templates/deployment.yaml | 3 +++ chart/values-hlg-a.yaml | 5 +++++ chart/values-hlg-b.yaml | 5 +++++ chart/values-hlg.yaml | 6 +++++- chart/values-prod-a.yaml | 5 +++++ chart/values-prod-b.yaml | 5 +++++ chart/values-prod.yaml | 6 +++++- chart/values.yaml | 1 + deploy/README.md | 20 ++++++++++++++++---- 9 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 chart/values-hlg-a.yaml create mode 100644 chart/values-hlg-b.yaml create mode 100644 chart/values-prod-a.yaml create mode 100644 chart/values-prod-b.yaml diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 607dbc0686..337d6d2098 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -32,6 +32,9 @@ spec: value: {{ .Values.environment | quote }} {{- with .Values.env }} {{ toYaml . | nindent 12 }} +{{- end }} +{{- with .Values.nodeEnv }} +{{ toYaml . | nindent 12 }} {{- end }} resources: {{ toYaml .Values.resources | nindent 12 }} diff --git a/chart/values-hlg-a.yaml b/chart/values-hlg-a.yaml new file mode 100644 index 0000000000..2a10dd4598 --- /dev/null +++ b/chart/values-hlg-a.yaml @@ -0,0 +1,5 @@ +nodeEnv: + - name: EVOLUTION_NODE_ID + value: evolution-hlg-a + - name: EVOLUTION_NODE_BASE_URL + value: http://evolution-api-hlg-a.hlg.svc.cluster.local:8080 diff --git a/chart/values-hlg-b.yaml b/chart/values-hlg-b.yaml new file mode 100644 index 0000000000..8f55f00e10 --- /dev/null +++ b/chart/values-hlg-b.yaml @@ -0,0 +1,5 @@ +nodeEnv: + - name: EVOLUTION_NODE_ID + value: evolution-hlg-b + - name: EVOLUTION_NODE_BASE_URL + value: http://evolution-api-hlg-b.hlg.svc.cluster.local:8080 diff --git a/chart/values-hlg.yaml b/chart/values-hlg.yaml index 7fbc1bcbab..53c53db019 100644 --- a/chart/values-hlg.yaml +++ b/chart/values-hlg.yaml @@ -22,6 +22,10 @@ env: - name: CACHE_LOCAL_ENABLED value: 'false' - name: EVOLUTION_OWNERSHIP_ENABLED - value: 'false' + value: 'true' + - name: EVOLUTION_OWNERSHIP_LEASE_TTL_MS + value: '90000' + - name: EVOLUTION_OWNERSHIP_RENEW_INTERVAL_MS + value: '30000' - name: TELEMETRY_ENABLED value: 'false' diff --git a/chart/values-prod-a.yaml b/chart/values-prod-a.yaml new file mode 100644 index 0000000000..cc528c4ccc --- /dev/null +++ b/chart/values-prod-a.yaml @@ -0,0 +1,5 @@ +nodeEnv: + - name: EVOLUTION_NODE_ID + value: evolution-prod-a + - name: EVOLUTION_NODE_BASE_URL + value: http://evolution-api-prod-a.prod.svc.cluster.local:8080 diff --git a/chart/values-prod-b.yaml b/chart/values-prod-b.yaml new file mode 100644 index 0000000000..09867c591a --- /dev/null +++ b/chart/values-prod-b.yaml @@ -0,0 +1,5 @@ +nodeEnv: + - name: EVOLUTION_NODE_ID + value: evolution-prod-b + - name: EVOLUTION_NODE_BASE_URL + value: http://evolution-api-prod-b.prod.svc.cluster.local:8080 diff --git a/chart/values-prod.yaml b/chart/values-prod.yaml index 1785c6af2d..ae6bba8366 100644 --- a/chart/values-prod.yaml +++ b/chart/values-prod.yaml @@ -29,6 +29,10 @@ env: - name: CACHE_LOCAL_ENABLED value: 'false' - name: EVOLUTION_OWNERSHIP_ENABLED - value: 'false' + value: 'true' + - name: EVOLUTION_OWNERSHIP_LEASE_TTL_MS + value: '90000' + - name: EVOLUTION_OWNERSHIP_RENEW_INTERVAL_MS + value: '30000' - name: TELEMETRY_ENABLED value: 'false' diff --git a/chart/values.yaml b/chart/values.yaml index ee23b84748..0a2682be8c 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -19,3 +19,4 @@ resources: memory: 1Gi env: [] +nodeEnv: [] diff --git a/deploy/README.md b/deploy/README.md index ad3ad95814..4facf5d1d9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,6 +1,6 @@ # Meets deployment -This chart deploys one internal Evolution provider. It creates only a +This chart deploys an internal Evolution provider node. It creates only a `Deployment` and a `ClusterIP` Service. PostgreSQL, MySQL, Redis, volumes, Ingress, HPA, namespaces and Secrets are intentionally external resources. @@ -38,9 +38,21 @@ helm upgrade --install evolution-api-dev chart/ \ --wait --timeout 10m ``` -Use `values-hlg.yaml` in namespace `hlg` and `values-prod.yaml` in namespace -`prod`. The public gateway remains a separate service; this provider has no -Ingress and is reached through its internal Kubernetes Service. +DEV uses one provider with ownership disabled. HLG and production use two +separate releases, each with its own stable Service identity: + +```bash +helm upgrade --install evolution-api-hlg-a chart/ --namespace hlg \ + --values chart/values-hlg.yaml --values chart/values-hlg-a.yaml +helm upgrade --install evolution-api-hlg-b chart/ --namespace hlg \ + --values chart/values-hlg.yaml --values chart/values-hlg-b.yaml +``` + +The equivalent production files are `values-prod.yaml`, `values-prod-a.yaml` +and `values-prod-b.yaml`. They enable ownership with a 90-second lease renewed +every 30 seconds. Do not deploy the production pair until the HLG ownership +acceptance test has passed. The public gateway remains a separate service; this +provider has no Ingress and is reached through its internal Kubernetes Service. Before the first rollout in an environment, run the Prisma migration using the same Secret-backed variables and then validate QR, reconnect, text, media and From 3c1adade81fc45a6bc7e30361f5aaecffec28430 Mon Sep 17 00:00:00 2001 From: MarcelaMartinsRap Date: Mon, 24 Aug 2026 12:20:58 -0300 Subject: [PATCH 04/10] docs: add Evolution acceptance test plan --- deploy/ACCEPTANCE_TESTS.md | 131 +++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 deploy/ACCEPTANCE_TESTS.md diff --git a/deploy/ACCEPTANCE_TESTS.md b/deploy/ACCEPTANCE_TESTS.md new file mode 100644 index 0000000000..84281cabf2 --- /dev/null +++ b/deploy/ACCEPTANCE_TESTS.md @@ -0,0 +1,131 @@ +# Evolution API — Acceptance Test Plan + +This document is the release gate for the Meets Evolution provider and gateway +integration. Run the applicable section after every environment deployment and +attach the result to the change or release record. + +## Scope and promotion flow + +| Branch | Environment | Image tag | Ownership | +| --- | --- | --- | --- | +| `develop` | DEV | `dev` | Disabled: one provider only | +| `homolog` | HLG | `hlg` | Enabled: two providers | +| `main` | Production | `stable` | Enabled: two providers | + +Promotion is allowed only after the prior environment has passed its required +acceptance checks. Production deployment requires explicit approval after HLG +validation. + +## Preconditions + +- [ ] The target image tag exists in the container registry. +- [ ] The environment Secret exists and contains no placeholder values. +- [ ] The database migration was executed with the target environment + credentials. +- [ ] Redis is reachable with the environment-specific key prefix. +- [ ] The gateway is reachable through its expected public URL. +- [ ] No Evolution provider has a public Ingress; the gateway is the public + integration endpoint. +- [ ] A dedicated test WhatsApp account is available for the environment. + +## Infrastructure acceptance + +### DEV + +- [ ] Exactly one Evolution provider is running. +- [ ] `EVOLUTION_OWNERSHIP_ENABLED=false`. +- [ ] The provider has a ClusterIP Service only. +- [ ] The provider has no HPA, LoadBalancer or dedicated persistent volume. +- [ ] The provider uses the existing approved MySQL and Redis services, with + `evolution:dev` Redis key prefix and the `evolution_dev` database client. +- [ ] Requests and limits match `chart/values-dev.yaml`. +- [ ] Scheduling uses existing approved capacity; no node pool is created for + this deployment. + +### HLG and production + +- [ ] Two providers are running, one for each release suffix (`-a` and `-b`). +- [ ] Both providers use the same environment database and Redis prefix only. +- [ ] `EVOLUTION_OWNERSHIP_ENABLED=true`. +- [ ] Lease TTL is 90 seconds and renewal interval is 30 seconds. +- [ ] Provider Services are internal only. +- [ ] Resource requests, limits and replica count match the target values file. + +## Gateway and webhook acceptance + +| Environment | Gateway URL | Webhook endpoint | +| --- | --- | --- | +| DEV | `https://whatsapp-evolution.crmdev.com.br` | `/api/webhooks/evolution` | +| HLG | `https://whatsapp-evolution-hlg.meets.com.br` | `/api/webhooks/evolution` | +| Production | `https://whatsapp-evolution.meets.com.br` | `/api/webhooks/evolution` | + +- [ ] The gateway health endpoint returns success through HTTPS. +- [ ] The provider can reach the gateway webhook endpoint. +- [ ] A webhook without the `jwt_key` header is rejected. +- [ ] A webhook with an invalid `jwt_key` is rejected. +- [ ] A valid event is accepted and is processed only once. +- [ ] Webhook secrets are unique per environment and are not logged. +- [ ] The browser/CRM communicates with the gateway, never directly with the + Evolution provider. + +## Functional acceptance + +Run each case with the test WhatsApp account and retain the conversation ID, +provider log correlation ID and gateway response as evidence. + +- [ ] Create an instance and obtain a QR code or pairing code. +- [ ] Connect the account and confirm the `open` connection state. +- [ ] Send and receive a plain text message. +- [ ] Send and receive image, document, video and audio/voice messages. +- [ ] Send a button and list message; validate configured fallback behavior. +- [ ] Edit and delete a sent message where the provider supports it. +- [ ] Transfer a conversation to a team/responsible user and verify the bot is + stopped. +- [ ] Return a finished conversation to the chatbot and verify + `bot_finalizado=false`, no responsible user and no Typebot session. +- [ ] Reconnect the instance and confirm no duplicate conversation or webhook + event is created. +- [ ] Restart the provider and confirm the expected session behavior for the + environment. + +## Ownership and failover acceptance (HLG only) + +- [ ] Create and connect an instance while both providers are healthy. +- [ ] Confirm exactly one provider owns the instance lease in Redis. +- [ ] Stop the owning provider. +- [ ] Confirm the second provider acquires the lease after the TTL and restores + the connection without creating a second active session. +- [ ] Restore the first provider and confirm it does not steal a valid lease. +- [ ] Repeat the text-message and webhook tests after failover. + +## Cost and operational acceptance + +- [ ] No new Kubernetes node pool, LoadBalancer, HPA or provider Ingress was + created for DEV. +- [ ] Database and Redis are shared approved services with environment-specific + credentials and prefixes; no duplicate managed database was provisioned. +- [ ] OCI CPU, memory and storage metrics were reviewed after deployment. +- [ ] The environment does not exceed its approved request/limit baseline. +- [ ] Logs are at the configured level and do not contain secrets, QR payloads + or message media. + +## Evidence and release decision + +Record the following before approving promotion: + +```text +Environment: +Image digest: +Gateway version/commit: +Provider version/commit: +Test WhatsApp account: +Date and operator: +Functional tests: PASS | FAIL +Webhook tests: PASS | FAIL +Ownership/failover (HLG only): PASS | FAIL | N/A +Cost review: PASS | FAIL +Known risks: +Approval for next environment: +``` + +Any failed required item blocks promotion until it is corrected and retested. From a808c4d7c36ed92cd54385b5e0040a0cf1fd8a76 Mon Sep 17 00:00:00 2001 From: MarcelaMartinsRap Date: Mon, 24 Aug 2026 12:25:50 -0300 Subject: [PATCH 05/10] docs: remove misplaced acceptance tests --- deploy/ACCEPTANCE_TESTS.md | 131 ------------------------------------- 1 file changed, 131 deletions(-) delete mode 100644 deploy/ACCEPTANCE_TESTS.md diff --git a/deploy/ACCEPTANCE_TESTS.md b/deploy/ACCEPTANCE_TESTS.md deleted file mode 100644 index 84281cabf2..0000000000 --- a/deploy/ACCEPTANCE_TESTS.md +++ /dev/null @@ -1,131 +0,0 @@ -# Evolution API — Acceptance Test Plan - -This document is the release gate for the Meets Evolution provider and gateway -integration. Run the applicable section after every environment deployment and -attach the result to the change or release record. - -## Scope and promotion flow - -| Branch | Environment | Image tag | Ownership | -| --- | --- | --- | --- | -| `develop` | DEV | `dev` | Disabled: one provider only | -| `homolog` | HLG | `hlg` | Enabled: two providers | -| `main` | Production | `stable` | Enabled: two providers | - -Promotion is allowed only after the prior environment has passed its required -acceptance checks. Production deployment requires explicit approval after HLG -validation. - -## Preconditions - -- [ ] The target image tag exists in the container registry. -- [ ] The environment Secret exists and contains no placeholder values. -- [ ] The database migration was executed with the target environment - credentials. -- [ ] Redis is reachable with the environment-specific key prefix. -- [ ] The gateway is reachable through its expected public URL. -- [ ] No Evolution provider has a public Ingress; the gateway is the public - integration endpoint. -- [ ] A dedicated test WhatsApp account is available for the environment. - -## Infrastructure acceptance - -### DEV - -- [ ] Exactly one Evolution provider is running. -- [ ] `EVOLUTION_OWNERSHIP_ENABLED=false`. -- [ ] The provider has a ClusterIP Service only. -- [ ] The provider has no HPA, LoadBalancer or dedicated persistent volume. -- [ ] The provider uses the existing approved MySQL and Redis services, with - `evolution:dev` Redis key prefix and the `evolution_dev` database client. -- [ ] Requests and limits match `chart/values-dev.yaml`. -- [ ] Scheduling uses existing approved capacity; no node pool is created for - this deployment. - -### HLG and production - -- [ ] Two providers are running, one for each release suffix (`-a` and `-b`). -- [ ] Both providers use the same environment database and Redis prefix only. -- [ ] `EVOLUTION_OWNERSHIP_ENABLED=true`. -- [ ] Lease TTL is 90 seconds and renewal interval is 30 seconds. -- [ ] Provider Services are internal only. -- [ ] Resource requests, limits and replica count match the target values file. - -## Gateway and webhook acceptance - -| Environment | Gateway URL | Webhook endpoint | -| --- | --- | --- | -| DEV | `https://whatsapp-evolution.crmdev.com.br` | `/api/webhooks/evolution` | -| HLG | `https://whatsapp-evolution-hlg.meets.com.br` | `/api/webhooks/evolution` | -| Production | `https://whatsapp-evolution.meets.com.br` | `/api/webhooks/evolution` | - -- [ ] The gateway health endpoint returns success through HTTPS. -- [ ] The provider can reach the gateway webhook endpoint. -- [ ] A webhook without the `jwt_key` header is rejected. -- [ ] A webhook with an invalid `jwt_key` is rejected. -- [ ] A valid event is accepted and is processed only once. -- [ ] Webhook secrets are unique per environment and are not logged. -- [ ] The browser/CRM communicates with the gateway, never directly with the - Evolution provider. - -## Functional acceptance - -Run each case with the test WhatsApp account and retain the conversation ID, -provider log correlation ID and gateway response as evidence. - -- [ ] Create an instance and obtain a QR code or pairing code. -- [ ] Connect the account and confirm the `open` connection state. -- [ ] Send and receive a plain text message. -- [ ] Send and receive image, document, video and audio/voice messages. -- [ ] Send a button and list message; validate configured fallback behavior. -- [ ] Edit and delete a sent message where the provider supports it. -- [ ] Transfer a conversation to a team/responsible user and verify the bot is - stopped. -- [ ] Return a finished conversation to the chatbot and verify - `bot_finalizado=false`, no responsible user and no Typebot session. -- [ ] Reconnect the instance and confirm no duplicate conversation or webhook - event is created. -- [ ] Restart the provider and confirm the expected session behavior for the - environment. - -## Ownership and failover acceptance (HLG only) - -- [ ] Create and connect an instance while both providers are healthy. -- [ ] Confirm exactly one provider owns the instance lease in Redis. -- [ ] Stop the owning provider. -- [ ] Confirm the second provider acquires the lease after the TTL and restores - the connection without creating a second active session. -- [ ] Restore the first provider and confirm it does not steal a valid lease. -- [ ] Repeat the text-message and webhook tests after failover. - -## Cost and operational acceptance - -- [ ] No new Kubernetes node pool, LoadBalancer, HPA or provider Ingress was - created for DEV. -- [ ] Database and Redis are shared approved services with environment-specific - credentials and prefixes; no duplicate managed database was provisioned. -- [ ] OCI CPU, memory and storage metrics were reviewed after deployment. -- [ ] The environment does not exceed its approved request/limit baseline. -- [ ] Logs are at the configured level and do not contain secrets, QR payloads - or message media. - -## Evidence and release decision - -Record the following before approving promotion: - -```text -Environment: -Image digest: -Gateway version/commit: -Provider version/commit: -Test WhatsApp account: -Date and operator: -Functional tests: PASS | FAIL -Webhook tests: PASS | FAIL -Ownership/failover (HLG only): PASS | FAIL | N/A -Cost review: PASS | FAIL -Known risks: -Approval for next environment: -``` - -Any failed required item blocks promotion until it is corrected and retested. From 02c98fa1e4da835f40cfa04cf9397f9be2e4fdaa Mon Sep 17 00:00:00 2001 From: MarcelaMartinsRap Date: Wed, 2 Sep 2026 17:51:22 -0300 Subject: [PATCH 06/10] refactor(deploy): use PostgreSQL and Redis per environment --- chart/values-dev.yaml | 2 +- chart/values-hlg.yaml | 2 +- chart/values-prod.yaml | 2 +- deploy/README.md | 13 ++++++------- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/chart/values-dev.yaml b/chart/values-dev.yaml index 292a534e78..903595010d 100644 --- a/chart/values-dev.yaml +++ b/chart/values-dev.yaml @@ -10,7 +10,7 @@ env: - name: DATABASE_ENABLED value: 'true' - name: DATABASE_PROVIDER - value: mysql + value: postgresql - name: DATABASE_CONNECTION_CLIENT_NAME value: evolution_dev - name: CACHE_REDIS_ENABLED diff --git a/chart/values-hlg.yaml b/chart/values-hlg.yaml index 53c53db019..40098e3492 100644 --- a/chart/values-hlg.yaml +++ b/chart/values-hlg.yaml @@ -10,7 +10,7 @@ env: - name: DATABASE_ENABLED value: 'true' - name: DATABASE_PROVIDER - value: mysql + value: postgresql - name: DATABASE_CONNECTION_CLIENT_NAME value: evolution_hlg - name: CACHE_REDIS_ENABLED diff --git a/chart/values-prod.yaml b/chart/values-prod.yaml index ae6bba8366..b72cee385a 100644 --- a/chart/values-prod.yaml +++ b/chart/values-prod.yaml @@ -17,7 +17,7 @@ env: - name: DATABASE_ENABLED value: 'true' - name: DATABASE_PROVIDER - value: mysql + value: postgresql - name: DATABASE_CONNECTION_CLIENT_NAME value: evolution_prod - name: CACHE_REDIS_ENABLED diff --git a/deploy/README.md b/deploy/README.md index 4facf5d1d9..8eb2683358 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,15 +1,14 @@ # Meets deployment This chart deploys an internal Evolution provider node. It creates only a -`Deployment` and a `ClusterIP` Service. PostgreSQL, MySQL, Redis, volumes, +`Deployment` and a `ClusterIP` Service. PostgreSQL, Redis, volumes, Ingress, HPA, namespaces and Secrets are intentionally external resources. ## Prerequisites -For each environment, Infrastructure must provision a dedicated MySQL database -and user, and grant the existing Redis service access to the `evolution:` -key prefix. Create the Kubernetes Secret from the approved secret manager; do -not commit its values. +For each environment, Infrastructure must provision a dedicated PostgreSQL +database and a dedicated Redis service for Evolution. Create the Kubernetes +Secret from the approved secret manager; do not commit its values. The Secret must contain these keys: @@ -26,8 +25,8 @@ kubectl -n dev create secret generic evolution-api-dev-secrets \ --from-env-file=/secure/path/evolution-dev.env ``` -The database URI must select MySQL and the environment database, for example -`mysql://:@:3306/evolution_dev`. +The database URI must select PostgreSQL and the environment database, for +example `postgresql://:@:5432/evolution_dev`. ## Deploy From 5a45f554cdcb92ac32508c56b7fa630e5c81e5ad Mon Sep 17 00:00:00 2001 From: victordevz Date: Tue, 22 Sep 2026 17:54:05 -0300 Subject: [PATCH 07/10] feat(evolution): unify develop changes for homolog --- .env.example | 4 + .github/workflows/check_code_quality.yml | 6 +- .github/workflows/publish_docker_image.yml | 30 ++-- .../publish_docker_image_homolog.yml | 50 ------- .../workflows/publish_docker_image_latest.yml | 50 ------- .github/workflows/security.yml | 4 +- .gitignore | 1 + chart/values-dev.yaml | 8 ++ src/api/controllers/chat.controller.ts | 5 + src/api/dto/chat.dto.ts | 6 + .../whatsapp/whatsapp.baileys.service.ts | 22 ++- src/api/routes/chat.router.ts | 12 ++ src/api/routes/index.router.ts | 1 + src/api/server.module.ts | 3 + .../services/instance-ownership.service.ts | 101 ++++++++++++++ src/api/services/monitor.service.ts | 131 +++++++++++------- src/config/env.config.ts | 27 ++++ src/validate/chat.schema.ts | 20 +++ 18 files changed, 312 insertions(+), 169 deletions(-) delete mode 100644 .github/workflows/publish_docker_image_homolog.yml delete mode 100644 .github/workflows/publish_docker_image_latest.yml create mode 100644 src/api/services/instance-ownership.service.ts diff --git a/.env.example b/.env.example index 73a3b40d35..b0c5cdd094 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,10 @@ EVENT_EMITTER_MAX_LISTENERS=50 # If you don't even want an expiration, enter the value false DEL_INSTANCE=false +# Maximum number of saved instances restored at the same time on startup. +# Prevents a reconnect storm when the service restarts with many accounts. +INSTANCE_LOAD_CONCURRENCY=10 + # Provider: postgresql | mysql | psql_bouncer DATABASE_PROVIDER=postgresql DATABASE_CONNECTION_URI='postgresql://user:pass@postgres:5432/evolution_db?schema=evolution_api' diff --git a/.github/workflows/check_code_quality.yml b/.github/workflows/check_code_quality.yml index df156f2117..da297a9bc8 100644 --- a/.github/workflows/check_code_quality.yml +++ b/.github/workflows/check_code_quality.yml @@ -2,9 +2,9 @@ name: Check Code Quality on: pull_request: - branches: [ main, develop ] + branches: [ main, homolog, develop ] push: - branches: [ main, develop ] + branches: [ main, homolog, develop ] jobs: check-lint-and-build: @@ -39,4 +39,4 @@ jobs: run: npm run db:generate - name: Check build - run: npm run build \ No newline at end of file + run: npm run build diff --git a/.github/workflows/publish_docker_image.yml b/.github/workflows/publish_docker_image.yml index c5a3996edb..58615e2504 100644 --- a/.github/workflows/publish_docker_image.yml +++ b/.github/workflows/publish_docker_image.yml @@ -2,9 +2,17 @@ name: Build Docker image on: push: + branches: + - main + - develop + - homolog tags: - "*.*.*" +env: + OCIR_REGISTRY: sa-saopaulo-1.ocir.io + IMAGE_NAME: sa-saopaulo-1.ocir.io/gr6emtyps1q4/evolution-api-v2 + jobs: build_deploy: name: Build and Deploy @@ -22,29 +30,31 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: evoapicloud/evolution-api - tags: type=semver,pattern=v{{version}} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + images: ${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + type=raw,value=hlg,enable=${{ github.ref == 'refs/heads/homolog' }} + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/develop' }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to GitHub Container Registry + - name: Login to OCIR uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} + registry: ${{ env.OCIR_REGISTRY }} + username: ${{ secrets.OCI_TENANCY_NAME }}/${{ secrets.OCI_REGISTRY_USERNAME }} + password: ${{ secrets.OCI_AUTH_TOKEN }} - name: Build and push id: docker_build uses: docker/build-push-action@v6 with: - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} \ No newline at end of file + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/publish_docker_image_homolog.yml b/.github/workflows/publish_docker_image_homolog.yml deleted file mode 100644 index a76e1008be..0000000000 --- a/.github/workflows/publish_docker_image_homolog.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Build Docker image - -on: - push: - branches: - - develop - -jobs: - build_deploy: - name: Build and Deploy - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - submodules: recursive - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: evoapicloud/evolution-api - tags: homolog - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Build and push - id: docker_build - uses: docker/build-push-action@v6 - with: - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/publish_docker_image_latest.yml b/.github/workflows/publish_docker_image_latest.yml deleted file mode 100644 index f73fe80e4e..0000000000 --- a/.github/workflows/publish_docker_image_latest.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Build Docker image - -on: - push: - branches: - - main - -jobs: - build_deploy: - name: Build and Deploy - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - submodules: recursive - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: evoapicloud/evolution-api - tags: latest - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Build and push - id: docker_build - uses: docker/build-push-action@v6 - with: - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b83f0b5d98..354b25570d 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -2,9 +2,9 @@ name: Security Scan on: push: - branches: [ main, develop ] + branches: [ main, homolog, develop ] pull_request: - branches: [ main, develop ] + branches: [ main, homolog, develop ] schedule: - cron: '0 0 * * 1' # Weekly on Mondays diff --git a/.gitignore b/.gitignore index 768d8afa41..f0d962c2dd 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ lerna-debug.log* /src/env.yml /store *.env +!deploy/**/.env.example /temp/* diff --git a/chart/values-dev.yaml b/chart/values-dev.yaml index 903595010d..ed8fba71cd 100644 --- a/chart/values-dev.yaml +++ b/chart/values-dev.yaml @@ -13,6 +13,14 @@ env: value: postgresql - name: DATABASE_CONNECTION_CLIENT_NAME value: evolution_dev + # Required by the gateway's paginated history/media synchronization. + # Data remains isolated in the existing DEV PostgreSQL instance. + - name: DATABASE_SAVE_DATA_NEW_MESSAGE + value: 'true' + - name: DATABASE_SAVE_MESSAGE_UPDATE + value: 'true' + - name: DATABASE_SAVE_DATA_HISTORIC + value: 'true' - name: CACHE_REDIS_ENABLED value: 'true' - name: CACHE_REDIS_PREFIX_KEY diff --git a/src/api/controllers/chat.controller.ts b/src/api/controllers/chat.controller.ts index 22e90b9faa..4886893a9d 100644 --- a/src/api/controllers/chat.controller.ts +++ b/src/api/controllers/chat.controller.ts @@ -2,6 +2,7 @@ import { ArchiveChatDto, BlockUserDto, DeleteMessage, + FetchMessageHistoryDto, getBase64FromMediaMessageDto, MarkChatUnreadDto, NumberDto, @@ -62,6 +63,10 @@ export class ChatController { return await this.waMonitor.waInstances[instanceName].fetchMessages(query); } + public async fetchMessageHistory({ instanceName }: InstanceDto, data: FetchMessageHistoryDto) { + return await this.waMonitor.waInstances[instanceName].fetchMessageHistory(data); + } + public async fetchStatusMessage({ instanceName }: InstanceDto, query: Query) { return await this.waMonitor.waInstances[instanceName].fetchStatusMessage(query); } diff --git a/src/api/dto/chat.dto.ts b/src/api/dto/chat.dto.ts index b11f32b054..4e0c4e67fb 100644 --- a/src/api/dto/chat.dto.ts +++ b/src/api/dto/chat.dto.ts @@ -22,6 +22,12 @@ export class getBase64FromMediaMessageDto { convertToMp4?: boolean; } +export class FetchMessageHistoryDto { + count?: number; + key: proto.IMessageKey; + messageTimestamp: number; +} + export class WhatsAppNumberDto { numbers: string[]; } diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc1..910403b5d7 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -1030,7 +1030,7 @@ export class BaileysStartupService extends ChannelStartupService { } } - if (messagesRepository?.has(m.key.id)) { + if (syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND && messagesRepository?.has(m.key.id)) { continue; } @@ -5119,4 +5119,24 @@ export class BaileysStartupService extends ChannelStartupService { }, }; } + + public async fetchMessageHistory(data: { count?: number; key: proto.IMessageKey; messageTimestamp: number }) { + if (!this.client) { + throw new BadRequestException('WhatsApp connection is not available'); + } + const count = Math.min(Math.max(Number(data.count) || 50, 1), 100); + const suppliedTimestamp = Number(data.messageTimestamp); + if (!Number.isFinite(suppliedTimestamp)) { + throw new BadRequestException('messageTimestamp is required to fetch message history'); + } + const messageTimestamp = suppliedTimestamp < 100_000_000_000 ? suppliedTimestamp * 1_000 : suppliedTimestamp; + const requestId = await this.client.fetchMessageHistory(count, data.key, Math.floor(messageTimestamp)); + + return { + status: 'requested', + requestId, + count, + remoteJid: data.key.remoteJid, + }; + } } diff --git a/src/api/routes/chat.router.ts b/src/api/routes/chat.router.ts index 158947ed22..289fbf804c 100644 --- a/src/api/routes/chat.router.ts +++ b/src/api/routes/chat.router.ts @@ -3,6 +3,7 @@ import { ArchiveChatDto, BlockUserDto, DeleteMessage, + FetchMessageHistoryDto, getBase64FromMediaMessageDto, MarkChatUnreadDto, NumberDto, @@ -24,6 +25,7 @@ import { blockUserSchema, contactValidateSchema, deleteMessageSchema, + fetchMessageHistorySchema, markChatUnreadSchema, messageUpSchema, messageValidateSchema, @@ -171,6 +173,16 @@ export class ChatRouter extends RouterBroker { return res.status(HttpStatus.OK).json(response); }) + .post(this.routerPath('fetchMessageHistory'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: fetchMessageHistorySchema, + ClassRef: FetchMessageHistoryDto, + execute: (instance, data) => chatController.fetchMessageHistory(instance, data), + }); + + return res.status(HttpStatus.ACCEPTED).json(response); + }) .post(this.routerPath('findStatusMessage'), ...guards, async (req, res) => { const response = await this.dataValidate>({ request: req, diff --git a/src/api/routes/index.router.ts b/src/api/routes/index.router.ts index 45c43fca5b..8be238902b 100644 --- a/src/api/routes/index.router.ts +++ b/src/api/routes/index.router.ts @@ -27,6 +27,7 @@ import { ViewsRouter } from './view.router'; enum HttpStatus { OK = 200, + ACCEPTED = 202, CREATED = 201, NOT_FOUND = 404, FORBIDDEN = 403, diff --git a/src/api/server.module.ts b/src/api/server.module.ts index 668b9e2722..3409911fde 100644 --- a/src/api/server.module.ts +++ b/src/api/server.module.ts @@ -40,6 +40,7 @@ import { S3Service } from './integrations/storage/s3/services/s3.service'; import { ProviderFiles } from './provider/sessions'; import { PrismaRepository } from './repository/repository.service'; import { CacheService } from './services/cache.service'; +import { InstanceOwnershipService } from './services/instance-ownership.service'; import { WAMonitoringService } from './services/monitor.service'; import { ProxyService } from './services/proxy.service'; import { SettingsService } from './services/settings.service'; @@ -54,6 +55,7 @@ if (configService.get('CHATWOOT').ENABLED) { export const cache = new CacheService(new CacheEngine(configService, 'instance').getEngine()); const baileysCache = new CacheService(new CacheEngine(configService, 'baileys').getEngine()); +const instanceOwnership = new InstanceOwnershipService(configService); let providerFiles: ProviderFiles = null; if (configService.get('PROVIDER').ENABLED) { @@ -70,6 +72,7 @@ export const waMonitor = new WAMonitoringService( cache, chatwootCache, baileysCache, + instanceOwnership, ); const s3Service = new S3Service(prismaRepository); diff --git a/src/api/services/instance-ownership.service.ts b/src/api/services/instance-ownership.service.ts new file mode 100644 index 0000000000..fdb5cf1d5c --- /dev/null +++ b/src/api/services/instance-ownership.service.ts @@ -0,0 +1,101 @@ +import { redisClient } from '@cache/rediscache.client'; +import { ConfigService, InstanceOwnership } from '@config/env.config'; +import { Logger } from '@config/logger.config'; +import { randomUUID } from 'crypto'; + +type OwnerLease = { + nodeId: string; + baseUrl: string; + leaseVersion: string; +}; + +export class InstanceOwnershipService { + private readonly logger = new Logger('InstanceOwnershipService'); + private readonly leases = new Map(); + private readonly renewals = new Map(); + + constructor(private readonly config: ConfigService) {} + + async acquire(instanceName: string): Promise { + const settings = this.settings(); + if (!settings.ENABLED) return true; + if (!settings.NODE_ID || !settings.NODE_BASE_URL) { + throw new Error('Evolution ownership requires EVOLUTION_NODE_ID and EVOLUTION_NODE_BASE_URL'); + } + + const client = await this.connection(); + const lease: OwnerLease = { + nodeId: settings.NODE_ID, + baseUrl: settings.NODE_BASE_URL, + leaseVersion: randomUUID(), + }; + const acquired = await client.set(this.key(instanceName), JSON.stringify(lease), { + NX: true, + PX: settings.LEASE_TTL_MS, + }); + if (acquired !== 'OK') return false; + this.leases.set(instanceName, lease); + return true; + } + + startRenewal(instanceName: string, onLost: () => Promise): void { + const settings = this.settings(); + if (!settings.ENABLED || this.renewals.has(instanceName)) return; + const timer = setInterval(async () => { + if (await this.renew(instanceName)) return; + this.stopRenewal(instanceName); + await onLost(); + }, settings.RENEW_INTERVAL_MS); + this.renewals.set(instanceName, timer); + } + + async release(instanceName: string): Promise { + this.stopRenewal(instanceName); + const settings = this.settings(); + const lease = this.leases.get(instanceName); + this.leases.delete(instanceName); + if (!settings.ENABLED || !lease) return; + const client = await this.connection(); + await client.eval( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", + { keys: [this.key(instanceName)], arguments: [JSON.stringify(lease)] }, + ); + } + + private async renew(instanceName: string): Promise { + const settings = this.settings(); + const lease = this.leases.get(instanceName); + if (!lease) return false; + try { + const client = await this.connection(); + const result = await client.eval( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end", + { keys: [this.key(instanceName)], arguments: [JSON.stringify(lease), String(settings.LEASE_TTL_MS)] }, + ); + return result === 1; + } catch (error) { + this.logger.error({ action: 'ownership.renew', instanceName, error }); + return false; + } + } + + private stopRenewal(instanceName: string): void { + const timer = this.renewals.get(instanceName); + if (timer) clearInterval(timer); + this.renewals.delete(instanceName); + } + + private settings(): InstanceOwnership { + return this.config.get('OWNERSHIP'); + } + + private key(instanceName: string): string { + return `evolution:owner:${instanceName}`; + } + + private async connection() { + const client = redisClient.getConnection(); + if (!client.isOpen) await client.connect(); + return client; + } +} diff --git a/src/api/services/monitor.service.ts b/src/api/services/monitor.service.ts index 438530b57e..158b96d15c 100644 --- a/src/api/services/monitor.service.ts +++ b/src/api/services/monitor.service.ts @@ -3,7 +3,15 @@ import { ProviderFiles } from '@api/provider/sessions'; import { PrismaRepository } from '@api/repository/repository.service'; import { channelController } from '@api/server.module'; import { Events, Integration } from '@api/types/wa.types'; -import { CacheConf, Chatwoot, ConfigService, Database, DelInstance, ProviderSession } from '@config/env.config'; +import { + CacheConf, + Chatwoot, + ConfigService, + Database, + DelInstance, + ProviderSession, + Startup, +} from '@config/env.config'; import { Logger } from '@config/logger.config'; import { INSTANCE_DIR, STORE_DIR } from '@config/path.config'; import { NotFoundException } from '@exceptions'; @@ -13,6 +21,7 @@ import { rmSync } from 'fs'; import { join } from 'path'; import { CacheService } from './cache.service'; +import { InstanceOwnershipService } from './instance-ownership.service'; export class WAMonitoringService { constructor( @@ -23,6 +32,7 @@ export class WAMonitoringService { private readonly cache: CacheService, private readonly chatwootCache: CacheService, private readonly baileysCache: CacheService, + private readonly ownership: InstanceOwnershipService, ) { this.removeInstance(); this.noConnection(); @@ -271,6 +281,10 @@ export class WAMonitoringService { } private async setInstance(instanceData: InstanceDto) { + if (!(await this.ownership.acquire(instanceData.instanceName))) { + this.logger.warn(`Skipping instance "${instanceData.instanceName}" because another node owns it`); + return; + } const instance = channelController.init(instanceData, { configService: this.configService, eventEmitter: this.eventEmitter, @@ -281,7 +295,10 @@ export class WAMonitoringService { providerFiles: this.providerFiles, }); - if (!instance) return; + if (!instance) { + await this.ownership.release(instanceData.instanceName); + return; + } instance.setInstance({ instanceId: instanceData.instanceId, @@ -305,35 +322,38 @@ export class WAMonitoringService { } this.waInstances[instanceData.instanceName] = instance; + this.ownership.startRenewal(instanceData.instanceName, async () => { + this.logger.error(`Lost ownership of instance "${instanceData.instanceName}"`); + await this.waInstances[instanceData.instanceName]?.client?.logout('Lost Evolution ownership'); + delete this.waInstances[instanceData.instanceName]; + }); } private async loadInstancesFromRedis() { const keys = await this.cache.keys(); if (keys?.length > 0) { - await Promise.all( - keys.map(async (k) => { - const instanceData = await this.prismaRepository.instance.findUnique({ - where: { id: k.split(':')[1] }, - }); - - if (!instanceData) { - return; - } + await this.loadInBatches(keys, async (k) => { + const instanceData = await this.prismaRepository.instance.findUnique({ + where: { id: k.split(':')[1] }, + }); - const instance = { - instanceId: k.split(':')[1], - instanceName: k.split(':')[2], - integration: instanceData.integration, - token: instanceData.token, - number: instanceData.number, - businessId: instanceData.businessId, - connectionStatus: instanceData.connectionStatus as any, // Pass connection status - }; - - this.setInstance(instance); - }), - ); + if (!instanceData) { + return; + } + + const instance = { + instanceId: k.split(':')[1], + instanceName: k.split(':')[2], + integration: instanceData.integration, + token: instanceData.token, + number: instanceData.number, + businessId: instanceData.businessId, + connectionStatus: instanceData.connectionStatus as any, // Pass connection status + }; + + await this.setInstance(instance); + }); } } @@ -348,20 +368,18 @@ export class WAMonitoringService { return; } - await Promise.all( - instances.map(async (instance) => { - this.setInstance({ - instanceId: instance.id, - instanceName: instance.name, - integration: instance.integration, - token: instance.token, - number: instance.number, - businessId: instance.businessId, - ownerJid: instance.ownerJid, - connectionStatus: instance.connectionStatus as any, // Pass connection status - }); - }), - ); + await this.loadInBatches(instances, async (instance) => { + await this.setInstance({ + instanceId: instance.id, + instanceName: instance.name, + integration: instance.integration, + token: instance.token, + number: instance.number, + businessId: instance.businessId, + ownerJid: instance.ownerJid, + connectionStatus: instance.connectionStatus as any, // Pass connection status + }); + }); } private async loadInstancesFromProvider() { @@ -371,22 +389,28 @@ export class WAMonitoringService { return; } - await Promise.all( - instances?.data?.map(async (instanceId: string) => { - const instance = await this.prismaRepository.instance.findUnique({ - where: { id: instanceId }, - }); + await this.loadInBatches(instances.data, async (instanceId: string) => { + const instance = await this.prismaRepository.instance.findUnique({ + where: { id: instanceId }, + }); - this.setInstance({ - instanceId: instance.id, - instanceName: instance.name, - integration: instance.integration, - token: instance.token, - businessId: instance.businessId, - connectionStatus: instance.connectionStatus as any, // Pass connection status - }); - }), - ); + await this.setInstance({ + instanceId: instance.id, + instanceName: instance.name, + integration: instance.integration, + token: instance.token, + businessId: instance.businessId, + connectionStatus: instance.connectionStatus as any, // Pass connection status + }); + }); + } + + private async loadInBatches(items: T[], load: (item: T) => Promise): Promise { + const { INSTANCE_LOAD_CONCURRENCY: concurrency } = this.configService.get('STARTUP'); + + for (let index = 0; index < items.length; index += concurrency) { + await Promise.all(items.slice(index, index + concurrency).map(load)); + } } private removeInstance() { @@ -404,6 +428,7 @@ export class WAMonitoringService { try { delete this.waInstances[instanceName]; + await this.ownership.release(instanceName); } catch (error) { this.logger.error(error); } diff --git a/src/config/env.config.ts b/src/config/env.config.ts index 7c4e382e7e..4f7d79034e 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -389,6 +389,18 @@ export type EventEmitter = { MAX_LISTENERS: number; }; +export type Startup = { + INSTANCE_LOAD_CONCURRENCY: number; +}; + +export type InstanceOwnership = { + ENABLED: boolean; + NODE_ID: string; + NODE_BASE_URL: string; + LEASE_TTL_MS: number; + RENEW_INTERVAL_MS: number; +}; + export type Production = boolean; export interface Env { @@ -406,6 +418,7 @@ export interface Env { LOG: Log; DEL_INSTANCE: DelInstance; DEL_TEMP_INSTANCES: boolean; + OWNERSHIP: InstanceOwnership; LANGUAGE: Language; WEBHOOK: Webhook; PUSHER: Pusher; @@ -428,6 +441,7 @@ export interface Env { FACEBOOK: Facebook; SENTRY: Sentry; EVENT_EMITTER: EventEmitter; + STARTUP: Startup; PRODUCTION?: Production; } @@ -743,6 +757,19 @@ export class ConfigService { DEL_TEMP_INSTANCES: isBooleanString(process.env?.DEL_TEMP_INSTANCES) ? process.env.DEL_TEMP_INSTANCES === 'true' : true, + STARTUP: { + INSTANCE_LOAD_CONCURRENCY: (() => { + const value = Number.parseInt(process.env.INSTANCE_LOAD_CONCURRENCY || '10', 10); + return Number.isFinite(value) ? Math.max(1, value) : 10; + })(), + }, + OWNERSHIP: { + ENABLED: process.env?.EVOLUTION_OWNERSHIP_ENABLED === 'true', + NODE_ID: process.env?.EVOLUTION_NODE_ID || '', + NODE_BASE_URL: process.env?.EVOLUTION_NODE_BASE_URL || '', + LEASE_TTL_MS: Number.parseInt(process.env?.EVOLUTION_OWNERSHIP_LEASE_TTL_MS || '90000', 10), + RENEW_INTERVAL_MS: Number.parseInt(process.env?.EVOLUTION_OWNERSHIP_RENEW_INTERVAL_MS || '30000', 10), + }, LANGUAGE: process.env?.LANGUAGE || 'en', WEBHOOK: { GLOBAL: { diff --git a/src/validate/chat.schema.ts b/src/validate/chat.schema.ts index 7dae44539b..1a5e710070 100644 --- a/src/validate/chat.schema.ts +++ b/src/validate/chat.schema.ts @@ -241,6 +241,26 @@ export const messageValidateSchema: JSONSchema7 = { }, }; +export const fetchMessageHistorySchema: JSONSchema7 = { + $id: v4(), + type: 'object', + required: ['key', 'messageTimestamp'], + properties: { + count: { type: 'integer', minimum: 1, maximum: 100 }, + messageTimestamp: { type: 'integer', minimum: 1 }, + key: { + type: 'object', + required: ['id', 'remoteJid'], + properties: { + id: { type: 'string', minLength: 1 }, + remoteJid: { type: 'string', minLength: 1 }, + fromMe: { type: 'boolean' }, + participant: { type: 'string' }, + }, + }, + }, +}; + export const messageUpSchema: JSONSchema7 = { $id: v4(), type: 'object', From a37c3b8402f8aada6dbb522aec075880d766c14e Mon Sep 17 00:00:00 2001 From: MarcelaMartinsRap Date: Wed, 7 Oct 2026 14:38:55 -0300 Subject: [PATCH 08/10] fix(history): preserve Baileys cursor timestamp unit --- .../channel/whatsapp/whatsapp.baileys.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 910403b5d7..9ab068fad3 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -5129,7 +5129,10 @@ export class BaileysStartupService extends ChannelStartupService { if (!Number.isFinite(suppliedTimestamp)) { throw new BadRequestException('messageTimestamp is required to fetch message history'); } - const messageTimestamp = suppliedTimestamp < 100_000_000_000 ? suppliedTimestamp * 1_000 : suppliedTimestamp; + const messageTimestamp = + suppliedTimestamp >= 100_000_000_000 + ? Math.floor(suppliedTimestamp / 1_000) + : Math.floor(suppliedTimestamp); const requestId = await this.client.fetchMessageHistory(count, data.key, Math.floor(messageTimestamp)); return { From 83c544d272fc1866ee41d7ff1b42dab1c0644307 Mon Sep 17 00:00:00 2001 From: MarcelaMartinsRap Date: Wed, 7 Oct 2026 14:56:57 -0300 Subject: [PATCH 09/10] fix(connection): preserve Baileys session on restart --- .../channel/whatsapp/whatsapp.baileys.service.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 9ab068fad3..813b26d341 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -739,6 +739,13 @@ export class BaileysStartupService extends ChannelStartupService { } } + public async restart(): Promise { + if (!this.client) { + throw new BadRequestException('WhatsApp connection is not available'); + } + this.client.ws?.close(); + } + public async reloadConnection(): Promise { try { return await this.createClient(this.phoneNumber); @@ -5130,9 +5137,7 @@ export class BaileysStartupService extends ChannelStartupService { throw new BadRequestException('messageTimestamp is required to fetch message history'); } const messageTimestamp = - suppliedTimestamp >= 100_000_000_000 - ? Math.floor(suppliedTimestamp / 1_000) - : Math.floor(suppliedTimestamp); + suppliedTimestamp >= 100_000_000_000 ? Math.floor(suppliedTimestamp / 1_000) : Math.floor(suppliedTimestamp); const requestId = await this.client.fetchMessageHistory(count, data.key, Math.floor(messageTimestamp)); return { From 2fbf91ae7e5ff1349da895ed04ec29208b31e9b9 Mon Sep 17 00:00:00 2001 From: MarcelaMartinsRap Date: Wed, 28 Oct 2026 09:51:56 -0300 Subject: [PATCH 10/10] fix: resolve LID pelo mapeamento do Baileys --- src/api/controllers/chat.controller.ts | 9 +++++++++ src/api/dto/chat.dto.ts | 4 ++++ .../channel/whatsapp/whatsapp.baileys.service.ts | 9 +++++++++ src/api/routes/chat.router.ts | 12 ++++++++++++ src/validate/chat.schema.ts | 12 ++++++++++++ 5 files changed, 46 insertions(+) diff --git a/src/api/controllers/chat.controller.ts b/src/api/controllers/chat.controller.ts index 4886893a9d..27a84d48a9 100644 --- a/src/api/controllers/chat.controller.ts +++ b/src/api/controllers/chat.controller.ts @@ -11,6 +11,7 @@ import { ProfilePictureDto, ProfileStatusDto, ReadMessageDto, + ResolveLidDto, SendPresenceDto, UpdateMessageDto, WhatsAppNumberDto, @@ -27,6 +28,14 @@ export class ChatController { return await this.waMonitor.waInstances[instanceName].whatsappNumber(data); } + public async resolveLid({ instanceName }: InstanceDto, data: ResolveLidDto) { + const instance = this.waMonitor.waInstances[instanceName]; + if (typeof instance.resolveLid !== 'function') { + return { lid: data.lid, phone: null }; + } + return await instance.resolveLid(data); + } + public async readMessage({ instanceName }: InstanceDto, data: ReadMessageDto) { return await this.waMonitor.waInstances[instanceName].markMessageAsRead(data); } diff --git a/src/api/dto/chat.dto.ts b/src/api/dto/chat.dto.ts index 4e0c4e67fb..46d5e2e3f6 100644 --- a/src/api/dto/chat.dto.ts +++ b/src/api/dto/chat.dto.ts @@ -32,6 +32,10 @@ export class WhatsAppNumberDto { numbers: string[]; } +export class ResolveLidDto { + lid: string; +} + export class NumberDto { number: string; } diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 813b26d341..60716e2269 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -11,6 +11,7 @@ import { OnWhatsAppDto, PrivacySettingDto, ReadMessageDto, + ResolveLidDto, SendPresenceDto, UpdateMessageDto, WhatsAppNumberDto, @@ -3680,6 +3681,14 @@ export class BaileysStartupService extends ChannelStartupService { return onWhatsapp; } + public async resolveLid(data: ResolveLidDto) { + const phone = await this.client.signalRepository.lidMapping.getPNForLID(data.lid); + return { + lid: data.lid, + phone: phone ? jidNormalizedUser(phone) : null, + }; + } + public async markMessageAsRead(data: ReadMessageDto) { try { const keys: proto.IMessageKey[] = []; diff --git a/src/api/routes/chat.router.ts b/src/api/routes/chat.router.ts index 289fbf804c..0a5806e8e1 100644 --- a/src/api/routes/chat.router.ts +++ b/src/api/routes/chat.router.ts @@ -12,6 +12,7 @@ import { ProfilePictureDto, ProfileStatusDto, ReadMessageDto, + ResolveLidDto, SendPresenceDto, UpdateMessageDto, WhatsAppNumberDto, @@ -36,6 +37,7 @@ import { profileSchema, profileStatusSchema, readMessageSchema, + resolveLidSchema, updateMessageSchema, whatsappNumberSchema, } from '@validate/validate.schema'; @@ -62,6 +64,16 @@ export class ChatRouter extends RouterBroker { return res.status(HttpStatus.BAD_REQUEST).json(error); } }) + .post(this.routerPath('resolveLid'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: resolveLidSchema, + ClassRef: ResolveLidDto, + execute: (instance, data) => chatController.resolveLid(instance, data), + }); + + return res.status(HttpStatus.OK).json(response); + }) .post(this.routerPath('markMessageAsRead'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, diff --git a/src/validate/chat.schema.ts b/src/validate/chat.schema.ts index 1a5e710070..0b1bc1b018 100644 --- a/src/validate/chat.schema.ts +++ b/src/validate/chat.schema.ts @@ -41,6 +41,18 @@ export const whatsappNumberSchema: JSONSchema7 = { }, }; +export const resolveLidSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + lid: { + type: 'string', + pattern: '^[0-9]+@lid$', + }, + }, + required: ['lid'], +}; + export const readMessageSchema: JSONSchema7 = { $id: v4(), type: 'object',