chatwoot-develop/progresso/whatsapp_media_wuzapi_documentation.md

515 lines
16 KiB
Markdown

# Documentação Técnica: WhatsApp Media (WuzAPI) no Chatwoot
> **Data de Criação:** 2026-01-24
> **Última Atualização:** 2026-01-24
> **Status:** ✅ FUNCIONANDO
> **Autor:** Rodrigo + Antigravity AI
---
## 📋 Índice
1. [Visão Geral](#visão-geral)
2. [Arquitetura do Fluxo](#arquitetura-do-fluxo)
3. [Arquivos Críticos](#arquivos-críticos)
4. [Fluxo Detalhado](#fluxo-detalhado)
5. [Algoritmo de Decriptografia](#algoritmo-de-decriptografia)
6. [Sanitização do Payload](#sanitização-do-payload)
7. [Troubleshooting](#troubleshooting)
8. [Checklist de Restauração](#checklist-de-restauração)
---
## Visão Geral
### O Problema Original
O WhatsApp (via WuzAPI/whatsmeow) envia mídia **CRIPTOGRAFADA**. Diferente da Cloud API oficial da Meta que já entrega arquivos descriptografados, o WuzAPI entrega:
1. **URL criptografada** (`mmg.whatsapp.net/...`) - arquivo AES-256 criptografado
2. **mediaKey** - chave em base64 para derivar as chaves de decriptografia
3. **JPEGThumbnail** - preview em baixa resolução (binário, causa problemas de serialização)
### A Solução Implementada
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ WhatsApp User │───▶│ WuzAPI Server │───▶│ Chatwoot Webhook │
│ envia imagem │ │ (whatsmeow) │ │ (Controller) │
└─────────────────┘ └──────────────────┘ └──────────┬──────────┘
┌───────────────▼───────────────┐
│ 1. SANITIZA PAYLOAD │
│ (Remove binários do JSON) │
└───────────────┬───────────────┘
┌───────────────▼───────────────┐
│ 2. SIDEKIQ JOB │
│ (WhatsappEventsJob) │
└───────────────┬───────────────┘
┌───────────────▼───────────────┐
│ 3. DECRIPTOGRAFIA │
│ WuzAPI endpoint OU local │
└───────────────┬───────────────┘
┌───────────────▼───────────────┐
│ 4. ACTIVE STORAGE │
│ (Salva arquivo descriptog.) │
└───────────────┬───────────────┘
┌───────────────▼───────────────┐
│ 5. FRONTEND │
│ (Exibe thumbnail + full) │
└───────────────────────────────┘
```
---
## Arquivos Críticos
| Arquivo | Função |
| ---------------------------------------------------------- | -------------------------------------------------------- |
| `app/controllers/webhooks/whatsapp_controller.rb` | Recebe webhook, SANITIZA payload, enfileira job |
| `app/jobs/webhooks/whatsapp_events_job.rb` | Processa eventos, roteia para service correto |
| `app/services/whatsapp/incoming_message_wuzapi_service.rb` | Cria mensagem, baixa/descriptografa mídia, anexa arquivo |
| `app/services/whatsapp/decryption_service.rb` | Algoritmo de decriptografia WhatsApp (HKDF + AES) |
| `app/services/whatsapp/providers/wuzapi/payload_parser.rb` | Extrai dados do payload WuzAPI |
| `lib/wuzapi/client.rb` | Cliente HTTP para WuzAPI (`/chat/downloadimage`) |
---
## Fluxo Detalhado
### FASE 1: Webhook Controller (Sanitização)
**Arquivo:** `app/controllers/webhooks/whatsapp_controller.rb`
```ruby
def process_payload
# CRÍTICO: Remove RawMessage IMEDIATAMENTE
# Isso DEVE ser a primeira coisa, antes de qualquer log
params[:event]&.delete('RawMessage')
params.dig(:event, 'Message')&.delete('RawMessage')
# ... resto do código
# WHITELIST sanitization antes de enfileirar
sanitized_payload = sanitize_payload_for_sidekiq(params.to_unsafe_hash)
Webhooks::WhatsappEventsJob.perform_later(sanitized_payload)
end
def sanitize_payload_for_sidekiq(raw_payload)
# USA WHITELIST - só campos permitidos passam
# NUNCA deixar passar: JPEGThumbnail, RawMessage, scansSidecar, etc.
end
```
> ⚠️ **CRÍTICO:** Se o `RawMessage` ou `JPEGThumbnail` chegarem ao Sidekiq, você verá o erro:
>
> ```
> JSON.generate: UTF-8 string passed as BINARY
> ```
>
> Isso acontece porque Sidekiq serializa argumentos em JSON, e dados binários não são UTF-8 válido.
### FASE 2: Sidekiq Job
**Arquivo:** `app/jobs/webhooks/whatsapp_events_job.rb`
```ruby
def perform(params)
# Identifica canal e roteia para service correto
if channel.provider == 'wuzapi'
Whatsapp::IncomingMessageWuzapiService.new(...)
else
Whatsapp::IncomingMessageService.new(...) # Cloud API
end
end
```
### FASE 3: Service de Mensagem
**Arquivo:** `app/services/whatsapp/incoming_message_wuzapi_service.rb`
```ruby
def perform
# 1. Parse do payload
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(@params)
# 2. Ignora tipos não-mensagem (ReadReceipt, etc)
return if parser.message_type == :ignore
# 3. Encontra/cria contato e conversa
set_contact
set_conversation
# 4. Cria mensagem (sem salvar ainda)
@message = build_message(parser)
# 5. Anexa arquivos se houver
attach_files(parser) if parser.attachment_params.present?
# 6. Salva tudo
@message.save!
end
```
### FASE 4: Download/Decriptografia
**Método:** `download_or_decrypt_media` em `incoming_message_wuzapi_service.rb`
```ruby
def download_or_decrypt_media(attachment_data, message_type)
media_url = attachment_data[:external_url]
# MÉTODO 1: WuzAPI endpoint (retorna mídia JÁ DESCRIPTOGRAFADA)
begin
wuzapi_response = wuzapi_client.download_media(wuzapi_token, media_url)
if wuzapi_response['data'].present?
decoded = Base64.decode64(wuzapi_response['data'])
return StringIO.new(decoded) if decoded.bytesize > 1000
end
rescue => e
Rails.logger.warn "WuzAPI endpoint failed: #{e.message}"
end
# MÉTODO 2: Decriptografia local
if attachment_data[:media_key].present?
decrypted = Whatsapp::DecryptionService.new(
media_url,
attachment_data[:media_key],
message_type
).decrypt
return decrypted if decrypted
end
# MÉTODO 3: Download direto (funciona para URLs não-criptografadas)
Down.download(media_url, ...)
end
```
---
## Algoritmo de Decriptografia
**Arquivo:** `app/services/whatsapp/decryption_service.rb`
### Protocolo WhatsApp de Mídia
1. **Mídia é criptografada** com AES-256-CBC (sem padding)
2. **Chave derivada via HKDF** a partir do `mediaKey`
3. **Últimos 10 bytes** são MAC (removidos antes de decriptar)
### Info Strings (HKDF)
```ruby
INFO_STRINGS = {
image: 'WhatsApp Image Keys',
video: 'WhatsApp Video Keys',
audio: 'WhatsApp Audio Keys',
document: 'WhatsApp Document Keys',
sticker: 'WhatsApp Image Keys'
}
```
### Derivação de Chaves
```ruby
# Input: mediaKey (32 bytes após decode base64)
# Output: 112 bytes expandidos
expanded_key = OpenSSL::KDF.hkdf(
@media_key,
salt: ''.b, # Salt vazio (IMPORTANTE: binário, não string)
info: @info, # Ex: 'WhatsApp Image Keys'
length: 112,
hash: 'sha256'
)
# Divisão dos 112 bytes:
iv = expanded_key[0...16] # 16 bytes - Vetor de inicialização
cipher_key = expanded_key[16...48] # 32 bytes - Chave AES-256
mac_key = expanded_key[48...80] # 32 bytes - Chave HMAC (opcional)
ref_key = expanded_key[80...112] # 32 bytes - Não usado
```
### Decriptografia
```ruby
# Remove últimos 10 bytes (MAC)
cipher_text = encrypted_bytes[0...-10]
# AES-256-CBC sem padding
decipher = OpenSSL::Cipher.new('AES-256-CBC')
decipher.decrypt
decipher.key = cipher_key
decipher.iv = iv
decipher.padding = 0 # CRÍTICO: WhatsApp não usa PKCS7
decrypted = decipher.update(cipher_text) + decipher.final
```
### Validação de Magic Bytes
Após decriptografia, valida que o arquivo é mídia válida:
```ruby
# JPEG: FF D8 FF
# PNG: 89 50 4E 47
# WebP: RIFF....WEBP
# MP4: ....ftyp
# MP3: ID3 ou FF FB
```
---
## Sanitização do Payload
### Campos que DEVEM ser removidos (NUNCA no JSON)
| Campo | Localização | Motivo |
| ------------------ | ---------------------------------------------- | --------------- |
| `RawMessage` | `event.RawMessage`, `event.Message.RawMessage` | Binário gigante |
| `JPEGThumbnail` | `event.Message.imageMessage.JPEGThumbnail` | Binário base64 |
| `scansSidecar` | `event.Message.imageMessage.scansSidecar` | Binário |
| `firstScanSidecar` | `event.Message.imageMessage.firstScanSidecar` | Binário |
| `streamingSidecar` | `event.Message.videoMessage.streamingSidecar` | Binário |
### Abordagem WHITELIST (Recomendada)
```ruby
def sanitize_payload_for_sidekiq(raw_payload)
# Constrói novo payload apenas com campos permitidos
{
'event' => {
'Type' => raw_payload.dig('event', 'Type'),
'Timestamp' => raw_payload.dig('event', 'Timestamp'),
'Message' => sanitize_message(raw_payload.dig('event', 'Message')),
# ... outros campos safe
}
}
end
def sanitize_message(msg)
return nil if msg.blank?
{
'ID' => msg['ID'],
'Timestamp' => msg['Timestamp'],
'PushName' => msg['PushName'],
# Para mídia, incluir APENAS metadados:
'imageMessage' => sanitize_media_message(msg['imageMessage']),
# NUNCA incluir: JPEGThumbnail, RawMessage, etc.
}
end
def sanitize_media_message(media)
return nil if media.blank?
{
'url' => media['url'],
'directPath' => media['directPath'],
'mediaKey' => media['mediaKey'],
'mimetype' => media['mimetype'],
'fileEncSha256' => media['fileEncSha256'],
'fileSha256' => media['fileSha256'],
'fileLength' => media['fileLength'],
# BLOQUEADOS: JPEGThumbnail, scansSidecar, etc.
}
end
```
---
## Troubleshooting
### Erro: `JSON.generate: UTF-8 string passed as BINARY`
**Causa:** Dados binários (JPEGThumbnail, RawMessage) chegando no Sidekiq.
**Solução:**
1. Verificar se `process_payload` remove `RawMessage` no início
2. Verificar se `sanitize_payload_for_sidekiq` usa WHITELIST
3. Verificar se não há `params.inspect` ou `.to_json` antes da sanitização
**Debug:**
```ruby
# No controller, ANTES de enfileirar:
Rails.logger.info "Payload keys: #{params[:event]&.keys}"
# Se aparecer 'RawMessage' ou 'JPEGThumbnail', a sanitização falhou
```
### Erro: `VipsForeignLoad: not a known file format`
**Causa:** Arquivo ainda está criptografado quando chega no ActiveStorage.
**Solução:**
1. Verificar se `mediaKey` está chegando no `attachment_params`
2. Verificar se `DecryptionService` está sendo chamado
3. Verificar se a decriptografia retorna dados válidos (magic bytes)
**Debug:**
```ruby
# Em download_or_decrypt_media:
Rails.logger.info "mediaKey present: #{attachment_data[:media_key].present?}"
Rails.logger.info "Decrypted size: #{decrypted&.size}"
```
### Erro: `WuzAPI endpoint failed - 502 Bad Gateway`
**Causa:** Servidor WuzAPI não conseguiu baixar mídia do WhatsApp.
**Solução:**
1. É esperado que falhe ocasionalmente - o fallback local funciona
2. Se falhar sempre, verificar conexão do WuzAPI com WhatsApp
3. A decriptografia local é o backup
### Imagem não aparece no frontend
**Verificar:**
1. Mensagem foi criada? (`Message.last`)
2. Attachment foi criado? (`Attachment.last`)
3. Blob existe? (`ActiveStorage::Blob.last`)
4. URLs estão populadas? (`Attachment.last.file_url`, `Attachment.last.thumb_url`)
```ruby
# Console debug:
a = Attachment.last
puts "Blob: #{a.file.blob.id}"
puts "Size: #{a.file.blob.byte_size}"
puts "Content-Type: #{a.file.blob.content_type}"
puts "URL: #{a.file_url}"
```
---
## Checklist de Restauração
Se o sistema quebrar, siga esta lista na ordem:
### 1. Verificar Controller (Sanitização)
```bash
# Abrir arquivo:
code app/controllers/webhooks/whatsapp_controller.rb
# Verificar:
# - Linha 1-10 de process_payload DEVE ter delete('RawMessage')
# - sanitize_payload_for_sidekiq DEVE existir e usar WHITELIST
# - perform_later DEVE receber sanitized_payload, NÃO params.to_unsafe_hash
```
### 2. Verificar Service (Download)
```bash
# Abrir arquivo:
code app/services/whatsapp/incoming_message_wuzapi_service.rb
# Verificar método download_or_decrypt_media:
# - Tenta WuzAPI endpoint primeiro
# - Fallback para DecryptionService
# - Fallback para download direto
```
### 3. Verificar DecryptionService
```bash
# Abrir arquivo:
code app/services/whatsapp/decryption_service.rb
# Verificar:
# - INFO_STRINGS corretos
# - HKDF com salt: ''.b (binário vazio)
# - AES-256-CBC com padding = 0
# - Remove últimos 10 bytes antes de decriptar
# - Validação de magic bytes
```
### 4. Verificar PayloadParser
```bash
# Abrir arquivo:
code app/services/whatsapp/providers/wuzapi/payload_parser.rb
# Verificar attachment_params inclui:
# - :external_url
# - :media_key
# - :mimetype
# - :file_name
```
### 5. Verificar Wuzapi Client
```bash
# Abrir arquivo:
code lib/wuzapi/client.rb
# Verificar método download_media:
# - POST /chat/downloadimage
# - Payload: { 'URL' => media_url }
```
### 6. Teste Manual
```bash
# Enviar imagem pelo WhatsApp e verificar logs:
tail -f log/sidekiq.log | grep -E "WuzAPI|Decrypt"
# Deve aparecer:
# - "WuzAPI: Processing attachment"
# - "WuzAPI: Attempting download via WuzAPI endpoint..."
# - OU "WuzAPI Decrypt: SUCCESS - Valid media detected"
# - "WuzAPI: Attachment queued for save"
# - "WuzAPI: Message created"
```
### 7. Verificar no Console
```ruby
# Rails console:
rails c
# Verificar última mensagem com imagem:
m = Message.where.not(attachments: { id: nil }).last
puts "Message ID: #{m.id}"
puts "Attachments: #{m.attachments.count}"
a = m.attachments.first
puts "Blob size: #{a.file.blob.byte_size}"
puts "Content-type: #{a.file.blob.content_type}"
puts "URL ok: #{a.file_url.present?}"
```
---
## Referências Técnicas
- **WhatsApp Media Encryption:** https://github.com/nicbarker/swift-whatsapp-media-decryptor
- **HKDF RFC 5869:** https://tools.ietf.org/html/rfc5869
- **WuzAPI (whatsmeow):** https://github.com/tulir/whatsmeow
- **ActiveStorage Rails:** https://guides.rubyonrails.org/active_storage_overview.html
---
## Contato para Suporte
Se após seguir este documento o problema persistir:
1. Verificar versão do WuzAPI (pode ter mudado protocolo)
2. Verificar se WhatsApp atualizou algoritmo de criptografia
3. Verificar logs detalhados: `tail -f log/sidekiq.log`
4. Buscar por issues no repo do whatsmeow
---
> **Nota:** Este documento foi criado após resolver um problema que levou várias horas de debug.
> A causa raiz era dupla:
>
> 1. **Serialização JSON de binários** no Sidekiq
> 2. **Algoritmo de decriptografia incorreto** (usava padding, deveria ser sem padding)