feat: add the support for call recordings
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user