Documentação / Fluxo de envio de mensagens (Pilot Status)

Fluxo de envio de mensagens (Pilot Status)

Entrar

Fluxo de envio de mensagens (Pilot Status)

Este documento descreve o fluxo atual de envio de mensagens (API → fila → worker → Evolution/WhatsApp → webhooks), as regras de validação e as garantias existentes. Ao final, lista gargalos/pontos de falha e recomendações para tornar o sistema mais resiliente (maximizando a probabilidade de entrega e evitando perdas/duplicidades).

Objetivo do fluxo

  • Receber uma requisição de envio (via API Key).
  • Validar permissões/regras (ambiente, template, opt-in, limites).
  • Persistir uma “intenção de envio” em banco.
  • Processar de forma assíncrona e resiliente (com retry) via fila.
  • Enviar a mensagem via Evolution API (gateway WhatsApp).
  • Atualizar status conforme confirmações assíncronas (webhook da Evolution).

Componentes e responsabilidades

  • API (apps/fullstack): valida requisição, resolve template, decide instância WhatsApp e enfileira.
    • Entrada principal: POST /api/v1/messages/send em apps/fullstack/src/app/api/v1/messages/send/route.ts.
  • Banco (Postgres/Prisma): guarda a entidade message e dados necessários para auditoria/status/reconciliação.
    • Escrita principal: apps/fullstack/src/services/message.service.ts (MessageService.send).
  • Fila (BullMQ/Redis): armazena jobs “send-message” por instância WhatsApp.
    • Criação/uso: apps/fullstack/src/lib/queue.ts e nomes em packages/shared/src/constants.ts.
  • Worker (apps/worker): consome jobs, renderiza template, simula “digitando…”, chama Evolution e aplica regras de retry.
    • Processamento: apps/worker/src/send-message.ts (processMessage).
    • Concurrency e atraso humano entre jobs: apps/worker/src/index.ts (worker concurrency: 1 + randomDelayMs()).
  • Evolution Webhook (apps/fullstack): recebe messages.update, mapeia status e atualiza a mensagem no banco.
    • Handler: apps/fullstack/src/app/api/internal/webhook/route.ts.
  • Webhooks do cliente (opcional): Pilot Status notifica o sistema do cliente sobre eventos (FAILED/DELIVERED/READ etc).
    • Envio: dispatchCustomerWebhook em apps/worker/src/send-message.ts e no webhook handler.

Modelo mental: estados e garantias (hoje)

Estados de mensagem no banco

O status é armazenado no registro message (ex.: QUEUED, SENT, DELIVERED, READ, FAILED).

  • QUEUED: mensagem criada e pronta para ser processada/enviada.
  • SENT: status setado pelo webhook da Evolution (mapeia SERVER_ACKSENT).
  • DELIVERED: status setado pelo webhook (mapeia DELIVERY_ACKDELIVERED).
  • READ: status setado pelo webhook (mapeia READ/PLAYEDREAD).
  • FAILED: falha terminal (decidida pelo worker, pelo reconciler, ou pelo webhook com status ERROR/FAILED).

Garantia prática do sistema

O design atual é, na prática, at-least-once (pelo menos uma tentativa), com mecanismos que tentam aumentar a chance de envio:

  • Persistência em banco antes de enviar.
  • Retry automático via BullMQ (até 10 tentativas com backoff fixo).
  • Reconciler que re-enfileira mensagens que ficaram “presas” em QUEUED.
  • Locks/dedup específicos para evitar alguns duplicados.

Observação importante: “at-least-once” implica que duplicidade é possível se não houver idempotência ponta a ponta (ver “Pontos de falha” e “Sugestões”).

Fluxo detalhado (ponta a ponta)

1) Entrada da API e validações

POST /api/v1/messages/send (apps/fullstack/src/app/api/v1/messages/send/route.ts)

