Files
chatwoot/enterprise/app/controllers/twilio/voice_controller.rb
0bd0cab868 feat(voice): Attach call recordings + show call duration on the bubble (#14344)
When an inbound voice call ends, the conversation bubble now (1) renders
an inline audio player as soon as Twilio finishes the recording and (2)
shows the call duration alongside "Call ended" so the agent gets the
at-a-glance summary without opening the recording.

Fixes
https://linear.app/chatwoot/issue/PLA-118/feat-recordings-on-calls-should-be-attached-on-the-conversation
and
https://linear.app/chatwoot/issue/PLA-119/duration-of-the-call-is-not-visible-on-the-chat-bubble

## How to test

1. Set up a Twilio voice inbox and trigger an inbound call.
2. Answer the call from an agent, talk for a few seconds, then hang up.
3. As soon as the call ends, the bubble should read **"Call ended —
0:NN"** (where NN is the call duration in seconds).
4. Wait a few seconds for Twilio to finish processing the recording
(usually <30s after hangup).
5. The same bubble should now show an inline audio player below the
duration. Press play; the recording should be audible.
6. Refresh the page — both the duration and the player should still be
there.
7. End a second call on the same conversation — its bubble should get
its own duration + player, independent of the first.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-05-04 17:14:01 +04:00

211 lines
5.8 KiB
Ruby

class Twilio::VoiceController < ApplicationController
CONFERENCE_EVENT_PATTERNS = {
/conference-start/i => 'start',
/participant-join/i => 'join',
/participant-leave/i => 'leave',
/conference-end/i => 'end'
}.freeze
before_action :set_inbox!
def status
Voice::StatusUpdateService.new(
account: current_account,
call_sid: twilio_call_sid,
call_status: params[:CallStatus],
payload: params.to_unsafe_h
).perform
head :no_content
end
def call_twiml
Rails.logger.info(
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
call = resolve_call
render xml: conference_twiml(call)
end
def conference_status
event = mapped_conference_event
if event.nil?
Rails.logger.info(
"TWILIO_VOICE_CONFERENCE_UNMAPPED_EVENT account=#{current_account.id} event=#{params[:StatusCallbackEvent]} call_sid=#{twilio_call_sid}"
)
return head :no_content
end
call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
persist_twilio_conference_sid!(call, params[:ConferenceSid])
Voice::Conference::Manager.new(
call: call,
event: event,
participant_label: participant_label
).process
head :no_content
end
def recording_status
Voice::RecordingStatusService.new(
account: current_account,
payload: params.to_unsafe_h
).perform
head :no_content
end
private
def twilio_call_sid
params[:CallSid]
end
def twilio_from
params[:From].to_s
end
def twilio_to
params[:To]
end
def twilio_direction
@twilio_direction ||= (params['Direction'] || params['CallDirection']).to_s
end
def mapped_conference_event
event = params[:StatusCallbackEvent].to_s
CONFERENCE_EVENT_PATTERNS.each do |pattern, mapped|
return mapped if event.match?(pattern)
end
nil
end
def agent_leg?(from_number)
from_number.start_with?('client:')
end
def resolve_call
return find_call_for_agent if agent_leg?(twilio_from)
case twilio_direction
when 'inbound'
Voice::InboundCallBuilder.perform!(
account: current_account,
inbox: inbox,
from_number: twilio_from,
call_sid: twilio_call_sid
)
when 'outbound-api', 'outbound-dial'
sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction)
else
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
end
end
def find_call_for_agent
sid = params[:call_sid].presence
raise ArgumentError, 'call_sid is required for agent leg' if sid.blank?
inbox_calls.find_by!(provider_call_id: sid)
end
def sync_outbound_leg(call_sid:, direction:)
parent_sid = params['ParentCallSid'].presence
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
call = inbox_calls.find_by!(provider_call_id: lookup_sid)
call.update!(parent_call_sid: parent_sid) if parent_sid.present? && call.parent_call_sid != parent_sid
call
end
def inbox_calls
Call.where(inbox_id: inbox.id, provider: :twilio)
end
def conference_twiml(call)
conference_sid = ensure_conference_sid!(call)
Twilio::TwiML::VoiceResponse.new.tap do |response|
response.dial do |dial|
dial.conference(
conference_sid,
start_conference_on_enter: agent_leg?(twilio_from),
end_conference_on_exit: false,
record: 'record-from-start',
recording_status_callback: recording_status_callback_url,
recording_status_callback_event: 'completed',
recording_status_callback_method: 'POST',
status_callback: conference_status_callback_url,
status_callback_event: 'start end join leave',
status_callback_method: 'POST',
participant_label: participant_label_for(twilio_from)
)
end
end.to_s
end
def ensure_conference_sid!(call)
return call.conference_sid if call.conference_sid.present?
call.update!(conference_sid: call.default_conference_sid)
call.conference_sid
end
def participant_label_for(from_number)
return from_number.delete_prefix('client:') if from_number.start_with?('client:')
'contact'
end
def conference_status_callback_url
phone_digits = inbox_channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def recording_status_callback_url
phone_digits = inbox_channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_recording_status_url(phone: phone_digits)
end
def find_call_for_conference!(friendly_name, call_sid)
name = friendly_name.to_s
call = inbox_calls.by_conference_sid(name).first if name.present?
call || inbox_calls.find_by!(provider_call_id: call_sid)
end
# Twilio's recording webhook only sends its internal ConferenceSid (CF...),
# not our FriendlyName. Persist Twilio's id the first time we see it on a
# conference event so the recording lookup can match later.
def persist_twilio_conference_sid!(call, sid)
return if sid.blank?
return if call.twilio_conference_sid == sid
call.update!(twilio_conference_sid: sid)
end
def set_inbox!
digits = params[:phone].to_s.gsub(/\D/, '')
phone_number = "+#{digits}"
channel = Channel::TwilioSms.find_by!(phone_number: phone_number)
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{phone_number}" unless channel.voice_enabled?
@inbox = channel.inbox
end
def current_account
@current_account ||= inbox_account
end
def participant_label
params[:ParticipantLabel].to_s
end
attr_reader :inbox
delegate :account, :channel, to: :inbox, prefix: true
end