Merge pull request #7 from Rodribm10/fix-media-audio-v2
Fix media audio v2
This commit is contained in:
commit
309618113e
@ -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
|
||||||
|
|||||||
@ -63,10 +63,10 @@ const handleReset = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4 w-full">
|
||||||
<!-- Aviso de risco -->
|
<!-- Aviso de risco -->
|
||||||
<div
|
<div
|
||||||
class="flex items-start gap-3 p-3 rounded-lg bg-yellow-50 border border-yellow-200 text-yellow-800"
|
class="flex items-start gap-3 p-3 rounded-lg bg-yellow-50 border border-yellow-200 text-yellow-800 w-full"
|
||||||
>
|
>
|
||||||
<span class="i-lucide-triangle-alert mt-0.5 shrink-0 text-yellow-500" />
|
<span class="i-lucide-triangle-alert mt-0.5 shrink-0 text-yellow-500" />
|
||||||
<p class="text-sm leading-relaxed">
|
<p class="text-sm leading-relaxed">
|
||||||
@ -75,23 +75,26 @@ const handleReset = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Textarea do prompt -->
|
<!-- Textarea do prompt -->
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-2 w-full">
|
||||||
|
<div class="flex flex-col">
|
||||||
<label class="text-sm font-medium text-n-slate-12">
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.LABEL') }}
|
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.LABEL') }}
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-n-slate-11">
|
<p class="text-xs text-n-slate-11">
|
||||||
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.DESCRIPTION') }}
|
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.DESCRIPTION') }}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
<textarea
|
<textarea
|
||||||
v-model="promptText"
|
v-model="promptText"
|
||||||
rows="18"
|
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.PLACEHOLDER')"
|
||||||
class="w-full rounded-lg border border-n-weak bg-n-alpha-1 px-3 py-2.5 text-sm text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-2 focus:ring-n-brand resize-y font-mono"
|
class="w-full min-h-[500px] rounded-lg border border-n-weak bg-n-alpha-1 px-3 py-2.5 text-sm text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-2 focus:ring-n-brand resize-y font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Botões -->
|
<!-- Botões -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 mt-2 w-full">
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg bg-n-brand px-4 py-2 text-sm font-medium text-white hover:bg-n-brand-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
class="inline-flex items-center gap-1.5 rounded-lg bg-n-brand px-4 py-2 text-sm font-medium text-white hover:bg-n-brand-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
:disabled="!isDirty"
|
:disabled="!isDirty"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 = [];
|
||||||
|
|
||||||
|
|||||||
@ -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 => {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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,57 +11,22 @@ 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 valid_input?(encrypted_bytes)
|
||||||
|
|
||||||
# 1. Download encrypted bytes
|
expanded_key = derive_keys
|
||||||
encrypted_bytes = download_content
|
|
||||||
return nil unless encrypted_bytes && encrypted_bytes.bytesize > 10
|
|
||||||
|
|
||||||
Rails.logger.info "WuzAPI Decrypt: Downloaded #{encrypted_bytes.bytesize} bytes"
|
|
||||||
|
|
||||||
# 2. Derive keys using HKDF SHA-256 (112 bytes total)
|
|
||||||
expanded_key = OpenSSL::KDF.hkdf(
|
|
||||||
@media_key,
|
|
||||||
salt: ''.b, # Empty binary string
|
|
||||||
info: @info,
|
|
||||||
length: 112,
|
|
||||||
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)]
|
|
||||||
# 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) || try_aes_ctr(cipher_key, iv, cipher_text)
|
||||||
decrypted = try_aes_cbc(cipher_key, iv, cipher_text)
|
validate_decrypted_media(decrypted)
|
||||||
|
|
||||||
# 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'
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "WuzAPI Decrypt Error: #{e.class} - #{e.message}"
|
Rails.logger.error "WuzAPI Decrypt Error: #{e.class} - #{e.message}"
|
||||||
nil
|
nil
|
||||||
@ -70,6 +34,37 @@ class Whatsapp::DecryptionService
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def valid_input?(bytes)
|
||||||
|
@media_key && bytes && bytes.bytesize > 10
|
||||||
|
end
|
||||||
|
|
||||||
|
def derive_keys
|
||||||
|
OpenSSL::KDF.hkdf(
|
||||||
|
@media_key,
|
||||||
|
salt: ''.b,
|
||||||
|
info: @info,
|
||||||
|
length: 112,
|
||||||
|
hash: 'sha256'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_decrypted_media(decrypted)
|
||||||
|
return nil unless decrypted
|
||||||
|
|
||||||
|
if valid_media?(decrypted)
|
||||||
|
Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected'
|
||||||
|
StringIO.new(decrypted)
|
||||||
|
else
|
||||||
|
log_invalid_media(decrypted)
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_invalid_media(decrypted)
|
||||||
|
first_bytes = decrypted.bytes[0..3].map { |b| format('%02X', b) }.join(' ')
|
||||||
|
Rails.logger.warn "WuzAPI Decrypt: Decrypted but invalid format (first bytes: #{first_bytes})"
|
||||||
|
end
|
||||||
|
|
||||||
def try_aes_cbc(key, iv, data)
|
def try_aes_cbc(key, iv, data)
|
||||||
decipher = OpenSSL::Cipher.new('AES-256-CBC')
|
decipher = OpenSSL::Cipher.new('AES-256-CBC')
|
||||||
decipher.decrypt
|
decipher.decrypt
|
||||||
@ -77,7 +72,9 @@ 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
|
# rubocop:disable Rails/SaveBang
|
||||||
|
decipher.update(data) + decipher.final
|
||||||
|
# rubocop:enable Rails/SaveBang
|
||||||
|
|
||||||
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 +87,9 @@ class Whatsapp::DecryptionService
|
|||||||
decipher.key = key
|
decipher.key = key
|
||||||
decipher.iv = iv
|
decipher.iv = iv
|
||||||
|
|
||||||
decipher.update!(data) + decipher.final
|
# rubocop:disable Rails/SaveBang
|
||||||
|
decipher.update(data) + decipher.final
|
||||||
|
# rubocop:enable Rails/SaveBang
|
||||||
|
|
||||||
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}" }
|
||||||
@ -102,44 +101,26 @@ class Whatsapp::DecryptionService
|
|||||||
|
|
||||||
bytes = data.bytes[0..7]
|
bytes = data.bytes[0..7]
|
||||||
|
|
||||||
# JPEG: FF D8 FF
|
# Quick header checks for common WhatsApp media types
|
||||||
return true if bytes[0..2] == [0xFF, 0xD8, 0xFF]
|
return true if bytes[0..2] == [0xFF, 0xD8, 0xFF] # JPEG
|
||||||
|
return true if bytes[0..3] == [0x89, 0x50, 0x4E, 0x47] # PNG
|
||||||
# PNG: 89 50 4E 47
|
return true if webp?(data)
|
||||||
return true if bytes[0..3] == [0x89, 0x50, 0x4E, 0x47]
|
return true if mp4?(data)
|
||||||
|
return true if audio?(data, bytes)
|
||||||
# WebP: RIFF....WEBP
|
return true if data[0..3] == 'OggS' || data[0..3] == '%PDF'
|
||||||
return true if data[0..3] == 'RIFF' && data[8..11] == 'WEBP'
|
|
||||||
|
|
||||||
# MP4/MOV: ftyp
|
|
||||||
return true if data[4..7] == 'ftyp'
|
|
||||||
|
|
||||||
# MP3: ID3 or FF FB/FF FA
|
|
||||||
return true if data[0..2] == 'ID3' || bytes[0..1] == [0xFF, 0xFB] || bytes[0..1] == [0xFF, 0xFA]
|
|
||||||
|
|
||||||
# OGG: OggS
|
|
||||||
return true if data[0..3] == 'OggS'
|
|
||||||
|
|
||||||
# PDF: %PDF
|
|
||||||
return true if data[0..3] == '%PDF'
|
|
||||||
|
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def download_content
|
def webp?(data)
|
||||||
uri = URI.parse(@media_url)
|
data[0..3] == 'RIFF' && data[8..11] == 'WEBP'
|
||||||
http = Net::HTTP.new(uri.host, uri.port)
|
end
|
||||||
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)
|
def mp4?(data)
|
||||||
response = http.request(request)
|
data[4..7] == 'ftyp'
|
||||||
|
end
|
||||||
|
|
||||||
response.is_a?(Net::HTTPSuccess) ? response.body.b : nil
|
def audio?(data, bytes)
|
||||||
rescue StandardError => e
|
data[0..2] == 'ID3' || [0xFF, 0xFB].include?(bytes[0..1]) || [0xFF, 0xFA].include?(bytes[0..1])
|
||||||
Rails.logger.error "WuzAPI Decrypt Download Error: #{e.message}"
|
|
||||||
nil
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,150 +1,132 @@
|
|||||||
class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseService
|
class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseService
|
||||||
|
include Whatsapp::Wuzapi::PayloadParserExtension
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
# 1. Parse Payload
|
@parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
|
||||||
# ----------------
|
return if ignore_message?
|
||||||
# Extract all necessary data from the WuzAPI webhook payload
|
|
||||||
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
|
|
||||||
Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}"
|
|
||||||
|
|
||||||
# 2. Basic Validation
|
@clean_source_id = "WAID:#{@parser.external_id}"
|
||||||
# -------------------
|
|
||||||
# Ignore statuses, presence updates, and errors for now
|
|
||||||
if parser.message_type == :chat_presence || parser.message_type == :error || parser.message_type == :ignore
|
|
||||||
Rails.logger.info "WuzAPI: Ignoring presence/error/ignore update (Type: #{parser.message_type})"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
allowed_types = [:text, :image, :audio, :video, :document, :sticker]
|
|
||||||
unless allowed_types.include?(parser.message_type)
|
|
||||||
Rails.logger.info(
|
|
||||||
"WuzAPI: Unsupported message type: #{parser.message_type} " \
|
|
||||||
"(webhook.type=#{params[:type]}, event.Info.Type=#{params.dig(:event, :Info, :Type)}, event.Type=#{params.dig(:event, :Type)})"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# 2.1 V1 Scope: Ignore Groups
|
|
||||||
if parser.group_message?
|
|
||||||
Rails.logger.info "WuzAPI: Ignoring group message (ID: #{parser.external_id})"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
if parser.sender_phone_number.blank? && !parser.from_me?
|
|
||||||
Rails.logger.warn "WuzAPI: Skipping processing for event with no valid sender phone (Type: #{parser.message_type})"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# 3. Strong Dedupe (Existing External ID)
|
|
||||||
# ---------------------------------------
|
|
||||||
# If we already have a message with this WAID, ignore it immediately.
|
|
||||||
# This catches standard retries from WuzAPI or webhook re-deliveries.
|
|
||||||
clean_source_id = "WAID:#{parser.external_id}"
|
|
||||||
|
|
||||||
# 4. Find/Create Resources
|
|
||||||
# ------------------------
|
|
||||||
# Based on the sender (customer) or recipient (if it's a mobile reply)
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
# Strong dedupe inside transaction to prevent TOCTOU race condition
|
return if duplicate?
|
||||||
if parser.external_id.present? && Message.exists?(source_id: clean_source_id, inbox_id: inbox.id)
|
|
||||||
Rails.logger.info "WuzAPI: Ignoring duplicate message (ID: #{clean_source_id})"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
@contact = find_or_create_contact(parser)
|
|
||||||
return if @contact.nil? # If contact couldn't be determined, stop processing
|
|
||||||
|
|
||||||
@conversation = find_or_create_conversation(@contact)
|
process_incoming_payload
|
||||||
|
|
||||||
# 5. Echo/AI Deduplication Logic
|
|
||||||
# ------------------------------
|
|
||||||
# If this is an outgoing message (from_me=true), it might be:
|
|
||||||
# A) A reply sent from the physical phone (needs to be created as outgoing)
|
|
||||||
# B) A confirmation echo of a message Chatwoot/AI just sent (needs to be merged)
|
|
||||||
if parser.from_me?
|
|
||||||
deduplicated_message = find_outgoing_message_to_deduplicate(parser, @conversation)
|
|
||||||
if deduplicated_message
|
|
||||||
# Merging logic: Update the local temporary message with the real WuzAPI ID
|
|
||||||
Rails.logger.info "WuzAPI: Merging echo into existing message #{deduplicated_message.id}"
|
|
||||||
deduplicated_message.update!(source_id: clean_source_id)
|
|
||||||
return # Stop processing, we successfully merged.
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# 6. Create Message
|
|
||||||
# -----------------
|
|
||||||
# If it wasn't a duplicate, create the new message (Incoming or Outgoing)
|
|
||||||
@message = build_message(parser, @conversation, clean_source_id)
|
|
||||||
|
|
||||||
# Attach media BEFORE saving (Chatwoot pattern)
|
|
||||||
attach_files(parser) if [:image, :audio, :video, :document, :sticker].include?(parser.message_type)
|
|
||||||
|
|
||||||
# Now save with attachments
|
|
||||||
@message.save!
|
|
||||||
Rails.logger.info "WuzAPI: Message created: #{@message.id} (SourceID: #{clean_source_id})"
|
|
||||||
end
|
end
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "WuzAPI Error: #{e.message}"
|
log_error(e)
|
||||||
Rails.logger.error e.backtrace.join("\n")
|
|
||||||
raise e
|
raise e
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def find_or_create_contact(parser)
|
def ignore_message?
|
||||||
# If from_me is true, the sender is US (the business).
|
return true if @parser.message_type == :chat_presence || @parser.message_type == :error || @parser.message_type == :ignore
|
||||||
# The CONTACT for the conversation is properly the RECIPIENT (the customer).
|
return true unless [:text, :image, :audio, :video, :document, :sticker].include?(@parser.message_type)
|
||||||
# If from_me is false, the sender is the CUSTOMER.
|
return true if @parser.group_message?
|
||||||
phone_number = if parser.from_me?
|
return true if @parser.sender_phone_number.blank? && !@parser.from_me?
|
||||||
parser.recipient_phone_number # Extracted from Chat ID
|
|
||||||
else
|
false
|
||||||
parser.sender_phone_number # Extracted from Sender ID
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def duplicate?
|
||||||
|
return false if @parser.external_id.blank?
|
||||||
|
return false unless Message.exists?(source_id: @clean_source_id, inbox_id: inbox.id)
|
||||||
|
|
||||||
|
Rails.logger.info "WuzAPI: Ignoring duplicate message (ID: #{@clean_source_id})"
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_incoming_payload
|
||||||
|
@contact = find_or_create_contact
|
||||||
|
return if @contact.nil?
|
||||||
|
|
||||||
|
@conversation = find_or_create_conversation
|
||||||
|
|
||||||
|
return if @parser.from_me? && handle_echo_message
|
||||||
|
|
||||||
|
create_new_message
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_echo_message
|
||||||
|
deduplicated_message = find_outgoing_message_to_deduplicate(@parser, @conversation)
|
||||||
|
return false unless deduplicated_message
|
||||||
|
|
||||||
|
Rails.logger.info "WuzAPI: Merging echo into existing message #{deduplicated_message.id}"
|
||||||
|
deduplicated_message.update!(source_id: @clean_source_id)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_new_message
|
||||||
|
@message = build_message(@parser, @conversation, @clean_source_id)
|
||||||
|
attach_files if [:image, :audio, :video, :document, :sticker].include?(@parser.message_type)
|
||||||
|
@message.save!
|
||||||
|
Rails.logger.info "WuzAPI: Message created: #{@message.id} (SourceID: #{@clean_source_id})"
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_error(error)
|
||||||
|
Rails.logger.error "WuzAPI Error: #{error.message}"
|
||||||
|
Rails.logger.error error.backtrace.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_contact
|
||||||
|
phone_number = @parser.from_me? ? @parser.recipient_phone_number : @parser.sender_phone_number
|
||||||
return nil if phone_number.blank?
|
return nil if phone_number.blank?
|
||||||
|
|
||||||
contact_inbox = ContactInbox.find_by(inbox_id: inbox.id, source_id: phone_number)
|
contact_inbox = ContactInbox.find_by(inbox_id: inbox.id, source_id: phone_number)
|
||||||
return contact_inbox.contact if contact_inbox
|
return contact_inbox.contact if contact_inbox
|
||||||
|
|
||||||
# Create or Find existing contact in the account
|
contact = find_existing_contact(phone_number)
|
||||||
# We use find_by to avoid uniqueness validation errors if the contact exists in another inbox
|
contact ||= create_contact(phone_number)
|
||||||
formatted_phone = "+#{phone_number.to_s.delete('+')}"
|
|
||||||
contact = inbox.account.contacts.find_by(phone_number: formatted_phone)
|
|
||||||
|
|
||||||
contact ||= inbox.account.contacts.create!(
|
|
||||||
name: parser.sender_name || phone_number,
|
|
||||||
phone_number: formatted_phone,
|
|
||||||
custom_attributes: { wuzapi_id: phone_number }
|
|
||||||
)
|
|
||||||
|
|
||||||
ContactInbox.create!(
|
|
||||||
contact: contact,
|
|
||||||
inbox: inbox,
|
|
||||||
source_id: phone_number
|
|
||||||
)
|
|
||||||
|
|
||||||
|
create_contact_inbox(contact, phone_number)
|
||||||
contact
|
contact
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_or_create_conversation(contact)
|
def find_existing_contact(phone_number)
|
||||||
# Find the LAST open conversation for this contact to append to
|
formatted_phone = "+#{phone_number.to_s.delete('+')}"
|
||||||
conversation = inbox.conversations.where(contact_id: contact.id)
|
inbox.account.contacts.find_by(phone_number: formatted_phone)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_contact(phone_number)
|
||||||
|
formatted_phone = "+#{phone_number.to_s.delete('+')}"
|
||||||
|
inbox.account.contacts.create!(
|
||||||
|
name: @parser.sender_name || phone_number,
|
||||||
|
phone_number: formatted_phone,
|
||||||
|
custom_attributes: { wuzapi_id: phone_number }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_contact_inbox(contact, phone_number)
|
||||||
|
ContactInbox.create!(contact: contact, inbox: inbox, source_id: phone_number)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_conversation
|
||||||
|
conversation = inbox.conversations.where(contact_id: @contact.id)
|
||||||
.where.not(status: :resolved)
|
.where.not(status: :resolved)
|
||||||
.order(updated_at: :desc)
|
.order(updated_at: :desc).first
|
||||||
.first
|
|
||||||
|
|
||||||
return conversation if conversation
|
return conversation if conversation
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
# 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,
|
||||||
status: :open,
|
status: :open,
|
||||||
account_id: inbox.account_id
|
account_id: inbox.account_id,
|
||||||
|
additional_attributes: conversation_attributes
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def conversation_attributes
|
||||||
|
referral = @parser.referral_info
|
||||||
|
return {} if referral.blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
'referer' => referral[:source_url].presence || 'meta_ads',
|
||||||
|
'source_type' => referral[:source_type],
|
||||||
|
'ctwa_clid' => referral[:ctwa_clid]
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
|
||||||
def find_outgoing_message_to_deduplicate(parser, conversation)
|
def find_outgoing_message_to_deduplicate(parser, conversation)
|
||||||
# We are looking for a message that:
|
# We are looking for a message that:
|
||||||
# 1. Is Outgoing (message_type: 1)
|
# 1. Is Outgoing (message_type: 1)
|
||||||
@ -168,155 +150,44 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
|
|||||||
|
|
||||||
def build_message(parser, conversation, clean_source_id)
|
def build_message(parser, conversation, clean_source_id)
|
||||||
is_outgoing = parser.from_me?
|
is_outgoing = parser.from_me?
|
||||||
|
|
||||||
msg_params = {
|
msg_params = {
|
||||||
content: parser.text_content,
|
content: parser.text_content,
|
||||||
account_id: inbox.account_id,
|
account_id: inbox.account_id, inbox_id: inbox.id,
|
||||||
inbox_id: inbox.id,
|
|
||||||
message_type: is_outgoing ? :outgoing : :incoming,
|
message_type: is_outgoing ? :outgoing : :incoming,
|
||||||
# If outgoing, sender is nil (system/agent). If incoming, sender is the contact.
|
|
||||||
sender: is_outgoing ? nil : @contact,
|
sender: is_outgoing ? nil : @contact,
|
||||||
source_id: clean_source_id,
|
source_id: clean_source_id,
|
||||||
created_at: parser.timestamp || Time.current
|
created_at: parser.timestamp || Time.current
|
||||||
}
|
}
|
||||||
|
|
||||||
# Handle Replies
|
add_reply_to_params(msg_params, parser, conversation)
|
||||||
# Handle Reply Logic (Aligned with Reference)
|
|
||||||
if (reply_id = parser.in_reply_to_external_id).present?
|
|
||||||
clean_reply_id = "WAID:#{reply_id}"
|
|
||||||
|
|
||||||
# Strict lookup within conversation to prevent cross-inbox leaks
|
|
||||||
original_message = conversation.messages.find_by(source_id: clean_reply_id)
|
|
||||||
|
|
||||||
if original_message
|
|
||||||
msg_params[:in_reply_to_id] = original_message.id
|
|
||||||
else
|
|
||||||
# Fallback: Store ID for UI "Replying to..." display even if not linked
|
|
||||||
msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Use .build so we can attach files before .save!
|
|
||||||
conversation.messages.build(msg_params)
|
conversation.messages.build(msg_params)
|
||||||
end
|
end
|
||||||
|
|
||||||
def attach_files(parser)
|
def add_reply_to_params(params, parser, conversation)
|
||||||
attachment_data = parser.attachment_params
|
reply_id = parser.in_reply_to_external_id
|
||||||
return if attachment_data.blank? || attachment_data[:external_url].blank?
|
return if reply_id.blank?
|
||||||
|
|
||||||
begin
|
clean_reply_id = "WAID:#{reply_id}"
|
||||||
Rails.logger.info "WuzAPI: Processing attachment (URL: #{attachment_data[:external_url]}, File: #{attachment_data[:file_name]})"
|
original_message = conversation.messages.find_by(source_id: clean_reply_id)
|
||||||
|
|
||||||
# 1. Download/Decrypt to get a file
|
if original_message
|
||||||
file_io = download_or_decrypt_media(attachment_data, parser.message_type)
|
params[:in_reply_to_id] = original_message.id
|
||||||
return if file_io.blank?
|
else
|
||||||
|
params[:content_attributes] = { in_reply_to_external_id: clean_reply_id }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# 2. Determine filename
|
def attach_files
|
||||||
original_filename = attachment_data[:file_name] || "wuzapi_#{Time.now.to_i}"
|
@attachment_data = @parser.attachment_params
|
||||||
extension = File.extname(original_filename)
|
return if @attachment_data.blank? || @attachment_data[:external_url].blank?
|
||||||
extension = detect_extension(attachment_data[:mimetype], parser.message_type) if extension.blank?
|
|
||||||
final_filename = "#{File.basename(original_filename, '.*')}#{extension}"
|
|
||||||
|
|
||||||
# 3. Attach using Chatwoot's standard pattern
|
|
||||||
@message.attachments.new(
|
|
||||||
account_id: @message.account_id,
|
|
||||||
file_type: file_content_type(parser.message_type),
|
|
||||||
file: {
|
|
||||||
io: file_io,
|
|
||||||
filename: final_filename,
|
|
||||||
content_type: attachment_data[:mimetype] || 'application/octet-stream'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Rails.logger.info "WuzAPI: Attachment queued for save (#{final_filename})"
|
|
||||||
|
|
||||||
|
Whatsapp::Wuzapi::MediaHandler.new(inbox, @parser).process(@message, @attachment_data)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "WuzAPI Attachment Error: #{e.message}"
|
log_attachment_error(e)
|
||||||
Rails.logger.error e.backtrace.first(10).join("\n")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def download_or_decrypt_media(attachment_data, message_type)
|
def log_attachment_error(error)
|
||||||
media_url = attachment_data[:external_url]
|
Rails.logger.error "WuzAPI Attachment Error: #{error.message}"
|
||||||
|
Rails.logger.error error.backtrace.first(10).join("\n")
|
||||||
# METHOD 1: Use WuzAPI's /chat/downloadimage endpoint (returns DECRYPTED media)
|
|
||||||
# This is the equivalent of Cloud API's media download
|
|
||||||
begin
|
|
||||||
Rails.logger.info 'WuzAPI: Attempting download via WuzAPI endpoint...'
|
|
||||||
wuzapi_response = wuzapi_client.download_media(wuzapi_token, media_url)
|
|
||||||
|
|
||||||
if wuzapi_response.is_a?(Hash) && wuzapi_response['data'].present?
|
|
||||||
# WuzAPI returns base64 in 'data' field
|
|
||||||
image_data = wuzapi_response['data']
|
|
||||||
# Strip data URI prefix if present
|
|
||||||
image_data = image_data.sub(/^data:.*?;base64,/, '') if image_data.start_with?('data:')
|
|
||||||
|
|
||||||
decoded = Base64.decode64(image_data)
|
|
||||||
if decoded.bytesize > 1000 # Valid image should be > 1KB
|
|
||||||
Rails.logger.info 'WuzAPI: Download via endpoint SUCCESS'
|
|
||||||
return StringIO.new(decoded)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
rescue StandardError => e
|
|
||||||
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(
|
|
||||||
media_url,
|
|
||||||
open_timeout: 10,
|
|
||||||
read_timeout: 30,
|
|
||||||
ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE
|
|
||||||
)
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error "WuzAPI: All download methods failed - #{e.message}"
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def wuzapi_client
|
|
||||||
@wuzapi_client ||= Wuzapi::Client.new(@inbox.channel.provider_config['wuzapi_base_url'])
|
|
||||||
end
|
|
||||||
|
|
||||||
def wuzapi_token
|
|
||||||
@inbox.channel.wuzapi_user_token
|
|
||||||
end
|
|
||||||
|
|
||||||
def detect_extension(mimetype, message_type)
|
|
||||||
return '.jpg' if message_type == :image || message_type == :sticker
|
|
||||||
return '.mp3' if message_type == :audio
|
|
||||||
return '.mp4' if message_type == :video
|
|
||||||
|
|
||||||
case mimetype
|
|
||||||
when 'image/png' then '.png'
|
|
||||||
when 'image/webp' then '.webp'
|
|
||||||
when 'image/gif' then '.gif'
|
|
||||||
when 'audio/ogg' then '.ogg'
|
|
||||||
when 'video/webm' then '.webm'
|
|
||||||
else '.bin'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def file_content_type(message_type)
|
|
||||||
case message_type
|
|
||||||
when :image, :sticker then :image
|
|
||||||
when :audio then :audio
|
|
||||||
when :video then :video
|
|
||||||
else :file
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -73,98 +73,7 @@ class Whatsapp::Providers::Wuzapi::PayloadParser
|
|||||||
params.dig(:event, :State)
|
params.dig(:event, :State)
|
||||||
end
|
end
|
||||||
|
|
||||||
def in_reply_to_external_id
|
include Whatsapp::Wuzapi::PayloadParserExtension
|
||||||
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
|
||||||
return nil unless msg.is_a?(Hash)
|
|
||||||
|
|
||||||
# DEBUG: Log the message structure to understand reply context
|
|
||||||
Rails.logger.info "WuzAPI Reply Debug: Message keys = #{msg.keys.inspect}"
|
|
||||||
|
|
||||||
# 1. Extended text
|
|
||||||
ctx = msg.dig(:extendedTextMessage, :contextInfo)
|
|
||||||
if ctx.present?
|
|
||||||
Rails.logger.info "WuzAPI Reply Debug: Found extendedTextMessage contextInfo = #{ctx.inspect}"
|
|
||||||
stanza = ctx[:stanzaID] || ctx[:stanzaId]
|
|
||||||
return stanza if stanza.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
# 2. Media Types direct contextInfo
|
|
||||||
[:imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key|
|
|
||||||
ctx = msg.dig(key, :contextInfo)
|
|
||||||
next if ctx.blank?
|
|
||||||
|
|
||||||
Rails.logger.info "WuzAPI Reply Debug: Found #{key} contextInfo = #{ctx.inspect}"
|
|
||||||
stanza = ctx[:stanzaID] || ctx[:stanzaId]
|
|
||||||
return stanza if stanza.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
# 3. Document With Caption
|
|
||||||
if msg.key?(:documentWithCaptionMessage)
|
|
||||||
ctx = msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo)
|
|
||||||
if ctx.present?
|
|
||||||
Rails.logger.info "WuzAPI Reply Debug: Found documentWithCaptionMessage contextInfo = #{ctx.inspect}"
|
|
||||||
return ctx[:stanzaID] || ctx[:stanzaId]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# 4. Check for simple conversation with contextInfo (text reply without extendedTextMessage)
|
|
||||||
if msg[:conversation].present? && msg[:contextInfo].present?
|
|
||||||
ctx = msg[:contextInfo]
|
|
||||||
Rails.logger.info "WuzAPI Reply Debug: Found conversation contextInfo = #{ctx.inspect}"
|
|
||||||
stanza = ctx[:stanzaID] || ctx[:stanzaId]
|
|
||||||
return stanza if stanza.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
Rails.logger.info 'WuzAPI Reply Debug: No reply context found'
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def text_content
|
|
||||||
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
|
||||||
# Legacy fallback used by some WuzAPI payload variants
|
|
||||||
return params.dig(:event, :Text) if params.dig(:event, :Text).present?
|
|
||||||
return nil unless msg.is_a?(Hash)
|
|
||||||
|
|
||||||
# 1. Simple text
|
|
||||||
return msg[:conversation] if msg[:conversation].present?
|
|
||||||
|
|
||||||
# 2. Extended Text
|
|
||||||
return msg.dig(:extendedTextMessage, :text) if msg.dig(:extendedTextMessage, :text).present?
|
|
||||||
|
|
||||||
# 3. Media Captions (Image, Video, Document)
|
|
||||||
[:imageMessage, :videoMessage, :documentMessage].each do |media_key|
|
|
||||||
caption = msg.dig(media_key, :caption)
|
|
||||||
return caption if caption.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
# 4. Document With Caption
|
|
||||||
return msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :caption) if msg.key?(:documentWithCaptionMessage)
|
|
||||||
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def attachment_params
|
|
||||||
media_key = case message_type
|
|
||||||
when :image then :imageMessage
|
|
||||||
when :audio then :audioMessage
|
|
||||||
when :video then :videoMessage
|
|
||||||
when :document then :documentMessage
|
|
||||||
when :sticker then :stickerMessage
|
|
||||||
end
|
|
||||||
return nil unless media_key
|
|
||||||
|
|
||||||
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
|
||||||
data = msg[media_key]
|
|
||||||
return nil unless data.is_a?(Hash)
|
|
||||||
|
|
||||||
{
|
|
||||||
external_url: data['URL'],
|
|
||||||
file_name: data['fileName'] || "file_#{external_id}",
|
|
||||||
mimetype: data['mimetype'],
|
|
||||||
thumbnail: data['JPEGThumbnail'],
|
|
||||||
media_key: data['mediaKey']
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def sender_phone_number
|
def sender_phone_number
|
||||||
jid = extract_jid
|
jid = extract_jid
|
||||||
@ -225,19 +134,16 @@ class Whatsapp::Providers::Wuzapi::PayloadParser
|
|||||||
end
|
end
|
||||||
|
|
||||||
def fallback_message_type_from_payload
|
def fallback_message_type_from_payload
|
||||||
# Fallback: detect type from message body shape, even when Info.Type is missing or inconsistent.
|
|
||||||
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||||
|
return :text if params.dig(:event, :Text).present?
|
||||||
|
return :unknown unless msg.is_a?(Hash)
|
||||||
|
|
||||||
if msg.is_a?(Hash)
|
return :text if msg[:conversation].present? || msg[:extendedTextMessage].present?
|
||||||
return :text if msg[:conversation].present? || msg[:extendedTextMessage].present? || msg.dig(:extendedTextMessage, :text).present?
|
|
||||||
return :image if msg[:imageMessage].present?
|
return :image if msg[:imageMessage].present?
|
||||||
return :audio if msg[:audioMessage].present?
|
return :audio if msg[:audioMessage].present?
|
||||||
return :video if msg[:videoMessage].present?
|
return :video if msg[:videoMessage].present?
|
||||||
return :document if msg[:documentMessage].present? || msg[:documentWithCaptionMessage].present?
|
|
||||||
return :sticker if msg[:stickerMessage].present?
|
return :sticker if msg[:stickerMessage].present?
|
||||||
end
|
return :document if msg[:documentMessage].present? || msg[:documentWithCaptionMessage].present?
|
||||||
|
|
||||||
return :text if params.dig(:event, :Text).present?
|
|
||||||
|
|
||||||
:unknown
|
:unknown
|
||||||
end
|
end
|
||||||
@ -250,27 +156,24 @@ class Whatsapp::Providers::Wuzapi::PayloadParser
|
|||||||
|
|
||||||
def extract_jid
|
def extract_jid
|
||||||
if from_me?
|
if from_me?
|
||||||
# For outgoing messages, prefer Chat if it's a real number
|
extract_recipient_jid
|
||||||
|
else
|
||||||
|
extract_sender_jid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_recipient_jid
|
||||||
chat = params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat)
|
chat = params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat)
|
||||||
return chat if chat&.include?('@s.whatsapp.net')
|
return chat if chat&.include?('@s.whatsapp.net')
|
||||||
|
|
||||||
# Fallback to RecipientAlt when Chat uses LID format
|
|
||||||
recipient_alt = params.dig(:event, :Info, :RecipientAlt) || params.dig(:event, :RecipientAlt)
|
recipient_alt = params.dig(:event, :Info, :RecipientAlt) || params.dig(:event, :RecipientAlt)
|
||||||
return recipient_alt if recipient_alt&.include?('@s.whatsapp.net')
|
recipient_alt&.include?('@s.whatsapp.net') ? recipient_alt : chat
|
||||||
|
end
|
||||||
|
|
||||||
chat # Return original Chat even if LID (will be filtered later)
|
def extract_sender_jid
|
||||||
else
|
|
||||||
sender = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender)
|
sender = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender)
|
||||||
sender_alt = params.dig(:event, :Info, :SenderAlt) || params.dig(:event, :SenderAlt)
|
sender_alt = params.dig(:event, :Info, :SenderAlt) || params.dig(:event, :SenderAlt)
|
||||||
|
|
||||||
# Prefer @s.whatsapp.net over @lid
|
sender&.include?('@s.whatsapp.net') ? sender : (sender_alt || sender)
|
||||||
if sender&.include?('@s.whatsapp.net')
|
|
||||||
sender
|
|
||||||
elsif sender_alt&.include?('@s.whatsapp.net')
|
|
||||||
sender_alt
|
|
||||||
else
|
|
||||||
sender
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
105
app/services/whatsapp/wuzapi/media_handler.rb
Normal file
105
app/services/whatsapp/wuzapi/media_handler.rb
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
class Whatsapp::Wuzapi::MediaHandler
|
||||||
|
def initialize(inbox, parser)
|
||||||
|
@inbox = inbox
|
||||||
|
@parser = parser
|
||||||
|
end
|
||||||
|
|
||||||
|
def process(message, attachment_data)
|
||||||
|
file_io = download_or_decrypt_media(attachment_data, @parser.message_type)
|
||||||
|
return if file_io.blank?
|
||||||
|
|
||||||
|
message.attachments.new(
|
||||||
|
account_id: message.account_id,
|
||||||
|
file_type: file_content_type(@parser.message_type),
|
||||||
|
file: {
|
||||||
|
io: file_io,
|
||||||
|
filename: final_filename(attachment_data),
|
||||||
|
content_type: attachment_data[:mimetype] || 'application/octet-stream'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def download_or_decrypt_media(data, type)
|
||||||
|
url = data[:external_url]
|
||||||
|
decoded = download_via_wuzapi(url)
|
||||||
|
return StringIO.new(decoded) if decoded
|
||||||
|
|
||||||
|
encrypted_bytes = download_from_cdn(url)
|
||||||
|
return nil if encrypted_bytes.blank?
|
||||||
|
|
||||||
|
if data[:media_key].present?
|
||||||
|
decrypted = decrypt_media(encrypted_bytes, data[:media_key], type)
|
||||||
|
return decrypted if decrypted
|
||||||
|
end
|
||||||
|
|
||||||
|
StringIO.new(encrypted_bytes)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "WuzAPI: Media handling failed - #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_via_wuzapi(url)
|
||||||
|
response = wuzapi_client.download_media(wuzapi_token, url)
|
||||||
|
return nil unless response.is_a?(Hash) && response['data'].present?
|
||||||
|
|
||||||
|
image_data = response['data'].sub(/^data:.*?;base64,/, '')
|
||||||
|
decoded = Base64.decode64(image_data)
|
||||||
|
decoded.bytesize > 1000 ? decoded : nil
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_from_cdn(url)
|
||||||
|
tempfile = Down.download(url, open_timeout: 10, read_timeout: 30, ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE)
|
||||||
|
tempfile.read.b
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "WuzAPI: CDN download failed - #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def decrypt_media(bytes, key, type)
|
||||||
|
Whatsapp::DecryptionService.new(key, file_content_type(type)).decrypt_bytes(bytes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def final_filename(data)
|
||||||
|
name = data[:file_name] || "wuzapi_#{Time.now.to_i}"
|
||||||
|
ext = File.extname(name)
|
||||||
|
ext = detect_extension(data[:mimetype], @parser.message_type) if ext.blank?
|
||||||
|
"#{File.basename(name, '.*')}#{ext}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def detect_extension(mimetype, type)
|
||||||
|
return '.jpg' if type == :image || type == :sticker
|
||||||
|
return '.mp3' if type == :audio
|
||||||
|
return '.mp4' if type == :video
|
||||||
|
|
||||||
|
case mimetype
|
||||||
|
when 'image/png' then '.png'
|
||||||
|
when 'image/webp' then '.webp'
|
||||||
|
when 'image/gif' then '.gif'
|
||||||
|
when 'audio/ogg' then '.ogg'
|
||||||
|
when 'video/webm' then '.webm'
|
||||||
|
else '.bin'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def file_content_type(type)
|
||||||
|
case type
|
||||||
|
when :image, :sticker then :image
|
||||||
|
when :audio then :audio
|
||||||
|
when :video then :video
|
||||||
|
else :file
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def wuzapi_client
|
||||||
|
@wuzapi_client ||= ::Wuzapi::Client.new(@inbox.channel.provider_config['wuzapi_base_url'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def wuzapi_token
|
||||||
|
@inbox.channel.wuzapi_user_token
|
||||||
|
end
|
||||||
|
end
|
||||||
67
app/services/whatsapp/wuzapi/payload_parser_extension.rb
Normal file
67
app/services/whatsapp/wuzapi/payload_parser_extension.rb
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
module Whatsapp::Wuzapi::PayloadParserExtension
|
||||||
|
def in_reply_to_external_id
|
||||||
|
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||||
|
return nil unless msg.is_a?(Hash)
|
||||||
|
|
||||||
|
reply_id_from_extended_text(msg) ||
|
||||||
|
reply_id_from_media(msg) ||
|
||||||
|
reply_id_from_document_with_caption(msg) ||
|
||||||
|
reply_id_from_conversation(msg)
|
||||||
|
end
|
||||||
|
|
||||||
|
def referral_info
|
||||||
|
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||||
|
return nil unless msg.is_a?(Hash)
|
||||||
|
|
||||||
|
ad_reply = msg.dig(:extendedTextMessage, :contextInfo, :externalAdReply)
|
||||||
|
ad_reply ||= msg.dig('extendedTextMessage', 'contextInfo', 'externalAdReply')
|
||||||
|
|
||||||
|
return parse_ad_reply(ad_reply) if ad_reply.is_a?(Hash) && ad_reply.present?
|
||||||
|
return { source_type: 'ad', source_url: nil } if business_category?
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def reply_id_from_extended_text(msg)
|
||||||
|
ctx = msg.dig(:extendedTextMessage, :contextInfo)
|
||||||
|
ctx ? (ctx[:stanzaID] || ctx[:stanzaId]) : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def reply_id_from_media(msg)
|
||||||
|
[:imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key|
|
||||||
|
ctx = msg.dig(key, :contextInfo)
|
||||||
|
next if ctx.blank?
|
||||||
|
|
||||||
|
stanza = ctx[:stanzaID] || ctx[:stanzaId]
|
||||||
|
return stanza if stanza.present?
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def reply_id_from_document_with_caption(msg)
|
||||||
|
ctx = msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo)
|
||||||
|
ctx ? (ctx[:stanzaID] || ctx[:stanzaId]) : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def reply_id_from_conversation(msg)
|
||||||
|
ctx = msg[:contextInfo] if msg[:conversation].present?
|
||||||
|
ctx ? (ctx[:stanzaID] || ctx[:stanzaId]) : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_ad_reply(ad_reply)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
|
||||||
|
def business_category?
|
||||||
|
params.dig(:event, :Info, :Category).to_s.downcase == 'business'
|
||||||
|
end
|
||||||
|
end
|
||||||
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).
|
# 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('/') }
|
||||||
|
|||||||
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,
|
mockReset: true,
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/rails': {
|
||||||
|
target: 'http://127.0.0.1:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user