feat: Adiciona configuração Active Storage proxy, refatora serviço de decriptografia WhatsApp para processar bytes diretamente e ajusta componentes de mídia.

This commit is contained in:
Rodrigo Borba 2026-02-28 12:48:17 -03:00
parent 26692bb5e2
commit 6b214b38db
15 changed files with 581 additions and 97 deletions

View File

@ -1,4 +1,4 @@
backend: bin/rails s -p 3001 backend: bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695 # https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: bundle exec sidekiq -C config/sidekiq.yml worker: bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite dev vite: bin/vite dev

View File

@ -1,8 +1,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import BaseBubble from './Base.vue'; import BaseBubble from './Base.vue';
import Button from 'next/button/Button.vue'; import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
@ -20,19 +19,11 @@ const attachment = computed(() => {
return attachments.value[0]; return attachments.value[0];
}); });
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({ const hasError = ref(false);
type: 'image',
});
const showGallery = ref(false); const showGallery = ref(false);
const isDownloading = ref(false); const isDownloading = ref(false);
onMounted(() => {
if (attachment.value?.dataUrl) {
loadWithRetry(attachment.value.dataUrl);
}
});
const downloadAttachment = async () => { const downloadAttachment = async () => {
const { fileType, dataUrl, extension } = attachment.value; const { fileType, dataUrl, extension } = attachment.value;
try { try {
@ -62,12 +53,13 @@ const handleImageError = () => {
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }} {{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
</p> </p>
</div> </div>
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden"> <div v-else class="relative group rounded-lg overflow-hidden">
<img <img
class="skip-context-menu" class="skip-context-menu"
:src="attachment.dataUrl" :src="attachment.dataUrl"
:width="attachment.width" :width="attachment.width"
:height="attachment.height" :height="attachment.height"
@error="handleImageError"
/> />
<div <div
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex" class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"

View File

@ -1,13 +1,6 @@
<script setup> <script setup>
import { import { computed, useTemplateRef, ref, getCurrentInstance } from 'vue';
computed,
onMounted,
useTemplateRef,
ref,
getCurrentInstance,
} from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper'; import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
import { downloadFile } from '@chatwoot/utils'; import { downloadFile } from '@chatwoot/utils';
@ -30,9 +23,7 @@ defineOptions({
}); });
const { t } = useI18n(); const { t } = useI18n();
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({ const hasError = ref(false);
type: 'audio',
});
const timeStampURL = computed(() => { const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl); return timeStampAppendedURL(attachment.dataUrl);
@ -59,11 +50,9 @@ const playbackSpeedLabel = computed(() => {
return `${playbackSpeed.value}x`; return `${playbackSpeed.value}x`;
}); });
onMounted(() => { const handleAudioError = () => {
if (attachment.dataUrl) { hasError.value = true;
loadWithRetry(attachment.dataUrl); };
}
});
// Listen for global audio play events and pause if it's not this audio // Listen for global audio play events and pause if it's not this audio
useEmitter('pause_playing_audio', currentPlayingId => { useEmitter('pause_playing_audio', currentPlayingId => {
@ -143,7 +132,7 @@ const downloadAudio = async () => {
{{ t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }} {{ t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
</p> </p>
</div> </div>
<template v-else-if="isLoaded"> <template v-else>
<audio <audio
ref="audioPlayer" ref="audioPlayer"
controls controls
@ -152,6 +141,7 @@ const downloadAudio = async () => {
@loadedmetadata="onLoadedMetadata" @loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate" @timeupdate="onTimeUpdate"
@ended="onEnd" @ended="onEnd"
@error="handleAudioError"
> >
<source :src="timeStampURL" /> <source :src="timeStampURL" />
</audio> </audio>

View File

@ -366,6 +366,9 @@ export default {
this.conversationPanel.removeEventListener('scroll', this.handleScroll); this.conversationPanel.removeEventListener('scroll', this.handleScroll);
}, },
scrollToBottom() { scrollToBottom() {
if (!this.conversationPanel) {
return;
}
this.isProgrammaticScroll = true; this.isProgrammaticScroll = true;
let relevantMessages = []; let relevantMessages = [];

View File

@ -110,12 +110,16 @@ export const hasValidAvatarUrl = avatarUrl => {
}; };
export const timeStampAppendedURL = dataUrl => { export const timeStampAppendedURL = dataUrl => {
const url = new URL(dataUrl); try {
const url = new URL(dataUrl, window.location.origin);
if (!url.searchParams.has('t')) { if (!url.searchParams.has('t')) {
url.searchParams.append('t', Date.now()); url.searchParams.append('t', Date.now());
} }
return url.toString(); return url.toString();
} catch (e) {
const connector = dataUrl.includes('?') ? '&' : '?';
return `${dataUrl}${connector}t=${Date.now()}`;
}
}; };
export const getHostNameFromURL = url => { export const getHostNameFromURL = url => {

View File

@ -50,7 +50,13 @@ class Attachment < ApplicationRecord
# NOTE: the URl returned does a 301 redirect to the actual file # NOTE: the URl returned does a 301 redirect to the actual file
def file_url def file_url
file.attached? ? url_for(file) : '' return '' unless file.attached?
if Rails.env.development?
rails_storage_proxy_url(file, **dev_url_options)
else
url_for(file)
end
end end
# NOTE: for External services use this methods since redirect doesn't work effectively in a lot of cases # NOTE: for External services use this methods since redirect doesn't work effectively in a lot of cases
@ -66,7 +72,12 @@ class Attachment < ApplicationRecord
return '' unless file.attached? && image? return '' unless file.attached? && image?
begin begin
url_for(file.representation(resize_to_fill: [250, nil])) representation = file.representation(resize_to_fill: [250, nil])
if Rails.env.development?
rails_storage_proxy_url(representation, **dev_url_options)
else
url_for(representation)
end
rescue ActiveStorage::UnrepresentableError => e rescue ActiveStorage::UnrepresentableError => e
Rails.logger.warn "Unrepresentable image attachment: #{id} (#{file.filename}) - #{e.message}" Rails.logger.warn "Unrepresentable image attachment: #{id} (#{file.filename}) - #{e.message}"
'' ''
@ -188,6 +199,12 @@ class Attachment < ApplicationRecord
file_content_type.start_with?('image/', 'video/', 'audio/') file_content_type.start_with?('image/', 'video/', 'audio/')
end end
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
# Marcel gem may detect OGG/Opus files as audio/opus instead of audio/ogg. # Marcel gem may detect OGG/Opus files as audio/opus instead of audio/ogg.
# Lazily normalize existing blobs so presigned URLs serve the correct Content-Type. # Lazily normalize existing blobs so presigned URLs serve the correct Content-Type.
# Only applies to .ogg files — .opus files legitimately use audio/opus. # Only applies to .ogg files — .opus files legitimately use audio/opus.

View File

@ -1,7 +1,6 @@
class Whatsapp::DecryptionService class Whatsapp::DecryptionService
require 'openssl' require 'openssl'
require 'base64' require 'base64'
require 'net/http'
# HKDF Info strings for different media types (WhatsApp protocol) # HKDF Info strings for different media types (WhatsApp protocol)
INFO_STRINGS = { INFO_STRINGS = {
@ -12,55 +11,42 @@ class Whatsapp::DecryptionService
sticker: 'WhatsApp Image Keys' sticker: 'WhatsApp Image Keys'
}.freeze }.freeze
def initialize(media_url, media_key, media_type) def initialize(media_key, media_type)
@media_url = media_url
@media_key = Base64.decode64(media_key) @media_key = Base64.decode64(media_key)
@media_type = media_type.to_sym @media_type = media_type.to_sym
@info = INFO_STRINGS[@media_type] || INFO_STRINGS[:document] @info = INFO_STRINGS[@media_type] || INFO_STRINGS[:document]
end end
def decrypt def decrypt_bytes(encrypted_bytes)
return nil unless @media_url && @media_key return nil unless @media_key && encrypted_bytes && encrypted_bytes.bytesize > 10
# 1. Download encrypted bytes Rails.logger.info "WuzAPI Decrypt: Processing #{encrypted_bytes.bytesize} bytes"
encrypted_bytes = download_content
return nil unless encrypted_bytes && encrypted_bytes.bytesize > 10
Rails.logger.info "WuzAPI Decrypt: Downloaded #{encrypted_bytes.bytesize} bytes" # Derive keys using HKDF SHA-256 (112 bytes total)
# 2. Derive keys using HKDF SHA-256 (112 bytes total)
expanded_key = OpenSSL::KDF.hkdf( expanded_key = OpenSSL::KDF.hkdf(
@media_key, @media_key,
salt: ''.b, # Empty binary string salt: ''.b,
info: @info, info: @info,
length: 112, length: 112,
hash: 'sha256' hash: 'sha256'
) )
# 3. Split derived key
iv = expanded_key[0...16] iv = expanded_key[0...16]
cipher_key = expanded_key[16...48] cipher_key = expanded_key[16...48]
# mac_key = expanded_key[48...80] # For verification (optional)
# ref_key = expanded_key[80...112] # Not used
# 4. WhatsApp file structure: [Encrypted Content] + [MAC (10 bytes)] # WhatsApp file structure: [Encrypted Content] + [MAC (10 bytes)]
# Remove the last 10 bytes (MAC)
cipher_text = encrypted_bytes[0...-10] cipher_text = encrypted_bytes[0...-10]
# 5. Try AES-256-CBC first (older WhatsApp versions)
decrypted = try_aes_cbc(cipher_key, iv, cipher_text) decrypted = try_aes_cbc(cipher_key, iv, cipher_text)
# 6. If CBC fails, try CTR mode (some implementations use this)
decrypted ||= try_aes_ctr(cipher_key, iv, cipher_text) decrypted ||= try_aes_ctr(cipher_key, iv, cipher_text)
return nil unless decrypted return nil unless decrypted
# 7. Validate that we got a valid image (check magic bytes)
if valid_media?(decrypted) if valid_media?(decrypted)
Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected' Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected'
StringIO.new(decrypted) StringIO.new(decrypted)
else else
Rails.logger.warn 'WuzAPI Decrypt: Decrypted but invalid media format' Rails.logger.warn "WuzAPI Decrypt: Decrypted but invalid format (first bytes: #{decrypted.bytes[0..3].map { |b| format('%02X', b) }.join(' ')})"
nil nil
end end
rescue StandardError => e rescue StandardError => e
@ -77,7 +63,7 @@ class Whatsapp::DecryptionService
decipher.iv = iv decipher.iv = iv
decipher.padding = 0 # WhatsApp doesn't use PKCS7 padding decipher.padding = 0 # WhatsApp doesn't use PKCS7 padding
decipher.update!(data) + decipher.final decipher.update(data) + decipher.final
rescue OpenSSL::Cipher::CipherError => e rescue OpenSSL::Cipher::CipherError => e
Rails.logger.debug { "AES-CBC failed: #{e.message}" } Rails.logger.debug { "AES-CBC failed: #{e.message}" }
@ -90,7 +76,7 @@ class Whatsapp::DecryptionService
decipher.key = key decipher.key = key
decipher.iv = iv decipher.iv = iv
decipher.update!(data) + decipher.final decipher.update(data) + decipher.final
rescue OpenSSL::Cipher::CipherError => e rescue OpenSSL::Cipher::CipherError => e
Rails.logger.debug { "AES-CTR failed: #{e.message}" } Rails.logger.debug { "AES-CTR failed: #{e.message}" }
@ -126,20 +112,4 @@ class Whatsapp::DecryptionService
false false
end end
def download_content
uri = URI.parse(@media_url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https')
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http.open_timeout = 10
http.read_timeout = 30
request = Net::HTTP::Get.new(uri.request_uri)
response = http.request(request)
response.is_a?(Net::HTTPSuccess) ? response.body.b : nil
rescue StandardError => e
Rails.logger.error "WuzAPI Decrypt Download Error: #{e.message}"
nil
end
end end

View File

@ -52,7 +52,7 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
@contact = find_or_create_contact(parser) @contact = find_or_create_contact(parser)
return if @contact.nil? # If contact couldn't be determined, stop processing return if @contact.nil? # If contact couldn't be determined, stop processing
@conversation = find_or_create_conversation(@contact) @conversation = find_or_create_conversation(@contact, parser)
# 5. Echo/AI Deduplication Logic # 5. Echo/AI Deduplication Logic
# ------------------------------ # ------------------------------
@ -124,7 +124,7 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
contact contact
end end
def find_or_create_conversation(contact) def find_or_create_conversation(contact, parser = nil)
# Find the LAST open conversation for this contact to append to # Find the LAST open conversation for this contact to append to
conversation = inbox.conversations.where(contact_id: contact.id) conversation = inbox.conversations.where(contact_id: contact.id)
.where.not(status: :resolved) .where.not(status: :resolved)
@ -136,12 +136,26 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
# Find the ContactInbox association to linking # Find the ContactInbox association to linking
contact_inbox = ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox.id) contact_inbox = ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox.id)
# Build additional_attributes — include referral info from Click-to-WhatsApp ads if present
extra_attrs = {}
if parser
referral = parser.referral_info
if referral.present?
# "referer" is the field Chatwoot automations use for "Link de origem" condition
extra_attrs['referer'] = referral[:source_url].presence || 'meta_ads'
extra_attrs['source_type'] = referral[:source_type] if referral[:source_type].present?
extra_attrs['ctwa_clid'] = referral[:ctwa_clid] if referral[:ctwa_clid].present?
Rails.logger.info "WuzAPI: Setting conversation referer='#{extra_attrs['referer']}' from ad referral"
end
end
# If no open conversation, create a new one # If no open conversation, create a new one
inbox.conversations.create!( inbox.conversations.create!(
contact: contact, contact: contact,
contact_inbox: contact_inbox, # Explicitly required by Chatwoot validation contact_inbox: contact_inbox, # Explicitly required by Chatwoot validation
status: :open, status: :open,
account_id: inbox.account_id account_id: inbox.account_id,
additional_attributes: extra_attrs
) )
end end
@ -261,28 +275,30 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}" Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}"
end end
# METHOD 2: Try local decryption if we have mediaKey # METHOD 2+3: Download from CDN (follows redirects) then decrypt if mediaKey available
if attachment_data[:media_key].present? Rails.logger.info "WuzAPI: Downloading from CDN #{media_url}"
Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...' encrypted_tempfile = Down.download(
decrypted = Whatsapp::DecryptionService.new(
media_url,
attachment_data[:media_key],
file_content_type(message_type)
).decrypt
return decrypted if decrypted
Rails.logger.warn 'WuzAPI: Local decryption failed...'
end
# METHOD 3: Direct download (only works for non-encrypted or already-decrypted URLs)
Rails.logger.info "WuzAPI: Direct download from #{media_url}"
Down.download(
media_url, media_url,
open_timeout: 10, open_timeout: 10,
read_timeout: 30, read_timeout: 30,
ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE
) )
encrypted_bytes = encrypted_tempfile.read.b
Rails.logger.info "WuzAPI: Downloaded #{encrypted_bytes.bytesize} bytes from CDN"
if attachment_data[:media_key].present?
Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...'
decrypted = Whatsapp::DecryptionService.new(
attachment_data[:media_key],
file_content_type(message_type)
).decrypt_bytes(encrypted_bytes)
return decrypted if decrypted
Rails.logger.warn 'WuzAPI: Local decryption failed, returning raw bytes'
end
StringIO.new(encrypted_bytes)
rescue StandardError => e rescue StandardError => e
Rails.logger.error "WuzAPI: All download methods failed - #{e.message}" Rails.logger.error "WuzAPI: All download methods failed - #{e.message}"
nil nil

View File

@ -166,6 +166,40 @@ class Whatsapp::Providers::Wuzapi::PayloadParser
} }
end end
# Returns referral/ad tracking info for Click-to-WhatsApp Meta Ads messages.
# WuzAPI/whatsmeow may include this in extendedTextMessage.contextInfo.externalAdReply
# or via Info.Category = "business". Returns nil if no referral data found.
def referral_info
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
# Check externalAdReply in extendedTextMessage (Click-to-WhatsApp ad flow)
if msg.is_a?(Hash)
ad_reply = msg.dig(:extendedTextMessage, :contextInfo, :externalAdReply)
ad_reply ||= msg.dig('extendedTextMessage', 'contextInfo', 'externalAdReply')
if ad_reply.is_a?(Hash) && ad_reply.present?
Rails.logger.info "WuzAPI: Click-to-WhatsApp referral detected: #{ad_reply.inspect}"
return {
source_url: ad_reply['sourceUrl'] || ad_reply[:sourceUrl],
source_id: ad_reply['sourceId'] || ad_reply[:sourceId],
source_type: 'ad',
ctwa_clid: ad_reply['ctwaClid'] || ad_reply[:ctwaClid],
headline: ad_reply['title'] || ad_reply[:title],
body: ad_reply['body'] || ad_reply[:body]
}
end
end
# Check Info.Category — some WuzAPI versions mark business-initiated as "business"
category = params.dig(:event, :Info, :Category).to_s.downcase
if category == 'business'
Rails.logger.info 'WuzAPI: Business category message detected (possible CTWA ad)'
return { source_type: 'ad', source_url: nil }
end
nil
end
def sender_phone_number def sender_phone_number
jid = extract_jid jid = extract_jid

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -32,6 +32,8 @@ Rails.application.configure do
# Store uploaded files on the local file system (see config/storage.yml for options). # Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = ENV.fetch('ACTIVE_STORAGE_SERVICE', 'local').to_sym config.active_storage.service = ENV.fetch('ACTIVE_STORAGE_SERVICE', 'local').to_sym
config.active_storage.resolve_model_to_route = :rails_storage_proxy
config.active_job.queue_adapter = :sidekiq config.active_job.queue_adapter = :sidekiq
Rails.application.routes.default_url_options = { host: ENV['FRONTEND_URL'].to_s.chomp('/') } Rails.application.routes.default_url_options = { host: ENV['FRONTEND_URL'].to_s.chomp('/') }

View File

@ -0,0 +1,224 @@
# 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:00` e não toca
- Imagem exibe "Esta imagem não está mais disponível"
- Mensagens de texto chegam normalmente
---
## Diagnóstico — Root Causes
Havia **três bugs independentes**, todos precisavam ser corrigidos.
---
### Bug 1 — URL de mídia apontando para `localhost` (invisível via ngrok)
**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, então `<img>` e `<audio>` falhavam silenciosamente.
```ruby
# ANTES (quebrado):
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:**
- 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)
**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 — CDN do WhatsApp redireciona → `nil` silencioso |
| 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.
Diagnóstico confirmado via `xxd`:
```
# JPEG começa com FF D8 FF
# Arquivo salvo começava com:
00000000: 3188 d20c 46ae 98f3 03bd... ← bytes encriptados
```
**Correção:**
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
**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
# ANTES (quebrado):
decipher.update!(data) + decipher.final
# DEPOIS (correto):
decipher.update(data) + decipher.final
```
---
## Estado correto após os fixes
### `app/models/attachment.rb`
- `file_url` e `thumb_url` usam `dev_url_options` que lê `FRONTEND_URL` do `.env`
- Em produção (`else`): comportamento inalterado com `url_for(file)`
### `app/services/whatsapp/incoming_message_wuzapi_service.rb`
- Method 1: WuzAPI endpoint (funciona quando disponível)
- Method 2+3 unificados: `Down.download` → decrypt com `mediaKey` → fallback raw
### `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
- Loga os primeiros bytes se o formato não for reconhecido (facilita debug futuro)
---
## Checklist de diagnóstico (quando áudio/imagem não funcionar)
### 1. Verificar URLs geradas
```bash
grep "data_url" log/development.log | tail -3
```
Deve aparecer: `https://SEU-NGROK.ngrok-free.dev/rails/active_storage/...`
❌ Se aparecer `http://localhost:3000/...` → Bug 1 voltou (checar `FRONTEND_URL` no `.env`)
---
### 2. Verificar se o arquivo está encriptado
```bash
# Pegar o blob key do arquivo (via Rails console ou log)
xxd storage/XX/XX/BLOB_KEY | head -2
```
- `FF D8 FF` → JPEG válido ✅
- `89 50 4E 47` → PNG válido ✅
- `4F 67 67 53` (OggS) → OGG válido ✅
- Qualquer outra coisa → encriptado ❌
---
### 3. Verificar logs de descriptografia
```bash
grep -E "WuzAPI Decrypt|SUCCESS|invalid format|first bytes" log/development.log | tail -10
```
- `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 Decrypt Error: NoMethodError` → bug no Ruby (checar `update` vs `update!`)
- Nada aparece após "Attempting local decryption" → mediaKey ausente no payload do WuzAPI
---
### 4. Verificar se o Sidekiq está rodando e processando
```bash
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:
```bash
pkill -f sidekiq
rm -f .overmind.sock
pnpm run dev
```
---
### 5. Verificar se o arquivo existe no disco
```bash
# 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 = o upload falhou silenciosamente.
---
### 6. Verificar status do WuzAPI endpoint de download
```bash
grep "WuzAPI: Endpoint download failed" log/development.log | tail -3
```
Se aparece `502 Bad Gateway` sistematicamente → WuzAPI `/chat/downloadimage` está down.
Isso é esperado e o sistema cai automaticamente para Method 2+3 (download direto + decrypt).
---
## Dependências críticas
| Variável | Onde | Valor esperado em dev com ngrok |
|----------|------|---------------------------------|
| `FRONTEND_URL` | `.env` | `https://SEU-URL.ngrok-free.dev` |
| `ACTIVE_STORAGE_SERVICE` | `.env` | `local` |
| `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 `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.
- Em **produção** (sem ngrok), o Bug 1 não existe pois `url_for(file)` usa `default_url_options` configurado corretamente.

View File

@ -0,0 +1,224 @@
# 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:00` e não toca
- Imagem exibe "Esta imagem não está mais disponível"
- Mensagens de texto chegam normalmente
---
## Diagnóstico — Root Causes
Havia **três bugs independentes**, todos precisavam ser corrigidos.
---
### Bug 1 — URL de mídia apontando para `localhost` (invisível via ngrok)
**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, então `<img>` e `<audio>` falhavam silenciosamente.
```ruby
# ANTES (quebrado):
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:**
- 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)
**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 — CDN do WhatsApp redireciona → `nil` silencioso |
| 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.
Diagnóstico confirmado via `xxd`:
```
# JPEG começa com FF D8 FF
# Arquivo salvo começava com:
00000000: 3188 d20c 46ae 98f3 03bd... ← bytes encriptados
```
**Correção:**
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
**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
# ANTES (quebrado):
decipher.update!(data) + decipher.final
# DEPOIS (correto):
decipher.update(data) + decipher.final
```
---
## Estado correto após os fixes
### `app/models/attachment.rb`
- `file_url` e `thumb_url` usam `dev_url_options` que lê `FRONTEND_URL` do `.env`
- Em produção (`else`): comportamento inalterado com `url_for(file)`
### `app/services/whatsapp/incoming_message_wuzapi_service.rb`
- Method 1: WuzAPI endpoint (funciona quando disponível)
- Method 2+3 unificados: `Down.download` → decrypt com `mediaKey` → fallback raw
### `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
- Loga os primeiros bytes se o formato não for reconhecido (facilita debug futuro)
---
## Checklist de diagnóstico (quando áudio/imagem não funcionar)
### 1. Verificar URLs geradas
```bash
grep "data_url" log/development.log | tail -3
```
Deve aparecer: `https://SEU-NGROK.ngrok-free.dev/rails/active_storage/...`
❌ Se aparecer `http://localhost:3000/...` → Bug 1 voltou (checar `FRONTEND_URL` no `.env`)
---
### 2. Verificar se o arquivo está encriptado
```bash
# Pegar o blob key do arquivo (via Rails console ou log)
xxd storage/XX/XX/BLOB_KEY | head -2
```
- `FF D8 FF` → JPEG válido ✅
- `89 50 4E 47` → PNG válido ✅
- `4F 67 67 53` (OggS) → OGG válido ✅
- Qualquer outra coisa → encriptado ❌
---
### 3. Verificar logs de descriptografia
```bash
grep -E "WuzAPI Decrypt|SUCCESS|invalid format|first bytes" log/development.log | tail -10
```
- `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 Decrypt Error: NoMethodError` → bug no Ruby (checar `update` vs `update!`)
- Nada aparece após "Attempting local decryption" → mediaKey ausente no payload do WuzAPI
---
### 4. Verificar se o Sidekiq está rodando e processando
```bash
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:
```bash
pkill -f sidekiq
rm -f .overmind.sock
pnpm run dev
```
---
### 5. Verificar se o arquivo existe no disco
```bash
# 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 = o upload falhou silenciosamente.
---
### 6. Verificar status do WuzAPI endpoint de download
```bash
grep "WuzAPI: Endpoint download failed" log/development.log | tail -3
```
Se aparece `502 Bad Gateway` sistematicamente → WuzAPI `/chat/downloadimage` está down.
Isso é esperado e o sistema cai automaticamente para Method 2+3 (download direto + decrypt).
---
## Dependências críticas
| Variável | Onde | Valor esperado em dev com ngrok |
|----------|------|---------------------------------|
| `FRONTEND_URL` | `.env` | `https://SEU-URL.ngrok-free.dev` |
| `ACTIVE_STORAGE_SERVICE` | `.env` | `local` |
| `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 `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.
- Em **produção** (sem ngrok), o Bug 1 não existe pois `url_for(file)` usa `default_url_options` configurado corretamente.

View File

@ -114,4 +114,12 @@ export default defineConfig({
mockReset: true, mockReset: true,
clearMocks: true, clearMocks: true,
}, },
server: {
proxy: {
'/rails': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
}); });