Skip to content

Commit 72354a4

Browse files
fix: normalize audio/opus content type to audio/ogg for WhatsApp attachments (#223)
1 parent bce4e9b commit 72354a4

File tree

5 files changed

+68
-17
lines changed

5 files changed

+68
-17
lines changed

app/models/attachment.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ def file_url
5656
# NOTE: for External services use this methods since redirect doesn't work effectively in a lot of cases
5757
def download_url
5858
ActiveStorage::Current.url_options = Rails.application.routes.default_url_options if ActiveStorage::Current.url_options.blank?
59-
file.attached? ? file.blob.url : ''
59+
return '' unless file.attached?
60+
61+
normalize_opus_blob_content_type!
62+
file.blob.url
6063
end
6164

6265
def thumb_url
@@ -184,6 +187,16 @@ def validate_file_size(byte_size)
184187
def media_file?(file_content_type)
185188
file_content_type.start_with?('image/', 'video/', 'audio/')
186189
end
190+
191+
# Marcel gem may detect OGG/Opus files as audio/opus instead of audio/ogg.
192+
# Lazily normalize existing blobs so presigned URLs serve the correct Content-Type.
193+
# Only applies to .ogg files — .opus files legitimately use audio/opus.
194+
def normalize_opus_blob_content_type!
195+
blob = file.blob
196+
return unless blob.content_type == 'audio/opus' && blob.filename.to_s.end_with?('.ogg')
197+
198+
blob.update_column(:content_type, 'audio/ogg') # rubocop:disable Rails/SkipsModelValidations
199+
end
187200
end
188201

189202
Attachment.include_mod_with('Concerns::Attachment')

app/services/whatsapp/providers/whatsapp_cloud_service.rb

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@ def send_text_message(phone_number, message)
156156

157157
def send_attachment_message(phone_number, message)
158158
attachment = message.attachments.first
159-
normalize_opus_content_type(attachment)
160159
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
161160
type_content = { 'link' => attachment.download_url }
162161
type_content['caption'] = message.outgoing_content unless %w[audio sticker].include?(type)
@@ -181,21 +180,6 @@ def voice_message?(type, attachment)
181180
type == 'audio' && attachment.meta&.dig('is_recorded_audio') && attachment.file.content_type == 'audio/ogg'
182181
end
183182

184-
# Marcel gem may re-detect OGG/Opus files as audio/opus after ActiveStorage
185-
# blob attachment, but WhatsApp Cloud API requires audio/ogg content type
186-
# for voice messages. Normalize so the download URL serves the correct
187-
# Content-Type header. No-op when the frontend already uploads as audio/ogg.
188-
def normalize_opus_content_type(attachment)
189-
return unless attachment.file.attached?
190-
191-
blob = attachment.file.blob
192-
return unless blob.content_type == 'audio/opus'
193-
194-
return if blob.update(content_type: 'audio/ogg')
195-
196-
Rails.logger.error("Failed to normalize blob #{blob.id} content_type from audio/opus to audio/ogg")
197-
end
198-
199183
def error_message(response)
200184
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
201185
response.parsed_response&.dig('error', 'message')
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Marcel gem may detect OGG Opus files as audio/opus instead of audio/ogg.
2+
# This is problematic because WhatsApp Cloud API (and other services)
3+
# expect audio/ogg for OGG container files with the Opus codec.
4+
#
5+
# This initializer patches ActiveStorage::Blob to normalize audio/opus → audio/ogg
6+
# at identification time for .ogg files, preventing the wrong content_type from
7+
# being persisted. Files with .opus extension are left as audio/opus since they
8+
# are genuinely Opus-only files.
9+
ActiveSupport.on_load(:active_storage_blob) do
10+
prepend(Module.new do
11+
private
12+
13+
def identify_content_type(io = nil)
14+
detected = super
15+
detected == 'audio/opus' && filename.to_s.end_with?('.ogg') ? 'audio/ogg' : detected
16+
end
17+
end)
18+
end

spec/models/attachment_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@
3030
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
3131
expect(attachment.download_url).not_to be_nil
3232
end
33+
34+
it 'normalizes audio/opus to audio/ogg in blob content_type' do
35+
attachment = message.attachments.new(account_id: message.account_id, file_type: :audio)
36+
attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'sample.ogg', content_type: 'audio/ogg')
37+
attachment.save!
38+
# Simulate Marcel detecting audio/opus
39+
attachment.file.blob.update_column(:content_type, 'audio/opus') # rubocop:disable Rails/SkipsModelValidations
40+
attachment.file.blob.reload
41+
42+
expect(attachment.file.blob.content_type).to eq('audio/opus')
43+
attachment.download_url
44+
expect(attachment.file.blob.content_type).to eq('audio/ogg')
45+
expect(attachment.file.blob.reload.content_type).to eq('audio/ogg')
46+
end
3347
end
3448

3549
describe 'with_attached_file?' do

spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,28 @@
146146
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
147147
expect(service.send_message('+123456789', message)).to eq 'message_id'
148148
end
149+
150+
it 'normalizes audio/opus to audio/ogg and sends voice flag for recorded audio' do
151+
attachment = message.attachments.new(account_id: message.account_id, file_type: :audio, meta: { 'is_recorded_audio' => true })
152+
attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'sample.ogg', content_type: 'audio/ogg')
153+
attachment.save!
154+
# Simulate Marcel detecting audio/opus (as happens with OGG Opus files in Marcel 1.1.0)
155+
attachment.file.blob.update_column(:content_type, 'audio/opus') # rubocop:disable Rails/SkipsModelValidations
156+
attachment.file.blob.reload
157+
158+
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
159+
.with(
160+
body: hash_including({
161+
messaging_product: 'whatsapp',
162+
to: '+123456789',
163+
type: 'audio',
164+
audio: WebMock::API.hash_including({ link: anything, voice: true })
165+
})
166+
)
167+
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
168+
expect(service.send_message('+123456789', message)).to eq 'message_id'
169+
expect(attachment.file.blob.reload.content_type).to eq('audio/ogg')
170+
end
149171
end
150172
end
151173

0 commit comments

Comments
 (0)