Principais validações/regras:

  • API key: requer header x-api-key e valida em ApiKeyService.validate.
  • LIVE aprovado: se environment === LIVE, exige project.productionApproved.
  • Schema do payload: valida com sendMessageSchema (@pilot-status/shared).
  • Regras de TEST:
    • Em TEST, só permite enviar para números WhatsApp configurados nos perfis do tenant.
  • Template:
    • Resolve versão aprovada mais recente do template (aceita templateId ou nome “friendly”).
    • Em LIVE, impede envio se a versão não estiver aprovada para produção.
    • Retorna erros específicos para: template inexistente, sem versão aprovada, project mismatch, env mismatch etc.
  • Escolha/validação da instância WhatsApp:
    • Se a API key estiver vinculada a whatsappInstanceId, a instância precisa existir e estar OPEN (senão retorna 409).
    • Caso contrário, usa EVOLUTION_INSTANCE_NAME (instância “default”).
    • Se não houver instância configurada, responde 500.
  • Categoria MARKETING exige número próprio:
    • Se estiver usando a instância default (número do Pilot Status), bloqueia templates MARKETING.
  • Opt-in transacional (quando requerido):
    • Verifica autorização do destino via WhatsAppTransactionalOptInService.assertDestinationAuthorized.
    • A validação é relevante em LIVE e na instância default, quando habilitada.
    • Exceção: templates OTP no número Pilot Status podem bypass (no endpoint).
  • Rate limit/limites de plano:
    • RateLimitService.check pode retornar 429.
    • Em LIVE, limites mensais (plano + pacotes) e, no plano FREE, limite diário.
    • Em TEST, há limite diário e exceção para enviar ao número do profile.

Se tudo passar, chama MessageService.send(...) e retorna 202 com id, correlationId, status (normalmente QUEUED) e origin (displayName ou instanceName).

2) Persistência e enqueue do job

MessageService.send em apps/fullstack/src/services/message.service.ts

  • Cria registro message com:
    • status = QUEUED
    • correlationId
    • deliverBy (default: agora, ou deliverAt)
    • deliverUntil (default por categoria):
      • OTP: 2 min
      • UTILITY: 1 hora
      • MARKETING: 4 horas
  • Enfileira BullMQ:
    • Nome do job: send-message
    • jobId = message.id (ajuda a impedir jobs duplicados para a mesma mensagem)
    • attempts = 10
    • backoff fixed = 30s
    • delay quando deliverAt está no futuro
    • priority menor quando urgent

Observação: o nome real da fila é por instância (messages--{instance}), calculado por getQueueNameForWhatsAppInstance em packages/shared/src/constants.ts. Isso significa que tudo que usa a mesma instância WhatsApp compartilha a mesma fila, independente do tenant.

3) Consumo no worker e envio via Evolution

processMessage em apps/worker/src/send-message.ts

Passos principais:

  1. Lock distribuído (Redis) por messageId:
    • SET NX EX 60 com chave lock:ps:message:{messageId}.
    • Se não adquirir, o job é ignorado.
  2. Carrega a mensagem do banco e valida:
    • Se não existe ou status !== QUEUED: ignora.
    • Se deliverUntil expirou: marca FAILED e (se houver webhook de cliente) emite message.failed.
  3. Renderiza conteúdo:
    • Se templateVersion existe: renderiza {{variavel}} no body (incluindo botões).
  4. Simula “digitando…” (best-effort):
    • Usa helper trySimulateWhatsAppTyping (packages/shared/src/whatsapp-typing.ts) para chamar /chat/sendPresence.
  5. Escolhe endpoint Evolution:
    • Texto: /message/sendText/{instance}
    • Botões: /message/sendButtons/{instance}
    • Se encontrar link no texto, ativa linkPreview.
  6. Retry/falhas:
    • Erros são classificados de forma heurística (por substring) em “NotFound” e “Disconnected”.
    • NotFound → marca FAILED, tenta marcar instância CLOSE no banco e dispara message.failed.
    • Erro genérico:
      • Se não é disconnected e é tentativa final: marca FAILED, dispara message.failed e falha o job.
      • Se é disconnected (ou ainda há tentativas): mantém QUEUED e:
        • Se ainda há tentativas: falha o job para BullMQ retry.
        • Se acabou tentativas: não falha o job; mantém QUEUED para o reconciler retentar depois.
  7. Após sucesso no HTTP 2xx da Evolution:
    • Atualiza no banco payload (se não redigir PII) e grava evolutionKeyId/evolutionInstanceId quando disponíveis.
    • Não altera o status para SENT: o sistema “aguarda o webhook SERVER_ACK” para confirmar SENT.

