From 1b0974b407bff8b71048952d6cf49dbb5c5058eb Mon Sep 17 00:00:00 2001 From: Muhsin Date: Fri, 13 Mar 2026 16:54:37 +0400 Subject: [PATCH] feat: add the support for call recordings --- .../message/bubbles/VoiceCall.spec.js | 21 ++++ .../message/bubbles/VoiceCall.vue | 20 +++- config/routes.rb | 1 + .../controllers/twilio/voice_controller.rb | 26 +++++ .../twilio/recording_attachment_job.rb | 17 +++ .../twilio/recording_attachment_service.rb | 97 +++++++++++++++++ .../voice/recording_status_service.rb | 43 ++++++++ .../twilio/voice_controller_spec.rb | 75 +++++++++++++ .../recording_attachment_service_spec.rb | 102 ++++++++++++++++++ 9 files changed, 399 insertions(+), 3 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 diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.spec.js b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.spec.js index fb8989e55..d21e19b8f 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.spec.js +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.spec.js @@ -35,6 +35,11 @@ const mountComponent = () => template: '
', }, Icon: true, + AudioChip: { + props: ['attachment'], + template: + '
{{ attachment.dataUrl }}
', + }, }, mocks: { $t: key => key, @@ -51,6 +56,7 @@ describe('VoiceCall.vue', () => { meta: {}, }, }), + attachments: ref([]), currentUserId: ref(1), messageType: ref(MESSAGE_TYPES.INCOMING), }; @@ -88,4 +94,19 @@ describe('VoiceCall.vue', () => { expect(wrapper.text()).toContain('Call ended'); expect(wrapper.text()).toContain('02:13'); }); + + it('renders the recording attachment on the voice call message', () => { + messageContext.attachments.value = [ + { + id: 1, + fileType: 'audio', + dataUrl: 'https://example.com/recording.mp3', + }, + ]; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-test-id="audio-chip"]').exists()).toBe(true); + expect(wrapper.text()).toContain('https://example.com/recording.mp3'); + }); }); diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index fbd7bfd69..f6348b52a 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -2,11 +2,16 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useMessageContext } from '../provider.js'; -import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants'; +import { + ATTACHMENT_TYPES, + MESSAGE_TYPES, + VOICE_CALL_STATUS, +} from '../constants'; 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 ICON_MAP = { [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call', @@ -23,7 +28,8 @@ const BG_COLOR_MAP = { }; const { t } = useI18n(); -const { contentAttributes, currentUserId, messageType } = useMessageContext(); +const { attachments, contentAttributes, currentUserId, messageType } = + useMessageContext(); const data = computed(() => contentAttributes.value?.data); const status = computed(() => data.value?.status?.toString()); @@ -33,6 +39,11 @@ const joinedBy = computed(() => { const callDuration = computed(() => { return data.value?.meta?.duration; }); +const recordingAttachment = computed(() => { + return attachments.value?.find( + attachment => attachment.fileType === ATTACHMENT_TYPES.AUDIO + ); +}); const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING); const isFailed = computed(() => @@ -103,7 +114,7 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); diff --git a/config/routes.rb b/config/routes.rb index 0992d21a2..7e9000ec4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -590,6 +590,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 aa2696b31..5686624db 100644 --- a/enterprise/app/controllers/twilio/voice_controller.rb +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -39,6 +39,7 @@ class Twilio::VoiceController < ApplicationController friendly_name: params[:FriendlyName], call_sid: twilio_call_sid ) + persist_twilio_conference_sid!(conversation, params[:ConferenceSid]) Voice::Conference::Manager.new( conversation: conversation, @@ -50,6 +51,14 @@ 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 @@ -139,6 +148,10 @@ class Twilio::VoiceController < ApplicationController response.dial do |dial| dial.conference( conference_sid, + record: 'record-from-start', + recording_status_callback: recording_status_callback_url, + recording_status_callback_event: 'completed', + recording_status_callback_method: 'POST', start_conference_on_enter: agent_leg, end_conference_on_exit: false, status_callback: conference_status_callback_url, @@ -155,6 +168,11 @@ 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_conversation_for_conference!(friendly_name:, call_sid:) name = friendly_name.to_s scope = current_account.conversations @@ -167,6 +185,14 @@ class Twilio::VoiceController < ApplicationController scope.find_by!(identifier: call_sid) end + def persist_twilio_conference_sid!(conversation, conference_sid) + return if conference_sid.blank? + return if conversation.additional_attributes&.dig('twilio_conference_sid') == conference_sid + + attrs = (conversation.additional_attributes || {}).merge('twilio_conference_sid' => conference_sid) + conversation.update!(additional_attributes: attrs) + end + def set_inbox! digits = params[:phone].to_s.gsub(/\D/, '') e164 = "+#{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 000000000..046ef84a5 --- /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(conversation_id, recording_sid, recording_url, recording_duration = nil) + conversation = Conversation.find_by(id: conversation_id) + return if conversation.blank? + + Voice::Provider::Twilio::RecordingAttachmentService.new( + conversation: conversation, + recording_sid: recording_sid, + recording_url: recording_url, + recording_duration: recording_duration + ).perform + end +end 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 000000000..a08b233f1 --- /dev/null +++ b/enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb @@ -0,0 +1,97 @@ +class Voice::Provider::Twilio::RecordingAttachmentService + DEFAULT_FILENAME_EXTENSION = 'wav'.freeze + + pattr_initialize [:conversation!, :recording_sid!, :recording_url!, { recording_duration: nil }] + + def perform + return if recording_sid.blank? || recording_url.blank? + + message = voice_call_message + return if message.blank? || recording_already_attached?(message) + + recording_file = download_recording + + message.reload.with_lock do + next if recording_already_attached?(message) + + attach_recording!(message, recording_file) + update_recording_metadata!(message) + end + ensure + recording_file.close! if recording_file.respond_to?(:close!) + end + + private + + def voice_call_message + @voice_call_message ||= conversation.messages.voice_calls.order(created_at: :desc).first + end + + def recording_already_attached?(message) + message.content_attributes + &.dig('data', 'meta', 'recording', 'sid') + .to_s == recording_sid.to_s + end + + def download_recording + Down.download(recording_url, http_basic_authentication: [account_sid, auth_token]) + end + + def attach_recording!(message, recording_file) + message.attachments.create!( + account_id: conversation.account_id, + file_type: :audio, + file: { + io: recording_file, + filename: recording_filename(recording_file), + content_type: recording_content_type(recording_file) + } + ) + end + + def update_recording_metadata!(message) + content_attributes = (message.content_attributes || {}).deep_dup + content_attributes['data'] ||= {} + content_attributes['data']['meta'] ||= {} + content_attributes['data']['meta']['recording'] = { + 'sid' => recording_sid, + 'duration' => normalized_recording_duration + }.compact + + message.update!(content_attributes: content_attributes) + end + + def normalized_recording_duration + return if recording_duration.blank? + + recording_duration.to_i + end + + def recording_filename(recording_file) + filename = recording_file.original_filename if recording_file.respond_to?(:original_filename) + return filename if filename.present? + + "call-recording-#{recording_sid}.#{recording_extension(recording_file)}" + end + + def recording_extension(recording_file) + content_type = recording_content_type(recording_file) + Rack::Mime::MIME_TYPES.invert[content_type].to_s.delete_prefix('.').presence || DEFAULT_FILENAME_EXTENSION + end + + def recording_content_type(recording_file) + recording_file.content_type.presence || 'audio/wav' + end + + def account_sid + @account_sid ||= channel_config.fetch('account_sid') + end + + def auth_token + @auth_token ||= channel_config.fetch('auth_token') + end + + def channel_config + @channel_config ||= conversation.inbox.channel.provider_config_hash + 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 000000000..e01065750 --- /dev/null +++ b/enterprise/app/services/voice/recording_status_service.rb @@ -0,0 +1,43 @@ +class Voice::RecordingStatusService + pattr_initialize [:account!, { payload: {} }] + + def perform + return unless completed_recording? + return if conference_sid.blank? || recording_sid.blank? || recording_url.blank? + + conversation = account.conversations.find_by( + "additional_attributes->>'twilio_conference_sid' = ?", + conference_sid + ) + return if conversation.blank? + + Voice::Provider::Twilio::RecordingAttachmentJob.perform_later( + conversation.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/controllers/twilio/voice_controller_spec.rb b/spec/enterprise/controllers/twilio/voice_controller_spec.rb index 43141414e..e9b3e2adf 100644 --- a/spec/enterprise/controllers/twilio/voice_controller_spec.rb +++ b/spec/enterprise/controllers/twilio/voice_controller_spec.rb @@ -3,12 +3,15 @@ require 'rails_helper' RSpec.describe 'Twilio::VoiceController', type: :request do + include ActiveJob::TestHelper + let(:account) { create(:account) } let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') } let(:inbox) { channel.inbox } let(:digits) { channel.phone_number.delete_prefix('+') } before do + clear_enqueued_jobs allow(Twilio::VoiceWebhookSetupService).to receive(:new) .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) end @@ -39,6 +42,8 @@ RSpec.describe 'Twilio::VoiceController', type: :request do expect(response).to have_http_status(:ok) expect(response.body).to include('') expect(response.body).to include('') + expect(response.body).to include('record="record-from-start"') + expect(response.body).to include("/twilio/voice/recording_status/#{digits}") end it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do @@ -143,4 +148,74 @@ RSpec.describe 'Twilio::VoiceController', type: :request do expect(response).to have_http_status(:not_found) end end + + describe 'POST /twilio/voice/conference_status/:phone' do + let(:call_sid) { 'CA_conference_status_sid_456' } + let!(:conversation) do + create( + :conversation, + account: account, + inbox: inbox, + identifier: call_sid, + additional_attributes: { 'conference_sid' => 'friendly-conference-name' } + ) + end + + it 'persists the Twilio conference SID from the callback' do + manager_double = instance_double(Voice::Conference::Manager, process: nil) + allow(Voice::Conference::Manager).to receive(:new).and_return(manager_double) + + post "/twilio/voice/conference_status/#{digits}", params: { + 'CallSid' => call_sid, + 'FriendlyName' => 'friendly-conference-name', + 'ConferenceSid' => 'CF123456789', + 'StatusCallbackEvent' => 'conference-start', + 'ParticipantLabel' => 'contact' + } + + expect(response).to have_http_status(:no_content) + expect(conversation.reload.additional_attributes['twilio_conference_sid']).to eq('CF123456789') + expect(manager_double).to have_received(:process) + end + end + + describe 'POST /twilio/voice/recording_status/:phone' do + let!(:conversation) do + create( + :conversation, + account: account, + inbox: inbox, + identifier: 'CA_recording_sid_456', + additional_attributes: { 'twilio_conference_sid' => 'CF999' } + ) + end + + it 'enqueues recording attachment when recording completes' do + expect do + post "/twilio/voice/recording_status/#{digits}", params: { + 'ConferenceSid' => 'CF999', + 'RecordingSid' => 'RE123', + 'RecordingUrl' => 'https://api.twilio.com/recordings/RE123', + 'RecordingDuration' => '42', + 'RecordingStatus' => 'completed' + } + end.to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + .with(conversation.id, 'RE123', 'https://api.twilio.com/recordings/RE123', '42') + + expect(response).to have_http_status(:no_content) + end + + it 'ignores non-completed recording events' do + expect do + post "/twilio/voice/recording_status/#{digits}", params: { + 'ConferenceSid' => 'CF999', + 'RecordingSid' => 'RE123', + 'RecordingUrl' => 'https://api.twilio.com/recordings/RE123', + 'RecordingStatus' => 'in-progress' + } + end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob) + + expect(response).to have_http_status(:no_content) + end + 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 000000000..b1ea6e47a --- /dev/null +++ b/spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Voice::Provider::Twilio::RecordingAttachmentService do + let(:account) { create(:account) } + let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230009') } + let(:inbox) { channel.inbox } + let(:contact) { create(:contact, account: account, phone_number: '+15550009999') } + let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: contact.phone_number) } + let(:conversation) do + create( + :conversation, + account: account, + inbox: inbox, + contact: contact, + contact_inbox: contact_inbox + ) + end + let(:voice_call_message) do + create( + :message, + account: account, + conversation: conversation, + inbox: inbox, + sender: contact, + message_type: :incoming, + content: 'Voice Call', + content_type: 'voice_call', + content_attributes: { + 'data' => { + 'status' => 'completed', + 'meta' => { + 'duration' => 42 + } + } + } + ) + end + let(:recording_sid) { 'RE-recording-123' } + let(:recording_url) { 'https://api.twilio.com/2010-04-01/Accounts/AC123/Recordings/RE-recording-123' } + let(:recording_file) do + Tempfile.new(['call-recording', '.wav']).tap do |file| + file.write('fake wav content') + file.rewind + file.define_singleton_method(:content_type) { 'audio/wav' } + file.define_singleton_method(:original_filename) { 'call-recording.wav' } + file.define_singleton_method(:close!) do + close + unlink + end + end + end + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new) + .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) + channel + voice_call_message + allow(Down).to receive(:download).with( + recording_url, + http_basic_authentication: [ + channel.provider_config_hash['account_sid'], + channel.provider_config_hash['auth_token'] + ] + ).and_return(recording_file) + allow(Messages::AudioTranscriptionJob).to receive(:perform_later) + end + + it 'attaches the recording to the existing voice_call message' do + described_class.new( + conversation: conversation, + recording_sid: recording_sid, + recording_url: recording_url, + recording_duration: '42' + ).perform + + voice_call_message.reload + + expect(voice_call_message.attachments.size).to eq(1) + expect(voice_call_message.attachments.first.file_type).to eq('audio') + expect(voice_call_message.content_attributes.dig('data', 'meta', 'recording')).to eq( + { 'sid' => recording_sid, 'duration' => 42 } + ) + end + + it 'is idempotent for the same recording sid' do + service = described_class.new( + conversation: conversation, + recording_sid: recording_sid, + recording_url: recording_url + ) + + service.perform + service.perform + + voice_call_message.reload + + expect(voice_call_message.attachments.count).to eq(1) + expect(voice_call_message.content_attributes.dig('data', 'meta', 'recording', 'sid')).to eq(recording_sid) + end +end