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>
This commit is contained in:
Muhsin Keloth
2026-05-04 17:14:01 +04:00
committed by GitHub
parent 2dee7457cd
commit 0bd0cab868
10 changed files with 430 additions and 4 deletions

View File

@@ -38,6 +38,7 @@ class Twilio::VoiceController < ApplicationController
end
call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
persist_twilio_conference_sid!(call, params[:ConferenceSid])
Voice::Conference::Manager.new(
call: call,
@@ -48,6 +49,15 @@ class Twilio::VoiceController < ApplicationController
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
@@ -125,6 +135,10 @@ class Twilio::VoiceController < ApplicationController
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',
@@ -152,12 +166,27 @@ class Twilio::VoiceController < ApplicationController
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}"

View File

@@ -0,0 +1,17 @@
class Voice::Provider::Twilio::RecordingAttachmentJob < ApplicationJob
queue_as :low
retry_on Down::Error, wait: 5.seconds, attempts: 3
def perform(call_id, recording_sid, recording_url, recording_duration = nil)
call = Call.find_by(id: call_id)
return if call.blank?
Voice::Provider::Twilio::RecordingAttachmentService.new(
call: call,
recording_sid: recording_sid,
recording_url: recording_url,
recording_duration: recording_duration
).perform
end
end

View File

@@ -34,7 +34,7 @@ class Call < ApplicationRecord
# Statuses where the call is finished and won't change again
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
enum :provider, { twilio: 0, whatsapp: 1 }
enum :direction, { incoming: 0, outgoing: 1 }
@@ -55,6 +55,7 @@ class Call < ApplicationRecord
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
scope :by_twilio_conference_sid, ->(sid) { where("meta->>'twilio_conference_sid' = ?", sid) }
def self.find_by_provider_call_id(provider, sid)
find_by(provider: provider, provider_call_id: sid)

View File

@@ -0,0 +1,81 @@
class Voice::Provider::Twilio::RecordingAttachmentService
DEFAULT_FILENAME_EXTENSION = 'wav'.freeze
ALLOWED_CONTENT_TYPE_PREFIXES = %w[audio/].freeze
pattr_initialize [:call!, :recording_sid!, :recording_url!, { recording_duration: nil }]
def perform
return if recording_sid.blank? || recording_url.blank?
return if already_attached?
SafeFetch.fetch(
recording_url,
http_basic_authentication: [account_sid, auth_token],
allowed_content_type_prefixes: ALLOWED_CONTENT_TYPE_PREFIXES
) do |result|
persist_recording!(result)
end
# Bump the message updated_at so the message.updated dispatcher rebroadcasts
# the embedded Call payload (now with recording_url) to connected clients.
call.message&.touch # rubocop:disable Rails/SkipsModelValidations
end
private
def persist_recording!(result)
call.with_lock do
next if already_attached?
attach_recording!(result)
call.recording_sid = recording_sid
call.duration_seconds ||= normalized_recording_duration
call.save!
end
end
def already_attached?
call.recording.attached? && call.recording_sid.to_s == recording_sid.to_s
end
def attach_recording!(result)
call.recording.attach(
io: result.tempfile,
filename: recording_filename(result),
content_type: recording_content_type(result)
)
end
def normalized_recording_duration
return if recording_duration.blank?
recording_duration.to_i
end
def recording_filename(result)
return result.original_filename if result.original_filename.present?
"call-recording-#{recording_sid}.#{recording_extension(result)}"
end
def recording_extension(result)
content_type = recording_content_type(result)
Rack::Mime::MIME_TYPES.invert[content_type].to_s.delete_prefix('.').presence || DEFAULT_FILENAME_EXTENSION
end
def recording_content_type(result)
result.content_type.presence || 'audio/wav'
end
def account_sid
@account_sid ||= channel.account_sid
end
def auth_token
@auth_token ||= channel.auth_token
end
def channel
@channel ||= call.inbox.channel
end
end

View File

@@ -0,0 +1,40 @@
class Voice::RecordingStatusService
pattr_initialize [:account!, { payload: {} }]
def perform
return unless completed_recording?
return if conference_sid.blank? || recording_sid.blank? || recording_url.blank?
call = Call.where(account_id: account.id).by_twilio_conference_sid(conference_sid).first
return if call.blank?
Voice::Provider::Twilio::RecordingAttachmentJob.perform_later(
call.id,
recording_sid,
recording_url,
recording_duration
)
end
private
def completed_recording?
payload['RecordingStatus'].to_s.casecmp('completed').zero?
end
def conference_sid
payload['ConferenceSid'].to_s
end
def recording_sid
payload['RecordingSid'].to_s
end
def recording_url
payload['RecordingUrl'].to_s
end
def recording_duration
payload['RecordingDuration']
end
end