4) Atualização de status via webhook da Evolution

Handler em apps/fullstack/src/app/api/internal/webhook/route.ts

  • Processa messages.update.
  • Mapeia status:
    • SERVER_ACKSENT
    • DELIVERY_ACKDELIVERED
    • READ/PLAYEDREAD
    • ERROR/FAILEDFAILED
  • Resolve a mensagem por:
    1. messageId (se vier compatível com o ID do Pilot Status), senão
    2. evolutionKeyId (+ instanceId quando possível), senão
    3. fallback por evolutionKeyId somente.
  • Aplica regra de progressão de status (não regredir READ → SENT, etc).
  • Emite webhooks do cliente (quando ativo) principalmente para FAILED, DELIVERED, READ (e evita duplicar evento).

Regras relevantes (resumo)

Regras de acesso/ambiente

  • x-api-key obrigatório.
  • LIVE exige aprovação de produção do projeto.
  • Templates em LIVE precisam estar aprovados para produção.

Regras por instância WhatsApp

  • Se API key estiver vinculada a uma instância, ela precisa estar OPEN para permitir enqueue.
  • Templates MARKETING não podem usar o número default (Pilot Status).

Opt-in transacional (quando aplicável)

  • Só é obrigatório quando:
    • validação habilitada (WHATSAPP_OPTIN_VALIDATION_ENABLED ≠ disabled),
    • instância é a default,
    • ambiente é LIVE,
    • existe projectId.
  • Caso contrário, é “skipped” (authorized).

Limites (rate limit)

  • TEST: limite diário com exceção para enviar ao número do profile.
  • LIVE: limite mensal por plano/pacote; no plano FREE pode haver limite diário.

Janela de entrega (deliverBy / deliverUntil)

  • deliverBy: quando a mensagem “pode” ser enviada (gera delay no job).
  • deliverUntil: prazo máximo para tentar entregar; ao expirar, a mensagem é marcada como FAILED.

Mecanismos atuais para “garantir envio”

  • Persistência antes do envio (registro no banco).
  • BullMQ retries: 10 tentativas com backoff fixo (30s).
  • Reconciler (apps/worker/src/message-reconciler.ts):
    • Varre mensagens QUEUED “vencidas” (deliverBy ≤ agora) e:
      • Enfileira se o job não existe.
      • Promove job delayed se já venceu.
      • Se job está completed ou failed, remove e re-enfileira.
    • Marca FAILED se deliverUntil expirou.
    • Marca FAILED por timeout “longo” (default 7 dias) se necessário.
  • Lock Redis por messageId: evita processamento concorrente do mesmo messageId.
  • Dedup especial do número Pilot Status:
    • Evita enviar repetidamente a “mesma última mensagem” para o mesmo número (baseado em hash do texto).

Quando “realmente dá falha” hoje

Falha terminal (vira FAILED) acontece quando:

  • Validações na API falham (nunca entra na fila):
    • API key inválida, produção não aprovada, template inválido/não aprovado, instância não conectada, opt-in negado, rate limit.
  • Expiração por prazo:
    • deliverUntil expirou no worker ou reconciler.
  • Configuração inválida no worker:
    • Evolution não configurada/instância ausente (alguns casos viram FAILED).
  • Instância não encontrada:
    • Worker identifica “not found” e falha definitivamente.
  • Erro “genérico” na Evolution na última tentativa (não classificado como desconexão):
    • Worker marca FAILED e falha o job.
  • Falhas internas inesperadas:
    • Worker marca FAILED no catch geral.

