From 4c579bc71e92c8670e4aff09d9151539b323c857 Mon Sep 17 00:00:00 2001 From: Sojan Date: Mon, 28 Apr 2025 02:16:57 -0700 Subject: [PATCH] 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 --- .../channels/voice/webhooks_controller.rb | 213 ++++++++++ app/controllers/twilio/voice_controller.rb | 386 ++++++++---------- config/routes.rb | 8 +- 3 files changed, 396 insertions(+), 211 deletions(-) create mode 100644 app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb diff --git a/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb b/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb new file mode 100644 index 000000000..c149f1b05 --- /dev/null +++ b/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb @@ -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 diff --git a/app/controllers/twilio/voice_controller.rb b/app/controllers/twilio/voice_controller.rb index 3a78c1c24..5a94ef7bd 100644 --- a/app/controllers/twilio/voice_controller.rb +++ b/app/controllers/twilio/voice_controller.rb @@ -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 \ No newline at end of file +end diff --git a/config/routes.rb b/config/routes.rb index 886479ff8..c22d2e7ea 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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'