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