Importante: “falhar no Pilot Status” ≠ “não chegar no WhatsApp”.

  • A mensagem pode ser aceita pela Evolution e ainda assim não ser entregue/visualizada por razões fora do controle (bloqueio, número inválido real, instabilidade WhatsApp, restrições do dispositivo, etc). O sistema depende de webhooks DELIVERY_ACK/READ para confirmar.

Gargalos e pontos de falha (onde uma mensagem pode não chegar)

1) Throughput por instância: concurrency=1 + delay 30–46s

No worker, cada fila/instância roda com concurrency: 1 e ainda impõe um randomDelayMs() (≈ 30–46s) após processar cada job (apps/worker/src/index.ts). Isso:

  • Reduz drasticamente o volume por instância (pode criar backlog grande).
  • Aumenta o risco de deliverUntil expirar, especialmente para OTP (2 min).
  • Faz com que um tenant “barulhento” possa impactar outros tenants que compartilham a mesma instância.

2) Dependência de webhook para sair de QUEUED (risco de reenvio)

Após a Evolution aceitar o envio (HTTP 2xx), o worker não muda o status para SENT; ele “aguarda SERVER_ACK” via webhook.

Se o webhook não chegar (instabilidade, perda, endpoint fora), a mensagem fica em QUEUED. O reconciler, por padrão, roda periodicamente e:

  • se detectar que o job está completed, remove e re-enfileira a mesma mensagem.

Consequência: duplicidade de envio é possível (o sistema pode reenviar uma mensagem que já foi aceita pela Evolution, mas cujo ACK não chegou ao backend).

3) Falta de timeout nas chamadas HTTP (Evolution e webhooks do cliente)

As chamadas fetch(...) não usam timeout explícito. Se houver hang de rede:

  • um job pode ficar preso por tempo indeterminado,
  • o lock Redis (TTL 60s) pode expirar no meio,
  • outro processamento pode ocorrer depois e gerar duplicidade,
  • a fila pode parar de progredir (concurrency=1).

4) Lock Redis com TTL curto (60s)

O lock por mensagem usa TTL fixo de 60s. Com:

  • typing delay,
  • latência alta,
  • e ausência de timeout,

o job pode exceder 60s e o lock expirar, abrindo espaço para duplicidade em cenários de reprocessamento/re-enfileiramento.

5) Heurística frágil para “disconnected”

A classificação de desconexão usa includes(...) em texto do erro. Isso é frágil:

  • pode classificar errado (falso positivo/negativo),
  • pode fazer retry infinito em erro permanente,
  • ou falhar cedo demais em erro transitório.

6) Retenção 0 (PII redacted) pode reduzir capacidade de reconciliação

Quando retentionDays <= 0, o sistema redige destinationNumber e payload no banco.

Isso cria um trade-off:

  • O job original na fila ainda contém destinationNumber/payload, então o envio pode funcionar.
  • Porém, se houver perda do job (Redis restart sem persistência adequada) ou necessidade de reenfileirar via reconciler, o reconciler pode não conseguir reconstruir o job (e marcar FAILED por “Destino/Payload não disponível”).

7) Rate limit/contabilização depende de sentAt (ACK)

O rate limit contabiliza mensagens por sentAt (que é setado no webhook SERVER_ACK). Se webhooks atrasarem/falharem:

  • mensagens enviadas podem não entrar na contagem,
  • pode haver envio acima do esperado,
  • e pode distorcer billing/limites/alertas.

Sugestões para melhorar resiliência (priorizado)

