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:
parent
26692bb5e2
commit
6b214b38db
@ -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
|
||||
worker: bundle exec sidekiq -C config/sidekiq.yml
|
||||
vite: bin/vite dev
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
import BaseBubble from './Base.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
@ -20,19 +19,11 @@ const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
type: 'image',
|
||||
});
|
||||
const hasError = ref(false);
|
||||
|
||||
const showGallery = ref(false);
|
||||
const isDownloading = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (attachment.value?.dataUrl) {
|
||||
loadWithRetry(attachment.value.dataUrl);
|
||||
}
|
||||
});
|
||||
|
||||
const downloadAttachment = async () => {
|
||||
const { fileType, dataUrl, extension } = attachment.value;
|
||||
try {
|
||||
@ -62,12 +53,13 @@ const handleImageError = () => {
|
||||
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
|
||||
<div v-else class="relative group rounded-lg overflow-hidden">
|
||||
<img
|
||||
class="skip-context-menu"
|
||||
:src="attachment.dataUrl"
|
||||
:width="attachment.width"
|
||||
:height="attachment.height"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<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"
|
||||
|
||||
@ -1,13 +1,6 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
useTemplateRef,
|
||||
ref,
|
||||
getCurrentInstance,
|
||||
} from 'vue';
|
||||
import { computed, useTemplateRef, ref, getCurrentInstance } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
@ -30,9 +23,7 @@ defineOptions({
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
type: 'audio',
|
||||
});
|
||||
const hasError = ref(false);
|
||||
|
||||
const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
@ -59,11 +50,9 @@ const playbackSpeedLabel = computed(() => {
|
||||
return `${playbackSpeed.value}x`;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (attachment.dataUrl) {
|
||||
loadWithRetry(attachment.dataUrl);
|
||||
}
|
||||
});
|
||||
const handleAudioError = () => {
|
||||
hasError.value = true;
|
||||
};
|
||||
|
||||
// Listen for global audio play events and pause if it's not this audio
|
||||
useEmitter('pause_playing_audio', currentPlayingId => {
|
||||
@ -143,7 +132,7 @@ const downloadAudio = async () => {
|
||||
{{ t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else-if="isLoaded">
|
||||
<template v-else>
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
controls
|
||||
@ -152,6 +141,7 @@ const downloadAudio = async () => {
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnd"
|
||||
@error="handleAudioError"
|
||||
>
|
||||
<source :src="timeStampURL" />
|
||||
</audio>
|
||||
|
||||
@ -366,6 +366,9 @@ export default {
|
||||
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
scrollToBottom() {
|
||||
if (!this.conversationPanel) {
|
||||
return;
|
||||
}
|
||||
this.isProgrammaticScroll = true;
|
||||
let relevantMessages = [];
|
||||
|
||||
|
||||
@ -110,12 +110,16 @@ export const hasValidAvatarUrl = avatarUrl => {
|
||||
};
|
||||
|
||||
export const timeStampAppendedURL = dataUrl => {
|
||||
const url = new URL(dataUrl);
|
||||
try {
|
||||
const url = new URL(dataUrl, window.location.origin);
|
||||
if (!url.searchParams.has('t')) {
|
||||
url.searchParams.append('t', Date.now());
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
} catch (e) {
|
||||
const connector = dataUrl.includes('?') ? '&' : '?';
|
||||
return `${dataUrl}${connector}t=${Date.now()}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const getHostNameFromURL = url => {
|
||||
|
||||
@ -50,7 +50,13 @@ class Attachment < ApplicationRecord
|
||||
|
||||
# NOTE: the URl returned does a 301 redirect to the actual file
|
||||
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
|
||||
|
||||
# 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?
|
||||
|
||||
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
|
||||
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/')
|
||||
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.
|
||||
# Lazily normalize existing blobs so presigned URLs serve the correct Content-Type.
|
||||
# Only applies to .ogg files — .opus files legitimately use audio/opus.
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
class Whatsapp::DecryptionService
|
||||
require 'openssl'
|
||||
require 'base64'
|
||||
require 'net/http'
|
||||
|
||||
# HKDF Info strings for different media types (WhatsApp protocol)
|
||||
INFO_STRINGS = {
|
||||
@ -12,55 +11,42 @@ class Whatsapp::DecryptionService
|
||||
sticker: 'WhatsApp Image Keys'
|
||||
}.freeze
|
||||
|
||||
def initialize(media_url, media_key, media_type)
|
||||
@media_url = media_url
|
||||
def initialize(media_key, media_type)
|
||||
@media_key = Base64.decode64(media_key)
|
||||
@media_type = media_type.to_sym
|
||||
@info = INFO_STRINGS[@media_type] || INFO_STRINGS[:document]
|
||||
end
|
||||
|
||||
def decrypt
|
||||
return nil unless @media_url && @media_key
|
||||
def decrypt_bytes(encrypted_bytes)
|
||||
return nil unless @media_key && encrypted_bytes && encrypted_bytes.bytesize > 10
|
||||
|
||||
# 1. Download encrypted bytes
|
||||
encrypted_bytes = download_content
|
||||
return nil unless encrypted_bytes && encrypted_bytes.bytesize > 10
|
||||
Rails.logger.info "WuzAPI Decrypt: Processing #{encrypted_bytes.bytesize} bytes"
|
||||
|
||||
Rails.logger.info "WuzAPI Decrypt: Downloaded #{encrypted_bytes.bytesize} bytes"
|
||||
|
||||
# 2. Derive keys using HKDF SHA-256 (112 bytes total)
|
||||
# Derive keys using HKDF SHA-256 (112 bytes total)
|
||||
expanded_key = OpenSSL::KDF.hkdf(
|
||||
@media_key,
|
||||
salt: ''.b, # Empty binary string
|
||||
salt: ''.b,
|
||||
info: @info,
|
||||
length: 112,
|
||||
hash: 'sha256'
|
||||
)
|
||||
|
||||
# 3. Split derived key
|
||||
iv = expanded_key[0...16]
|
||||
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)]
|
||||
# Remove the last 10 bytes (MAC)
|
||||
# WhatsApp file structure: [Encrypted Content] + [MAC (10 bytes)]
|
||||
cipher_text = encrypted_bytes[0...-10]
|
||||
|
||||
# 5. Try AES-256-CBC first (older WhatsApp versions)
|
||||
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)
|
||||
|
||||
return nil unless decrypted
|
||||
|
||||
# 7. Validate that we got a valid image (check magic bytes)
|
||||
if valid_media?(decrypted)
|
||||
Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected'
|
||||
StringIO.new(decrypted)
|
||||
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
|
||||
end
|
||||
rescue StandardError => e
|
||||
@ -77,7 +63,7 @@ class Whatsapp::DecryptionService
|
||||
decipher.iv = iv
|
||||
decipher.padding = 0 # WhatsApp doesn't use PKCS7 padding
|
||||
|
||||
decipher.update!(data) + decipher.final
|
||||
decipher.update(data) + decipher.final
|
||||
|
||||
rescue OpenSSL::Cipher::CipherError => e
|
||||
Rails.logger.debug { "AES-CBC failed: #{e.message}" }
|
||||
@ -90,7 +76,7 @@ class Whatsapp::DecryptionService
|
||||
decipher.key = key
|
||||
decipher.iv = iv
|
||||
|
||||
decipher.update!(data) + decipher.final
|
||||
decipher.update(data) + decipher.final
|
||||
|
||||
rescue OpenSSL::Cipher::CipherError => e
|
||||
Rails.logger.debug { "AES-CTR failed: #{e.message}" }
|
||||
@ -126,20 +112,4 @@ class Whatsapp::DecryptionService
|
||||
false
|
||||
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
|
||||
|
||||
@ -52,7 +52,7 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
|
||||
@contact = find_or_create_contact(parser)
|
||||
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
|
||||
# ------------------------------
|
||||
@ -124,7 +124,7 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
|
||||
contact
|
||||
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
|
||||
conversation = inbox.conversations.where(contact_id: contact.id)
|
||||
.where.not(status: :resolved)
|
||||
@ -136,12 +136,26 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
|
||||
# Find the ContactInbox association to linking
|
||||
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
|
||||
inbox.conversations.create!(
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox, # Explicitly required by Chatwoot validation
|
||||
status: :open,
|
||||
account_id: inbox.account_id
|
||||
account_id: inbox.account_id,
|
||||
additional_attributes: extra_attrs
|
||||
)
|
||||
end
|
||||
|
||||
@ -261,28 +275,30 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
|
||||
Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}"
|
||||
end
|
||||
|
||||
# METHOD 2: Try local decryption if we have mediaKey
|
||||
if attachment_data[:media_key].present?
|
||||
Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...'
|
||||
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(
|
||||
# METHOD 2+3: Download from CDN (follows redirects) then decrypt if mediaKey available
|
||||
Rails.logger.info "WuzAPI: Downloading from CDN #{media_url}"
|
||||
encrypted_tempfile = Down.download(
|
||||
media_url,
|
||||
open_timeout: 10,
|
||||
read_timeout: 30,
|
||||
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
|
||||
Rails.logger.error "WuzAPI: All download methods failed - #{e.message}"
|
||||
nil
|
||||
|
||||
@ -166,6 +166,40 @@ class Whatsapp::Providers::Wuzapi::PayloadParser
|
||||
}
|
||||
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
|
||||
jid = extract_jid
|
||||
|
||||
|
||||
BIN
chatwoot_zero/chatwoot-main-1/public/assets/administrate/bot/avatar.png
Executable file
BIN
chatwoot_zero/chatwoot-main-1/public/assets/administrate/bot/avatar.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
chatwoot_zero/chatwoot-main-1/public/assets/administrate/user/avatar.png
Executable file
BIN
chatwoot_zero/chatwoot-main-1/public/assets/administrate/user/avatar.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@ -32,6 +32,8 @@ Rails.application.configure do
|
||||
# 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.resolve_model_to_route = :rails_storage_proxy
|
||||
|
||||
config.active_job.queue_adapter = :sidekiq
|
||||
|
||||
Rails.application.routes.default_url_options = { host: ENV['FRONTEND_URL'].to_s.chomp('/') }
|
||||
|
||||
224
docs/troubleshooting/media-audio-imagem-whatsapp.md
Normal file
224
docs/troubleshooting/media-audio-imagem-whatsapp.md
Normal 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.
|
||||
224
progresso/2026-02-28_fix_media_audio_imagem_whatsapp.md
Normal file
224
progresso/2026-02-28_fix_media_audio_imagem_whatsapp.md
Normal 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.
|
||||
@ -114,4 +114,12 @@ export default defineConfig({
|
||||
mockReset: true,
|
||||
clearMocks: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/rails': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user