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:
Muhsin Keloth
2026-05-04 17:14:01 +04:00
committed by GitHub
co-authored by Muhsin
parent 2dee7457cd
commit 0bd0cab868
10 changed files with 430 additions and 4 deletions
@@ -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