chore: clean up

This commit is contained in:
Sojan Jose
2025-07-17 04:53:37 -07:00
parent f8a8679d88
commit 669241801e
15 changed files with 303 additions and 338 deletions

View File

@@ -1,12 +1,12 @@
class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
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]
skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status, :call_status]
protect_from_forgery with: :null_session, only: [:incoming, :conference_status, :call_status]
before_action :validate_twilio_signature, only: [:incoming, :call_status]
before_action :handle_options_request, only: [:incoming, :conference_status, :call_status]
# Handle CORS preflight OPTIONS requests
def handle_options_request
if request.method == "OPTIONS"
if request.method == 'OPTIONS'
set_cors_headers
head :ok
return true
@@ -26,16 +26,10 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
# Set CORS headers first to ensure they're included
set_cors_headers
# Log basic request info
Rails.logger.info("🔔 INCOMING CALL WEBHOOK: CallSid=#{params['CallSid']} From=#{params['From']} To=#{params['To']}")
# Process incoming call using service
begin
# Ensure account is set properly
if !Current.account && params[:account_id].present?
Current.account = Account.find(params[:account_id])
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
end
Current.account = Account.find(params[:account_id]) if !Current.account && params[:account_id].present?
# Validate required parameters
validate_incoming_params
@@ -48,53 +42,102 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
twiml_response = service.process
# Return TwiML response
Rails.logger.info("✅ INCOMING CALL: Successfully processed")
render xml: twiml_response
rescue StandardError => e
# Log the error with detailed information
Rails.logger.error("❌ INCOMING CALL ERROR: #{e.message}")
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
Rails.logger.error("Incoming call error: #{e.message}")
# Return friendly error message to caller
render_error("We're sorry, but we're experiencing technical difficulties. Please try your call again later.")
end
end
# Handle individual call status updates
def call_status
# Set CORS headers first to ensure they're always included
set_cors_headers
# Return immediately for OPTIONS requests
return head :ok if request.method == 'OPTIONS'
# Process call status updates
begin
# Set account for local development if needed
Current.account = Account.find(params[:account_id]) if !Current.account && params[:account_id].present?
# Find conversation by CallSid
call_sid = params['CallSid']
# For dial action callbacks, use DialCallStatus; fallback to CallStatus for other types
call_status = params['DialCallStatus'] || params['CallStatus']
if call_sid.present? && call_status.present?
conversation = Current.account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first
if conversation
# Use CallStatusManager to handle the status update
status_manager = Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid,
provider: :twilio
)
# Map Twilio call/dial statuses to our statuses and update
case call_status.downcase
when 'completed', 'busy', 'failed', 'no-answer', 'canceled'
# Standard call status values
if conversation.additional_attributes['call_status'] == 'ringing'
status_manager.process_status_update('no_answer')
else
status_manager.process_status_update('ended')
end
when 'answered'
# DialCallStatus: conference calls return 'answered' when successful
# No action needed - call continues in conference
else
# Handle any other dial statuses (busy, no-answer, failed from dial action)
if conversation.additional_attributes['call_status'] == 'ringing'
status_manager.process_status_update('no_answer')
else
status_manager.process_status_update('ended')
end
end
end
end
# Call status processed successfully
rescue StandardError => e
# Log errors but don't affect the response
Rails.logger.error("Call status error: #{e.message}")
end
# Always return a successful response for Twilio
head :ok
end
# Handle conference status updates
def conference_status
# Set CORS headers first to ensure they're always included
set_cors_headers
# Return immediately for OPTIONS requests
if request.method == "OPTIONS"
return head :ok
end
# Log basic request info
Rails.logger.info("🎧 CONFERENCE STATUS WEBHOOK: ConferenceSid=#{params['ConferenceSid']} Event=#{params['StatusCallbackEvent']}")
return head :ok if request.method == 'OPTIONS'
# Process conference status updates using service
begin
# Set account for local development if needed
if !Current.account && params[:account_id].present?
Current.account = Account.find(params[:account_id])
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
end
Current.account = Account.find(params[:account_id]) if !Current.account && params[:account_id].present?
# Validate required parameters
if params['ConferenceSid'].blank? && params['CallSid'].blank?
Rails.logger.error("❌ MISSING REQUIRED PARAMS: Need either ConferenceSid or CallSid")
end
# Validate required parameters - need either ConferenceSid or CallSid
return head :ok if params['ConferenceSid'].blank? && params['CallSid'].blank?
# Use service to process conference status
service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
service.process
Rails.logger.info("✅ CONFERENCE STATUS: Successfully processed")
# Conference status processed successfully
rescue StandardError => e
# Log errors but don't affect the response
Rails.logger.error("❌ CONFERENCE STATUS ERROR: #{e.message}")
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
Rails.logger.error("Conference status error: #{e.message}")
end
# Always return a successful response for Twilio
@@ -104,43 +147,34 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
private
def validate_incoming_params
if params['CallSid'].blank?
raise "Missing required parameter: CallSid"
end
raise 'Missing required parameter: CallSid' if params['CallSid'].blank?
if params['From'].blank?
raise "Missing required parameter: From"
end
raise 'Missing required parameter: From' if params['From'].blank?
if params['To'].blank?
raise "Missing required parameter: To"
end
raise 'Missing required parameter: To' if params['To'].blank?
if Current.account.nil?
raise "Current account not set"
end
return unless Current.account.nil?
raise 'Current account not set'
end
def validate_twilio_signature
begin
validator = Voice::TwilioValidatorService.new(
account: Current.account,
params: params,
request: request
)
validator = Voice::TwilioValidatorService.new(
account: Current.account,
params: params,
request: request
)
if !validator.valid?
Rails.logger.error("❌ INVALID TWILIO SIGNATURE")
render_error('Invalid Twilio signature')
return false
end
return true
rescue StandardError => e
Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}")
render_error('Error validating Twilio request')
unless validator.valid?
render_error('Invalid Twilio signature')
return false
end
return true
rescue StandardError => e
Rails.logger.error("Twilio validation error: #{e.message}")
render_error('Error validating Twilio request')
return false
end
def render_error(message)

