fix: incoming call functionality in voice webhooks controller - Fix polymorphic association query for finding voice inbox - Fix contact and conversation creation with proper associations - Remove unnecessary verify_authenticity_token skip - Ensure proper validation and error handling for contact creation

This commit is contained in:
Sojan
2025-04-28 02:16:57 -07:00
parent a7ff808d01
commit 4c579bc71e
3 changed files with 396 additions and 211 deletions
@@ -0,0 +1,213 @@
class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
before_action :validate_twilio_signature, only: [:incoming]
# Handle incoming calls from Twilio
def incoming
# Find the corresponding voice channel/inbox for this number
to_number = params['To']
inbox = Current.account.inboxes
.where(channel_type: 'Channel::Voice')
.joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
.where('channel_voice.phone_number = ?', to_number)
.first
unless inbox
render_error('Inbox not found for this phone number')
return
end
# Get caller information
from_number = params['From']
call_sid = params['CallSid']
# Find or create the contact
contact = Current.account.contacts.find_or_create_by!(phone_number: from_number) do |c|
c.name = "Contact from #{from_number}"
end
# Find or create the contact inbox
contact_inbox = ContactInbox.find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id
)
contact_inbox.update!(source_id: from_number) if contact_inbox.source_id.blank?
# Create a new conversation for this call
conversation = Current.account.conversations.create!(
contact_inbox_id: contact_inbox.id,
inbox_id: inbox.id,
status: :open,
contact: contact,
additional_attributes: {
'call_sid' => call_sid,
'call_status' => 'ringing',
'call_direction' => 'inbound'
}
)
# Create an activity message for the incoming call
Messages::MessageBuilder.new(
nil,
conversation,
{
content: 'Incoming call',
message_type: :activity,
additional_attributes: {
call_sid: call_sid,
call_status: 'ringing',
call_direction: 'inbound'
}
}
).perform
# Generate minimal TwiML response
response = Twilio::TwiML::VoiceResponse.new
response.say(message: 'Thank you for calling. An agent will be with you shortly.')
response.pause(length: 2)
# Add minimal conference with just the essential parameters
response.dial do |dial|
dial.conference(
"conf_#{call_sid}",
status_callback: "#{base_url}/api/v1/accounts/#{Current.account.id}/channels/voice/webhooks/conference_status",
status_callback_event: 'start end join leave',
status_callback_method: 'POST',
end_conference_on_exit: true
)
end
render xml: response.to_s
end
# Handle conference status updates
def conference_status
call_sid = params['CallSid']
conference_sid = params['ConferenceSid']
event = params['StatusCallbackEvent']
# For local development, set Current.account if not set
if Rails.env.development? && !Current.account
account_id = params[:account_id]
Current.account = Account.find(account_id) if account_id
end
# Try to find the conversation by call_sid or conference_sid
conversation = if call_sid.present?
Current.account.conversations
.where("additional_attributes->>'call_sid' = ?", call_sid)
.first
elsif conference_sid.present?
Current.account.conversations
.where("additional_attributes->>'conference_sid' = ?", conference_sid)
.first
end
# Return minimal error if conversation not found
return head :not_found unless conversation
# Update conversation with conference info
conversation.additional_attributes ||= {}
conversation.additional_attributes['conference_sid'] = conference_sid
case event
when 'conference-start'
conversation.additional_attributes['conference_status'] = 'started'
activity_message = 'Conference started'
when 'conference-end'
conversation.additional_attributes['conference_status'] = 'ended'
conversation.additional_attributes['call_status'] = 'completed'
conversation.additional_attributes['call_ended_at'] = Time.now.to_i
conversation.status = :resolved
activity_message = 'Conference ended'
when 'participant-join'
activity_message = 'Participant joined the call'
when 'participant-leave'
activity_message = 'Participant left the call'
else
activity_message = 'Call event occurred'
end
conversation.save!
# Create activity message with minimal attributes
Messages::MessageBuilder.new(
nil,
conversation,
{
content: activity_message,
message_type: :activity,
additional_attributes: {
call_sid: call_sid,
event_type: event
}
}
).perform
# Broadcast minimal update to frontend
ActionCable.server.broadcast(
"#{conversation.account_id}_#{conversation.inbox_id}",
{
event_name: 'call_status_changed',
data: {
call_sid: call_sid,
status: conversation.additional_attributes['call_status'] || 'in-progress',
conversation_id: conversation.id
}
}
)
# Return minimal response
head :ok
end
private
def validate_twilio_signature
# Find the inbox for the phone number
to_number = params['To']
# Skip validation for local development
return true if Rails.env.development?
inbox = Current.account.inboxes
.where(channel_type: 'Channel::Voice')
.joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
.where('channel_voice.phone_number = ?', to_number)
.first
return render_error('Inbox not found for this phone number') unless inbox
# Get Twilio Auth Token from inbox's channel
channel = inbox.channel
return render_error('Channel is not a voice channel') unless channel.is_a?(Channel::Voice)
auth_token = channel.provider_config_hash['auth_token']
# Validate incoming request signature
validator = Twilio::Security::RequestValidator.new(auth_token)
signature = request.headers['X-Twilio-Signature']
# Check if incoming signature is valid
url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}"
is_valid = validator.validate(url, params.to_unsafe_h, signature)
unless is_valid
render_error('Invalid Twilio signature')
return false
end
true
end
def render_error(message)
response = Twilio::TwiML::VoiceResponse.new
response.say(message: message)
response.hangup
render xml: response.to_s
end
def base_url
ENV.fetch('FRONTEND_URL', "https://#{request.host_with_port}")
end
end
+177 -209
View File
@@ -1,31 +1,27 @@
class Twilio::VoiceController < ActionController::Base
skip_forgery_protection
def twiml
# ULTRA minimal TwiML - just a simple greeting and record
response = Twilio::TwiML::VoiceResponse.new
# Just a simple message about recent signup and feedback
response.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup. We would love to hear any feedback or questions you might have about your experience so far. Please share your thoughts after the beep.')
# Record their feedback
response.pause(length: 1) # give a moment before the beep and recording
# Record their feedback after a beep, then let handle_recording hang up
response.record(
action: '/twilio/voice/handle_recording',
method: 'POST',
maxLength: 30,
timeout: 2,
statusCallback: '/twilio/voice/status_callback',
statusCallbackMethod: 'POST',
statusCallbackEvent: ['completed']
maxLength: 3600,
timeout: 30,
playBeep: true
)
# Always end the call to avoid any complexity
response.hangup
# Render the response immediately
render xml: response.to_s, status: :ok
end
def handle_user_input
call_sid = params['CallSid']
digits = params['Digits']
@@ -34,30 +30,30 @@ class Twilio::VoiceController < ActionController::Base
to_number = params['To']
direction = params['Direction']
is_outbound = direction == 'outbound-api'
# Find the inbox for this voice call based on the direction
inbox = find_inbox(is_outbound ? from_number : to_number)
if inbox.present?
# Create or find the conversation for this call
conversation = find_or_create_conversation(inbox, is_outbound ? to_number : from_number, call_sid)
# Create an activity message showing the user input
input_text = if digits.present?
"Caller pressed #{digits}"
elsif speech_result.present?
"Caller said: \"#{speech_result}\""
else
"Caller responded"
end
"Caller pressed #{digits}"
elsif speech_result.present?
"Caller said: \"#{speech_result}\""
else
'Caller responded'
end
Messages::MessageBuilder.new(
nil,
conversation,
{
content: input_text,
nil,
conversation,
{
content: input_text,
message_type: :activity,
additional_attributes: {
additional_attributes: {
call_sid: call_sid,
call_status: 'in-progress',
user_input: true
@@ -65,12 +61,12 @@ class Twilio::VoiceController < ActionController::Base
}
).perform
end
# Redirect back to the main TwiML to continue the call flow
response = Twilio::TwiML::VoiceResponse.new do |r|
r.redirect(url: "/twilio/voice/twiml?ReturnCall=true&Direction=#{direction.to_s}&step=check_messages")
r.redirect(url: "/twilio/voice/twiml?ReturnCall=true&Direction=#{direction}&step=check_messages")
end
render xml: response.to_s, status: :ok
end
@@ -81,172 +77,146 @@ class Twilio::VoiceController < ActionController::Base
recording_url = params['RecordingUrl']
recording_sid = params['RecordingSid']
direction = params['Direction']
# Determine if outbound call
is_outbound = direction == 'outbound-api'
# Find inbox and save recording if available
if recording_url.present? && call_sid.present?
inbox_number = is_outbound ? from_number : to_number
inbox = find_inbox(inbox_number)
if inbox.present?
contact_number = is_outbound ? to_number : from_number
conversation = find_or_create_conversation(inbox, contact_number, call_sid)
contact = conversation.contact
# Create a message with the recording
if contact.present?
begin
message_params = {
content: 'Feedback about recent signup',
message_type: :incoming,
additional_attributes: {
call_sid: call_sid,
recording_url: recording_url,
recording_sid: recording_sid
}
}
message = Messages::MessageBuilder.new(contact, conversation, message_params).perform
# Download and attach the recording if we have a valid URL
if message.present? && recording_url.present?
begin
# Validate that the recording URL is accessible
uri = URI.parse(recording_url)
if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only create an attachment if we have a valid Twilio recording URL
# Twilio recording URL format: https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Recordings/{RecordingSid}
if recording_url.present? && recording_url.include?('/Recordings/') && recording_sid.present?
# Get authentication details from the channel config to access the recording
config = inbox.channel.provider_config_hash
account_sid = config['account_sid']
auth_token = config['auth_token']
# Create an authenticated URL that includes auth details
# This is needed because Twilio recording URLs require authentication
recording_mp3_url = "#{recording_url}.mp3"
begin
# Create an attachment record with proper file type for audio
attachment = message.attachments.new(
file_type: :audio, # Use audio type for proper player rendering
account_id: inbox.account_id,
external_url: recording_mp3_url,
fallback_title: 'Voice Recording',
meta: {
recording_sid: recording_sid,
twilio_account_sid: account_sid,
auth_required: true
}
)
# Save the attachment
if attachment.save
Rails.logger.info("Successfully attached voice recording from #{recording_url}")
else
Rails.logger.error("Failed to save attachment: #{attachment.errors.full_messages.join(', ')}")
end
rescue => e
Rails.logger.error("Failed to handle recording: #{e.message}")
# If the audio attachment fails, try with a more generic file type
begin
fallback_attachment = message.attachments.new(
file_type: :file,
account_id: inbox.account_id,
external_url: recording_mp3_url,
fallback_title: 'Voice Recording (.mp3)',
meta: {
recording_sid: recording_sid,
twilio_account_sid: account_sid,
auth_required: true
}
)
fallback_attachment.save
rescue => e
Rails.logger.error("Failed to create fallback attachment: #{e.message}")
end
end
else
Rails.logger.error("Invalid Twilio recording URL format or missing SID: #{recording_url}")
end
else
Rails.logger.error("Invalid recording URL format: #{recording_url}")
end
rescue => e
# Log error but continue
Rails.logger.error("Error processing recording: #{e.message}")
end
return unless recording_url.present? && call_sid.present?
inbox_number = is_outbound ? from_number : to_number
inbox = find_inbox(inbox_number)
return unless inbox.present?
contact_number = is_outbound ? to_number : from_number
conversation = find_or_create_conversation(inbox, contact_number, call_sid)
contact = conversation.contact
# Create a single feedback message for this recording
return unless contact.present?
existing_msg = conversation.messages.where('additional_attributes @> ?', { recording_sid: recording_sid }.to_json).first
return if existing_msg
begin
message_params = {
content: 'Feedback about recent signup',
message_type: :incoming,
additional_attributes: {
call_sid: call_sid,
recording_url: recording_url,
recording_sid: recording_sid
}
}
message = Messages::MessageBuilder.new(contact, conversation, message_params).perform
# Download and attach the recording if we have a valid URL
if message.present? && recording_url.present?
begin
# Validate that the recording URL is accessible
uri = URI.parse(recording_url)
if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only create an attachment if we have a valid Twilio recording URL
# Twilio recording URL format: https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Recordings/{RecordingSid}
if recording_url.present? && recording_url.include?('/Recordings/') && recording_sid.present?
# Get authentication details from the channel config to access the recording
config = inbox.channel.provider_config_hash
account_sid = config['account_sid']
auth_token = config['auth_token']
# Download the recording and attach via ActiveStorage
recording_mp3_url = "#{recording_url}.mp3"
download_file = Down.download(
recording_mp3_url,
http_basic_authentication: [account_sid, auth_token]
)
attachment = message.attachments.new(
file_type: :audio,
account_id: inbox.account_id,
extension: 'mp3',
fallback_title: 'Voice Recording',
meta: {
recording_sid: recording_sid,
twilio_account_sid: account_sid,
auth_required: true
}
)
attachment.file.attach(
io: download_file,
filename: "#{recording_sid}.mp3",
content_type: 'audio/mpeg'
)
attachment.save!
Rails.logger.info("Successfully downloaded and attached voice recording: #{recording_url}")
else
Rails.logger.error("Invalid Twilio recording URL format or missing SID: #{recording_url}")
end
# Loop recording until caller hangs up
response = Twilio::TwiML::VoiceResponse.new
response.say(message: 'Segment recorded. Please leave more feedback after the beep, or hang up to finish.')
response.record(
action: '/twilio/voice/handle_recording',
method: 'POST',
maxLength: 30,
timeout: 2,
playBeep: true,
statusCallback: '/twilio/voice/status_callback',
statusCallbackMethod: 'POST',
statusCallbackEvent: ['completed', 'in-progress', 'absent']
)
render xml: response.to_s, status: :ok
rescue => e
# Log the error but don't crash
Rails.logger.error("Error processing recording: #{e.message}")
else
Rails.logger.error("Invalid recording URL format: #{recording_url}")
end
rescue StandardError => e
# Log error but continue
Rails.logger.error("Error processing recording: #{e.message}")
end
end
# End the call after a single recording
response = Twilio::TwiML::VoiceResponse.new do |r|
r.say(message: 'Thank you for your feedback. Goodbye.')
r.hangup
end
render xml: response.to_s, status: :ok
rescue StandardError => e
# Log the error but don't crash
Rails.logger.error("Error processing recording: #{e.message}")
end
end
def transcription_callback
# Process the transcription asynchronously
if params['CallSid'].present?
# Queue the processing as a background job
CallTranscriptionJob.perform_later(params.permit!.to_h)
end
# Return an empty TwiML response to satisfy Twilio
response = Twilio::TwiML::VoiceResponse.new
render xml: response.to_s, status: :ok
end
# This endpoint will be called by Twilio's StatusCallback
# This endpoint will be called by Twilio's StatusCallback
# parameter to notify of call status changes
def status_callback
call_sid = params['CallSid']
call_status = params['CallStatus']
direction = params['Direction']
direction = params['Direction']
is_outbound = direction == 'outbound-api'
from_number = params['From']
to_number = params['To']
Rails.logger.info("Twilio status callback: CallSid=#{call_sid}, Status=#{call_status}, Direction=#{direction}")
# Find the inbox
inbox = find_inbox(is_outbound ? from_number : to_number)
if inbox.present?
# Find or create the conversation
conversation = find_or_create_conversation(inbox, is_outbound ? to_number : from_number, call_sid)
# Add activity for the status change
track_call_activity(conversation, call_status, false, is_outbound)
# If call is completed/failed, update conversation status and notify frontend
if ['completed', 'busy', 'failed', 'no-answer', 'canceled'].include?(call_status)
if %w[completed busy failed no-answer canceled].include?(call_status)
# Update conversation with call status
conversation.additional_attributes ||= {}
conversation.additional_attributes['call_status'] = call_status
conversation.additional_attributes['call_ended_at'] = Time.now.to_i
conversation.status = :resolved
conversation.save!
# Publish update to frontend via ActionCable
ActionCable.server.broadcast(
"#{conversation.account_id}_#{conversation.inbox_id}",
@@ -259,19 +229,19 @@ class Twilio::VoiceController < ActionController::Base
}
}
)
# Create an activity message for call ending if it's a user hangup
if call_status == 'completed'
end_reason = params['CallDuration'] ? 'Call ended by hangup' : 'Call ended'
call_duration = params['CallDuration'] ? params['CallDuration'].to_i : nil
Messages::MessageBuilder.new(
nil,
conversation,
{
content: end_reason,
nil,
conversation,
{
content: end_reason,
message_type: :activity,
additional_attributes: {
additional_attributes: {
call_sid: call_sid,
call_status: call_status,
call_direction: is_outbound ? 'outbound' : 'inbound',
@@ -282,74 +252,73 @@ class Twilio::VoiceController < ActionController::Base
end
end
end
# Return an empty response
head :ok
end
# Simple TwiML with signup follow-up message
def simple_twiml
call_sid = params['CallSid']
from_number = params['From']
to_number = params['To']
direction = params['Direction']
# Determine if outbound call
is_outbound = direction == 'outbound-api'
response = Twilio::TwiML::VoiceResponse.new
# The signup follow-up message
response.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup. We would love to hear any feedback or questions you might have about your experience so far. Please share your thoughts after the beep.')
# Record their feedback
response.pause(length: 1) # give a moment before the beep and recording
# Record their feedback after a beep, then let handle_recording hang up
response.record(
action: '/twilio/voice/handle_recording',
method: 'POST',
maxLength: 60,
timeout: 3,
statusCallback: '/twilio/voice/status_callback',
statusCallbackMethod: 'POST',
statusCallbackEvent: ['completed', 'in-progress', 'absent']
maxLength: 3600,
timeout: 30,
playBeep: true
)
# End the call
response.hangup
# If we have call details, log them for the conversation
if call_sid.present?
# Find the inbox for this voice call
inbox_number = is_outbound ? from_number : to_number
inbox = find_inbox(inbox_number)
if inbox.present?
# Create or find conversation
contact_number = is_outbound ? to_number : from_number
conversation = find_or_create_conversation(inbox, contact_number, call_sid)
# Add call activity message
track_call_activity(conversation, 'in-progress', true, is_outbound)
end
end
render xml: response.to_s, status: :ok
end
private
def find_inbox(phone_number)
Inbox.joins("INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id")
.where("channel_voice.phone_number = ?", phone_number)
.first
Inbox.joins('INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id')
.where('channel_voice.phone_number = ?', phone_number)
.first
end
def find_or_create_conversation(inbox, phone_number, call_sid)
account = inbox.account
# Reuse if existing conversation for this call SID
existing = account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first
return existing if existing
# Ensure contact and inbox
contact = account.contacts.find_or_create_by(phone_number: phone_number) do |c|
c.name = "Contact from #{phone_number}"
@@ -357,26 +326,26 @@ class Twilio::VoiceController < ActionController::Base
contact_inbox = ContactInbox.find_or_initialize_by(contact_id: contact.id, inbox_id: inbox.id)
contact_inbox.source_id ||= phone_number
contact_inbox.save!
# Create new conversation for this call
convo = account.conversations.create!(contact_inbox_id: contact_inbox.id, inbox_id: inbox.id, status: :open)
convo.additional_attributes = { 'call_sid' => call_sid, 'call_status' => 'in-progress' }
convo.save!
convo
end
def track_call_activity(conversation, call_status, is_first_response, is_outbound)
return unless conversation.present?
# Only create status messages when status changes or on first response
prev_status = conversation.additional_attributes&.dig('call_status')
return if !is_first_response && prev_status == call_status
# Update conversation with call status
conversation.additional_attributes ||= {}
conversation.additional_attributes['call_status'] = call_status
conversation.save!
# Create an appropriate activity message based on status
activity_message = case call_status
when 'ringing'
@@ -392,14 +361,14 @@ class Twilio::VoiceController < ActionController::Base
else
"Call status: #{call_status}"
end
Messages::MessageBuilder.new(
nil,
conversation,
{
content: activity_message,
nil,
conversation,
{
content: activity_message,
message_type: :activity,
additional_attributes: {
additional_attributes: {
call_sid: conversation.additional_attributes&.dig('call_sid'),
call_status: call_status,
call_direction: is_outbound ? 'outbound' : 'inbound'
@@ -407,31 +376,30 @@ class Twilio::VoiceController < ActionController::Base
}
).perform
end
def get_one_message(call_sid)
redis_key = "voice_message:#{call_sid}"
# Get just one message
redis_message = Redis::Alfred.lpop(redis_key)
return nil unless redis_message.present?
begin
message = JSON.parse(redis_message)
return message
JSON.parse(redis_message)
rescue JSON::ParserError => e
Rails.logger.error("Failed to parse voice message from Redis: #{e.message}")
return nil
nil
end
end
def mark_message_delivered(message_id)
# Find the message
message = Message.find_by(id: message_id)
return unless message.present?
# Update the message delivery status
additional_attributes = message.additional_attributes || {}
additional_attributes[:voice_delivery_status] = 'delivered'
message.update(additional_attributes: additional_attributes)
end
end
end
+6 -2
View File
@@ -90,6 +90,10 @@ Rails.application.routes.draw do
resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]
namespace :channels do
resource :twilio_channel, only: [:create]
namespace :voice do
post 'webhooks/incoming', to: 'webhooks#incoming'
post 'webhooks/conference_status', to: 'webhooks#conference_status'
end
end
resources :conversations, only: [:index, :create, :show, :update] do
collection do
@@ -177,7 +181,7 @@ Rails.application.routes.draw do
post :set_agent_bot, on: :member
delete :avatar, on: :member
end
# Voice call management
post 'voice/end_call', to: 'voice#end_call'
get 'voice/call_status', to: 'voice#call_status'
@@ -483,7 +487,7 @@ Rails.application.routes.draw do
namespace :twilio do
resources :callback, only: [:create]
resources :delivery_status, only: [:create]
# Define controller explicitly to avoid the plural/singular confusion
get 'voice/twiml', to: 'voice#twiml'
post 'voice/twiml', to: 'voice#twiml'