docs: enriquece troubleshooting de mídia WhatsApp com bugs 4-6 e diagnóstico rápido
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled

- 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>
This commit is contained in:
Rodrigo Borba 2026-02-28 19:37:59 -03:00
parent cfa2dc71bd
commit fa061dc08c

View File

@ -11,43 +11,59 @@
- Áudio exibe `00:00 / 00:00` e não toca - Áudio exibe `00:00 / 00:00` e não toca
- Imagem exibe "Esta imagem não está mais disponível" - Imagem exibe "Esta imagem não está mais disponível"
- Mensagens de texto chegam normalmente - 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.
```bash
# 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 ## Diagnóstico — Root Causes
Havia **três bugs independentes**, todos precisavam ser corrigidos. 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` (invisível via ngrok) ### Bug 1 — URL de mídia apontando para `localhost` hardcoded *(histórico)*
**Arquivo:** `app/models/attachment.rb` **Arquivo:** `app/models/attachment.rb`
**Problema:** **Problema:**
`file_url` e `thumb_url` geravam URLs hardcoded com `localhost:3000`. O browser acessando via ngrok não consegue resolver `localhost:3000` diretamente, então `<img>` e `<audio>` falhavam silenciosamente. `file_url` e `thumb_url` geravam URLs hardcoded com `localhost:3000`. O browser acessando via ngrok não consegue resolver `localhost:3000` diretamente.
```ruby ```ruby
# ANTES (quebrado): # QUEBRADO:
rails_storage_proxy_url(file, host: 'localhost:3000', protocol: 'http') rails_storage_proxy_url(file, host: 'localhost:3000', protocol: 'http')
# DEPOIS (correto):
rails_storage_proxy_url(file, **dev_url_options)
def dev_url_options
uri = URI.parse(ENV.fetch('FRONTEND_URL', 'http://localhost:3000').chomp('/'))
host = [80, 443].include?(uri.port) ? uri.host : "#{uri.host}:#{uri.port}"
{ host: host, protocol: uri.scheme }
end
``` ```
**Efeito visual:** **Resolvido em:** commit `6b214b38d` — passou a usar `dev_url_options` que lê `FRONTEND_URL`.
- Imagem → `@error``hasError = true` → "Esta imagem não está mais disponível"
- Áudio → `onLoadedMetadata` nunca disparado → `duration = 0` → "00:00 / 00:00"
--- ---
### Bug 2 — Arquivo de mídia salvo criptografado (sem descriptografar) ### Bug 2 — Arquivo de mídia salvo criptografado *(histórico)*
**Arquivo:** `app/services/whatsapp/incoming_message_wuzapi_service.rb` + `decryption_service.rb` **Arquivo:** `app/services/whatsapp/incoming_message_wuzapi_service.rb` + `decryption_service.rb`
@ -57,115 +73,295 @@ O fluxo de download tinha 3 métodos em cascata:
| Método | O que faz | Status | | Método | O que faz | Status |
|--------|-----------|--------| |--------|-----------|--------|
| Method 1 | WuzAPI `/chat/downloadimage` endpoint | ❌ Sempre falhava com `502 Bad Gateway` | | Method 1 | WuzAPI `/chat/downloadimage` endpoint | ❌ Sempre falhava com `502 Bad Gateway` |
| Method 2 | `DecryptionService` com `Net::HTTP` | ❌ `Net::HTTP` não segue redirects — CDN do WhatsApp redireciona `nil` silencioso | | 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** | | Method 3 | `Down.download` direto do CDN | ⚠️ Baixava mas salvava bytes **encriptados** |
O WhatsApp entrega mídia encriptada (AES-256-CBC) no CDN com URLs `.enc`. O Method 3 baixava os bytes encriptados corretamente mas os salvava sem descriptografar. O arquivo no disco era inválido. 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).
Diagnóstico confirmado via `xxd`: **Correção:** Unificar Methods 2+3: `Down.download` (que segue redirects) → descriptografar → salvar.
```
# JPEG começa com FF D8 FF
# Arquivo salvo começava com:
00000000: 3188 d20c 46ae 98f3 03bd... ← bytes encriptados
```
**Correção:** **Resolvido em:** commit `6b214b38d`
Unificar Methods 2+3: usar `Down.download` (que segue redirects) para baixar os bytes, depois descriptografar em memória antes de salvar.
```ruby
# DEPOIS:
encrypted_tempfile = Down.download(media_url, ...)
encrypted_bytes = encrypted_tempfile.read.b
if attachment_data[:media_key].present?
decrypted = Whatsapp::DecryptionService.new(
attachment_data[:media_key],
file_content_type(message_type)
).decrypt_bytes(encrypted_bytes)
return decrypted if decrypted
end
StringIO.new(encrypted_bytes) # fallback
```
--- ---
### Bug 3 — `OpenSSL::Cipher#update!` não existe em Ruby ### Bug 3 — `OpenSSL::Cipher#update!` não existe em Ruby *(histórico)*
**Arquivo:** `app/services/whatsapp/decryption_service.rb` **Arquivo:** `app/services/whatsapp/decryption_service.rb`
**Problema:**
O método de descriptografia usava `decipher.update!(data)` mas Ruby só tem `decipher.update(data)` (sem `!`). Causava `NoMethodError` em todo attempt de descriptografia.
```ruby ```ruby
# ANTES (quebrado): # QUEBRADO:
decipher.update!(data) + decipher.final decipher.update!(data) + decipher.final
# DEPOIS (correto): # CORRETO:
decipher.update(data) + decipher.final decipher.update(data) + decipher.final
``` ```
**Resolvido em:** commit `6b214b38d`
--- ---
## Estado correto após os fixes ### 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 → `loadedmetadata` nunca dispara → `duration = 0` → "00:00 / 00:00"
**Diagnóstico:**
```bash
# 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:
```ruby
# 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`:
```ruby
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 para `http://localhost:3000/rails/...` → Rails serve direto
- Browser em `https://ngrok-url/``/rails/...` → resolve para `https://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 em `incoming_message_wuzapi_service.rb:140,154`
- `attachment_params` → usado em `incoming_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:**
```bash
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).
```bash
# 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:
```ruby
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:
```ruby
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` ### `app/models/attachment.rb`
- `file_url` e `thumb_url` usam `dev_url_options` que lê `FRONTEND_URL` do `.env` - `file_url`: usa `rails_storage_proxy_path(file)` em dev (URL relativa)
- Em produção (`else`): comportamento inalterado com `url_for(file)` - `thumb_url`: usa `rails_storage_proxy_path(representation)` em dev (URL relativa)
- `audio_metadata`: chama `normalize_opus_blob_content_type!` para corrigir blobs antigos
- `normalize_opus_blob_content_type!`: atualiza `audio/opus``audio/ogg` no blob
- Em produção: comportamento inalterado com `url_for(file)`
### `app/services/whatsapp/incoming_message_wuzapi_service.rb` ### `app/services/whatsapp/wuzapi/media_handler.rb`
- Method 1: WuzAPI endpoint (funciona quando disponível) - `sanitize_content_type`: normaliza `audio/opus``audio/ogg` para novos uploads
- Method 2+3 unificados: `Down.download` → decrypt com `mediaKey` → fallback raw - `detect_extension`: retorna `.ogg` para audio (não `.mp3`)
- `final_filename`: força `.ogg` se audio chegou com extensão `.mp3`
### `app/services/whatsapp/providers/wuzapi/payload_parser.rb`
- `text_content`: presente e funcional
- `attachment_params`: presente e funcional
- `UndecryptableMessage`: na lista de eventos ignoráveis
### `app/services/whatsapp/decryption_service.rb` ### `app/services/whatsapp/decryption_service.rb`
- Constructor aceita `(media_key, media_type)` — sem URL (download separado) - Constructor aceita `(media_key, media_type)` — sem URL (download separado)
- Método principal: `decrypt_bytes(encrypted_bytes)` — recebe bytes já baixados - Método principal: `decrypt_bytes(encrypted_bytes)` — recebe bytes já baixados
- Algoritmo: HKDF SHA-256 → AES-256-CBC → fallback AES-256-CTR - Algoritmo: HKDF SHA-256 → AES-256-CBC → fallback AES-256-CTR
- Loga os primeiros bytes se o formato não for reconhecido (facilita debug futuro) - Usa `decipher.update(data)` (sem `!`)
### `config/environments/development.rb`
```ruby
config.active_storage.resolve_model_to_route = :rails_storage_proxy
```
### `vite.config.ts`
```javascript
server: {
proxy: {
'/rails': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
```
--- ---
## Checklist de diagnóstico (quando áudio/imagem não funcionar) ## Checklist de diagnóstico completo
### 1. Verificar URLs geradas ### 1. Mensagens de texto chegam? (PayloadParser OK?)
```bash
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
```bash ```bash
grep "data_url" log/development.log | tail -3 grep "data_url" log/development.log | tail -3
``` ```
Deve aparecer: `https://SEU-NGROK.ngrok-free.dev/rails/active_storage/...` ✅ Deve aparecer URL **relativa**: `/rails/active_storage/blobs/proxy/...`
❌ Se aparecer `http://localhost:3000/...` → Bug 1 voltou (checar `FRONTEND_URL` no `.env`) ❌ 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)
--- ---
### 2. Verificar se o arquivo está encriptado ### 3. Verificar se o arquivo está encriptado
```bash ```bash
# Pegar o blob key do arquivo (via Rails console ou log) bin/rails runner "
xxd storage/XX/XX/BLOB_KEY | head -2 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 ✅ - `FF D8 FF` → JPEG válido ✅
- `89 50 4E 47` → PNG válido ✅ - `89 50 4E 47` → PNG válido ✅
- `4F 67 67 53` (OggS) → OGG válido ✅ - `4F 67 67 53` (OggS) → OGG válido ✅
- Qualquer outra coisa → encriptado ❌ - Qualquer outra coisa → arquivo encriptado ❌ → Bug 2
--- ---
### 3. Verificar logs de descriptografia ### 4. Verificar logs de descriptografia
```bash ```bash
grep -E "WuzAPI Decrypt|SUCCESS|invalid format|first bytes" log/development.log | tail -10 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 Decrypt: SUCCESS - Valid media detected`
- `WuzAPI Decrypt: Decrypted but invalid format (first bytes: XX XX XX XX)` → algoritmo errado — os bytes revelam o tipo real - `WuzAPI: Endpoint download failed - API Error: 502` → normal, fallback ativo ✅
- `WuzAPI Decrypt Error: NoMethodError` → bug no Ruby (checar `update` vs `update!`) - `WuzAPI Decrypt: Decrypted but invalid format (first bytes: XX XX XX XX)` → algoritmo errado
- Nada aparece após "Attempting local decryption" → mediaKey ausente no payload do WuzAPI - `WuzAPI Decrypt Error: NoMethodError` → Bug 3 (`update!` em vez de `update`)
- Nenhuma linha `WuzAPI Decrypt:``mediaKey` ausente no payload OU Bug 5
--- ---
### 4. Verificar se o Sidekiq está rodando e processando ### 5. Verificar content-type do blob de áudio
```bash
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 em `audio_metadata`, ou corrigir via migração)
- `application/octet-stream``sanitize_content_type` não está rodando (checar `media_handler.rb`)
---
### 6. Verificar se o Sidekiq está rodando e processando
```bash ```bash
ps aux | grep sidekiq | grep -v grep ps aux | grep sidekiq | grep -v grep
@ -182,7 +378,7 @@ pnpm run dev
--- ---
### 5. Verificar se o arquivo existe no disco ### 7. Verificar se o arquivo existe no disco
```bash ```bash
# No Rails console: # No Rails console:
@ -190,27 +386,33 @@ Attachment.last.file.attached? # deve ser true
Attachment.last.file.blob.service.exist?(Attachment.last.file.blob.key) # 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 = o upload falhou silenciosamente. `ActiveStorage::FileNotFoundError` no log = blob existe no banco mas arquivo não está no disco = upload falhou silenciosamente.
--- ---
### 6. Verificar status do WuzAPI endpoint de download ### 8. Testar URL diretamente (bypass browser)
```bash ```bash
grep "WuzAPI: Endpoint download failed" log/development.log | tail -3 # 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"
``` ```
Se aparece `502 Bad Gateway` sistematicamente → WuzAPI `/chat/downloadimage` está down. - HTTP 200, Size > 0 → arquivo acessível ✅
Isso é esperado e o sistema cai automaticamente para Method 2+3 (download direto + decrypt). - HTTP 404 → blob não existe no disco
- HTTP 200, Size = 0 → HEAD request (usar curl sem -I)
--- ---
## Dependências críticas ## Dependências críticas
| Variável | Onde | Valor esperado em dev com ngrok | | Variável/Config | Onde | Valor esperado em dev |
|----------|------|---------------------------------| |-----------------|------|-----------------------|
| `FRONTEND_URL` | `.env` | `https://SEU-URL.ngrok-free.dev` | | `FRONTEND_URL` | `.env` | `https://SEU-URL.ngrok-free.dev` (usado para webhooks) |
| `ACTIVE_STORAGE_SERVICE` | `.env` | `local` | | `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 | | `mediaKey` | Payload WuzAPI | Obrigatório para descriptografia |
--- ---
@ -221,4 +423,6 @@ Isso é esperado e o sistema cai automaticamente para Method 2+3 (download diret
- A chave (`mediaKey`) é entregue no payload do webhook junto com a URL - A chave (`mediaKey`) é entregue no payload do webhook junto com a URL
- Sem `mediaKey` não é possível descriptografar — o arquivo vai aparecer corrompido - Sem `mediaKey` não é possível descriptografar — o arquivo vai aparecer corrompido
- O WuzAPI endpoint `/chat/downloadimage` deveria retornar mídia já descriptografada (Method 1), mas está com instabilidade (502). O fallback via HKDF+AES é a solução robusta. - O WuzAPI endpoint `/chat/downloadimage` deveria 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), o Bug 1 não existe pois `url_for(file)` usa `default_url_options` configurado corretamente. - Em **produção** (sem ngrok), `url_for(file)` usa `default_url_options` configurado corretamente — sem interstitial.
- **URL relativa vs absoluta em dev:** `rails_storage_proxy_path` é mais robusto que `rails_storage_proxy_url` pois funciona independente de onde o browser esteja acessando.
- **Refactoring é perigoso aqui:** os métodos `text_content` e `attachment_params` no `PayloadParser` são contratos públicos chamados por `incoming_message_wuzapi_service.rb`. Qualquer refactoring deve verificar todos os callers externos.