chore: code cleanup
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
|
||||
skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
|
||||
# Removed skip_before_action :verify_authenticity_token (it's not defined in BaseController)
|
||||
protect_from_forgery with: :null_session, only: [:incoming, :conference_status]
|
||||
before_action :validate_twilio_signature, only: [:incoming]
|
||||
before_action :handle_options_request, only: [:incoming, :conference_status]
|
||||
@@ -24,312 +23,60 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
|
||||
|
||||
# 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'
|
||||
}
|
||||
)
|
||||
# Process incoming call using service
|
||||
service = Voice::IncomingCallService.new(account: Current.account, params: params.merge(host_with_port: request.host_with_port))
|
||||
twiml_response = service.process
|
||||
|
||||
# Use format that includes account ID and conversation display ID
|
||||
conference_name = "conf_account_#{Current.account.id}_conv_#{conversation.display_id}"
|
||||
|
||||
# Add conference name to conversation
|
||||
conversation.additional_attributes['conference_sid'] = conference_name
|
||||
conversation.save!
|
||||
|
||||
# SUPER EXPLICIT DEBUG logging for the conference name
|
||||
Rails.logger.info("🎧🎧🎧 CREATING INITIAL CONFERENCE: '#{conference_name}' for account_id: #{Current.account.id}, conversation: #{conversation.display_id}")
|
||||
Rails.logger.info("🎧🎧🎧 SAVED TO conversation.additional_attributes['conference_sid'] = '#{conversation.additional_attributes['conference_sid']}'")
|
||||
Rails.logger.info("Creating conference: #{conference_name} for account: #{Current.account.id}, conversation: #{conversation.display_id}")
|
||||
|
||||
# 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
|
||||
|
||||
# Broadcast incoming call notification on account-level channel
|
||||
broadcast_call_status('incoming_call', {
|
||||
call_sid: call_sid,
|
||||
conversation_id: conversation.id,
|
||||
inbox_id: inbox.id,
|
||||
inbox_name: inbox.name,
|
||||
contact_name: contact.name || from_number,
|
||||
contact_id: contact.id
|
||||
}, account_id: inbox.account_id)
|
||||
|
||||
# Generate simplified TwiML response
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.')
|
||||
|
||||
# Log what we're doing
|
||||
Rails.logger.info("🎧🎧🎧 CALLER CONNECTING TO CONFERENCE: '#{conference_name}'")
|
||||
|
||||
# Simple dialog approach for caller
|
||||
response.dial do |dial|
|
||||
dial.conference(
|
||||
conference_name,
|
||||
startConferenceOnEnter: false, # Caller waits for agent
|
||||
endConferenceOnExit: true, # End when agent leaves
|
||||
beep: false, # No beep sounds
|
||||
muted: false, # Caller can speak
|
||||
waitUrl: '', # No hold music
|
||||
statusCallback: "#{base_url.gsub(/\/$/, '')}/api/v1/accounts/#{Current.account.id}/channels/voice/webhooks/conference_status",
|
||||
statusCallbackMethod: 'POST',
|
||||
statusCallbackEvent: 'start end join leave',
|
||||
participantLabel: "caller-#{call_sid.last(8)}"
|
||||
)
|
||||
end
|
||||
|
||||
# Simplified logging
|
||||
Rails.logger.info("🔊 Created simplified conference #{conference_name} for account #{Current.account.id}")
|
||||
Rails.logger.info("🔊 Conference parameters: startConferenceOnEnter=false, endConferenceOnExit=true")
|
||||
|
||||
render xml: response.to_s
|
||||
# Return TwiML response
|
||||
render xml: twiml_response
|
||||
rescue => e
|
||||
Rails.logger.error("Error processing incoming call: #{e.message}")
|
||||
render_error("An error occurred while processing your call. Please try again later.")
|
||||
end
|
||||
|
||||
# Handle conference status updates with enhanced logging for audio troubleshooting
|
||||
# Handle conference status updates
|
||||
def conference_status
|
||||
# Set CORS headers first to ensure they're always included
|
||||
set_cors_headers
|
||||
|
||||
# SUPER IMPORTANT: Return a minimal response immediately for OPTIONS requests
|
||||
# Return immediately for OPTIONS requests
|
||||
if request.method == "OPTIONS"
|
||||
return head :ok
|
||||
end
|
||||
|
||||
# Wrap everything in a rescue block to prevent large error responses
|
||||
# Process conference status updates using service
|
||||
begin
|
||||
# Log only essential parameters to avoid large log messages
|
||||
Rails.logger.info("📞 Conference status webhook: event=#{params['StatusCallbackEvent']}, call_sid=#{params['CallSid']&.truncate(10)}")
|
||||
|
||||
call_sid = params['CallSid']
|
||||
conference_sid = params['ConferenceSid']
|
||||
event = params['StatusCallbackEvent']
|
||||
account_id = params[:account_id]
|
||||
participant_label = params['ParticipantLabel']
|
||||
|
||||
# For local development, set Current.account if not set
|
||||
if !Current.account
|
||||
Current.account = Account.find(account_id) if account_id
|
||||
# Set account for local development if needed
|
||||
if !Current.account && params[:account_id].present?
|
||||
Current.account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
# Try to find the conversation by parsing conference_sid directly or through additional attributes
|
||||
conversation = nil
|
||||
|
||||
# First try to find by exact conference_sid match
|
||||
if conference_sid.present?
|
||||
conversation = Current.account.conversations
|
||||
.where("additional_attributes->>'conference_sid' = ?", conference_sid)
|
||||
.first
|
||||
end
|
||||
|
||||
# If not found and conference_sid looks like our format, extract conversation ID directly
|
||||
if conversation.nil? && conference_sid.present? && conference_sid.start_with?('conf_account_')
|
||||
# Try to parse conversation ID from conference name (conf_account_X_conv_Y)
|
||||
conference_parts = conference_sid.match(/conf_account_\d+_conv_(\d+)/)
|
||||
if conference_parts && conference_parts[1].present?
|
||||
conversation_display_id = conference_parts[1]
|
||||
conversation = Current.account.conversations.find_by(display_id: conversation_display_id)
|
||||
Rails.logger.info("🎧 Found conversation by display_id=#{conversation_display_id} from conference_sid=#{conference_sid}")
|
||||
end
|
||||
end
|
||||
|
||||
# If still not found, try by call_sid
|
||||
if conversation.nil? && call_sid.present?
|
||||
conversation = Current.account.conversations
|
||||
.where("additional_attributes->>'call_sid' = ?", call_sid)
|
||||
.first
|
||||
end
|
||||
|
||||
# If conversation found, update it
|
||||
if conversation
|
||||
# Add participant info to conversation for debugging
|
||||
begin
|
||||
# Update participant list directly in conversation for real-time monitoring
|
||||
conversation.additional_attributes ||= {}
|
||||
conversation.additional_attributes['participants'] ||= []
|
||||
|
||||
# Check if this participant is already in the list
|
||||
existing_participant = conversation.additional_attributes['participants'].find do |p|
|
||||
p['call_sid'] == call_sid
|
||||
end
|
||||
|
||||
if event == 'join'
|
||||
# Add participant if not exists
|
||||
unless existing_participant
|
||||
conversation.additional_attributes['participants'] << {
|
||||
'call_sid' => call_sid,
|
||||
'label' => participant_label,
|
||||
'joined_at' => Time.now.to_i
|
||||
}
|
||||
end
|
||||
elsif event == 'leave'
|
||||
# Remove participant if exists
|
||||
conversation.additional_attributes['participants'].reject! { |p| p['call_sid'] == call_sid }
|
||||
end
|
||||
|
||||
# Always flag outbound calls that need agent join
|
||||
if conversation.additional_attributes['call_direction'] == 'outbound' &&
|
||||
participant_label&.start_with?('caller-') &&
|
||||
event == 'join'
|
||||
|
||||
# This is the customer joining an outbound call - flag for agent to join immediately
|
||||
conversation.additional_attributes['requires_agent_join'] = true
|
||||
|
||||
# Broadcast an immediate "incoming call" notification for the agent
|
||||
broadcast_call_status('incoming_call', {
|
||||
call_sid: call_sid,
|
||||
conversation_id: conversation.id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
inbox_name: conversation.inbox.name,
|
||||
contact_name: conversation.contact.name || 'Outbound Call',
|
||||
contact_id: conversation.contact_id,
|
||||
is_outbound: true
|
||||
}, account_id: conversation.account_id)
|
||||
end
|
||||
|
||||
# Save the updated conversation
|
||||
conversation.save!
|
||||
rescue => participant_error
|
||||
Rails.logger.error("Error updating participants: #{participant_error.message}")
|
||||
end
|
||||
|
||||
# Process conversation updates in the background to avoid delaying response
|
||||
Sidekiq::Client.enqueue_to(
|
||||
'default',
|
||||
'ProcessConferenceStatusJob',
|
||||
conversation_id: conversation.id,
|
||||
event: event,
|
||||
call_sid: call_sid,
|
||||
conference_sid: conference_sid,
|
||||
account_id: Current.account.id,
|
||||
participant_sid: params['ParticipantSid'],
|
||||
participant_label: participant_label,
|
||||
call_sid_ending_with: params['CallSidEndingWith'],
|
||||
audio_level: params['AudioLevel']
|
||||
)
|
||||
else
|
||||
Rails.logger.error("⚠️ Conference webhook: Conversation not found for call_sid=#{call_sid}, conference_sid=#{conference_sid}")
|
||||
end
|
||||
# Use service to process conference status
|
||||
service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
|
||||
service.process
|
||||
rescue => e
|
||||
# Just log errors but don't let them affect the response
|
||||
Rails.logger.error("Error in conference_status: #{e.message[0..100]}")
|
||||
# Log errors but don't affect the response
|
||||
Rails.logger.error("Error processing conference status: #{e.message[0..100]}")
|
||||
end
|
||||
|
||||
# CRITICAL: Always return a minimal success response - this is what Twilio expects
|
||||
# Return just a 200 OK header with minimal content
|
||||
# Always return a successful response for Twilio
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_twilio_signature
|
||||
# Skip for OPTIONS requests
|
||||
return true if request.method == "OPTIONS"
|
||||
validator = Voice::TwilioValidatorService.new(
|
||||
account: Current.account,
|
||||
params: params,
|
||||
request: request
|
||||
)
|
||||
|
||||
# Find the inbox for the phone number
|
||||
to_number = params['To']
|
||||
|
||||
# Skip validation for local development
|
||||
return true if Rails.env.development?
|
||||
|
||||
# Skip if no To param (happens in some callback scenarios)
|
||||
return true if to_number.blank?
|
||||
|
||||
begin
|
||||
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
|
||||
|
||||
# If inbox not found, we'll log it but allow the request for Twilio callbacks
|
||||
# This is necessary because conference callbacks may not have the original To number
|
||||
unless inbox
|
||||
Rails.logger.warn("⚠️ No inbox found for phone number #{to_number} - allowing request for Twilio callback")
|
||||
return true
|
||||
end
|
||||
|
||||
# Get Twilio Auth Token from inbox's channel
|
||||
channel = inbox.channel
|
||||
unless channel.is_a?(Channel::Voice)
|
||||
Rails.logger.warn("⚠️ Channel is not a voice channel - allowing request for Twilio callback")
|
||||
return true
|
||||
end
|
||||
|
||||
auth_token = channel.provider_config_hash['auth_token']
|
||||
|
||||
# Validate incoming request signature if present
|
||||
signature = request.headers['X-Twilio-Signature']
|
||||
|
||||
# Allow requests without signature for callbacks
|
||||
unless signature.present?
|
||||
Rails.logger.warn("⚠️ No Twilio signature in request - allowing for callbacks")
|
||||
return true
|
||||
end
|
||||
|
||||
# Validate the signature
|
||||
validator = Twilio::Security::RequestValidator.new(auth_token)
|
||||
url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}"
|
||||
is_valid = validator.validate(url, params.to_unsafe_h, signature)
|
||||
|
||||
unless is_valid
|
||||
Rails.logger.error("⚠️ Invalid Twilio signature detected")
|
||||
render_error('Invalid Twilio signature')
|
||||
return false
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error("Error validating Twilio signature: #{e.message}")
|
||||
# Always allow callbacks even if validation fails
|
||||
return true
|
||||
if !validator.valid?
|
||||
render_error('Invalid Twilio signature')
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
@@ -339,21 +86,4 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
|
||||
response.hangup
|
||||
render xml: response.to_s
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', "https://#{request.host_with_port}")
|
||||
end
|
||||
|
||||
def broadcast_call_status(event_name, data, account_id:)
|
||||
# Include account_id in the data to help with validation
|
||||
data_with_account = data.merge(account_id: account_id)
|
||||
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{account_id}",
|
||||
{
|
||||
event: event_name,
|
||||
data: data_with_account
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,98 +1,25 @@
|
||||
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
|
||||
require 'securerandom'
|
||||
before_action :fetch_contact
|
||||
|
||||
def create
|
||||
# Find a Voice channel
|
||||
voice_inbox = Current.account.inboxes.find_by(channel_type: 'Channel::Voice')
|
||||
|
||||
if voice_inbox.blank?
|
||||
render json: { error: 'No Voice channel found' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Validate that contact has a phone number
|
||||
if @contact.phone_number.blank?
|
||||
render json: { error: 'Contact has no phone number' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Create a new conversation for this call
|
||||
conversation = find_or_create_conversation(voice_inbox)
|
||||
|
||||
# CRITICAL: Create a conference name FIRST to ensure consistency
|
||||
conference_name = "conf_account_#{Current.account.id}_conv_#{conversation.display_id}"
|
||||
|
||||
# Create conference for outbound call
|
||||
|
||||
# Initiate the call using the channel's implementation - this returns the call details
|
||||
call_details = voice_inbox.channel.initiate_call(
|
||||
to: @contact.phone_number,
|
||||
conference_name: conference_name
|
||||
# Use the outgoing call service to handle the entire process
|
||||
service = Voice::OutgoingCallService.new(
|
||||
account: Current.account,
|
||||
contact: @contact,
|
||||
user: Current.user
|
||||
)
|
||||
|
||||
# Add the conference name to the call details
|
||||
call_details[:conference_sid] = conference_name
|
||||
|
||||
# Create a message for this call with call details
|
||||
params = {
|
||||
content: "Outgoing voice call initiated to #{@contact.phone_number}",
|
||||
message_type: :activity,
|
||||
additional_attributes: call_details,
|
||||
source_id: call_details[:call_sid] # Use call SID as source_id
|
||||
}
|
||||
|
||||
message = Messages::MessageBuilder.new(Current.user, conversation, params).perform
|
||||
|
||||
# Make sure the conversation has the latest activity timestamp
|
||||
conversation.update(last_activity_at: Time.current)
|
||||
|
||||
# Add the conference_sid to the conversation's additional_attributes
|
||||
# Ensure we don't lose other attributes that might already be set
|
||||
updated_attributes = (conversation.additional_attributes || {}).merge(call_details)
|
||||
|
||||
# CRITICAL: Add additional attributes needed for immediate agent join notification
|
||||
updated_attributes[:call_status] = 'in-progress'
|
||||
updated_attributes[:requires_agent_join] = true
|
||||
|
||||
# Now update the conversation with all the attributes
|
||||
conversation.update!(additional_attributes: updated_attributes)
|
||||
|
||||
# Conference created successfully
|
||||
|
||||
# DIRECT AGENT NOTIFICATION: Immediately broadcast to ActionCable that agent needs to join
|
||||
# This bypasses any queueing and directly tells the frontend a call needs agent
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{Current.account.id}",
|
||||
{
|
||||
event: 'incoming_call',
|
||||
data: {
|
||||
call_sid: call_details[:call_sid],
|
||||
conversation_id: conversation.id,
|
||||
inbox_id: voice_inbox.id,
|
||||
inbox_name: voice_inbox.name,
|
||||
contact_name: @contact.name || @contact.phone_number,
|
||||
contact_id: @contact.id,
|
||||
account_id: Current.account.id,
|
||||
is_outbound: true,
|
||||
conference_sid: conference_name,
|
||||
requires_agent_join: true,
|
||||
# Send additional information to help with debugging
|
||||
call_direction: 'outbound'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Broadcast the conversation and message to the appropriate ActionCable channels
|
||||
ActionCableBroadcastJob.perform_later(
|
||||
conversation.account_id,
|
||||
'conversation.created',
|
||||
conversation.push_event_data.merge(
|
||||
message: message.push_event_data,
|
||||
status: 'open'
|
||||
)
|
||||
)
|
||||
# Process the call - this handles all the steps
|
||||
conversation = service.process
|
||||
|
||||
# Return the conversation
|
||||
render json: conversation
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error initiating call: #{e.message}")
|
||||
@@ -105,43 +32,4 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
||||
def fetch_contact
|
||||
@contact = Current.account.contacts.find(params[:contact_id])
|
||||
end
|
||||
|
||||
def find_or_create_conversation(inbox)
|
||||
conversation = inbox.conversations.where(contact_id: @contact.id).last
|
||||
|
||||
if conversation.nil? || !conversation.open?
|
||||
# Find or create a contact_inbox for this contact and inbox
|
||||
contact_inbox = ContactInbox.find_or_initialize_by(
|
||||
contact_id: @contact.id,
|
||||
inbox_id: inbox.id
|
||||
)
|
||||
|
||||
# Set the source_id if it's a new record
|
||||
if contact_inbox.new_record?
|
||||
# For voice channels, use the phone number as the source_id
|
||||
contact_inbox.source_id = @contact.phone_number
|
||||
end
|
||||
|
||||
contact_inbox.save!
|
||||
|
||||
conversation = ::Conversation.create!(
|
||||
account_id: Current.account.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: @contact.id,
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
status: :open
|
||||
)
|
||||
|
||||
# Add a note about the call being initiated
|
||||
params = {
|
||||
message_type: :activity,
|
||||
content: "Voice call initiated to #{@contact.phone_number}",
|
||||
source_id: "voice_call_#{SecureRandom.uuid}" # Generate a unique source_id
|
||||
}
|
||||
|
||||
Messages::MessageBuilder.new(Current.user, conversation, params).perform
|
||||
end
|
||||
|
||||
conversation
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,7 @@ import BubbleLocation from './bubble/Location.vue';
|
||||
import BubbleMailHead from './bubble/MailHead.vue';
|
||||
import BubbleReplyTo from './bubble/ReplyTo.vue';
|
||||
import BubbleText from './bubble/Text.vue';
|
||||
import VoiceCall from './VoiceCall.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
import InstagramStory from './bubble/InstagramStory.vue';
|
||||
import InstagramStoryReply from './bubble/InstagramStoryReply.vue';
|
||||
@@ -43,6 +44,7 @@ export default {
|
||||
InstagramStoryReply,
|
||||
Spinner,
|
||||
NextButton,
|
||||
VoiceCall,
|
||||
},
|
||||
props: {
|
||||
data: {
|
||||
@@ -111,7 +113,8 @@ export default {
|
||||
this.data.content ||
|
||||
this.isEmailContentType ||
|
||||
this.isUnsupported ||
|
||||
this.isAnIntegrationMessage
|
||||
this.isAnIntegrationMessage ||
|
||||
this.isVoiceCall
|
||||
);
|
||||
},
|
||||
emailMessageContent() {
|
||||
@@ -258,6 +261,9 @@ export default {
|
||||
isAnIntegrationMessage() {
|
||||
return this.contentType === 'integrations';
|
||||
},
|
||||
isVoiceCall() {
|
||||
return this.contentType === 'voice_call';
|
||||
},
|
||||
emailHeadAttributes() {
|
||||
return {
|
||||
email: this.contentAttributes.email,
|
||||
@@ -490,12 +496,17 @@ export default {
|
||||
</template>
|
||||
</div>
|
||||
<BubbleText
|
||||
v-else-if="data.content"
|
||||
v-else-if="data.content && !isVoiceCall"
|
||||
:message="message"
|
||||
:is-email="isEmailContentType"
|
||||
:display-quoted-button="displayQuotedButton"
|
||||
/>
|
||||
<VoiceCall
|
||||
v-else-if="isVoiceCall"
|
||||
:message="data"
|
||||
/>
|
||||
<BubbleIntegration
|
||||
v-else-if="isAnIntegrationMessage"
|
||||
:message-id="data.id"
|
||||
:content-attributes="contentAttributes"
|
||||
:inbox-id="data.inbox_id"
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="voice-call-widget" :class="statusClass">
|
||||
<div class="status-icon">
|
||||
<span :class="statusIcon"></span>
|
||||
</div>
|
||||
<div class="call-info">
|
||||
<div class="call-status">{{ callStatusText }}</div>
|
||||
<div v-if="statusSubtext" class="call-subtext">{{ statusSubtext }}</div>
|
||||
</div>
|
||||
<NextButton
|
||||
v-if="showJoinButton"
|
||||
size="sm"
|
||||
icon="i-lucide-phone"
|
||||
variant="success"
|
||||
:label="$t('CONVERSATION.VOICE_CALL.JOIN_CALL')"
|
||||
:is-loading="isLoading"
|
||||
@click="joinCall"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
status: this.message.content_attributes?.data?.status || 'ringing',
|
||||
joinedAt: null,
|
||||
duration: this.message.content_attributes?.data?.duration || null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
// Watch for changes to message content_attributes to update call status
|
||||
'message.content_attributes.data': {
|
||||
handler(newData) {
|
||||
if (newData) {
|
||||
if (newData.status && newData.status !== this.status) {
|
||||
const oldStatus = this.status;
|
||||
this.status = newData.status;
|
||||
|
||||
// If call status changes to 'ended' or 'missed', close the floating call widget
|
||||
if ((newData.status === 'ended' || newData.status === 'missed') &&
|
||||
['ringing', 'active'].includes(oldStatus)) {
|
||||
this.closeCallWidget();
|
||||
}
|
||||
}
|
||||
if (newData.duration && newData.duration !== this.duration) {
|
||||
this.duration = newData.duration;
|
||||
}
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentConversationId: 'getSelectedChatConversationId',
|
||||
}),
|
||||
hasActiveCall() {
|
||||
return this.$store.getters['calls/hasActiveCall'];
|
||||
},
|
||||
callData() {
|
||||
return this.message.content_attributes?.data || {};
|
||||
},
|
||||
callStatusText() {
|
||||
switch (this.status) {
|
||||
case 'ringing':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.RINGING');
|
||||
case 'active':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.ACTIVE');
|
||||
case 'missed':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.MISSED');
|
||||
case 'ended':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.ENDED');
|
||||
default:
|
||||
return this.$t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
},
|
||||
statusIcon() {
|
||||
switch (this.status) {
|
||||
case 'ringing':
|
||||
return 'i-lucide-phone-incoming';
|
||||
case 'active':
|
||||
return 'i-lucide-phone';
|
||||
case 'missed':
|
||||
return 'i-lucide-phone-missed';
|
||||
case 'ended':
|
||||
return 'i-lucide-phone-off';
|
||||
default:
|
||||
return 'i-lucide-phone-incoming';
|
||||
}
|
||||
},
|
||||
statusClass() {
|
||||
return {
|
||||
'ringing': this.status === 'ringing',
|
||||
'active': this.status === 'active',
|
||||
'missed': this.status === 'missed',
|
||||
'ended': this.status === 'ended',
|
||||
};
|
||||
},
|
||||
callConversationId() {
|
||||
return this.callData.conversation_id;
|
||||
},
|
||||
showJoinButton() {
|
||||
// Show join button only if the call is ringing or active and we're on the same conversation
|
||||
return (['ringing', 'active'].includes(this.status)) &&
|
||||
(this.callConversationId === this.currentConversationId);
|
||||
},
|
||||
formattedDuration() {
|
||||
if (!this.duration) return '';
|
||||
|
||||
const minutes = Math.floor(this.duration / 60);
|
||||
const seconds = this.duration % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
statusSubtext() {
|
||||
if (this.status === 'ended' && this.formattedDuration) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.DURATION', { duration: this.formattedDuration });
|
||||
}
|
||||
if (this.status === 'missed') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
isIncoming() {
|
||||
return this.message.message_type === 0; // 0 = incoming
|
||||
},
|
||||
isOutgoing() {
|
||||
return this.message.message_type === 1; // 1 = outgoing
|
||||
},
|
||||
senderText() {
|
||||
if (this.isIncoming) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.INCOMING_FROM', { name: this.message.sender?.name || 'Unknown' });
|
||||
}
|
||||
if (this.isOutgoing) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING_FROM', { name: this.message.sender?.name || 'Unknown' });
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// We don't need custom subscriptions anymore as we'll
|
||||
// get updates through Chatwoot's standard message update system
|
||||
// Just check if we have an active call with the same call_sid
|
||||
this.checkActiveCall();
|
||||
},
|
||||
methods: {
|
||||
async joinCall() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
// Update UI immediately
|
||||
this.status = 'active';
|
||||
this.joinedAt = new Date();
|
||||
|
||||
// Get the current conversation
|
||||
const conversation = await this.$store.dispatch('getConversation', {
|
||||
conversationId: this.callConversationId,
|
||||
});
|
||||
|
||||
const callSid = this.callData.call_sid;
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('No call SID found');
|
||||
}
|
||||
|
||||
// Show floating call widget
|
||||
this.$store.dispatch('calls/setActiveCall', {
|
||||
callSid,
|
||||
conversationId: this.callConversationId,
|
||||
inboxId: this.message.inbox_id,
|
||||
contactId: conversation.contact_id,
|
||||
contactName: conversation.contact?.name || 'Unknown',
|
||||
messageId: this.message.id,
|
||||
});
|
||||
|
||||
// Join the call via API
|
||||
await VoiceAPI.joinCall({
|
||||
call_sid: callSid,
|
||||
conversation_id: this.callConversationId,
|
||||
});
|
||||
|
||||
// Success notification
|
||||
useAlert(this.$t('CONVERSATION.VOICE_CALL.CALL_JOINED'));
|
||||
} catch (err) {
|
||||
console.error('Failed to join call:', err);
|
||||
useAlert(this.$t('CONVERSATION.VOICE_CALL.JOIN_ERROR'));
|
||||
// Reset status if join failed
|
||||
this.status = 'ringing';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// We don't need custom subscription methods anymore as we'll use
|
||||
// Chatwoot's standard message update events
|
||||
|
||||
checkActiveCall() {
|
||||
// If there's an active call in the store with the same conversation ID
|
||||
// update our local state to match
|
||||
const activeCall = this.$store.getters['calls/getActiveCall'];
|
||||
if (activeCall && activeCall.conversationId === this.callConversationId) {
|
||||
this.status = 'active';
|
||||
this.joinedAt = new Date();
|
||||
}
|
||||
},
|
||||
|
||||
updateStatus(newStatus, duration = null) {
|
||||
this.status = newStatus;
|
||||
|
||||
if (newStatus === 'ended') {
|
||||
if (duration) {
|
||||
this.duration = duration;
|
||||
} else if (this.joinedAt) {
|
||||
this.duration = Math.floor((new Date() - this.joinedAt) / 1000);
|
||||
}
|
||||
// Close the floating call widget when call ends
|
||||
this.closeCallWidget();
|
||||
}
|
||||
},
|
||||
|
||||
closeCallWidget() {
|
||||
// Get the call SID from the message data
|
||||
const callSid = this.callData.call_sid;
|
||||
if (!callSid) return;
|
||||
|
||||
// Handle the call status change directly
|
||||
this.$store.dispatch('calls/handleCallStatusChanged', {
|
||||
callSid,
|
||||
status: 'ended'
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.voice-call-widget {
|
||||
@apply flex items-center gap-2 p-3 rounded-lg my-1;
|
||||
@apply bg-slate-50 dark:bg-slate-700;
|
||||
@apply border border-slate-200 dark:border-slate-600;
|
||||
@apply transition-all duration-200;
|
||||
|
||||
&.ringing {
|
||||
@apply border-green-500 dark:border-green-500;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply border-woot-500 dark:border-woot-500 bg-woot-50 dark:bg-woot-800;
|
||||
}
|
||||
|
||||
&.missed {
|
||||
@apply border-red-300 dark:border-slate-600 bg-red-50 dark:bg-slate-700;
|
||||
}
|
||||
|
||||
&.ended {
|
||||
@apply border-slate-300 dark:border-slate-600;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
@apply flex items-center justify-center;
|
||||
@apply w-10 h-10 rounded-full;
|
||||
@apply bg-slate-200 dark:bg-slate-600;
|
||||
@apply text-slate-700 dark:text-slate-200;
|
||||
|
||||
.i-lucide-phone-incoming {
|
||||
@apply text-green-600 dark:text-green-400;
|
||||
}
|
||||
|
||||
.i-lucide-phone {
|
||||
@apply text-woot-600 dark:text-woot-400;
|
||||
}
|
||||
|
||||
.i-lucide-phone-missed {
|
||||
@apply text-red-600 dark:text-red-400;
|
||||
}
|
||||
|
||||
.i-lucide-phone-off {
|
||||
@apply text-slate-600 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
|
||||
.call-info {
|
||||
@apply flex-1;
|
||||
|
||||
.call-status {
|
||||
@apply font-medium text-slate-800 dark:text-slate-200;
|
||||
}
|
||||
|
||||
.call-subtext {
|
||||
@apply text-xs text-slate-500 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -28,6 +28,12 @@ export default {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Log integration type for debugging if needed
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log('Integration component mounted with type:', this.contentAttributes.type);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -38,4 +44,13 @@ export default {
|
||||
:message-id="messageId"
|
||||
:meeting-data="contentAttributes.data"
|
||||
/>
|
||||
<div v-else class="integration-not-supported">
|
||||
{{ contentAttributes.type || 'Unknown' }} integration
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.integration-not-supported {
|
||||
@apply text-xs text-slate-500 dark:text-slate-400 p-2 bg-slate-50 dark:bg-slate-700 rounded;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -234,8 +234,13 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
inboxId: data.inbox_id,
|
||||
};
|
||||
|
||||
// Update store
|
||||
this.app.$store.dispatch('calls/setActiveCall', normalizedPayload);
|
||||
// Update store with call status change
|
||||
this.app.$store.dispatch('calls/handleCallStatusChanged', normalizedPayload);
|
||||
|
||||
// For non-terminal statuses, update the active call
|
||||
if (!['ended', 'missed', 'completed'].includes(data.status)) {
|
||||
this.app.$store.dispatch('calls/setActiveCall', normalizedPayload);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,29 @@ const getters = {
|
||||
};
|
||||
|
||||
const actions = {
|
||||
setActiveCall({ commit }, callData) {
|
||||
// This action will handle both message updates and direct call status changes
|
||||
handleCallStatusChanged({ state, dispatch }, { callSid, status }) {
|
||||
// If this is the active call and it has ended or was missed, close the widget
|
||||
if (callSid === state.activeCall?.callSid &&
|
||||
(status === 'ended' || status === 'missed' || status === 'completed')) {
|
||||
dispatch('clearActiveCall');
|
||||
}
|
||||
},
|
||||
|
||||
setActiveCall({ commit, dispatch, state }, callData) {
|
||||
if (!callData || !callData.callSid) {
|
||||
throw new Error('Invalid call data provided');
|
||||
}
|
||||
|
||||
// If the call has a status, check if it's a terminal status
|
||||
if (callData.status && ['ended', 'missed', 'completed'].includes(callData.status)) {
|
||||
// If the call is already in a terminal state, clear any active call
|
||||
if (callData.callSid === state.activeCall?.callSid) {
|
||||
return dispatch('clearActiveCall');
|
||||
}
|
||||
// Otherwise just ignore it - don't set an already ended call as active
|
||||
return;
|
||||
}
|
||||
|
||||
commit('SET_ACTIVE_CALL', callData);
|
||||
|
||||
@@ -25,12 +44,18 @@ const actions = {
|
||||
},
|
||||
|
||||
clearActiveCall({ commit }) {
|
||||
// Store the messageId before clearing the call
|
||||
const messageId = state.activeCall?.messageId;
|
||||
|
||||
commit('CLEAR_ACTIVE_CALL');
|
||||
|
||||
// Update app state if in browser environment
|
||||
if (typeof window !== 'undefined' && window.app?.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// We no longer need to update call widget status as we'll use reactive Vue props
|
||||
// and updates will come through Chatwoot's standard message update events
|
||||
},
|
||||
|
||||
setIncomingCall({ commit, state }, callData) {
|
||||
@@ -48,16 +73,30 @@ const actions = {
|
||||
return;
|
||||
}
|
||||
|
||||
commit('SET_INCOMING_CALL', callData);
|
||||
const enrichedCallData = {
|
||||
...callData,
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
|
||||
commit('SET_INCOMING_CALL', enrichedCallData);
|
||||
|
||||
// Update app state if in browser environment
|
||||
if (typeof window !== 'undefined' && window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
|
||||
// We no longer need to update call widget status as we'll use reactive Vue props
|
||||
// and updates will come through Chatwoot's standard message update events
|
||||
},
|
||||
|
||||
clearIncomingCall({ commit }) {
|
||||
// Store the messageId before clearing the call
|
||||
const messageId = state.incomingCall?.messageId;
|
||||
|
||||
commit('CLEAR_INCOMING_CALL');
|
||||
|
||||
// We no longer need to update call widget status as we'll use reactive Vue props
|
||||
// and updates will come through Chatwoot's standard message update events
|
||||
},
|
||||
|
||||
acceptIncomingCall({ commit, state }) {
|
||||
@@ -70,8 +109,12 @@ const actions = {
|
||||
commit('SET_ACTIVE_CALL', {
|
||||
...incomingCall,
|
||||
isJoined: true,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
commit('CLEAR_INCOMING_CALL');
|
||||
|
||||
// We no longer need to update call widget status as we'll use reactive Vue props
|
||||
// and updates will come through Chatwoot's standard message update events
|
||||
},
|
||||
};
|
||||
|
||||
@@ -88,6 +131,9 @@ const mutations = {
|
||||
CLEAR_INCOMING_CALL($state) {
|
||||
$state.incomingCall = null;
|
||||
},
|
||||
// We no longer need to update call widget status as we'll use reactive Vue props
|
||||
|
||||
// We no longer need subscription mutations
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
class ProcessConferenceStatusJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(options = {})
|
||||
# Extract parameters from options
|
||||
conversation_id = options[:conversation_id]
|
||||
event = options[:event]
|
||||
call_sid = options[:call_sid]
|
||||
conference_sid = options[:conference_sid]
|
||||
account_id = options[:account_id]
|
||||
participant_sid = options[:participant_sid]
|
||||
participant_label = options[:participant_label]
|
||||
call_sid_ending_with = options[:call_sid_ending_with]
|
||||
audio_level = options[:audio_level]
|
||||
|
||||
# Set the current account (required for proper routing)
|
||||
Current.account = Account.find(account_id)
|
||||
|
||||
# Find the conversation
|
||||
conversation = Current.account.conversations.find_by(id: conversation_id)
|
||||
return unless conversation
|
||||
|
||||
# Update conversation with conference info
|
||||
conversation.additional_attributes ||= {}
|
||||
conversation.additional_attributes['conference_sid'] = conference_sid
|
||||
|
||||
# Store more detailed audio diagnostics for speak events
|
||||
if event == 'participant-speak'
|
||||
conversation.additional_attributes['last_speak_event'] = {
|
||||
participant_sid: participant_sid,
|
||||
timestamp: Time.now.to_i,
|
||||
audio_level: audio_level || 'unknown'
|
||||
}
|
||||
end
|
||||
|
||||
# Process the event
|
||||
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'
|
||||
# Track participant type for debugging
|
||||
participant_type = participant_label || (call_sid_ending_with || '').start_with?('agent') ? 'agent' : 'caller'
|
||||
activity_message = "#{participant_type.capitalize} joined the call"
|
||||
|
||||
# Track all participants for audio diagnostics
|
||||
conversation.additional_attributes['participants'] ||= {}
|
||||
conversation.additional_attributes['participants'][participant_sid] = {
|
||||
joined_at: Time.now.to_i,
|
||||
type: participant_type,
|
||||
call_sid: call_sid,
|
||||
status: 'joined'
|
||||
}
|
||||
when 'participant-leave'
|
||||
# Update participant status
|
||||
if conversation.additional_attributes['participants']&.key?(participant_sid)
|
||||
participant_type = conversation.additional_attributes['participants'][participant_sid]['type'] || 'Participant'
|
||||
activity_message = "#{participant_type.capitalize} left the call"
|
||||
|
||||
# Mark participant as left
|
||||
conversation.additional_attributes['participants'][participant_sid]['status'] = 'left'
|
||||
conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i
|
||||
else
|
||||
activity_message = 'Participant left the call'
|
||||
end
|
||||
when 'participant-speak'
|
||||
# This is critical for diagnosing audio issues
|
||||
if conversation.additional_attributes['participants']&.key?(participant_sid)
|
||||
participant_type = conversation.additional_attributes['participants'][participant_sid]['type'] || 'Participant'
|
||||
activity_message = "#{participant_type} speaking detected"
|
||||
|
||||
# Track speaking events
|
||||
participant = conversation.additional_attributes['participants'][participant_sid]
|
||||
participant['speak_events'] ||= []
|
||||
participant['speak_events'] << Time.now.to_i
|
||||
|
||||
# Only keep the last 5 events to avoid bloating the database
|
||||
participant['speak_events'] = participant['speak_events'].last(5) if participant['speak_events'].size > 5
|
||||
|
||||
conversation.additional_attributes['participants'][participant_sid] = participant
|
||||
else
|
||||
activity_message = 'Speech detected'
|
||||
end
|
||||
else
|
||||
activity_message = "Call event: #{event}"
|
||||
end
|
||||
|
||||
# Save conversation with enhanced tracking
|
||||
begin
|
||||
conversation.save!
|
||||
Rails.logger.info("✅ Conference status updated: #{event} for conversation_id=#{conversation.id}")
|
||||
rescue => e
|
||||
Rails.logger.error("❌ Failed to save conversation: #{e.message}")
|
||||
end
|
||||
|
||||
# Create activity message with enhanced attributes
|
||||
begin
|
||||
Messages::MessageBuilder.new(
|
||||
nil,
|
||||
conversation,
|
||||
{
|
||||
content: activity_message,
|
||||
message_type: :activity,
|
||||
additional_attributes: {
|
||||
call_sid: call_sid,
|
||||
event_type: event,
|
||||
conference_sid: conference_sid,
|
||||
timestamp: Time.now.to_i,
|
||||
participant_sid: participant_sid,
|
||||
audio_level: audio_level
|
||||
}
|
||||
}
|
||||
).perform
|
||||
rescue => e
|
||||
Rails.logger.error("❌ Failed to create activity message: #{e.message}")
|
||||
end
|
||||
|
||||
# Broadcast call status updates on account-level channel
|
||||
begin
|
||||
# Include account_id in the data to help with validation
|
||||
data_with_account = {
|
||||
call_sid: call_sid,
|
||||
status: conversation.additional_attributes['call_status'] || 'in-progress',
|
||||
conversation_id: conversation.id,
|
||||
event: event,
|
||||
account_id: conversation.account_id
|
||||
}
|
||||
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{conversation.account_id}",
|
||||
{
|
||||
event: 'call_status_changed',
|
||||
data: data_with_account
|
||||
}
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.error("❌ Failed to broadcast call status: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,10 +16,10 @@ class Channel::Voice < ApplicationRecord
|
||||
"#{provider.capitalize} Voice"
|
||||
end
|
||||
|
||||
def initiate_call(to:, conference_name: nil)
|
||||
def initiate_call(to:, conference_name: nil, agent_id: nil)
|
||||
case provider
|
||||
when 'twilio'
|
||||
initiate_twilio_call(to, conference_name)
|
||||
initiate_twilio_call(to, conference_name, agent_id)
|
||||
# Add more providers as needed
|
||||
# when 'other_provider'
|
||||
# initiate_other_provider_call(to)
|
||||
@@ -30,7 +30,7 @@ class Channel::Voice < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def initiate_twilio_call(to, conference_name = nil)
|
||||
def initiate_twilio_call(to, conference_name = nil, agent_id = nil)
|
||||
config = provider_config_hash
|
||||
|
||||
# Generate a public URL for Twilio to request TwiML (must set FRONTEND_URL)
|
||||
@@ -39,12 +39,23 @@ class Channel::Voice < ApplicationRecord
|
||||
# Use the simplest possible TwiML endpoint
|
||||
callback_url = "#{host}/twilio/voice/simple"
|
||||
|
||||
# Start building query parameters
|
||||
query_params = []
|
||||
|
||||
# Add conference name as a parameter if provided
|
||||
if conference_name.present?
|
||||
callback_url += "?conference_name=#{CGI.escape(conference_name)}"
|
||||
|
||||
# Log this for debugging
|
||||
Rails.logger.info("🚨 OUTBOUND CALL: Adding conference_name '#{conference_name}' to callback URL: #{callback_url}")
|
||||
query_params << "conference_name=#{CGI.escape(conference_name)}"
|
||||
end
|
||||
|
||||
# Add agent ID as a parameter if provided
|
||||
if agent_id.present?
|
||||
query_params << "agent_id=#{agent_id}"
|
||||
end
|
||||
|
||||
# Append query parameters to URL if any exist
|
||||
if query_params.any?
|
||||
callback_url += "?#{query_params.join('&')}"
|
||||
Rails.logger.info("🚨 OUTBOUND CALL: Using callback URL with params: #{callback_url}")
|
||||
end
|
||||
|
||||
# Parameters including status callbacks for call progress tracking
|
||||
@@ -66,7 +77,8 @@ class Channel::Voice < ApplicationRecord
|
||||
call_sid: call.sid,
|
||||
status: call.status,
|
||||
call_direction: 'outbound', # CRITICAL: Tag as outbound so webhooks know to prompt agent
|
||||
requires_agent_join: true # Flag that agent should join immediately
|
||||
requires_agent_join: true, # Flag that agent should join immediately
|
||||
agent_id: agent_id # Include agent_id for tracking who initiated the call
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -92,7 +92,8 @@ class Message < ApplicationRecord
|
||||
incoming_email: 8,
|
||||
input_csat: 9,
|
||||
integrations: 10,
|
||||
sticker: 11
|
||||
sticker: 11,
|
||||
voice_call: 12
|
||||
}
|
||||
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
|
||||
# [:submitted_email, :items, :submitted_values] : Used for bot message types
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
module Voice
|
||||
class ConferenceStatusService
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def process
|
||||
find_conversation
|
||||
queue_status_processing if @conversation
|
||||
end
|
||||
|
||||
def status_info
|
||||
{
|
||||
call_sid: params['CallSid'],
|
||||
conference_sid: params['ConferenceSid'],
|
||||
event: params['StatusCallbackEvent'],
|
||||
participant_sid: params['ParticipantSid'],
|
||||
participant_label: params['ParticipantLabel'],
|
||||
call_sid_ending_with: params['CallSidEndingWith'],
|
||||
audio_level: params['AudioLevel']
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_conversation
|
||||
@conversation = nil
|
||||
|
||||
# Try finding by conference_sid
|
||||
if status_info[:conference_sid].present?
|
||||
@conversation = account.conversations
|
||||
.where("additional_attributes->>'conference_sid' = ?", status_info[:conference_sid])
|
||||
.first
|
||||
end
|
||||
|
||||
# If not found and conference_sid looks like our format, extract conversation ID
|
||||
if @conversation.nil? && status_info[:conference_sid].present? && status_info[:conference_sid].start_with?('conf_account_')
|
||||
conference_parts = status_info[:conference_sid].match(/conf_account_\d+_conv_(\d+)/)
|
||||
if conference_parts && conference_parts[1].present?
|
||||
conversation_display_id = conference_parts[1]
|
||||
@conversation = account.conversations.find_by(display_id: conversation_display_id)
|
||||
Rails.logger.info("🎧 Found conversation by display_id=#{conversation_display_id} from conference_sid=#{status_info[:conference_sid]}")
|
||||
end
|
||||
end
|
||||
|
||||
# If still not found, try by call_sid
|
||||
if @conversation.nil? && status_info[:call_sid].present?
|
||||
@conversation = account.conversations
|
||||
.where("additional_attributes->>'call_sid' = ?", status_info[:call_sid])
|
||||
.first
|
||||
end
|
||||
|
||||
# Update participant info if conversation found
|
||||
update_participant_info if @conversation
|
||||
end
|
||||
|
||||
def update_participant_info
|
||||
# Initialize or get current participants list
|
||||
@conversation.additional_attributes ||= {}
|
||||
@conversation.additional_attributes['participants'] ||= []
|
||||
|
||||
# Check if this participant is already in the list
|
||||
existing_participant = @conversation.additional_attributes['participants'].find do |p|
|
||||
p['call_sid'] == status_info[:call_sid]
|
||||
end
|
||||
|
||||
# Update based on event type
|
||||
if status_info[:event] == 'join'
|
||||
# Add participant if not exists
|
||||
unless existing_participant
|
||||
@conversation.additional_attributes['participants'] << {
|
||||
'call_sid' => status_info[:call_sid],
|
||||
'label' => status_info[:participant_label],
|
||||
'joined_at' => Time.now.to_i
|
||||
}
|
||||
end
|
||||
elsif status_info[:event] == 'leave'
|
||||
# Remove participant if exists
|
||||
@conversation.additional_attributes['participants'].reject! { |p| p['call_sid'] == status_info[:call_sid] }
|
||||
end
|
||||
|
||||
# Flag outbound calls that need agent join
|
||||
if @conversation.additional_attributes['call_direction'] == 'outbound' &&
|
||||
status_info[:participant_label]&.start_with?('caller-') &&
|
||||
status_info[:event] == 'join'
|
||||
|
||||
# This is the customer joining an outbound call - flag for agent to join immediately
|
||||
@conversation.additional_attributes['requires_agent_join'] = true
|
||||
|
||||
# Broadcast an immediate "incoming call" notification for the agent
|
||||
broadcast_agent_join_notification
|
||||
end
|
||||
|
||||
# Save the updated conversation
|
||||
@conversation.save!
|
||||
end
|
||||
|
||||
def broadcast_agent_join_notification
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{account.id}",
|
||||
{
|
||||
event: 'incoming_call',
|
||||
data: {
|
||||
call_sid: status_info[:call_sid],
|
||||
conversation_id: @conversation.id,
|
||||
inbox_id: @conversation.inbox_id,
|
||||
inbox_name: @conversation.inbox.name,
|
||||
contact_name: @conversation.contact.name || 'Outbound Call',
|
||||
contact_id: @conversation.contact_id,
|
||||
is_outbound: true,
|
||||
account_id: account.id
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def queue_status_processing
|
||||
# Process the status update directly using the service
|
||||
Voice::ConferenceStatusUpdateService.new(
|
||||
conversation: @conversation,
|
||||
event: status_info[:event],
|
||||
call_sid: status_info[:call_sid],
|
||||
conference_sid: status_info[:conference_sid],
|
||||
participant_sid: status_info[:participant_sid],
|
||||
participant_label: status_info[:participant_label]
|
||||
).process
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,160 @@
|
||||
module Voice
|
||||
class ConferenceStatusUpdateService
|
||||
pattr_initialize [:conversation!, :event!, :call_sid!, :conference_sid, :participant_sid, :participant_label]
|
||||
|
||||
def process
|
||||
update_conversation
|
||||
create_activity_message
|
||||
# We no longer need to explicitly broadcast call status
|
||||
# since the Message model's after_update_commit hook will broadcast updates
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_conversation
|
||||
# No need to track status changes for broadcasting anymore
|
||||
|
||||
# Find the message to update
|
||||
message = find_call_message
|
||||
|
||||
case event
|
||||
when 'conference-start'
|
||||
conversation.additional_attributes['conference_status'] = 'started'
|
||||
update_call_message_widget(message, 'ringing') if message
|
||||
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
|
||||
|
||||
# Calculate call duration if possible
|
||||
if conversation.additional_attributes['call_started_at']
|
||||
call_duration = Time.now.to_i - conversation.additional_attributes['call_started_at']
|
||||
update_call_message_widget(message, 'ended', call_duration) if message
|
||||
else
|
||||
update_call_message_widget(message, 'ended') if message
|
||||
end
|
||||
when 'participant-join'
|
||||
update_participant_info('joined')
|
||||
|
||||
# Is this participant an agent?
|
||||
is_agent = participant_label&.start_with?('agent')
|
||||
|
||||
# If this is an agent joining, update the call status
|
||||
if is_agent && conversation.additional_attributes['call_status'] == 'ringing'
|
||||
conversation.additional_attributes['call_status'] = 'active'
|
||||
conversation.additional_attributes['call_started_at'] = Time.now.to_i
|
||||
update_call_message_widget(message, 'active') if message
|
||||
end
|
||||
when 'participant-leave'
|
||||
update_participant_info('left')
|
||||
|
||||
# Was this participant the caller?
|
||||
is_caller = participant_label&.start_with?('caller')
|
||||
|
||||
# If this is the caller leaving and call is still ringing (no agent joined), mark as missed
|
||||
if is_caller && conversation.additional_attributes['call_status'] == 'ringing'
|
||||
has_agent_joined = conversation.additional_attributes['participants']&.values&.any? do |p|
|
||||
p['type'] == 'agent' && p['status'] == 'joined'
|
||||
end
|
||||
|
||||
unless has_agent_joined
|
||||
conversation.additional_attributes['call_status'] = 'missed'
|
||||
update_call_message_widget(message, 'missed') if message
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Save the updated conversation
|
||||
conversation.save!
|
||||
end
|
||||
|
||||
def create_activity_message
|
||||
# Determine the message content based on the event
|
||||
content = case event
|
||||
when 'conference-start'
|
||||
'Conference started'
|
||||
when 'conference-end'
|
||||
'Conference ended'
|
||||
when 'participant-join'
|
||||
participant_type = participant_label&.start_with?('agent') ? 'Agent' : 'Caller'
|
||||
"#{participant_type} joined the call"
|
||||
when 'participant-leave'
|
||||
participant_type = participant_label&.start_with?('agent') ? 'Agent' : 'Caller'
|
||||
"#{participant_type} left the call"
|
||||
else
|
||||
"Call event: #{event}"
|
||||
end
|
||||
|
||||
# Create an activity message
|
||||
Messages::MessageBuilder.new(
|
||||
nil,
|
||||
conversation,
|
||||
{
|
||||
content: content,
|
||||
message_type: :activity,
|
||||
additional_attributes: {
|
||||
call_sid: call_sid,
|
||||
event_type: event,
|
||||
conference_sid: conference_sid,
|
||||
timestamp: Time.now.to_i,
|
||||
participant_sid: participant_sid
|
||||
}
|
||||
}
|
||||
).perform
|
||||
end
|
||||
|
||||
def update_participant_info(status)
|
||||
# Initialize participants tracking if not already present
|
||||
conversation.additional_attributes['participants'] ||= {}
|
||||
|
||||
# Update participant info
|
||||
if status == 'joined'
|
||||
conversation.additional_attributes['participants'][participant_sid] = {
|
||||
joined_at: Time.now.to_i,
|
||||
type: participant_label&.start_with?('agent') ? 'agent' : 'caller',
|
||||
call_sid: call_sid,
|
||||
status: 'joined'
|
||||
}
|
||||
elsif status == 'left'
|
||||
# Only update if the participant is in the list
|
||||
if conversation.additional_attributes['participants'].key?(participant_sid)
|
||||
conversation.additional_attributes['participants'][participant_sid]['status'] = 'left'
|
||||
conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# We no longer need a separate broadcasting method
|
||||
# The Message model's after_update_commit hook will handle broadcasting updates
|
||||
|
||||
# This method is no longer needed as we update the call widget directly in the update_conversation method
|
||||
# It was keeping for backward compatibility in case any old calls were processed with this method
|
||||
|
||||
def find_call_message
|
||||
conversation.messages
|
||||
.where(content_type: 'voice_call')
|
||||
.where("content_attributes->'data'->>'call_sid' = ?", call_sid)
|
||||
.first
|
||||
end
|
||||
|
||||
def update_call_message_widget(message, status, duration = nil)
|
||||
return unless message
|
||||
|
||||
# Update the message's content attributes
|
||||
content_attributes = message.content_attributes || {}
|
||||
message_data = content_attributes['data'] || {}
|
||||
|
||||
# Update status and add duration if provided
|
||||
message_data['status'] = status
|
||||
message_data['duration'] = duration if duration
|
||||
message_data['meta'] ||= {}
|
||||
message_data['meta']["#{status}_at"] = Time.now.to_i
|
||||
|
||||
content_attributes['data'] = message_data
|
||||
message.content_attributes = content_attributes
|
||||
message.save!
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,163 @@
|
||||
module Voice
|
||||
class IncomingCallService
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def process
|
||||
create_contact
|
||||
create_conversation
|
||||
create_conversation_messages
|
||||
generate_twiml_response
|
||||
end
|
||||
|
||||
def caller_info
|
||||
{
|
||||
call_sid: params['CallSid'],
|
||||
from_number: params['From'],
|
||||
to_number: params['To']
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_contact
|
||||
@contact = account.contacts.find_or_create_by!(phone_number: caller_info[:from_number]) do |c|
|
||||
c.name = "Contact from #{caller_info[:from_number]}"
|
||||
end
|
||||
end
|
||||
|
||||
def create_conversation
|
||||
# Find the inbox for this phone number
|
||||
@inbox = find_voice_inbox
|
||||
|
||||
# Create or update contact inbox
|
||||
contact_inbox = create_contact_inbox
|
||||
|
||||
# Create a new conversation with call details
|
||||
@conversation = account.conversations.create!(
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
inbox_id: @inbox.id,
|
||||
status: :open,
|
||||
contact: @contact,
|
||||
additional_attributes: {
|
||||
'call_sid' => caller_info[:call_sid],
|
||||
'call_status' => 'ringing',
|
||||
'call_direction' => 'inbound',
|
||||
'call_initiated_at' => Time.now.to_i,
|
||||
'call_type' => 'inbound'
|
||||
}
|
||||
)
|
||||
|
||||
# Set up conference name
|
||||
conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
|
||||
@conversation.additional_attributes['conference_sid'] = conference_name
|
||||
@conversation.save!
|
||||
|
||||
Rails.logger.info("🎧 Creating conference: #{conference_name} for account: #{account.id}, conversation: #{@conversation.display_id}")
|
||||
end
|
||||
|
||||
def create_conversation_messages
|
||||
# Create a single incoming message from contact for this call
|
||||
Messages::MessageBuilder.new(
|
||||
@contact, # For incoming calls, sender is the contact
|
||||
@conversation,
|
||||
{
|
||||
content: 'Voice Call',
|
||||
message_type: :incoming,
|
||||
content_type: 'voice_call', # Direct content type for voice calls
|
||||
content_attributes: {
|
||||
data: {
|
||||
call_sid: caller_info[:call_sid],
|
||||
status: 'ringing',
|
||||
conversation_id: @conversation.id,
|
||||
call_direction: 'inbound',
|
||||
meta: {
|
||||
created_at: Time.now.to_i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
).perform
|
||||
|
||||
# Create a simple activity message (no sender needed)
|
||||
Messages::MessageBuilder.new(
|
||||
nil, # Activity messages don't need a sender
|
||||
@conversation,
|
||||
{
|
||||
content: "Incoming call from #{@contact.name.presence || caller_info[:from_number]}",
|
||||
message_type: :activity,
|
||||
additional_attributes: {
|
||||
call_sid: caller_info[:call_sid],
|
||||
call_status: 'ringing',
|
||||
call_direction: 'inbound'
|
||||
}
|
||||
}
|
||||
).perform
|
||||
|
||||
# Broadcast call notification
|
||||
broadcast_call_status
|
||||
end
|
||||
|
||||
def broadcast_call_status
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{account.id}",
|
||||
{
|
||||
event: 'incoming_call',
|
||||
data: {
|
||||
call_sid: caller_info[:call_sid],
|
||||
conversation_id: @conversation.id,
|
||||
inbox_id: @inbox.id,
|
||||
inbox_name: @inbox.name,
|
||||
contact_name: @contact.name || caller_info[:from_number],
|
||||
contact_id: @contact.id,
|
||||
account_id: account.id
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def generate_twiml_response
|
||||
conference_name = @conversation.additional_attributes['conference_sid']
|
||||
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.')
|
||||
|
||||
response.dial do |dial|
|
||||
dial.conference(
|
||||
conference_name,
|
||||
startConferenceOnEnter: false,
|
||||
endConferenceOnExit: true,
|
||||
beep: false,
|
||||
muted: false,
|
||||
waitUrl: '',
|
||||
statusCallback: "#{base_url.gsub(/\/$/, '')}/api/v1/accounts/#{account.id}/channels/voice/webhooks/conference_status",
|
||||
statusCallbackMethod: 'POST',
|
||||
statusCallbackEvent: 'start end join leave',
|
||||
participantLabel: "caller-#{caller_info[:call_sid].last(8)}"
|
||||
)
|
||||
end
|
||||
|
||||
response.to_s
|
||||
end
|
||||
|
||||
def find_voice_inbox
|
||||
account.inboxes
|
||||
.where(channel_type: 'Channel::Voice')
|
||||
.joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
|
||||
.where('channel_voice.phone_number = ?', caller_info[:to_number])
|
||||
.first or raise "Inbox not found for phone number #{caller_info[:to_number]}"
|
||||
end
|
||||
|
||||
def create_contact_inbox
|
||||
contact_inbox = ContactInbox.find_or_create_by!(
|
||||
contact_id: @contact.id,
|
||||
inbox_id: @inbox.id
|
||||
)
|
||||
contact_inbox.update!(source_id: caller_info[:from_number]) if contact_inbox.source_id.blank?
|
||||
contact_inbox
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', "https://#{params['host_with_port']}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,145 @@
|
||||
module Voice
|
||||
class OutgoingCallService
|
||||
pattr_initialize [:account!, :contact!, :user!]
|
||||
|
||||
def process
|
||||
find_voice_inbox
|
||||
create_conversation
|
||||
initiate_call
|
||||
create_conversation_messages
|
||||
broadcast_to_agent
|
||||
@conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_voice_inbox
|
||||
@voice_inbox = account.inboxes.find_by(channel_type: 'Channel::Voice')
|
||||
raise "No Voice channel found" if @voice_inbox.blank?
|
||||
raise "Contact has no phone number" if contact.phone_number.blank?
|
||||
end
|
||||
|
||||
def create_conversation
|
||||
# Find or create contact inbox
|
||||
contact_inbox = ContactInbox.find_or_initialize_by(
|
||||
contact_id: contact.id,
|
||||
inbox_id: @voice_inbox.id
|
||||
)
|
||||
|
||||
# Set phone number as source_id if new
|
||||
if contact_inbox.new_record?
|
||||
contact_inbox.source_id = contact.phone_number
|
||||
end
|
||||
|
||||
contact_inbox.save!
|
||||
|
||||
# Create a new conversation with call details
|
||||
@conversation = account.conversations.create!(
|
||||
account_id: account.id,
|
||||
inbox_id: @voice_inbox.id,
|
||||
contact_id: contact.id,
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
status: :open,
|
||||
additional_attributes: {
|
||||
'call_initiated_at' => Time.now.to_i,
|
||||
'call_type' => 'outbound',
|
||||
'call_direction' => 'outbound'
|
||||
}
|
||||
)
|
||||
|
||||
# Create conference name for outbound call
|
||||
@conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
|
||||
end
|
||||
|
||||
def initiate_call
|
||||
# Initiate the call using the channel's implementation
|
||||
@call_details = @voice_inbox.channel.initiate_call(
|
||||
to: contact.phone_number,
|
||||
conference_name: @conference_name,
|
||||
agent_id: user.id # Pass the agent ID to track who initiated the call
|
||||
)
|
||||
|
||||
# Add conference details to the conversation
|
||||
@call_details[:conference_sid] = @conference_name
|
||||
|
||||
# Update conversation with call details
|
||||
updated_attributes = (@conversation.additional_attributes || {}).merge(@call_details)
|
||||
updated_attributes[:call_status] = 'in-progress'
|
||||
updated_attributes[:requires_agent_join] = true
|
||||
updated_attributes[:agent_id] = user.id # Store the agent ID who initiated the call
|
||||
@conversation.update!(additional_attributes: updated_attributes)
|
||||
end
|
||||
|
||||
def create_conversation_messages
|
||||
# Create a single outgoing message from agent for this call
|
||||
@widget_message = Messages::MessageBuilder.new(
|
||||
user, # For outgoing calls, sender is the agent
|
||||
@conversation,
|
||||
{
|
||||
content: 'Voice Call',
|
||||
message_type: :outgoing, # Make sure this is 'outgoing' to be sent from the agent
|
||||
content_type: 'voice_call', # Direct content type for voice calls
|
||||
content_attributes: {
|
||||
data: {
|
||||
call_sid: @call_details[:call_sid],
|
||||
status: 'ringing',
|
||||
conversation_id: @conversation.id,
|
||||
call_direction: 'outbound',
|
||||
meta: {
|
||||
created_at: Time.now.to_i
|
||||
}
|
||||
}
|
||||
},
|
||||
sender: user
|
||||
}
|
||||
).perform
|
||||
|
||||
# Create a simple activity message (no sender needed)
|
||||
Messages::MessageBuilder.new(
|
||||
nil, # Activity messages don't need a sender
|
||||
@conversation,
|
||||
{
|
||||
content: "Outgoing call to #{contact.name || contact.phone_number}",
|
||||
message_type: :activity,
|
||||
additional_attributes: @call_details
|
||||
}
|
||||
).perform
|
||||
|
||||
# Update last activity timestamp
|
||||
@conversation.update(last_activity_at: Time.current)
|
||||
end
|
||||
|
||||
def broadcast_to_agent
|
||||
# Direct notification that agent needs to join
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{account.id}",
|
||||
{
|
||||
event: 'incoming_call',
|
||||
data: {
|
||||
call_sid: @call_details[:call_sid],
|
||||
conversation_id: @conversation.id,
|
||||
inbox_id: @voice_inbox.id,
|
||||
inbox_name: @voice_inbox.name,
|
||||
contact_name: contact.name || contact.phone_number,
|
||||
contact_id: contact.id,
|
||||
account_id: account.id,
|
||||
is_outbound: true,
|
||||
conference_sid: @conference_name,
|
||||
requires_agent_join: true,
|
||||
call_direction: 'outbound'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Broadcast the conversation and message
|
||||
ActionCableBroadcastJob.perform_later(
|
||||
@conversation.account_id,
|
||||
'conversation.created',
|
||||
@conversation.push_event_data.merge(
|
||||
message: @widget_message.push_event_data,
|
||||
status: 'open'
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
module Voice
|
||||
class TwilioValidatorService
|
||||
pattr_initialize [:account!, :params!, :request!]
|
||||
|
||||
def valid?
|
||||
# Skip for OPTIONS requests
|
||||
return true if request.method == "OPTIONS"
|
||||
|
||||
# Skip validation for local development
|
||||
return true if Rails.env.development?
|
||||
|
||||
# Skip if no To param (happens in some callback scenarios)
|
||||
to_number = params['To']
|
||||
return true if to_number.blank?
|
||||
|
||||
begin
|
||||
inbox = find_voice_inbox(to_number)
|
||||
|
||||
# If inbox not found, allow the request for Twilio callbacks
|
||||
unless inbox
|
||||
Rails.logger.warn("⚠️ No inbox found for phone number #{to_number} - allowing request for Twilio callback")
|
||||
return true
|
||||
end
|
||||
|
||||
# Get Twilio Auth Token from inbox's channel
|
||||
channel = inbox.channel
|
||||
unless channel.is_a?(Channel::Voice)
|
||||
Rails.logger.warn("⚠️ Channel is not a voice channel - allowing request for Twilio callback")
|
||||
return true
|
||||
end
|
||||
|
||||
auth_token = channel.provider_config_hash['auth_token']
|
||||
|
||||
# Validate incoming request signature if present
|
||||
signature = request.headers['X-Twilio-Signature']
|
||||
|
||||
# Allow requests without signature for callbacks
|
||||
unless signature.present?
|
||||
Rails.logger.warn("⚠️ No Twilio signature in request - allowing for callbacks")
|
||||
return true
|
||||
end
|
||||
|
||||
# Validate the signature
|
||||
validator = Twilio::Security::RequestValidator.new(auth_token)
|
||||
url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}"
|
||||
is_valid = validator.validate(url, params.to_unsafe_h, signature)
|
||||
|
||||
unless is_valid
|
||||
Rails.logger.error("⚠️ Invalid Twilio signature detected")
|
||||
return false
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error("Error validating Twilio signature: #{e.message}")
|
||||
# Always allow callbacks even if validation fails
|
||||
return true
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_voice_inbox(to_number)
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -60,6 +60,19 @@ en:
|
||||
CALL_END_ERROR: 'Failed to end call. Please try again.'
|
||||
AUDIO_NOT_SUPPORTED: 'Your browser does not support audio playback'
|
||||
TRANSCRIPTION: 'Transcription'
|
||||
VOICE_CALL:
|
||||
RINGING: 'Incoming Call - Join'
|
||||
ACTIVE: 'Call in progress'
|
||||
MISSED: 'Missed Call'
|
||||
ENDED: 'Call Ended'
|
||||
INCOMING_CALL: 'Incoming Call'
|
||||
JOIN_CALL: 'Join'
|
||||
CALL_JOINED: 'Joining call...'
|
||||
JOIN_ERROR: 'Failed to join call. Please try again.'
|
||||
MISSED_CALL: 'Call was not answered'
|
||||
DURATION: 'Duration: %{duration}'
|
||||
INCOMING_FROM: 'Incoming call from %{name}'
|
||||
OUTGOING_FROM: 'Outgoing call from %{name}'
|
||||
CONTACT_PANEL:
|
||||
NEW_MESSAGE: 'New Message'
|
||||
MERGE_CONTACT: 'Merge Contact'
|
||||
|
||||
Reference in New Issue
Block a user