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