Pular para conteúdo

WhatsApp Cloud API

Papel e localização

A integração recebe mensagens da Cloud API oficial da Meta e envia respostas de texto livre. A entrada adapta o webhook para os contratos internos; a saída implementa WhatsAppClient.send_message(*, to: PhoneNumber, text: str) -> StubResponse.

Arquivo Responsabilidade
src/webhook_handler/app.py GET de verificação, HMAC, lote inbound, caminhos síncronos e dispatch SQS
src/core/integrations/whatsapp/adapter.py WhatsAppCloudInboundMessage, extract_inbound_messages, normalização
src/core/integrations/whatsapp/cloud.py WhatsAppCloudConfig, WhatsAppCloudClient, request HTTP de saída
src/core/integrations/whatsapp/interface.py WhatsAppClient
src/core/integrations/whatsapp/stub.py WhatsAppClientStub, ID mock_wa_msg
src/core/integrations/factory.py build_whatsapp_client()
src/orchestrator/app.py Entrega/reentrega da resposta persistida

Consumidores de saída incluem webhook, orchestrator, atendimento humano e notificação de operadores. O client não altera estado de sessão; quem chama decide o que persistir e como reagir à falha.

Autenticação e verificação do webhook

O endpoint SAM é /webhook. Para ativação na Meta:

GET /webhook?hub.mode=subscribe&hub.verify_token=<VERIFY_TOKEN>&hub.challenge=desafio-ficticio

_handle_meta_verification() compara o token com WHATSAPP_VERIFY_TOKEN usando hmac.compare_digest. Sucesso responde 200, Content-Type: text/plain, corpo desafio-ficticio; token ausente/incorreto ou modo diferente responde 403 {"error":"verification_failed"}. Esse GET não precisa carregar todas as settings/OpenAI.

Com Cloud habilitado, POST Meta exige X-Hub-Signature-256: sha256=<HMAC> calculado sobre os bytes originais do corpo, com WHATSAPP_APP_SECRET. O código aceita body base64 de API Gateway, normaliza o nome do header e usa comparação constante. Assinatura inválida produz 401 {"error":"invalid_signature"} antes de persistir o inbound.

O formato interno {phone_number, content} do emulador recebe 403 em Cloud mode, evitando que uma entrada sem assinatura alcance comandos de operador. Com Cloud desabilitado, a validação de assinatura não é exigida; mantenha esse modo restrito ao desenvolvimento controlado.

Request inbound e transformação

Exemplo estrutural sanitizado; os identificadores em <...> são placeholders, não valores para enviar ao provider:

{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "<WABA_ID>",
    "changes": [{
      "field": "messages",
      "value": {
        "metadata": {"phone_number_id": "<PHONE_NUMBER_ID>"},
        "messages": [{
          "from": "5500000000000",
          "id": "wamid.EXEMPLO",
          "timestamp": "1789383600",
          "type": "text",
          "text": {"body": "Quero informações sobre um serviço."}
        }]
      }
    }]
  }]
}

O número acima é fictício e não deve ser usado como destino de envio. O adapter percorre todas as mensagens de todas as entradas/changes, remove caracteres não numéricos de from, acrescenta +, preserva id como provider_message_id e converte o timestamp Unix para UTC. Timestamp inválido/ausente usa o horário UTC atual. metadata.phone_number_id é transportado; a instância default no adapter é gogenetic, sem roteamento por WABA para goyou.

{
  "phone_number": "+5500000000000",
  "content": "Quero informações sobre um serviço.",
  "message_type": "text",
  "instance": "gogenetic",
  "provider_message_id": "wamid.EXEMPLO",
  "received_at": "2026-09-14T11:00:00+00:00",
  "phone_number_id": "<PHONE_NUMBER_ID>"
}

O webhook registra o lote inteiro no DynamoDB antes de despachar qualquer mensagem, permitindo que latest-message-wins observe a mensagem mais recente do mesmo lote. O SQS transporta somente phone_number e message_id; o conteúdo durável está no receipt. Status de entrega/leitura sem mensagem são ignorados com 200 {"status":"ignored"}. Entrada JSON inválida ou campos obrigatórios ausentes retorna 400.

Mídia e respostas síncronas

Tipos conhecidos: text, image, audio, video, document, sticker; tipo desconhecido é classificado como document. Texto usa text.body; mídia usa caption, quando existe, ou marcador [audio], [image] etc. Não há download, transcrição nem interpretação multimodal nesta integração. src/core/application/non_text_intercept.py decide a interceptação e resposta fixa; não envie o marcador ao LLM como se o arquivo tivesse sido compreendido.

