+
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