Files
chatwoot/app/services/whatsapp/providers/whatsapp_cloud_service.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

234 lines
7.4 KiB
Ruby

class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseService
def send_message(phone_number, message)
@message = message
if message.attachments.present?
send_attachment_message(phone_number, message)
elsif message.content_type == 'input_select'
send_interactive_text_message(phone_number, message)
else
send_text_message(phone_number, message)
end
end
def send_template(phone_number, template_info, message)
template_body = template_body_parameters(template_info)
request_body = {
messaging_product: 'whatsapp',
recipient_type: 'individual', # Only individual messages supported (not group messages)
to: phone_number,
type: 'template',
template: template_body
}
response = HTTParty.post(
"#{phone_id_path}/messages",
headers: api_headers,
body: request_body.to_json
)
process_response(response, message)
end
def sync_templates
# ensuring that channels with wrong provider config wouldn't keep trying to sync templates
whatsapp_channel.mark_message_templates_updated
templates = fetch_whatsapp_templates("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
whatsapp_channel.update(message_templates: templates, message_templates_last_updated: Time.now.utc) if templates.present?
end
def fetch_whatsapp_templates(url)
response = HTTParty.get(url)
return [] unless response.success?
next_url = next_url(response)
return response['data'] + fetch_whatsapp_templates(next_url) if next_url.present?
response['data']
end
def next_url(response)
response['paging'] ? response['paging']['next'] : ''
end
def validate_provider_config?
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
response.success?
end
def api_headers
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
end
def create_csat_template(template_config)
csat_template_service.create_template(template_config)
end
def delete_csat_template(template_name = nil)
template_name ||= CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
csat_template_service.delete_template(template_name)
end
def get_template_status(template_name)
csat_template_service.get_template_status(template_name)
end
def media_url(media_id)
"#{api_base_path}/v13.0/#{media_id}"
end
private
def csat_template_service
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
end
def api_base_path
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
end
# TODO: See if we can unify the API versions and for both paths and make it consistent with out facebook app API versions
def phone_id_path(version = 'v13.0')
"#{api_base_path}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}"
end
def business_account_path
"#{api_base_path}/v14.0/#{whatsapp_channel.provider_config['business_account_id']}"
end
def send_text_message(phone_number, message)
response = HTTParty.post(
"#{phone_id_path}/messages",
headers: api_headers,
body: {
messaging_product: 'whatsapp',
context: whatsapp_reply_context(message),
to: phone_number,
text: { body: message.outgoing_content },
type: 'text'
}.to_json
)
process_response(response, message)
end
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 = build_attachment_content(type, attachment, message)
response = HTTParty.post(
"#{phone_id_path('v24.0')}/messages",
headers: api_headers,
body: {
:messaging_product => 'whatsapp',
:context => whatsapp_reply_context(message),
'to' => phone_number,
'type' => type,
type.to_s => type_content
}.to_json
)
process_response(response, message)
end
def error_message(response)
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
response.parsed_response&.dig('error', 'message')
end
def voice_message?(type, attachment)
type == 'audio' && attachment.meta&.dig('is_voice_message') && 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 build_attachment_content(type, attachment, message)
type_content = { 'link' => attachment.download_url }
type_content['caption'] = message.outgoing_content unless %w[audio sticker].include?(type)
type_content['filename'] = attachment.file.filename if type == 'document'
type_content['voice'] = true if voice_message?(type, attachment)
type_content
end
def template_body_parameters(template_info)
template_body = {
name: template_info[:name],
language: {
policy: 'deterministic',
code: template_info[:lang_code]
}
}
# Enhanced template parameters structure
# Note: Legacy format support (simple parameter arrays) has been removed
# in favor of the enhanced component-based structure that supports
# headers, buttons, and authentication templates.
#
# Expected payload format from frontend:
# {
# processed_params: {
# body: { '1': 'John', '2': '123 Main St' },
# header: {
# media_url: 'https://...',
# media_type: 'image',
# media_name: 'filename.pdf' # Optional, for document templates only
# },
# buttons: [{ type: 'url', parameter: 'otp123456' }]
# }
# }
# This gets transformed into WhatsApp API component format:
# [
# { type: 'body', parameters: [...] },
# { type: 'header', parameters: [...] },
# { type: 'button', sub_type: 'url', parameters: [...] }
# ]
template_body[:components] = template_info[:parameters] || []
template_body
end
def whatsapp_reply_context(message)
reply_to = message.content_attributes[:in_reply_to_external_id]
return nil if reply_to.blank?
{
message_id: reply_to
}
end
def send_interactive_text_message(phone_number, message)
payload = create_payload_based_on_items(message)
response = HTTParty.post(
"#{phone_id_path}/messages",
headers: api_headers,
body: {
messaging_product: 'whatsapp',
to: phone_number,
interactive: payload,
type: 'interactive'
}.to_json
)
process_response(response, message)
end
end
Whatsapp::Providers::WhatsappCloudService.prepend_mod_with('Whatsapp::Providers::WhatsappCloudService')