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
parent 2dee7457cd
commit 0bd0cab868
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 430 additions and 4 deletions

View File

@ -5,9 +5,11 @@ import { useStore } from 'vuex';
import { useMessageContext } from '../provider.js';
import { VOICE_CALL_STATUS } from '../constants';
import { useCallSession } from 'dashboard/composables/useCallSession';
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 LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
@ -76,12 +78,16 @@ const labelKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const formattedDuration = computed(() =>
formatDuration(call.value?.durationSeconds)
);
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
return formattedDuration.value;
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
@ -128,6 +134,17 @@ const canJoinCall = computed(() => {
return true;
});
const recordingAttachment = computed(() => {
const url = call.value?.recordingUrl;
if (!url) return null;
return {
dataUrl: url,
fileType: 'audio',
extension: 'wav',
transcribedText: call.value?.transcript || '',
};
});
const handleJoinCall = async () => {
if (!canJoinCall.value || isJoining.value) return;
@ -149,7 +166,7 @@ const handleJoinCall = async () => {
<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"
@ -169,7 +186,7 @@ const handleJoinCall = async () => {
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
<span v-if="subtext" class="text-xs text-n-slate-11">
{{ subtext }}
</span>
<button
@ -183,6 +200,10 @@ const handleJoinCall = async () => {
</button>
</div>
</div>
<div v-if="recordingAttachment" class="px-3 pb-3">
<AudioChip :attachment="recordingAttachment" />
</div>
</div>
</BaseBubble>
</template>

View File

@ -94,6 +94,29 @@ export const shortTimestamp = (time, withAgo = false) => {
return convertToShortTime;
};
/**
* Formats a duration in seconds into mm:ss or hh:mm:ss.
* @param {number|string} durationInSeconds - Duration in seconds.
* @returns {string} Formatted duration string. Empty string for invalid input.
*/
export const formatDuration = durationInSeconds => {
if (durationInSeconds === null || durationInSeconds === undefined) return '';
const totalSeconds = Number(durationInSeconds);
if (Number.isNaN(totalSeconds) || totalSeconds < 0) return '';
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const mm = minutes.toString().padStart(2, '0');
const ss = seconds.toString().padStart(2, '0');
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${mm}:${ss}`;
}
return `${mm}:${ss}`;
};
/**
* Calculates the difference in days between now and a given timestamp.
* @param {Date} now - Current date/time.

View File

@ -607,6 +607,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

View File

@ -38,6 +38,7 @@ class Twilio::VoiceController < ApplicationController
end
call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
persist_twilio_conference_sid!(call, params[:ConferenceSid])
Voice::Conference::Manager.new(
call: call,
@ -48,6 +49,15 @@ 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
@ -125,6 +135,10 @@ class Twilio::VoiceController < ApplicationController
conference_sid,
start_conference_on_enter: agent_leg?(twilio_from),
end_conference_on_exit: false,
record: 'record-from-start',
recording_status_callback: recording_status_callback_url,
recording_status_callback_event: 'completed',
recording_status_callback_method: 'POST',
status_callback: conference_status_callback_url,
status_callback_event: 'start end join leave',
status_callback_method: 'POST',
@ -152,12 +166,27 @@ 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_call_for_conference!(friendly_name, call_sid)
name = friendly_name.to_s
call = inbox_calls.by_conference_sid(name).first if name.present?
call || inbox_calls.find_by!(provider_call_id: call_sid)
end
# Twilio's recording webhook only sends its internal ConferenceSid (CF...),
# not our FriendlyName. Persist Twilio's id the first time we see it on a
# conference event so the recording lookup can match later.
def persist_twilio_conference_sid!(call, sid)
return if sid.blank?
return if call.twilio_conference_sid == sid
call.update!(twilio_conference_sid: sid)
end
def set_inbox!
digits = params[:phone].to_s.gsub(/\D/, '')
phone_number = "+#{digits}"

View File

@ -0,0 +1,17 @@
class Voice::Provider::Twilio::RecordingAttachmentJob < ApplicationJob
queue_as :low
retry_on Down::Error, wait: 5.seconds, attempts: 3
def perform(call_id, recording_sid, recording_url, recording_duration = nil)
call = Call.find_by(id: call_id)
return if call.blank?
Voice::Provider::Twilio::RecordingAttachmentService.new(
call: call,
recording_sid: recording_sid,
recording_url: recording_url,
recording_duration: recording_duration
).perform
end
end

View File

@ -34,7 +34,7 @@ class Call < ApplicationRecord
# Statuses where the call is finished and won't change again
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
enum :provider, { twilio: 0, whatsapp: 1 }
enum :direction, { incoming: 0, outgoing: 1 }
@ -55,6 +55,7 @@ class Call < ApplicationRecord
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
scope :by_twilio_conference_sid, ->(sid) { where("meta->>'twilio_conference_sid' = ?", sid) }
def self.find_by_provider_call_id(provider, sid)
find_by(provider: provider, provider_call_id: sid)

View File

@ -0,0 +1,81 @@
class Voice::Provider::Twilio::RecordingAttachmentService
DEFAULT_FILENAME_EXTENSION = 'wav'.freeze
ALLOWED_CONTENT_TYPE_PREFIXES = %w[audio/].freeze
pattr_initialize [:call!, :recording_sid!, :recording_url!, { recording_duration: nil }]
def perform
return if recording_sid.blank? || recording_url.blank?
return if already_attached?
SafeFetch.fetch(
recording_url,
http_basic_authentication: [account_sid, auth_token],
allowed_content_type_prefixes: ALLOWED_CONTENT_TYPE_PREFIXES
) do |result|
persist_recording!(result)
end
# Bump the message updated_at so the message.updated dispatcher rebroadcasts
# the embedded Call payload (now with recording_url) to connected clients.
call.message&.touch # rubocop:disable Rails/SkipsModelValidations
end
private
def persist_recording!(result)
call.with_lock do
next if already_attached?
attach_recording!(result)
call.recording_sid = recording_sid
call.duration_seconds ||= normalized_recording_duration
call.save!
end
end
def already_attached?
call.recording.attached? && call.recording_sid.to_s == recording_sid.to_s
end
def attach_recording!(result)
call.recording.attach(
io: result.tempfile,
filename: recording_filename(result),
content_type: recording_content_type(result)
)
end
def normalized_recording_duration
return if recording_duration.blank?
recording_duration.to_i
end
def recording_filename(result)
return result.original_filename if result.original_filename.present?
"call-recording-#{recording_sid}.#{recording_extension(result)}"
end
def recording_extension(result)
content_type = recording_content_type(result)
Rack::Mime::MIME_TYPES.invert[content_type].to_s.delete_prefix('.').presence || DEFAULT_FILENAME_EXTENSION
end
def recording_content_type(result)
result.content_type.presence || 'audio/wav'
end
def account_sid
@account_sid ||= channel.account_sid
end
def auth_token
@auth_token ||= channel.auth_token
end
def channel
@channel ||= call.inbox.channel
end
end

View File

@ -0,0 +1,40 @@
class Voice::RecordingStatusService
pattr_initialize [:account!, { payload: {} }]
def perform
return unless completed_recording?
return if conference_sid.blank? || recording_sid.blank? || recording_url.blank?
call = Call.where(account_id: account.id).by_twilio_conference_sid(conference_sid).first
return if call.blank?
Voice::Provider::Twilio::RecordingAttachmentJob.perform_later(
call.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

View File

@ -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

View File

@ -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