Comandos de operador, debug, mídia e mensagens em atendimento humano podem ser tratados diretamente no webhook. Veja fluxo da mensagem e operadores.

Request outbound e response

POST https://graph.facebook.com/<VERSION>/<PHONE_NUMBER_ID>/messages
Authorization: Bearer <TOKEN>
Content-Type: application/json
{
  "messaging_product": "whatsapp",
  "recipient_type": "individual",
  "to": "5500000000000",
  "type": "text",
  "text": {"preview_url": false, "body": "Como podemos ajudar?"}
}

O número de saída fica só com dígitos. Texto vazio é sucesso local com detail="empty_message_skipped", sem request. Destino sem nenhum dígito lança ValueError antes do loop de transporte. Uma resposta fictícia {"messages":[{"id":"wamid.RESPOSTA_EXEMPLO"}]} vira StubResponse(success=True, mock_id="wamid.RESPOSTA_EXEMPLO"); JSON válido sem ID também é aceito como sucesso, com ID nulo.

Falha HTTP 401, por exemplo, retorna StubResponse(success=False, detail="whatsapp_http_401"). Os adapters não repassam o corpo de erro da Meta ao cliente final. Não há templates, campanhas ou APIs de mídia de saída.

Timeouts, retries e idempotência

Camada Comportamento efetivo
Client HTTP Timeout default 10 s por chamada; até 2 retries para HTTP 429 ou 5xx, esperas 1 s e 2 s (min(2**attempt, 8))
Rede/JSON OSError, URLError ou JSON inválido retornam falha imediatamente; não entram no retry HTTP
Configuração de retry WhatsAppCloudConfig.retry_max=2; não existe WHATSAPP_RETRY_MAX em settings/factory
Inbound Receipts e provider_message_id; SQS dedupe por SHA-256 do ID e grupo por telefone
Outbound no orchestrator Reutiliza resposta persistida na falha e solicita retry parcial do item SQS, evitando nova chamada LLM para a mesma resposta

Isso não garante exactly-once no provider: se a Meta aceitar o POST e a resposta se perder, a aplicação pode reenviar. Não há Idempotency-Key outbound nem confirmação por status callback. WHATSAPP_FREE_ONLY=true é uma restrição de implementação; o client não mede a janela de atendimento para cada envio e não prova gratuidade. A renotificação do cliente tem uma checagem própria de 24 h, descrita em handoff.

Configuração, logs e segurança

WHATSAPP_CLOUD_ENABLED=false escolhe stub. Habilitar exige WHATSAPP_ACCESS_TOKEN, WHATSAPP_APP_SECRET, WHATSAPP_VERIFY_TOKEN e WHATSAPP_PHONE_NUMBER_ID. WHATSAPP_BUSINESS_ACCOUNT_ID é carregado, mas não compõe a URL de envio. Versão default WHATSAPP_GRAPH_API_VERSION=v23.0; timeout em WHATSAPP_SEND_TIMEOUT_SECONDS. WHATSAPP_FREE_ONLY=false é rejeitado com Paid WhatsApp modes are not implemented. Veja variáveis.

Eventos úteis: webhook_received, whatsapp_signature_invalid, internal_payload_rejected_in_cloud_mode, whatsapp_cloud_send_success, whatsapp_cloud_send_http_failed, whatsapp_cloud_send_failed, whatsapp_send_failed, sync_whatsapp_reply_failed. Os logs de transporte guardam tamanho do texto e status, não token nem corpo. O logger não é um anonimizado universal de telefones; use request_id, session_id e ID fictício/sanitizado ao compartilhar diagnóstico.

Testes e migração

Testes locais: tests/unit/integrations/test_whatsapp_adapter.py, test_whatsapp_cloud_client.py, test_factory.py, tests/unit/lambdas/test_webhook_handler.py e test_orchestrator.py. Cobrem lote, assinatura, bloqueio do formato interno, transporte, retries e reentrega. Execute um arquivo sem rede:

python -m pytest tests/unit/integrations/test_whatsapp_cloud_client.py -q

scripts/smoke.py --target whatsapp usa o runner em src/core/application/smokes/whatsapp_smoke.py; envio exige SMOKE_ALLOW_REAL_SEND e destino configurado. Consulte as restrições do smoke antes de executar.

Para trocar provider, implemente WhatsAppClient e ajuste build_whatsapp_client; preserve StubResponse e os testes de falha. Se mudar a entrada, adicione outro adapter e mantenha verificação de origem, IDs estáveis, normalização de telefone, registro integral do lote e receipts. Trocar somente o client de saída não migra o webhook Meta.