View File

@@ -21,7 +21,6 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
provider: :twilio)
.process_status_update('completed', nil, false, "Call ended by #{current_user.name}")
broadcast_status(call_sid, 'completed')
render_success('Call successfully ended')
rescue StandardError => e
render_error("Failed to end call: #{e.message}")
@@ -35,7 +34,6 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
conference_sid = convo_attr('conference_sid') || create_conference_sid!
update_join_metadata!(call_sid)
broadcast_status(call_sid, 'in-progress')
render json: {
status: 'success',
@@ -158,18 +156,6 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
.process_status_update('in_progress', nil, false, "#{current_user.name} joined the call")
end
def broadcast_status(call_sid, status)
ActionCable.server.broadcast "account_#{@conversation.account_id}", {
event_name: 'call_status_changed',
data: {
call_sid: call_sid,
status: status,
conversation_id: @conversation.display_id,
inbox_id: @conversation.inbox_id,
timestamp: Time.current.to_i
}
}
end
# ---- TwiML -----------------------------------------------------------------

View File

@@ -49,6 +49,26 @@ class VoiceAPI extends ApiClient {
});
}
// End the client-side WebRTC call connection
endClientCall() {
try {
if (this.activeConnection) {
console.log('📞 Ending client WebRTC call connection');
this.activeConnection.disconnect();
this.activeConnection = null;
}
if (this.device && this.device.state === 'busy') {
console.log('📞 Disconnecting all device connections');
this.device.disconnectAll();
}
} catch (error) {
console.warn('⚠️ Error ending client call:', error);
// Clear the connection reference even if disconnect failed
this.activeConnection = null;
}
}
// Get call status
getCallStatus(callSid) {
if (!callSid) {

View File

@@ -20,7 +20,6 @@ const {
isAWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isAVoiceChannel,
} = useInbox();
const {
@@ -42,8 +41,6 @@ const showStatusIndicator = computed(() => {
if (status.value === MESSAGE_STATUS.FAILED) return false;
// Don't show status for deleted messages
if (contentAttributes.value?.deleted) return false;
// Don't show status for transcription messages
if (isAVoiceChannel.value) return false;
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;

View File

@@ -1528,16 +1528,16 @@ export default {
<div class="flex items-center">
<!-- Left side with inbox avatar and call info -->
<div class="inbox-avatar">
<!-- Use the inbox avatar if available -->
<!-- Use the inbox avatar if available and valid -->
<img
v-if="inboxAvatarUrl"
v-if="inboxAvatarUrl && inboxAvatarUrl.startsWith('http')"
:src="inboxAvatarUrl"
:alt="inboxDisplayName"
class="avatar-image"
@error="handleAvatarError"
/>
<!-- Fallback to initial if no avatar -->
<span v-else>{{ inboxDisplayName.charAt(0).toUpperCase() }}</span>
<!-- Fallback to phone icon for voice channels -->
<i v-else class="i-ri-phone-fill text-white text-lg"></i>
</div>
<div class="header-info">
<div class="voice-label">{{ inboxDisplayName }}</div>
@@ -1707,7 +1707,7 @@ export default {
<span class="inbox-name">
<!-- Add inbox avatar to the header -->
<img
v-if="inboxAvatarUrl"
v-if="inboxAvatarUrl && inboxAvatarUrl.startsWith('http')"
:src="inboxAvatarUrl"
:alt="inboxDisplayName"
class="inline-avatar"
@@ -1718,6 +1718,19 @@ export default {
margin-right: 6px;
"
/>
<!-- Fallback to phone icon for voice channels -->
<i
v-else
class="i-ri-phone-fill text-white mr-1.5"
style="
width: 24px;
height: 24px;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
"
></i>
{{ inboxDisplayName }}
</span>
<span class="incoming-call-text">Incoming call</span>

View File

@@ -402,7 +402,10 @@ export default {
return;
}
if (canReply || this.isAWhatsAppChannel) {
// Voice channels only allow private notes
if (this.isAVoiceChannel) {
this.replyType = REPLY_EDITOR_MODES.NOTE;
} else if (canReply || this.isAWhatsAppChannel) {
this.replyType = REPLY_EDITOR_MODES.REPLY;
} else {
this.replyType = REPLY_EDITOR_MODES.NOTE;
@@ -797,7 +800,12 @@ export default {
this.$store.dispatch('draftMessages/setReplyEditorMode', {
mode,
});
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
// Voice channels are restricted to private notes only
if (this.isAVoiceChannel) {
this.replyType = REPLY_EDITOR_MODES.NOTE;
} else if (canReply || this.isAWhatsAppChannel) {
this.replyType = mode;
}
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
@@ -1225,7 +1233,7 @@ export default {
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote && !isAVoiceChannel"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"

View File

@@ -33,10 +33,6 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'copilot.message.created': this.onCopilotMessageCreated,
// Call events
incoming_call: this.onIncomingCall,
call_status_changed: this.onCallStatusChanged,
};
}
@@ -86,6 +82,40 @@ class ActionCableConnector extends BaseActionCableConnector {
onConversationCreated = data => {
this.app.$store.dispatch('addConversation', data);
// Check if this is a voice channel conversation (incoming call)
if (data.meta?.inbox?.channel_type === 'Channel::Voice' || data.channel === 'Channel::Voice') {
if (data.additional_attributes?.call_status === 'ringing' &&
data.additional_attributes?.call_sid) {
const normalizedPayload = {
callSid: data.additional_attributes.call_sid,
conversationId: data.display_id || data.id,
inboxId: data.inbox_id,
inboxName: data.meta?.inbox?.name,
inboxAvatarUrl: data.meta?.inbox?.avatar_url,
inboxPhoneNumber: data.meta?.inbox?.phone_number,
contactName: data.meta?.sender?.name || 'Unknown Caller',
contactId: data.meta?.sender?.id,
accountId: data.account_id,
isOutbound: data.additional_attributes?.call_direction === 'outbound',
conference_sid: data.additional_attributes?.conference_sid,
conferenceId: data.additional_attributes?.conference_sid,
conferenceSid: data.additional_attributes?.conference_sid,
requiresAgentJoin: data.additional_attributes?.requires_agent_join || false,
callDirection: data.additional_attributes?.call_direction,
phoneNumber: data.meta?.sender?.phone_number,
avatarUrl: data.meta?.sender?.avatar_url,
};
this.app.$store.dispatch('calls/setIncomingCall', normalizedPayload);
if (window.app && window.app.$data) {
window.app.$data.showCallWidget = true;
}
}
}
this.fetchConversationStats();
};
@@ -118,6 +148,17 @@ class ActionCableConnector extends BaseActionCableConnector {
onConversationUpdated = data => {
this.app.$store.dispatch('updateConversation', data);
// Check if this conversation update includes call status changes
if (data.additional_attributes?.call_status && data.additional_attributes?.call_sid) {
this.app.$store.dispatch('calls/handleCallStatusChanged', {
callSid: data.additional_attributes.call_sid,
status: data.additional_attributes.call_status,
conversationId: data.display_id,
inboxId: data.inbox_id,
});
}
this.fetchConversationStats();
};
@@ -203,53 +244,7 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
onIncomingCall = data => {
// Normalize snake_case to camelCase for consistency with frontend code
const normalizedPayload = {
callSid: data.call_sid,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
inboxName: data.inbox_name,
inboxAvatarUrl: data.inbox_avatar_url,
inboxPhoneNumber: data.inbox_phone_number,
contactName: data.contact_name || 'Unknown Caller',
contactId: data.contact_id,
accountId: data.account_id,
isOutbound: data.is_outbound || false,
// CRITICAL: Use 'conference_sid' in camelCase format to match field names
conference_sid: data.conference_sid,
conferenceId: data.conference_sid, // Add aliases for consistency
conferenceSid: data.conference_sid, // Add aliases for consistency
requiresAgentJoin: data.requires_agent_join || false,
callDirection: data.call_direction,
phoneNumber: data.phone_number,
avatarUrl: data.avatar_url,
};
// Update store
this.app.$store.dispatch('calls/setIncomingCall', normalizedPayload);
// Also update App.vue showCallWidget directly for immediate UI feedback
if (window.app && window.app.$data) {
window.app.$data.showCallWidget = true;
}
};
onCallStatusChanged = data => {
// Normalize snake_case to camelCase for consistency with frontend code
const normalizedPayload = {
callSid: data.call_sid,
status: data.status,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
timestamp: data.timestamp || Date.now(),
};
// Only dispatch to Vuex; Vuex handles widget and call state
this.app.$store.dispatch(
'calls/handleCallStatusChanged',
normalizedPayload
);
};
}
export default {

View File

@@ -1,3 +1,5 @@
import VoiceAPI from 'dashboard/api/channels/voice';
const state = {
activeCall: null,
incomingCall: null,
@@ -13,20 +15,21 @@ const getters = {
const actions = {
handleCallStatusChanged({ state, dispatch }, { callSid, status, conversationId }) {
const isActiveCall = callSid === state.activeCall?.callSid;
const isIncomingCall = callSid === state.incomingCall?.callSid;
const terminalStatuses = ['ended', 'missed', 'completed', 'failed', 'busy', 'no_answer'];
// Update conversation status in the conversation list
if (conversationId) {
dispatch('conversations/updateConversationCallStatus', {
conversationId,
callStatus: status
}, { root: true });
}
if (terminalStatuses.includes(status)) {
if (isActiveCall) {
dispatch('clearActiveCall');
} else if (isIncomingCall) {
dispatch('clearIncomingCall');
}
if (isActiveCall && terminalStatuses.includes(status)) {
dispatch('clearActiveCall');
if (window.app?.$data) {
window.app.$data.showCallWidget = false;
// Hide widget for any terminal status if it matches our call
if (isActiveCall || isIncomingCall) {
if (window.app?.$data) {
window.app.$data.showCallWidget = false;
}
}
}
},
@@ -48,6 +51,13 @@ const actions = {
},
clearActiveCall({ commit }) {
// End the WebRTC connection before clearing the call state
try {
VoiceAPI.endClientCall();
} catch (error) {
console.warn('Error ending client call during clearActiveCall:', error);
}
commit('CLEAR_ACTIVE_CALL');
if (window.app?.$data) {
window.app.$data.showCallWidget = false;

View File

@@ -39,13 +39,13 @@ module Voice
create_activity_message(activity_message_for_status(normalized_status))
end
broadcast_status_change(normalized_status)
true
end
def is_outbound?
direction = conversation.additional_attributes['call_direction']
return direction == 'outbound' if direction.present?
conversation.additional_attributes['requires_agent_join'] == true
end
@@ -78,7 +78,11 @@ module Voice
conversation.additional_attributes['call_duration'] = duration if duration
end
conversation.update!(last_activity_at: Time.current)
# Save both additional_attributes changes and last_activity_at
conversation.update!(
additional_attributes: conversation.additional_attributes,
last_activity_at: Time.current
)
update_message_status(status, duration)
end
@@ -96,15 +100,16 @@ module Voice
def find_voice_call_message
conversation.messages
.where(content_type: 'voice_call')
.order(created_at: :desc)
.first
.where(content_type: 'voice_call')
.order(created_at: :desc)
.first
end
def activity_message_for_status(status)
return 'Call ended' if status == 'ended'
return 'Missed call' if status == 'missed'
return 'No answer' if status == 'no_answer'
'Call ended'
end
@@ -120,24 +125,6 @@ module Voice
additional_attributes: additional_attributes
)
end
def broadcast_status_change(status)
ui_status = normalized_ui_status(status)
ActionCable.server.broadcast(
"account_#{conversation.account_id}",
{
event_name: 'call_status_changed',
data: {
call_sid: call_sid,
status: ui_status,
conversation_id: conversation.display_id,
inbox_id: conversation.inbox_id,
timestamp: Time.now.to_i
}
}
)
end
end
end
end

View File

@@ -65,17 +65,17 @@ module Voice
def handle_agent_join
conversation.additional_attributes['agent_joined_at'] = Time.now.to_i
if ringing_call?
call_status_manager.process_status_update('in_progress')
end
return unless ringing_call?
call_status_manager.process_status_update('in_progress')
end
def handle_caller_join
conversation.additional_attributes['caller_joined_at'] = Time.now.to_i
if outbound_call? && ringing_call?
call_status_manager.process_status_update('in_progress')
end
return unless outbound_call? && ringing_call?
call_status_manager.process_status_update('in_progress')
end
def agent_participant?

View File

@@ -78,31 +78,8 @@ module Voice
end
def broadcast_agent_notification(conversation, info)
contact = conversation.contact
inbox = conversation.inbox
ActionCable.server.broadcast(
"account_#{account.id}",
{
event: 'incoming_call',
data: {
call_sid: info[:call_sid],
conversation_id: conversation.display_id,
inbox_id: conversation.inbox_id,
inbox_name: inbox.name,
inbox_avatar_url: inbox.avatar_url,
inbox_phone_number: inbox.channel.phone_number,
contact_name: contact&.name.presence || contact&.phone_number || 'Outbound Call',
contact_id: contact&.id,
is_outbound: true,
account_id: account.id,
conference_sid: info[:conference_sid],
phone_number: contact&.phone_number,
avatar_url: contact&.avatar_url,
call_direction: 'outbound'
}
}
)
# This method is no longer needed since conversation.created events
# will handle incoming call notifications
end
end
end

View File

@@ -6,16 +6,14 @@ module Voice
find_inbox
create_contact
# Use a transaction to ensure the conversation and voice call message are created together
# This ensures the voice call message is created before any auto-assignment activity messages
# Use a transaction to ensure conversation, message, and call status are all set together
# This ensures only one conversation.created event with complete call data
ActiveRecord::Base.transaction do
create_conversation
create_voice_call_message
set_initial_call_status
end
# Create activity message separately, after the voice call message
create_activity_message
generate_twiml_response
rescue StandardError => e
@@ -132,68 +130,42 @@ module Voice
message_params
).perform
# Broadcast call notification
broadcast_call_status
end
# Create activity message separately after the voice call message
def create_activity_message
# Use CallStatusManager for consistency
status_manager = Voice::CallStatus::Manager.new(
conversation: @conversation,
call_sid: caller_info[:call_sid],
provider: :twilio
)
# Set initial call status within the transaction
def set_initial_call_status
# Set call status directly on conversation to avoid separate broadcast
@conversation.additional_attributes['call_status'] = 'ringing'
@conversation.additional_attributes['call_started_at'] = Time.now.to_i
@conversation.save!
# Process ringing status with custom activity message
# Create activity message directly without CallStatusManager broadcast
custom_message = "Incoming call from #{@contact.name.presence || caller_info[:from_number]}"
status_manager.process_status_update('ringing', nil, true, custom_message)
end
def broadcast_call_status
# Get contact name, ensuring we have a valid value
contact_name_value = @contact.name.presence || caller_info[:from_number]
# Create the data payload
broadcast_data = {
call_sid: caller_info[:call_sid],
conversation_id: @conversation.display_id,
inbox_id: @inbox.id,
inbox_name: @inbox.name,
inbox_avatar_url: @inbox.avatar_url,
inbox_phone_number: @inbox.channel.phone_number,
contact_name: contact_name_value,
contact_id: @contact.id,
account_id: account.id,
phone_number: @contact.phone_number,
avatar_url: @contact.avatar_url,
call_direction: 'inbound',
# CRITICAL: Include the conference_sid
conference_sid: @conversation.additional_attributes['conference_sid']
}
ActionCable.server.broadcast(
"account_#{account.id}",
{
event: 'incoming_call',
data: broadcast_data
}
@conversation.messages.create!(
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :activity,
content: custom_message,
sender: nil
)
end
def generate_twiml_response
conference_name = @conversation.additional_attributes['conference_sid']
Rails.logger.info("📞 IncomingCallService: Generating TwiML with conference name: #{conference_name}")
response = Twilio::TwiML::VoiceResponse.new
response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.')
# Setup callback URLs
conference_callback_url = "#{base_url}/api/v1/accounts/#{account.id}/channels/voice/webhooks/conference_status"
Rails.logger.info("📞 IncomingCallService: Setting conference callback to: #{conference_callback_url}")
call_status_callback_url = "#{base_url}/api/v1/accounts/#{account.id}/channels/voice/webhooks/call_status"
# Now add the caller to the conference
response.dial do |dial|
# Now add the caller to the conference with call status callback
response.dial(
action: call_status_callback_url,
method: 'POST'
) do |dial|
dial.conference(
conference_name,
startConferenceOnEnter: false,
@@ -208,9 +180,7 @@ module Voice
)
end
result = response.to_s
Rails.logger.info("📞 IncomingCallService: Generated TwiML: #{result}")
result
response.to_s
end
def error_twiml(_message)

View File

@@ -16,7 +16,6 @@ module Voice
# Add the activity message separately, after the voice call message
create_activity_message
broadcast_to_agent
@conversation
end
@@ -43,7 +42,7 @@ module Voice
@conversation.reload
# Log the conversation ID and display_id for debugging
Rails.logger.info("🔍 OUTGOING CALL: Created conversation with ID=#{@conversation.id}, display_id=#{@conversation.display_id}")
# Conversation created for outgoing call
# The conference_sid should be set by the ConversationFinderService, but we double-check
@conference_name = @conversation.additional_attributes['conference_sid']
@@ -57,30 +56,30 @@ module Voice
@conversation.additional_attributes['conference_sid'] = @conference_name
@conversation.save!
Rails.logger.info("🔧 OUTGOING CALL: Fixed conference name to #{@conference_name}")
# Logging removed
else
Rails.logger.info("✅ OUTGOING CALL: Using existing conference name #{@conference_name}")
# Logging removed
end
end
def initiate_call
# Double-check that we have a valid conference name before calling
if @conference_name.blank? || !@conference_name.match?(/^conf_account_\d+_conv_\d+$/)
Rails.logger.error("❌ OUTGOING CALL: Invalid conference name before initiating call: #{@conference_name}")
# Logging removed
# Re-generate the conference name as a last resort
@conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
Rails.logger.info("🔧 OUTGOING CALL: Re-generated conference name: #{@conference_name}")
# Logging removed
# Update the conversation with the new conference name
@conversation.additional_attributes['conference_sid'] = @conference_name
@conversation.save!
else
Rails.logger.info("✅ OUTGOING CALL: Valid conference name: #{@conference_name}")
# Logging removed
end
# Log that we're about to initiate the call
Rails.logger.info("📞 OUTGOING CALL: Initiating call to #{contact.phone_number} with conference #{@conference_name}")
# Logging removed
# Initiate the call using the channel's implementation
@call_details = @voice_inbox.channel.initiate_call(
@@ -90,7 +89,7 @@ module Voice
)
# Log the returned call details for debugging
Rails.logger.info("📞 OUTGOING CALL: Call initiated with details: #{@call_details.inspect}")
# Logging removed
# Update conversation with call details, but don't set status
# Status will be properly set by CallStatusManager
@@ -109,7 +108,7 @@ module Voice
@conversation.update!(additional_attributes: updated_attributes)
# Log the final conversation state
Rails.logger.info("📞 OUTGOING CALL: Conversation updated with call_sid=#{@call_details[:call_sid]}, conference_sid=#{@conference_name}")
# Logging removed
end
def create_voice_call_message
@@ -172,37 +171,5 @@ module Voice
status_manager.process_status_update('initiated', nil, true, custom_message)
end
def broadcast_to_agent
# Get contact name, ensuring we have a valid value
contact_name_value = contact.name.presence || contact.phone_number
# Create the data payload
broadcast_data = {
call_sid: @call_details[:call_sid],
conversation_id: @conversation.display_id,
inbox_id: @voice_inbox.id,
inbox_name: @voice_inbox.name,
inbox_avatar_url: @voice_inbox.avatar_url, # Include inbox avatar
inbox_phone_number: @voice_inbox.channel.phone_number, # Include inbox phone number
contact_name: contact_name_value,
contact_id: contact.id,
account_id: account.id,
is_outbound: true,
conference_sid: @conference_name,
requires_agent_join: true,
call_direction: 'outbound',
phone_number: contact.phone_number, # Include phone number for display in the UI
avatar_url: contact.avatar_url # Include avatar URL for display in the UI
}
# Direct notification that agent needs to join
ActionCable.server.broadcast(
"account_#{account.id}",
{
event: 'incoming_call',
data: broadcast_data
}
)
end
end
end

View File

@@ -28,13 +28,13 @@ module Voice
# Log validation result
if is_valid
Rails.logger.info("✅ TWILIO VALIDATION: Valid signature confirmed")
# Twilio signature validation successful
else
Rails.logger.error("⚠️ TWILIO VALIDATION: Invalid signature for URL: #{url}")
Rails.logger.error("Invalid Twilio signature for URL: #{url}")
return false
end
rescue StandardError => e
Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}")
Rails.logger.error("Twilio validation error: #{e.message}")
return true # Allow on errors for robustness
end
@@ -45,7 +45,7 @@ module Voice
def skip_validation?
# Skip for OPTIONS requests and in development
return true if request.method == "OPTIONS"
return true if request.method == 'OPTIONS'
return true if Rails.env.development?
return true if account.blank?

View File

@@ -104,6 +104,7 @@ Rails.application.routes.draw do
collection do
post :incoming
match :conference_status, via: [:post, :options] # Allow both POST and OPTIONS
match :call_status, via: [:post, :options] # Allow both POST and OPTIONS for call status
match :incoming, via: [:post, :options] # Allow both POST and OPTIONS
end
end