From 0bd0cab868a15f4861bf08ecb720c5fee8d2a70d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 4 May 2026 17:14:01 +0400 Subject: [PATCH] feat(voice): Attach call recordings + show call duration on the bubble (#14344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../message/bubbles/VoiceCall.vue | 27 +++- app/javascript/shared/helpers/timeHelper.js | 23 ++++ config/routes.rb | 1 + .../controllers/twilio/voice_controller.rb | 29 ++++ .../twilio/recording_attachment_job.rb | 17 +++ enterprise/app/models/call.rb | 3 +- .../twilio/recording_attachment_service.rb | 81 ++++++++++++ .../voice/recording_status_service.rb | 40 ++++++ .../recording_attachment_service_spec.rb | 124 ++++++++++++++++++ .../voice/recording_status_service_spec.rb | 89 +++++++++++++ 10 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 enterprise/app/jobs/voice/provider/twilio/recording_attachment_job.rb create mode 100644 enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb create mode 100644 enterprise/app/services/voice/recording_status_service.rb create mode 100644 spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb create mode 100644 spec/enterprise/services/voice/recording_status_service_spec.rb diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index 229fbec0300..a0f950ad4f2 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -5,9 +5,11 @@ import { useStore } from 'vuex'; import { useMessageContext } from '../provider.js'; import { VOICE_CALL_STATUS } from '../constants'; import { useCallSession } from 'dashboard/composables/useCallSession'; +import { formatDuration } from 'shared/helpers/timeHelper'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; +import AudioChip from 'next/message/chips/Audio.vue'; const LABEL_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS', @@ -76,12 +78,16 @@ const labelKey = computed(() => { : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; }); +const formattedDuration = computed(() => + formatDuration(call.value?.durationSeconds) +); + const subtext = computed(() => { if (status.value === VOICE_CALL_STATUS.RINGING) { return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); } if (status.value === VOICE_CALL_STATUS.COMPLETED) { - return t('CONVERSATION.VOICE_CALL.CALL_ENDED'); + return formattedDuration.value; } if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'); @@ -128,6 +134,17 @@ const canJoinCall = computed(() => { return true; }); +const recordingAttachment = computed(() => { + const url = call.value?.recordingUrl; + if (!url) return null; + return { + dataUrl: url, + fileType: 'audio', + extension: 'wav', + transcribedText: call.value?.transcript || '', + }; +}); + const handleJoinCall = async () => { if (!canJoinCall.value || isJoining.value) return; @@ -149,7 +166,7 @@ const handleJoinCall = async () => { diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js index 07b30277647..db5609d8951 100644 --- a/app/javascript/shared/helpers/timeHelper.js +++ b/app/javascript/shared/helpers/timeHelper.js @@ -94,6 +94,29 @@ export const shortTimestamp = (time, withAgo = false) => { return convertToShortTime; }; +/** + * Formats a duration in seconds into mm:ss or hh:mm:ss. + * @param {number|string} durationInSeconds - Duration in seconds. + * @returns {string} Formatted duration string. Empty string for invalid input. + */ +export const formatDuration = durationInSeconds => { + if (durationInSeconds === null || durationInSeconds === undefined) return ''; + + const totalSeconds = Number(durationInSeconds); + if (Number.isNaN(totalSeconds) || totalSeconds < 0) return ''; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const mm = minutes.toString().padStart(2, '0'); + const ss = seconds.toString().padStart(2, '0'); + if (hours > 0) { + return `${hours.toString().padStart(2, '0')}:${mm}:${ss}`; + } + return `${mm}:${ss}`; +}; + /** * Calculates the difference in days between now and a given timestamp. * @param {Date} now - Current date/time. diff --git a/config/routes.rb b/config/routes.rb index eff17e714f7..c1a34bc85b8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -607,6 +607,7 @@ Rails.application.routes.draw do post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call post 'voice/status/:phone', to: 'voice#status', as: :voice_status post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status + post 'voice/recording_status/:phone', to: 'voice#recording_status', as: :voice_recording_status end end diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb index 6024e2b7737..37317d9dede 100644 --- a/enterprise/app/controllers/twilio/voice_controller.rb +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -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}" diff --git a/enterprise/app/jobs/voice/provider/twilio/recording_attachment_job.rb b/enterprise/app/jobs/voice/provider/twilio/recording_attachment_job.rb new file mode 100644 index 00000000000..26b849d0423 --- /dev/null +++ b/enterprise/app/jobs/voice/provider/twilio/recording_attachment_job.rb @@ -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 diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb index f421f1e94d9..e5c98baa183 100644 --- a/enterprise/app/models/call.rb +++ b/enterprise/app/models/call.rb @@ -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) diff --git a/enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb b/enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb new file mode 100644 index 00000000000..05cd3d22711 --- /dev/null +++ b/enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb @@ -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 diff --git a/enterprise/app/services/voice/recording_status_service.rb b/enterprise/app/services/voice/recording_status_service.rb new file mode 100644 index 00000000000..4dbbd44b88f --- /dev/null +++ b/enterprise/app/services/voice/recording_status_service.rb @@ -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 diff --git a/spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb b/spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb new file mode 100644 index 00000000000..b67d5b7cd97 --- /dev/null +++ b/spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Voice::Provider::Twilio::RecordingAttachmentService do + let(:account) { create(:account) } + let(:channel) do + create(:channel_twilio_sms, :with_voice, + account: account, + phone_number: '+15551238888', + account_sid: 'AC_account_sid', + auth_token: 'auth_token_value') + end + let(:inbox) { channel.inbox } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + let(:call) do + create( + :call, + account: account, + inbox: inbox, + conversation: conversation, + contact: conversation.contact + ) + end + let!(:message) do + msg = conversation.messages.create!( + account_id: account.id, + inbox_id: inbox.id, + message_type: :incoming, + sender: conversation.contact, + content: 'Voice Call', + content_type: 'voice_call' + ) + call.update!(message_id: msg.id) + msg + end + + let(:recording_sid) { 'RE9999' } + let(:recording_url) { 'https://api.twilio.com/2010-04-01/Accounts/AC1/Recordings/RE9999' } + let(:recording_duration) { '47' } + + let(:downloaded_tempfile) do + file = Tempfile.new(['call-recording', '.wav']) + file.binmode + file.write('FAKE_AUDIO_BYTES') + file.rewind + file + end + + let(:safe_fetch_result) do + SafeFetch::Result.new( + tempfile: downloaded_tempfile, + filename: 'recording.wav', + content_type: 'audio/wav' + ) + end + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new) + .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}")) + + allow(SafeFetch).to receive(:fetch) + .with(recording_url, http_basic_authentication: %w[AC_account_sid auth_token_value], + allowed_content_type_prefixes: %w[audio/]) + .and_yield(safe_fetch_result) + end + + def perform_service(overrides = {}) + described_class.new( + call: call, + recording_sid: overrides.fetch(:recording_sid, recording_sid), + recording_url: overrides.fetch(:recording_url, recording_url), + recording_duration: overrides.fetch(:recording_duration, recording_duration) + ).perform + end + + describe '#perform' do + it 'attaches the recording to the call and persists recording_sid + duration_seconds' do + previous_updated_at = message.updated_at + travel 1.second + + perform_service + + call.reload + message.reload + + aggregate_failures do + expect(call.recording).to be_attached + expect(call.recording_sid).to eq(recording_sid) + expect(call.duration_seconds).to eq(47) + expect(message.updated_at).to be > previous_updated_at + end + end + + it 'preserves a duration_seconds value that was already set on the call' do + call.update!(duration_seconds: 99) + + perform_service + + expect(call.reload.duration_seconds).to eq(99) + end + + it 'is idempotent when the same recording_sid is already attached' do + perform_service + + expect(SafeFetch).to have_received(:fetch).once + + perform_service + + expect(SafeFetch).to have_received(:fetch).once + expect(call.reload.recording.blob.checksum).to be_present + end + + it 'is a no-op when recording_sid is blank' do + expect { perform_service(recording_sid: '') }.not_to change { call.reload.recording.attached? }.from(false) + expect(SafeFetch).not_to have_received(:fetch) + end + + it 'is a no-op when recording_url is blank' do + expect { perform_service(recording_url: '') }.not_to change { call.reload.recording.attached? }.from(false) + expect(SafeFetch).not_to have_received(:fetch) + end + end +end diff --git a/spec/enterprise/services/voice/recording_status_service_spec.rb b/spec/enterprise/services/voice/recording_status_service_spec.rb new file mode 100644 index 00000000000..3d922770991 --- /dev/null +++ b/spec/enterprise/services/voice/recording_status_service_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Voice::RecordingStatusService do + let(:account) { create(:account) } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551237777') } + let(:inbox) { channel.inbox } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + let(:conference_sid) { 'CFabc123def456' } + let!(:call) do + create( + :call, + account: account, + inbox: inbox, + conversation: conversation, + contact: conversation.contact, + meta: { 'twilio_conference_sid' => conference_sid } + ) + end + + let(:recording_sid) { 'RE1234567890abcdef' } + let(:recording_url) { 'https://api.twilio.com/2010-04-01/Accounts/AC1/Recordings/RE1' } + let(:recording_duration) { '12' } + + let(:complete_payload) do + { + 'RecordingStatus' => 'completed', + 'ConferenceSid' => conference_sid, + 'RecordingSid' => recording_sid, + 'RecordingUrl' => recording_url, + 'RecordingDuration' => recording_duration + } + end + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new) + .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}")) + end + + describe '#perform' do + it 'enqueues the recording attachment job for the matching call' do + expect do + described_class.new(account: account, payload: complete_payload).perform + end.to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + .with(call.id, recording_sid, recording_url, recording_duration) + end + + it 'is a no-op when RecordingStatus is not completed' do + payload = complete_payload.merge('RecordingStatus' => 'in-progress') + + expect do + described_class.new(account: account, payload: payload).perform + end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + end + + it 'is a no-op when ConferenceSid is missing' do + payload = complete_payload.except('ConferenceSid') + + expect do + described_class.new(account: account, payload: payload).perform + end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + end + + it 'is a no-op when RecordingSid is missing' do + payload = complete_payload.except('RecordingSid') + + expect do + described_class.new(account: account, payload: payload).perform + end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + end + + it 'is a no-op when RecordingUrl is missing' do + payload = complete_payload.except('RecordingUrl') + + expect do + described_class.new(account: account, payload: payload).perform + end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + end + + it 'is a no-op when no Call matches the ConferenceSid' do + payload = complete_payload.merge('ConferenceSid' => 'CFunknown') + + expect do + described_class.new(account: account, payload: payload).perform + end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + end + end +end