diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 10629e9ed..5840b2def 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -56,7 +56,10 @@ class Attachment < ApplicationRecord # NOTE: for External services use this methods since redirect doesn't work effectively in a lot of cases def download_url ActiveStorage::Current.url_options = Rails.application.routes.default_url_options if ActiveStorage::Current.url_options.blank? - file.attached? ? file.blob.url : '' + return '' unless file.attached? + + normalize_opus_blob_content_type! + file.blob.url end def thumb_url @@ -184,6 +187,16 @@ class Attachment < ApplicationRecord def media_file?(file_content_type) file_content_type.start_with?('image/', 'video/', 'audio/') 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. + def normalize_opus_blob_content_type! + blob = file.blob + return unless blob.content_type == 'audio/opus' && blob.filename.to_s.end_with?('.ogg') + + blob.update_column(:content_type, 'audio/ogg') # rubocop:disable Rails/SkipsModelValidations + end end Attachment.include_mod_with('Concerns::Attachment') diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 0acc888f3..dcce0bc21 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -156,7 +156,6 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi def send_attachment_message(phone_number, message) attachment = message.attachments.first - normalize_opus_content_type(attachment) type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document' type_content = { 'link' => attachment.download_url } type_content['caption'] = message.outgoing_content unless %w[audio sticker].include?(type) @@ -181,21 +180,6 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi type == 'audio' && attachment.meta&.dig('is_recorded_audio') && attachment.file.content_type == 'audio/ogg' end - # Marcel gem may re-detect OGG/Opus files as audio/opus after ActiveStorage - # blob attachment, but WhatsApp Cloud API requires audio/ogg content type - # for voice messages. Normalize so the download URL serves the correct - # Content-Type header. No-op when the frontend already uploads as audio/ogg. - def normalize_opus_content_type(attachment) - return unless attachment.file.attached? - - blob = attachment.file.blob - return unless blob.content_type == 'audio/opus' - - return if blob.update(content_type: 'audio/ogg') - - Rails.logger.error("Failed to normalize blob #{blob.id} content_type from audio/opus to audio/ogg") - end - def error_message(response) # https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response response.parsed_response&.dig('error', 'message') diff --git a/config/initializers/active_storage_opus_fix.rb b/config/initializers/active_storage_opus_fix.rb new file mode 100644 index 000000000..34dcdea81 --- /dev/null +++ b/config/initializers/active_storage_opus_fix.rb @@ -0,0 +1,18 @@ +# Marcel gem may detect OGG Opus files as audio/opus instead of audio/ogg. +# This is problematic because WhatsApp Cloud API (and other services) +# expect audio/ogg for OGG container files with the Opus codec. +# +# This initializer patches ActiveStorage::Blob to normalize audio/opus → audio/ogg +# at identification time for .ogg files, preventing the wrong content_type from +# being persisted. Files with .opus extension are left as audio/opus since they +# are genuinely Opus-only files. +ActiveSupport.on_load(:active_storage_blob) do + prepend(Module.new do + private + + def identify_content_type(io = nil) + detected = super + detected == 'audio/opus' && filename.to_s.end_with?('.ogg') ? 'audio/ogg' : detected + end + end) +end diff --git a/spec/models/attachment_spec.rb b/spec/models/attachment_spec.rb index a82718e3a..22a1fbc2e 100644 --- a/spec/models/attachment_spec.rb +++ b/spec/models/attachment_spec.rb @@ -30,6 +30,20 @@ RSpec.describe Attachment do attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') expect(attachment.download_url).not_to be_nil end + + it 'normalizes audio/opus to audio/ogg in blob content_type' do + attachment = message.attachments.new(account_id: message.account_id, file_type: :audio) + attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'sample.ogg', content_type: 'audio/ogg') + attachment.save! + # Simulate Marcel detecting audio/opus + attachment.file.blob.update_column(:content_type, 'audio/opus') # rubocop:disable Rails/SkipsModelValidations + attachment.file.blob.reload + + expect(attachment.file.blob.content_type).to eq('audio/opus') + attachment.download_url + expect(attachment.file.blob.content_type).to eq('audio/ogg') + expect(attachment.file.blob.reload.content_type).to eq('audio/ogg') + end end describe 'with_attached_file?' do diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb index 8655978de..aadfb6558 100644 --- a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb @@ -146,6 +146,28 @@ describe Whatsapp::Providers::WhatsappCloudService do .to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers) expect(service.send_message('+123456789', message)).to eq 'message_id' end + + it 'normalizes audio/opus to audio/ogg and sends voice flag for recorded audio' do + attachment = message.attachments.new(account_id: message.account_id, file_type: :audio, meta: { 'is_recorded_audio' => true }) + attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'sample.ogg', content_type: 'audio/ogg') + attachment.save! + # Simulate Marcel detecting audio/opus (as happens with OGG Opus files in Marcel 1.1.0) + attachment.file.blob.update_column(:content_type, 'audio/opus') # rubocop:disable Rails/SkipsModelValidations + attachment.file.blob.reload + + stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages') + .with( + body: hash_including({ + messaging_product: 'whatsapp', + to: '+123456789', + type: 'audio', + audio: WebMock::API.hash_including({ link: anything, voice: true }) + }) + ) + .to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers) + expect(service.send_message('+123456789', message)).to eq 'message_id' + expect(attachment.file.blob.reload.content_type).to eq('audio/ogg') + end end end