feat: add the support for call recordings

This commit is contained in:
Muhsin
2026-03-13 16:54:37 +04:00
parent e4f9355352
commit 1b0974b407
9 changed files with 399 additions and 3 deletions
@@ -35,6 +35,11 @@ const mountComponent = () =>
template: '<div><slot /></div>',
},
Icon: true,
AudioChip: {
props: ['attachment'],
template:
'<div data-test-id="audio-chip">{{ attachment.dataUrl }}</div>',
},
},
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');
});
});
@@ -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');
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div class="flex overflow-hidden flex-col w-full max-w-xs">
<div class="flex overflow-hidden flex-col w-full max-w-sm">
<div class="flex gap-3 items-center p-3 w-full">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
@@ -128,6 +139,9 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
</span>
</div>
</div>
<div v-if="recordingAttachment" class="px-3 pb-3">
<AudioChip class="text-n-slate-12" :attachment="recordingAttachment" />
</div>
</div>
</BaseBubble>
</template>
+1
View File
@@ -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
@@ -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}"
@@ -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
@@ -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
@@ -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
@@ -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('<Response>')
expect(response.body).to include('<Dial>')
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
@@ -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