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:
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
40
enterprise/app/services/voice/recording_status_service.rb
Normal file
40
enterprise/app/services/voice/recording_status_service.rb
Normal 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
|
||||
Reference in New Issue
Block a user