- Bug 4: ngrok interstitial (URL absoluta → relativa com rails_storage_proxy_path) - Bug 5: refactoring removeu text_content e attachment_params do PayloadParser - Bug 6: content-type audio/opus → audio/ogg - Seção de diagnóstico rápido com tabela de interpretação - Checklist expandido com comandos Rails runner prontos para usar - Notas sobre perigos de refactoring nos contratos públicos do PayloadParser Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
15 KiB
Troubleshooting: Áudio e Imagem não aparecem no Chatwoot (WhatsApp via WuzAPI)
Data: 2026-02-28 Branch: fix-media-audio-v2 Canal: WhatsApp via WuzAPI
Sintomas
- Áudio exibe
00:00 / 00:00e não toca - Imagem exibe "Esta imagem não está mais disponível"
- Mensagens de texto chegam normalmente
- Transcrição do áudio aparece corretamente, mas o áudio não toca
⚡ Diagnóstico Rápido (comece aqui)
Antes de ler tudo, rode esses comandos na ordem. O primeiro que falhar identifica o bug.
# 1. Arquivo chegou? Descriptografia OK?
grep -E "WuzAPI Decrypt|SUCCESS|invalid format" log/development.log | tail -5
# 2. Arquivo está no disco?
bin/rails runner "a = Attachment.last; puts a.file.blob.byte_size; puts File.binread(ActiveStorage::Blob.service.path_for(a.file.blob.key), 4).bytes.map{|b| '%02X'%b}.join(' ')"
# 3. URL que o browser recebe é relativa ou absoluta?
grep "data_url" log/development.log | tail -3
Interpretação:
| Resultado | Bug |
|---|---|
Sem WuzAPI Decrypt: nos logs |
Bug 5 (PayloadParser sem attachment_params) |
WuzAPI Decrypt Error: NoMethodError |
Bug 3 (update! em vez de update) |
Bytes: 31 88 d2 ou similares (não FF D8, 4F 67, 89 50) |
Bug 2 (arquivo encriptado) |
URL absoluta https://ngrok-url/... |
Bug 4 (ngrok interstitial — ver abaixo) |
URL relativa /rails/active_storage/... e bytes válidos |
Bug de infra (Sidekiq parado, ngrok caído) |
Diagnóstico — Root Causes
Havia seis bugs independentes documentados ao longo do tempo. Os primeiros três são históricos. Os outros três surgiram com refatorações.
Bug 1 — URL de mídia apontando para localhost hardcoded (histórico)
Arquivo: app/models/attachment.rb
Problema:
file_url e thumb_url geravam URLs hardcoded com localhost:3000. O browser acessando via ngrok não consegue resolver localhost:3000 diretamente.
# QUEBRADO:
rails_storage_proxy_url(file, host: 'localhost:3000', protocol: 'http')
Resolvido em: commit 6b214b38d — passou a usar dev_url_options que lê FRONTEND_URL.
Bug 2 — Arquivo de mídia salvo criptografado (histórico)
Arquivo: app/services/whatsapp/incoming_message_wuzapi_service.rb + decryption_service.rb
Problema: O fluxo de download tinha 3 métodos em cascata:
| Método | O que faz | Status |
|---|---|---|
| Method 1 | WuzAPI /chat/downloadimage endpoint |
❌ Sempre falhava com 502 Bad Gateway |
| Method 2 | DecryptionService com Net::HTTP |
❌ Net::HTTP não segue redirects → nil silencioso |
| Method 3 | Down.download direto do CDN |
⚠️ Baixava mas salvava bytes encriptados |
O arquivo no disco começava com bytes aleatórios (31 88 d2...) em vez de FF D8 FF (JPEG) ou 4F 67 67 53 (OGG).
Correção: Unificar Methods 2+3: Down.download (que segue redirects) → descriptografar → salvar.
Resolvido em: commit 6b214b38d
Bug 3 — OpenSSL::Cipher#update! não existe em Ruby (histórico)
Arquivo: app/services/whatsapp/decryption_service.rb
# QUEBRADO:
decipher.update!(data) + decipher.final
# CORRETO:
decipher.update(data) + decipher.final
Resolvido em: commit 6b214b38d
Bug 4 — ngrok interstitial bloqueia mídia no browser (recorrente — leia com atenção)
Arquivo: app/models/attachment.rb
Problema: Mesmo com o Bug 1 corrigido, a URL da mídia era gerada como absoluta com o host do ngrok:
https://SEU-NGROK.ngrok-free.dev/rails/active_storage/blobs/proxy/.../file.ogg
O ngrok exibe uma página HTML de aviso para qualquer request de browser sem o cookie ngrok válido. Quando <img src="ngrok-url"> ou <audio src="ngrok-url"> é carregado:
- O browser não tem o cookie ngrok (acessando Chatwoot via
http://localhost:3000) - O ngrok retorna HTML de interstitial em vez do arquivo
- O browser tenta parsear HTML como imagem/áudio → falha silenciosamente
- Imagem:
@error→hasError = true→ "Esta imagem não está mais disponível" - Áudio: nenhum dado decodificável →
loadedmetadatanunca dispara →duration = 0→ "00:00 / 00:00"
Diagnóstico:
# Via curl (não usa User-Agent de browser → bypassa interstitial):
curl -s -w "HTTP: %{http_code} Size: %{size_download}\n" -o /dev/null "URL_DO_ARQUIVO"
# → HTTP: 200 Size: 5968 (arquivo chegou via curl!)
# Mas no browser a mídia não carrega → é o interstitial do ngrok
Por que curl funciona mas browser não?
O ngrok detecta browser pelo User-Agent header. Curl não envia User-Agent de browser, então recebe o arquivo direto. O browser envia User-Agent: Mozilla/5.0... e recebe a página de aviso.
Correção: Usar URL relativa em vez de absoluta. O browser resolve contra o servidor atual — sem passar pelo ngrok:
# ANTES (absoluta com ngrok → interstitial):
rails_storage_proxy_url(file, **dev_url_options)
# → https://seu-ngrok.ngrok-free.dev/rails/active_storage/blobs/proxy/TOKEN/file.ogg
# DEPOIS (relativa → carrega diretamente do servidor):
rails_storage_proxy_path(file)
# → /rails/active_storage/blobs/proxy/TOKEN/file.ogg
Aplicar em file_url e thumb_url:
def file_url
return '' unless file.attached?
if Rails.env.development?
rails_storage_proxy_path(file) # ← relativo, sem host
else
url_for(file)
end
end
def thumb_url
return '' unless file.attached? && image?
begin
representation = file.representation(resize_to_fill: [250, nil])
if Rails.env.development?
rails_storage_proxy_path(representation) # ← relativo, sem host
else
url_for(representation)
end
rescue ActiveStorage::UnrepresentableError => e
Rails.logger.warn "Unrepresentable image: #{id} - #{e.message}"
''
end
end
Por que funciona:
- Browser em
http://localhost:3000→/rails/...→ resolve parahttp://localhost:3000/rails/...→ Rails serve direto - Browser em
https://ngrok-url/→/rails/...→ resolve parahttps://ngrok-url/rails/...→ ngrok já tem cookie → funciona
Resolvido em: commit cfa2dc71b
Bug 5 — Refactoring removeu métodos críticos do PayloadParser (recorrente)
Arquivo: app/services/whatsapp/providers/wuzapi/payload_parser.rb
Problema:
O commit de refactoring c48047ba5 ("modulariza processamento de mídias e payloads para conformidade com RuboCop") extraiu código para novos arquivos mas acidentalmente removeu dois métodos públicos do PayloadParser que eram chamados externamente:
text_content→ usado emincoming_message_wuzapi_service.rb:140,154attachment_params→ usado emincoming_message_wuzapi_service.rb:attach_files
Sintoma:
NoMethodError: undefined method 'text_content' for an instance of Whatsapp::Providers::Wuzapi::PayloadParser
Todas as mensagens de texto falhavam. Mídia falhava silenciosamente (rescuado em attach_files).
Como detectar:
grep "NoMethodError.*text_content\|NoMethodError.*attachment_params" log/development.log | tail -5
Correção: Restaurar os dois métodos no PayloadParser. Ver commit ec6cfc317 e e6e4c3652 para o código completo.
Onde ficam os métodos:
text_content— extrai texto do payload (conversation, extendedText, caption de mídia)attachment_params— extrai URL, filename, mimetype, mediaKey da mídia- Arquivo atual:
app/services/whatsapp/providers/wuzapi/payload_parser.rb
Resolvido em: commits e6e4c3652 + ec6cfc317
Bug 6 — Audio salvo com content-type audio/opus em vez de audio/ogg
Arquivo: app/services/whatsapp/wuzapi/media_handler.rb + app/models/attachment.rb
Problema:
O WhatsApp envia áudio como container OGG com codec Opus (bytes OggS = 4F 67 67 53). O WuzAPI declarava mimetype: audio/ogg; codecs=opus ou audio/opus. Browsers não conseguem reproduzir um container OGG declarado como audio/opus (raw Opus).
# Confirmar via Rails console:
a = Attachment.find(ID)
a.file.blob.content_type # → "audio/opus" (errado)
File.binread(ActiveStorage::Blob.service.path_for(a.file.blob.key), 4)
# → "\x4FOggS" (é OGG, não Opus raw)
Correção em media_handler.rb — normalizar ao salvar:
def sanitize_content_type(mimetype, type)
return 'audio/ogg' if type == :audio && mimetype.to_s.include?('opus')
mimetype || 'application/octet-stream'
end
Correção em attachment.rb — normalizar blobs antigos na primeira leitura:
def audio_metadata
normalize_opus_blob_content_type! # corrige blobs salvos com audio/opus
# ...
end
def normalize_opus_blob_content_type!
blob = file.blob
return unless blob.content_type == 'audio/opus'
blob.update_column(:content_type, 'audio/ogg')
end
Resolvido em: commit 5d3ce4e56
Estado correto após todos os fixes
app/models/attachment.rb
file_url: usarails_storage_proxy_path(file)em dev (URL relativa)thumb_url: usarails_storage_proxy_path(representation)em dev (URL relativa)audio_metadata: chamanormalize_opus_blob_content_type!para corrigir blobs antigosnormalize_opus_blob_content_type!: atualizaaudio/opus→audio/oggno blob- Em produção: comportamento inalterado com
url_for(file)
app/services/whatsapp/wuzapi/media_handler.rb
sanitize_content_type: normalizaaudio/opus→audio/oggpara novos uploadsdetect_extension: retorna.oggpara audio (não.mp3)final_filename: força.oggse audio chegou com extensão.mp3
app/services/whatsapp/providers/wuzapi/payload_parser.rb
text_content: presente e funcionalattachment_params: presente e funcionalUndecryptableMessage: na lista de eventos ignoráveis
app/services/whatsapp/decryption_service.rb
- Constructor aceita
(media_key, media_type)— sem URL (download separado) - Método principal:
decrypt_bytes(encrypted_bytes)— recebe bytes já baixados - Algoritmo: HKDF SHA-256 → AES-256-CBC → fallback AES-256-CTR
- Usa
decipher.update(data)(sem!)
config/environments/development.rb
config.active_storage.resolve_model_to_route = :rails_storage_proxy
vite.config.ts
server: {
proxy: {
'/rails': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
Checklist de diagnóstico completo
1. Mensagens de texto chegam? (PayloadParser OK?)
grep "NoMethodError.*PayloadParser\|undefined method.*text_content\|undefined method.*attachment_params" log/development.log | tail -5
❌ Encontrou → Bug 5 (métodos removidos do PayloadParser)
2. Verificar URLs geradas
grep "data_url" log/development.log | tail -3
✅ Deve aparecer URL relativa: /rails/active_storage/blobs/proxy/...
❌ URL absoluta com ngrok https://SEU-NGROK.../... → Bug 4 (interstitial) — trocar para rails_storage_proxy_path
❌ URL com localhost:3000 hardcoded → Bug 1 (versão antiga)
3. Verificar se o arquivo está encriptado
bin/rails runner "
a = Attachment.last
key = ActiveStorage::Blob.service.path_for(a.file.blob.key)
bytes = File.binread(key, 4).bytes.map { |b| format('%02X', b) }.join(' ')
puts \"#{a.id} #{a.file_type} content_type=#{a.file.blob.content_type} bytes=#{bytes}\"
"
FF D8 FF→ JPEG válido ✅89 50 4E 47→ PNG válido ✅4F 67 67 53(OggS) → OGG válido ✅- Qualquer outra coisa → arquivo encriptado ❌ → Bug 2
4. Verificar logs de descriptografia
grep -E "WuzAPI Decrypt|SUCCESS|invalid format|first bytes|Endpoint download|CDN download" log/development.log | tail -10
WuzAPI Decrypt: SUCCESS - Valid media detected✅WuzAPI: Endpoint download failed - API Error: 502→ normal, fallback ativo ✅WuzAPI Decrypt: Decrypted but invalid format (first bytes: XX XX XX XX)→ algoritmo erradoWuzAPI Decrypt Error: NoMethodError→ Bug 3 (update!em vez deupdate)- Nenhuma linha
WuzAPI Decrypt:→mediaKeyausente no payload OU Bug 5
5. Verificar content-type do blob de áudio
bin/rails runner "Attachment.where(file_type: 1).last(3).each { |a| puts \"#{a.id} ct=#{a.file.blob.content_type}\" }"
audio/ogg✅audio/opus→ Bug 6 (normalizado lazily emaudio_metadata, ou corrigir via migração)application/octet-stream→sanitize_content_typenão está rodando (checarmedia_handler.rb)
6. Verificar se o Sidekiq está rodando e processando
ps aux | grep sidekiq | grep -v grep
Deve aparecer somente uma linha com [N of 12 busy] sem stopping.
Se aparecer stopping ou dois processos:
pkill -f sidekiq
rm -f .overmind.sock
pnpm run dev
7. Verificar se o arquivo existe no disco
# No Rails console:
Attachment.last.file.attached? # deve ser true
Attachment.last.file.blob.service.exist?(Attachment.last.file.blob.key) # deve ser true
ActiveStorage::FileNotFoundError no log = blob existe no banco mas arquivo não está no disco = upload falhou silenciosamente.
8. Testar URL diretamente (bypass browser)
# Substitua pela URL do attachment que falha (via log ou Rails console)
curl -s -w "HTTP: %{http_code} CT: %{content_type} Size: %{size_download}\n" -o /dev/null "/rails/active_storage/blobs/proxy/TOKEN/file.ogg"
# Ou via URL absoluta local:
curl -s -w "HTTP: %{http_code} CT: %{content_type} Size: %{size_download}\n" -o /dev/null "http://localhost:3000/rails/active_storage/blobs/proxy/TOKEN/file.ogg"
- HTTP 200, Size > 0 → arquivo acessível ✅
- HTTP 404 → blob não existe no disco
- HTTP 200, Size = 0 → HEAD request (usar curl sem -I)
Dependências críticas
| Variável/Config | Onde | Valor esperado em dev |
|---|---|---|
FRONTEND_URL |
.env |
https://SEU-URL.ngrok-free.dev (usado para webhooks) |
ACTIVE_STORAGE_SERVICE |
.env |
local |
resolve_model_to_route |
config/environments/development.rb |
:rails_storage_proxy |
Vite proxy /rails |
vite.config.ts |
target: http://127.0.0.1:3000 |
mediaKey |
Payload WuzAPI | Obrigatório para descriptografia |
Notas de arquitetura
- O WhatsApp sempre encripta mídia no CDN (arquivos
.enc) - A chave (
mediaKey) é entregue no payload do webhook junto com a URL - Sem
mediaKeynão é possível descriptografar — o arquivo vai aparecer corrompido - O WuzAPI endpoint
/chat/downloadimagedeveria retornar mídia já descriptografada (Method 1), mas está com instabilidade (502). O fallback via HKDF+AES é a solução robusta. - Em produção (sem ngrok),
url_for(file)usadefault_url_optionsconfigurado corretamente — sem interstitial. - URL relativa vs absoluta em dev:
rails_storage_proxy_pathé mais robusto querails_storage_proxy_urlpois funciona independente de onde o browser esteja acessando. - Refactoring é perigoso aqui: os métodos
text_contenteattachment_paramsnoPayloadParsersão contratos públicos chamados porincoming_message_wuzapi_service.rb. Qualquer refactoring deve verificar todos os callers externos.