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:
@@ -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
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user