chatwoot-develop/progresso/whatsapp_media_wuzapi_documentation.md

16 KiB

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
  2. Arquitetura do Fluxo
  3. Arquivos Críticos
  4. Fluxo Detalhado
  5. Algoritmo de Decriptografia
  6. Sanitização do Payload
  7. Troubleshooting
  8. 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

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

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

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

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)

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

# 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

# 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:

# 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)

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:

# 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:

# 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)
# 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)

# 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)

# 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

# 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

# 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

# Abrir arquivo:
code lib/wuzapi/client.rb

# Verificar método download_media:
# - POST /chat/downloadimage
# - Payload: { 'URL' => media_url }

6. Teste Manual

# 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

# 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


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)