Files
chatwoot/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb
37eed5de1e feat(whatsapp): Add support for voice messages (#14606)
> Reopened from #13613, now from a personal fork
(`gabrieljablonski/chatwoot`) so maintainers can push edits —
organization-owned forks don't support "Allow edits from maintainers".
The previous PR is closed in favor of this one; same commits, same diff.

## Description

This PR adds support for sending voice messages (voice notes) through
the WhatsApp Cloud API. When agents record audio in Chatwoot, it is now
transcoded in the browser from WebM/Opus to OGG/Opus and sent with the
`voice: true` flag, so it appears as a native voice note bubble on
WhatsApp — not as a file/document attachment.

Closes #13283

**Key Changes:**
- Added `webmOpusToOgg.js` — a pure JS EBML parser + OGG page builder
that remuxes browser-recorded WebM/Opus audio into OGG/Opus entirely
client-side, with no server-side dependencies.
- Updated `AudioRecorder.vue` to use an explicit `mimeType` hint, proper
resource cleanup, and an `AUDIO_EXTENSION_MAP` for correct file
extensions.
- Renamed `mp3ConversionUtils.js` → `audioConversionUtils.js` and added
OGG conversion support via the new remuxer.
- Updated `ReplyBox.vue` to request OGG format for WhatsApp channels,
pass `isVoiceMessage` per-attachment, and handle recording errors with a
user-facing alert.
- Updated `MessageBuilder` to read the `is_voice_message` param and
persist it in attachment metadata.
- Updated `WhatsappCloudService` to:
- Normalize `audio/opus` → `audio/ogg` content type on ActiveStorage
blobs (works around Marcel gem re-detection).
- Send the `voice: true` flag when the attachment is a voice message
with `audio/ogg` content type.
  - Use WhatsApp Cloud API `v24.0` for the attachment endpoint.
- Added `AUDIO_CONVERSION_FAILED` i18n key.

**How it works:**
1. The browser records audio as WebM/Opus (Chrome/Firefox default).
2. `audioConversionUtils.js` remuxes it to OGG/Opus using the pure-JS
`webmOpusToOgg` remuxer — no server transcoding needed.
3. The OGG file is uploaded with `is_voice_message: true` in the form
payload.
4. `MessageBuilder` persists `is_voice_message` in the attachment's
`meta` hash.
5. `WhatsappCloudService` normalizes the blob content type if needed,
then sends the attachment with `voice: true` so WhatsApp renders it as a
voice note.

## Type of change

- [X] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

1. Record a voice message in a WhatsApp Cloud conversation.
2. Verify the audio is transcoded to OGG (check file extension in the
attachment preview).
3. Verify the message arrives on WhatsApp as a voice note bubble (not a
document/file).
4. Send an image or document attachment and verify it still works as
before (no `voice` flag).
5. Send a regular (non-voice) audio file and verify it arrives without
the voice flag.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:33:32 +04:00

439 lines
18 KiB
Ruby

require 'rails_helper'
describe Whatsapp::Providers::WhatsappCloudService do
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
let(:conversation) { create(:conversation, inbox: whatsapp_channel.inbox) }
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
let(:message) do
create(:message, conversation: conversation, message_type: :outgoing, content: 'test', inbox: whatsapp_channel.inbox, source_id: 'external_id')
end
let(:message_with_reply) do
create(:message, conversation: conversation, message_type: :outgoing, content: 'reply', inbox: whatsapp_channel.inbox,
content_attributes: { in_reply_to: message.id })
end
let(:response_headers) { { 'Content-Type' => 'application/json' } }
let(:whatsapp_response) { { messages: [{ id: 'message_id' }] } }
before do
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key')
end
describe '#send_message' do
context 'when called' do
it 'calls message endpoints for normal messages' do
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
.with(
body: {
messaging_product: 'whatsapp',
context: nil,
to: '+123456789',
text: { body: message.content },
type: 'text'
}.to_json
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
it 'calls message endpoints for a reply to messages' do
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
.with(
body: {
messaging_product: 'whatsapp',
context: {
message_id: message.source_id
},
to: '+123456789',
text: { body: message_with_reply.content },
type: 'text'
}.to_json
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message_with_reply)).to eq 'message_id'
end
it 'calls message endpoints for image attachment message messages' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
to: '+123456789',
type: 'image',
image: WebMock::API.hash_including({ caption: message.content, link: anything })
})
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
it 'calls message endpoints for document attachment message messages' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
attachment.file.attach(io: Rails.root.join('spec/assets/sample.pdf').open, filename: 'sample.pdf', content_type: 'application/pdf')
# ref: https://github.com/bblimke/webmock/issues/900
# reason for Webmock::API.hash_including
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
to: '+123456789',
type: 'document',
document: WebMock::API.hash_including({ filename: 'sample.pdf', caption: message.content, link: anything })
})
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
it 'calls message endpoints for audio voice message with voice flag' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :audio, meta: { 'is_voice_message' => true })
attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'voice.ogg', content_type: 'audio/ogg')
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'
end
it 'calls message endpoints for regular audio attachment without voice flag' 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: 'audio.ogg', content_type: 'audio/ogg')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
to: '+123456789',
type: 'audio'
})
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
result = service.send_message('+123456789', message)
expect(result).to eq 'message_id'
end
end
end
describe '#send_interactive message' do
context 'when called' do
it 'calls message endpoints with button payload when number of items is less than or equal to 3' do
message = create(:message, message_type: :outgoing, content: 'test',
inbox: whatsapp_channel.inbox, content_type: 'input_select',
content_attributes: {
items: [
{ title: 'Burito', value: 'Burito' },
{ title: 'Pasta', value: 'Pasta' },
{ title: 'Sushi', value: 'Sushi' }
]
})
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
.with(
body: {
messaging_product: 'whatsapp', to: '+123456789',
interactive: {
type: 'button',
body: {
text: 'test'
},
action: '{"buttons":[{"type":"reply","reply":{"id":"Burito","title":"Burito"}},{"type":"reply",' \
'"reply":{"id":"Pasta","title":"Pasta"}},{"type":"reply","reply":{"id":"Sushi","title":"Sushi"}}]}'
}, type: 'interactive'
}.to_json
).to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
it 'calls message endpoints with list payload when number of items is greater than 3' do
items = %w[Burito Pasta Sushi Salad].map { |i| { title: i, value: i } }
message = create(:message, message_type: :outgoing, content: 'test', inbox: whatsapp_channel.inbox,
content_type: 'input_select', content_attributes: { items: items })
expected_action = {
button: I18n.t('conversations.messages.whatsapp.list_button_label'),
sections: [{ rows: %w[Burito Pasta Sushi Salad].map { |i| { id: i, title: i } } }]
}.to_json
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
.with(
body: {
messaging_product: 'whatsapp', to: '+123456789',
interactive: {
type: 'list',
body: {
text: 'test'
},
action: expected_action
},
type: 'interactive'
}.to_json
).to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
end
end
describe '#send_template' do
let(:template_info) do
{
name: 'test_template',
namespace: 'test_namespace',
lang_code: 'en_US',
parameters: [{ type: 'text', text: 'test' }]
}
end
let(:template_body) do
{
messaging_product: 'whatsapp',
recipient_type: 'individual', # Added recipient_type field
to: '+123456789',
type: 'template',
template: {
name: template_info[:name],
language: {
policy: 'deterministic',
code: template_info[:lang_code]
},
components: template_info[:parameters] # Changed to use parameters directly (enhanced format)
}
}
end
context 'when called' do
it 'calls message endpoints with template params for template messages' do
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
.with(
body: template_body.to_json
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_template('+123456789', template_info, message)).to eq('message_id')
end
end
end
describe '#sync_templates' do
context 'when called' do
it 'updated the message templates' do
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key')
.to_return(
{ status: 200, headers: response_headers,
body: { data: [
{ id: '123456789', name: 'test_template' }
], paging: { next: 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key' } }.to_json },
{ status: 200, headers: response_headers,
body: { data: [
{ id: '123456789', name: 'next_template' }
], paging: { next: 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key' } }.to_json },
{ status: 200, headers: response_headers,
body: { data: [
{ id: '123456789', name: 'last_template' }
], paging: { prev: 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key' } }.to_json }
)
timstamp = whatsapp_channel.reload.message_templates_last_updated
expect(subject.sync_templates).to be(true)
expect(whatsapp_channel.reload.message_templates.first).to eq({ id: '123456789', name: 'test_template' }.stringify_keys)
expect(whatsapp_channel.reload.message_templates.second).to eq({ id: '123456789', name: 'next_template' }.stringify_keys)
expect(whatsapp_channel.reload.message_templates.last).to eq({ id: '123456789', name: 'last_template' }.stringify_keys)
expect(whatsapp_channel.reload.message_templates_last_updated).not_to eq(timstamp)
end
it 'updates message_templates_last_updated even when template request fails' do
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key')
.to_return(status: 401)
timstamp = whatsapp_channel.reload.message_templates_last_updated
subject.sync_templates
expect(whatsapp_channel.reload.message_templates_last_updated).not_to eq(timstamp)
end
end
end
describe '#validate_provider_config' do
context 'when called' do
it 'returns true if valid' do
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key')
expect(subject.validate_provider_config?).to be(true)
expect(whatsapp_channel.errors.present?).to be(false)
end
it 'returns false if invalid' do
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key').to_return(status: 401)
expect(subject.validate_provider_config?).to be(false)
end
end
end
describe 'Ability to configure Base URL' do
context 'when environment variable WHATSAPP_CLOUD_BASE_URL is not set' do
it 'uses the default base url' do
expect(subject.send(:api_base_path)).to eq('https://graph.facebook.com')
end
end
context 'when environment variable WHATSAPP_CLOUD_BASE_URL is set' do
it 'uses the base url from the environment variable' do
with_modified_env WHATSAPP_CLOUD_BASE_URL: 'http://test.com' do
expect(subject.send(:api_base_path)).to eq('http://test.com')
end
end
end
end
describe '#handle_error' do
let(:error_message) { 'Invalid message format' }
let(:error_response) do
{
'error' => {
'message' => error_message,
'code' => 100
}
}
end
let(:error_response_object) do
instance_double(
HTTParty::Response,
body: error_response.to_json,
parsed_response: error_response
)
end
before do
allow(Rails.logger).to receive(:error)
end
context 'when there is a message' do
it 'logs error and updates message status' do
service.instance_variable_set(:@message, message)
service.send(:handle_error, error_response_object, message)
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq(error_message)
end
end
context 'when error message is blank' do
let(:error_response_object) do
instance_double(
HTTParty::Response,
body: '{}',
parsed_response: {}
)
end
it 'logs error but does not update message' do
service.instance_variable_set(:@message, message)
service.send(:handle_error, error_response_object, message)
expect(message.reload.status).not_to eq('failed')
expect(message.reload.external_error).to be_nil
end
end
end
describe 'CSAT template methods' do
let(:mock_csat_template_service) { instance_double(Whatsapp::CsatTemplateService) }
let(:expected_template_name) { "customer_satisfaction_survey_#{whatsapp_channel.inbox.id}" }
let(:template_config) do
{
name: expected_template_name,
language: 'en',
category: 'UTILITY'
}
end
before do
allow(Whatsapp::CsatTemplateService).to receive(:new)
.with(whatsapp_channel)
.and_return(mock_csat_template_service)
end
describe '#create_csat_template' do
it 'delegates to csat_template_service with correct config' do
allow(mock_csat_template_service).to receive(:create_template)
.with(template_config)
.and_return({ success: true, template_id: '123' })
result = service.create_csat_template(template_config)
expect(mock_csat_template_service).to have_received(:create_template).with(template_config)
expect(result).to eq({ success: true, template_id: '123' })
end
end
describe '#delete_csat_template' do
it 'delegates to csat_template_service with default template name' do
allow(mock_csat_template_service).to receive(:delete_template)
.with(expected_template_name)
.and_return({ success: true })
result = service.delete_csat_template
expect(mock_csat_template_service).to have_received(:delete_template).with(expected_template_name)
expect(result).to eq({ success: true })
end
it 'delegates to csat_template_service with custom template name' do
custom_template_name = 'custom_csat_template'
allow(mock_csat_template_service).to receive(:delete_template)
.with(custom_template_name)
.and_return({ success: true })
result = service.delete_csat_template(custom_template_name)
expect(mock_csat_template_service).to have_received(:delete_template).with(custom_template_name)
expect(result).to eq({ success: true })
end
end
describe '#get_template_status' do
it 'delegates to csat_template_service with template name' do
template_name = 'customer_survey_template'
expected_response = { success: true, template: { status: 'APPROVED' } }
allow(mock_csat_template_service).to receive(:get_template_status)
.with(template_name)
.and_return(expected_response)
result = service.get_template_status(template_name)
expect(mock_csat_template_service).to have_received(:get_template_status).with(template_name)
expect(result).to eq(expected_response)
end
end
describe 'csat_template_service memoization' do
it 'creates and memoizes the csat_template_service instance' do
allow(Whatsapp::CsatTemplateService).to receive(:new)
.with(whatsapp_channel)
.and_return(mock_csat_template_service)
allow(mock_csat_template_service).to receive(:get_template_status)
.and_return({ success: true })
# Call multiple methods that use the service
service.get_template_status('test1')
service.get_template_status('test2')
# Verify the service was only instantiated once
expect(Whatsapp::CsatTemplateService).to have_received(:new).once
end
end
end
end