A) Evitar duplicidade por falta de ACK (alta prioridade)

  1. Introduzir estado intermediário “DESPACHADA/ACEITA” (ex.: DISPATCHED ou SENT_PENDING_ACK)

    • Ao receber HTTP 2xx da Evolution, atualizar message.status para um estado intermediário e registrar dispatchedAt, dispatchAttempt.
    • O webhook SERVER_ACK promoveria para SENT.
    • O reconciler deve ignorar mensagens nesse estado por um “grace period” (ex.: 15–60 min) antes de reenfileirar.
  2. Ajustar o reconciler para considerar “prova de despacho”

    • Se evolutionKeyId já está setado, tratar como “provavelmente já enviada” e não reenfileirar agressivamente.
  3. Se a Evolution suportar idempotência, enviar um idempotency key

    • Ex.: enviar messageId como identificador na request (se houver campo suportado).
    • Assim, retries/reconciler não causariam duplicidade real no WhatsApp.

B) Timeouts e cancelamento de chamadas HTTP (alta prioridade)

  • Adicionar timeout com AbortController em:
    • chamadas para Evolution (sendText/sendButtons, sendPresence, connectionState),
    • webhooks do cliente.
  • Definir timeouts diferentes:
    • typing/presence: curto (2–5s),
    • envio: médio (10–20s),
    • webhook do cliente: curto-médio (5–10s).

C) Retry mais inteligente (alta prioridade)

  1. Basear retry em status code e tipo de erro (não em substring)

    • 5xx/timeout/429: retry com backoff.
    • 401/403 (apikey inválida), 400 (payload inválido): falha terminal imediata.
    • 404 instância inexistente: falha terminal + sinalizar instância.
  2. Usar backoff exponencial com jitter

    • Evita thundering herd e melhora recuperação em incidentes.
  3. Definir um “budget” de tentativas por janela (deliverUntil)

    • Ex.: para OTP, tentar mais vezes no começo (rápido), e desistir cedo.

D) Melhorar lock/idempotência no worker (média prioridade)

  • Aumentar TTL do lock (ou renovar lock durante processamento).
  • Registrar lastAttemptAt e attemptCount no banco para ajudar reconciliação e observabilidade.
  • Considerar usar mecanismos de lock do próprio BullMQ (ou Redlock), evitando TTL curto “hardcoded”.

E) Throughput e qualidade de serviço por instância (média prioridade)

  • Trocar randomDelayMs() fixo por um “rate limiter” por instância:
    • token bucket/leaky bucket,
    • configurável por ambiente/categoria (OTP vs marketing),
    • com prioridade real (OTP > utility > marketing).
  • Considerar concurrency > 1 em instâncias “próprias” do cliente (não a default), mantendo limites para evitar bloqueios do WhatsApp.
  • Garantir fairness entre tenants que compartilham instância (ex.: prioridades/quotas).

F) Resiliência com retenção 0 (trade-off PII vs confiabilidade)

Se o objetivo for “tentar ao máximo” mesmo com retenção 0:

  • Criar um armazenamento transitório e seguro para destinationNumber/payload:
    • tabela “outbox” com criptografia e TTL curto,
    • ou Redis com persistência (AOF) + criptografia no payload,
    • limpando assim que SENT/DELIVERED ocorrer.

G) Observabilidade e operação (média prioridade)

  • Métricas:
    • latência queued → dispatched → sentAck → delivered/read,
    • profundidade de fila por instância,
    • taxa de retries e erros por status code,
    • volume de QUEUED acima de X minutos.
  • Alertas:
    • webhook da Evolution sem eventos por N minutos,
    • reconciler reenfileirando muitas mensagens repetidas,
    • instância com muitos “disconnected” seguidos.
  • Ferramentas operacionais:
    • reprocessar manualmente uma mensagem,
    • “pause sending” por instância,
    • DLQ (dead letter queue) explícita para análise.

Conclusão

O fluxo atual já possui pilares importantes de resiliência (persistência + fila + retries + reconciler). O principal risco para “sempre tentar enviar ao máximo” sem efeitos colaterais é a dependência do webhook para tirar a mensagem de QUEUED, que pode levar a reenvios duplicados quando o ACK não chega. Ao introduzir um estado intermediário de “despachada”, adicionar timeouts e melhorar a estratégia de retry, o sistema fica significativamente mais robusto e previsível, mantendo a capacidade de retentar agressivamente sem gerar duplicidades indesejadas.