diff --git a/app/controllers/twilio/recording_controller.rb b/app/controllers/twilio/recording_controller.rb
new file mode 100644
index 000000000..48f708e0f
--- /dev/null
+++ b/app/controllers/twilio/recording_controller.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+class Twilio::RecordingController < ActionController::Base
+ skip_forgery_protection
+
+ # POST /twilio/recording_callback
+ # This endpoint is called by Twilio when a call recording is available
+ def recording_callback
+ conference_sid = params['conference_sid']
+ call_sid = params['CallSid']
+ recording_url = params['RecordingUrl']
+ recording_sid = params['RecordingSid']
+ account_id = params['account_id']
+ Rails.logger.info("[Twilio::RecordingController] Incoming recording_callback with params: #{params.inspect}")
+ unless recording_url && account_id && (conference_sid || call_sid)
+ Rails.logger.warn("[Twilio::RecordingController] Missing required params. recording_url: #{recording_url}, account_id: #{account_id}, conference_sid: #{conference_sid}, call_sid: #{call_sid}")
+ return head :bad_request
+ end
+
+ # Find the account
+ account = Account.find_by(id: account_id)
+ unless account
+ Rails.logger.warn("[Twilio::RecordingController] Account not found for id: #{account_id}")
+ return head :not_found
+ end
+
+ # Prefer lookup by conference_sid (most robust for conference recordings)
+ conversation = if conference_sid
+ account.conversations.find_by("additional_attributes ->> 'conference_sid' = ?", conference_sid)
+ elsif call_sid
+ account.conversations.find_by("additional_attributes ->> 'call_sid' = ?", call_sid)
+ end
+ unless conversation
+ Rails.logger.warn("[Twilio::RecordingController] Conversation not found for conference_sid: #{conference_sid} or call_sid: #{call_sid}")
+ return head :not_found
+ end
+
+ # Find the original voice call message (should be unique per conference)
+ message = conversation.messages.voice_call.order(:created_at).first
+ unless message
+ Rails.logger.warn("[Twilio::RecordingController] No voice_call message found in conversation_id: #{conversation.id}")
+ return head :not_found
+ end
+
+ # Download the recording from Twilio
+ begin
+ Rails.logger.info("[Twilio::RecordingController] Downloading recording from: #{recording_url}.mp3")
+ file = URI.open(recording_url + '.mp3')
+ rescue => e
+ Rails.logger.error("[Twilio::RecordingController] Failed to download recording: #{e.message}")
+ return head :internal_server_error
+ end
+
+ # Attach the audio file to the message as an audio attachment
+ begin
+ att = message.attachments.create!(
+ account_id: account.id,
+ file: {
+ io: file,
+ filename: "twilio_recording_#{recording_sid}.mp3",
+ content_type: 'audio/mpeg'
+ },
+ file_type: :audio,
+ external_url: recording_url + '.mp3',
+ meta: { recording_sid: recording_sid, conference_sid: conference_sid, call_sid: call_sid }
+ )
+ Rails.logger.info("[Twilio::RecordingController] Successfully attached recording to message_id: #{message.id}, attachment_id: #{att.id}")
+ rescue => e
+ Rails.logger.error("[Twilio::RecordingController] Failed to attach recording: #{e.message}")
+ return head :internal_server_error
+ end
+
+ # Optionally, update message content_attributes to indicate recording is attached
+ content_attributes = message.content_attributes || {}
+ content_attributes['recording_attached'] = true
+ content_attributes['conference_sid'] = conference_sid if conference_sid
+ message.update!(content_attributes: content_attributes)
+
+ head :ok
+ end
+end
diff --git a/app/controllers/twilio/voice_controller.rb b/app/controllers/twilio/voice_controller.rb
index 419f0130c..91aec6c5b 100644
--- a/app/controllers/twilio/voice_controller.rb
+++ b/app/controllers/twilio/voice_controller.rb
@@ -86,7 +86,10 @@ class Twilio::VoiceController < ActionController::Base
statusCallback: conference_callback_url,
statusCallbackMethod: 'POST',
statusCallbackEvent: 'start end join leave',
- participantLabel: "caller-#{@call_sid.last(8)}"
+ participantLabel: "caller-#{@call_sid.last(8)}",
+ record: 'record-from-start',
+ recording_status_callback: "#{base_url}/twilio/recording_callback?account_id=#{@inbox.account_id}&conference_sid=#{conference_name}",
+ recording_status_callback_method: 'POST'
)
end
end
diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
index 5ce46b408..1b4dc4cb0 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
@@ -25,12 +25,18 @@
-
+
+
+
@@ -60,6 +66,7 @@ export default {
isAnimating: false,
recordingUrl: '',
isPlaying: false,
+ hasAudioAttachment: false,
};
},
setup(props) {
@@ -300,12 +307,14 @@ export default {
message: {
handler() {
this.setupVoiceCall();
+ this.setAudioAttachment();
},
deep: true,
},
},
mounted() {
this.setupVoiceCall();
+ this.setAudioAttachment();
},
beforeUnmount() {
// Clean up all intervals to prevent memory leaks
@@ -398,6 +407,39 @@ export default {
},
handlePlaybackEnd() {
this.isPlaying = false;
+ },
+ setAudioAttachment() {
+ // Look for audio attachment in message.attachments or message.contentAttributes.attachments
+ let attachments = [];
+ if (this.message?.attachments && Array.isArray(this.message.attachments)) {
+ attachments = this.message.attachments;
+ } else if (this.message?.contentAttributes?.attachments && Array.isArray(this.message.contentAttributes.attachments)) {
+ attachments = this.message.contentAttributes.attachments;
+ }
+ // Find the first audio attachment, supporting both camelCase and snake_case fields
+ const audio = attachments.find(att => {
+ if (!att) return false;
+ // Check file_type or fileType
+ if ((att.file_type && att.file_type.startsWith('audio')) ||
+ (att.fileType && att.fileType.startsWith('audio'))) return true;
+ // Check content_type or contentType
+ if ((att.content_type && att.content_type.startsWith('audio')) ||
+ (att.contentType && att.contentType.startsWith('audio'))) return true;
+ // Check data_url or dataUrl
+ if ((att.data_url && att.data_url.match(/\.(mp3|wav|ogg|m4a)$/i)) ||
+ (att.dataUrl && att.dataUrl.match(/\.(mp3|wav|ogg|m4a)$/i))) return true;
+ // Check file_url or fileUrl
+ if ((att.file_url && att.file_url.match(/\.(mp3|wav|ogg|m4a)$/i)) ||
+ (att.fileUrl && att.fileUrl.match(/\.(mp3|wav|ogg|m4a)$/i))) return true;
+ return false;
+ });
+ if (audio) {
+ this.recordingUrl = audio.data_url || audio.file_url || audio.dataUrl || audio.fileUrl || '';
+ this.hasAudioAttachment = true;
+ } else {
+ this.recordingUrl = '';
+ this.hasAudioAttachment = false;
+ }
}
},
};
diff --git a/app/services/voice/incoming_call_service.rb b/app/services/voice/incoming_call_service.rb
index 20d65b221..4cd507de8 100644
--- a/app/services/voice/incoming_call_service.rb
+++ b/app/services/voice/incoming_call_service.rb
@@ -243,7 +243,10 @@ module Voice
statusCallback: conference_callback_url,
statusCallbackMethod: 'POST',
statusCallbackEvent: 'start end join leave',
- participantLabel: "caller-#{caller_info[:call_sid].last(8)}"
+ participantLabel: "caller-#{caller_info[:call_sid].last(8)}",
+ record: 'record-from-start',
+ recording_status_callback: "#{base_url}/twilio/recording_callback?account_id=#{account.id}&conference_sid=#{conference_name}",
+ recording_status_callback_method: 'POST'
)
end
diff --git a/config/routes.rb b/config/routes.rb
index 3b7dd8380..64db05bac 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -513,6 +513,9 @@ Rails.application.routes.draw do
# Transcription webhook
post :transcription_callback, to: 'transcription#transcription_callback'
+ # Recording webhook
+ post :recording_callback, to: 'recording#recording_callback'
+
# Use resource scope to avoid plural/singular confusion
resource :voice, only: [], controller: 'voice' do
collection do