feat(voice): Attach call recordings + show call duration on the bubble (#14344)
When an inbound voice call ends, the conversation bubble now (1) renders an inline audio player as soon as Twilio finishes the recording and (2) shows the call duration alongside "Call ended" so the agent gets the at-a-glance summary without opening the recording. Fixes https://linear.app/chatwoot/issue/PLA-118/feat-recordings-on-calls-should-be-attached-on-the-conversation and https://linear.app/chatwoot/issue/PLA-119/duration-of-the-call-is-not-visible-on-the-chat-bubble ## How to test 1. Set up a Twilio voice inbox and trigger an inbound call. 2. Answer the call from an agent, talk for a few seconds, then hang up. 3. As soon as the call ends, the bubble should read **"Call ended — 0:NN"** (where NN is the call duration in seconds). 4. Wait a few seconds for Twilio to finish processing the recording (usually <30s after hangup). 5. The same bubble should now show an inline audio player below the duration. Press play; the recording should be audible. 6. Refresh the page — both the duration and the player should still be there. 7. End a second call on the same conversation — its bubble should get its own duration + player, independent of the first. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user