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 class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
skip_before_action :authenticate_user!, :set_current_user, 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] protect_from_forgery with: :null_session, only: [:incoming, :conference_status, :call_status]
before_action :validate_twilio_signature, only: [:incoming] before_action :validate_twilio_signature, only: [:incoming, :call_status]
before_action :handle_options_request, only: [:incoming, :conference_status] before_action :handle_options_request, only: [:incoming, :conference_status, :call_status]
# Handle CORS preflight OPTIONS requests # Handle CORS preflight OPTIONS requests
def handle_options_request def handle_options_request
if request.method == "OPTIONS" if request.method == 'OPTIONS'
set_cors_headers set_cors_headers
head :ok head :ok
return true 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 first to ensure they're included
set_cors_headers 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 # Process incoming call using service
begin begin
# Ensure account is set properly # Ensure account is set properly
if !Current.account && params[:account_id].present? Current.account = Account.find(params[:account_id]) 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
# Validate required parameters # Validate required parameters
validate_incoming_params validate_incoming_params
@@ -48,53 +42,102 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
twiml_response = service.process twiml_response = service.process
# Return TwiML response # Return TwiML response
Rails.logger.info("✅ INCOMING CALL: Successfully processed")
render xml: twiml_response render xml: twiml_response
rescue StandardError => e rescue StandardError => e
# Log the error with detailed information # Log the error with detailed information
Rails.logger.error("❌ INCOMING CALL ERROR: #{e.message}") Rails.logger.error("Incoming call error: #{e.message}")
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
# Return friendly error message to caller # Return friendly error message to caller
render_error("We're sorry, but we're experiencing technical difficulties. Please try your call again later.") render_error("We're sorry, but we're experiencing technical difficulties. Please try your call again later.")
end end
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 # Handle conference status updates
def conference_status def conference_status
# Set CORS headers first to ensure they're always included # Set CORS headers first to ensure they're always included
set_cors_headers set_cors_headers
# Return immediately for OPTIONS requests # Return immediately for OPTIONS requests
if request.method == "OPTIONS" return head :ok if request.method == 'OPTIONS'
return head :ok
end
# Log basic request info
Rails.logger.info("🎧 CONFERENCE STATUS WEBHOOK: ConferenceSid=#{params['ConferenceSid']} Event=#{params['StatusCallbackEvent']}")
# Process conference status updates using service # Process conference status updates using service
begin begin
# Set account for local development if needed # Set account for local development if needed
if !Current.account && params[:account_id].present? Current.account = Account.find(params[:account_id]) 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
# Validate required parameters # Validate required parameters - need either ConferenceSid or CallSid
if params['ConferenceSid'].blank? && params['CallSid'].blank? return head :ok if params['ConferenceSid'].blank? && params['CallSid'].blank?
Rails.logger.error("❌ MISSING REQUIRED PARAMS: Need either ConferenceSid or CallSid")
end
# Use service to process conference status # Use service to process conference status
service = Voice::ConferenceStatusService.new(account: Current.account, params: params) service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
service.process service.process
Rails.logger.info("✅ CONFERENCE STATUS: Successfully processed") # Conference status processed successfully
rescue StandardError => e rescue StandardError => e
# Log errors but don't affect the response # Log errors but don't affect the response
Rails.logger.error("❌ CONFERENCE STATUS ERROR: #{e.message}") Rails.logger.error("Conference status error: #{e.message}")
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
end end
# Always return a successful response for Twilio # Always return a successful response for Twilio
@@ -104,44 +147,35 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
private private
def validate_incoming_params def validate_incoming_params
if params['CallSid'].blank? raise 'Missing required parameter: CallSid' if params['CallSid'].blank?
raise "Missing required parameter: CallSid"
end
if params['From'].blank? raise 'Missing required parameter: From' if params['From'].blank?
raise "Missing required parameter: From"
end
if params['To'].blank? raise 'Missing required parameter: To' if params['To'].blank?
raise "Missing required parameter: To"
end
if Current.account.nil? return unless Current.account.nil?
raise "Current account not set"
end raise 'Current account not set'
end end
def validate_twilio_signature def validate_twilio_signature
begin
validator = Voice::TwilioValidatorService.new( validator = Voice::TwilioValidatorService.new(
account: Current.account, account: Current.account,
params: params, params: params,
request: request request: request
) )
if !validator.valid? unless validator.valid?
Rails.logger.error("❌ INVALID TWILIO SIGNATURE")
render_error('Invalid Twilio signature') render_error('Invalid Twilio signature')
return false return false
end end
return true return true
rescue StandardError => e rescue StandardError => e
Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}") Rails.logger.error("Twilio validation error: #{e.message}")
render_error('Error validating Twilio request') render_error('Error validating Twilio request')
return false return false
end end
end
def render_error(message) def render_error(message)
response = Twilio::TwiML::VoiceResponse.new response = Twilio::TwiML::VoiceResponse.new

View File

@@ -21,7 +21,6 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
provider: :twilio) provider: :twilio)
.process_status_update('completed', nil, false, "Call ended by #{current_user.name}") .process_status_update('completed', nil, false, "Call ended by #{current_user.name}")
broadcast_status(call_sid, 'completed')
render_success('Call successfully ended') render_success('Call successfully ended')
rescue StandardError => e rescue StandardError => e
render_error("Failed to end call: #{e.message}") 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! conference_sid = convo_attr('conference_sid') || create_conference_sid!
update_join_metadata!(call_sid) update_join_metadata!(call_sid)
broadcast_status(call_sid, 'in-progress')
render json: { render json: {
status: 'success', 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") .process_status_update('in_progress', nil, false, "#{current_user.name} joined the call")
end 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 ----------------------------------------------------------------- # ---- 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 // Get call status
getCallStatus(callSid) { getCallStatus(callSid) {
if (!callSid) { if (!callSid) {

View File

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

View File

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

View File

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

View File

@@ -33,10 +33,6 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.updated': this.onConversationUpdated, 'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate, 'account.cache_invalidated': this.onCacheInvalidate,
'copilot.message.created': this.onCopilotMessageCreated, '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 => { onConversationCreated = data => {
this.app.$store.dispatch('addConversation', 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(); this.fetchConversationStats();
}; };
@@ -118,6 +148,17 @@ class ActionCableConnector extends BaseActionCableConnector {
onConversationUpdated = data => { onConversationUpdated = data => {
this.app.$store.dispatch('updateConversation', 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(); this.fetchConversationStats();
}; };
@@ -203,53 +244,7 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team }); 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 { export default {

View File

@@ -1,3 +1,5 @@
import VoiceAPI from 'dashboard/api/channels/voice';
const state = { const state = {
activeCall: null, activeCall: null,
incomingCall: null, incomingCall: null,
@@ -13,22 +15,23 @@ const getters = {
const actions = { const actions = {
handleCallStatusChanged({ state, dispatch }, { callSid, status, conversationId }) { handleCallStatusChanged({ state, dispatch }, { callSid, status, conversationId }) {
const isActiveCall = callSid === state.activeCall?.callSid; const isActiveCall = callSid === state.activeCall?.callSid;
const isIncomingCall = callSid === state.incomingCall?.callSid;
const terminalStatuses = ['ended', 'missed', 'completed', 'failed', 'busy', 'no_answer']; const terminalStatuses = ['ended', 'missed', 'completed', 'failed', 'busy', 'no_answer'];
// Update conversation status in the conversation list if (terminalStatuses.includes(status)) {
if (conversationId) { if (isActiveCall) {
dispatch('conversations/updateConversationCallStatus', { dispatch('clearActiveCall');
conversationId, } else if (isIncomingCall) {
callStatus: status dispatch('clearIncomingCall');
}, { root: true });
} }
if (isActiveCall && terminalStatuses.includes(status)) { // Hide widget for any terminal status if it matches our call
dispatch('clearActiveCall'); if (isActiveCall || isIncomingCall) {
if (window.app?.$data) { if (window.app?.$data) {
window.app.$data.showCallWidget = false; window.app.$data.showCallWidget = false;
} }
} }
}
}, },
setActiveCall({ commit, dispatch, state }, callData) { setActiveCall({ commit, dispatch, state }, callData) {
@@ -48,6 +51,13 @@ const actions = {
}, },
clearActiveCall({ commit }) { 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'); commit('CLEAR_ACTIVE_CALL');
if (window.app?.$data) { if (window.app?.$data) {
window.app.$data.showCallWidget = false; window.app.$data.showCallWidget = false;

View File

@@ -39,13 +39,13 @@ module Voice
create_activity_message(activity_message_for_status(normalized_status)) create_activity_message(activity_message_for_status(normalized_status))
end end
broadcast_status_change(normalized_status)
true true
end end
def is_outbound? def is_outbound?
direction = conversation.additional_attributes['call_direction'] direction = conversation.additional_attributes['call_direction']
return direction == 'outbound' if direction.present? return direction == 'outbound' if direction.present?
conversation.additional_attributes['requires_agent_join'] == true conversation.additional_attributes['requires_agent_join'] == true
end end
@@ -78,7 +78,11 @@ module Voice
conversation.additional_attributes['call_duration'] = duration if duration conversation.additional_attributes['call_duration'] = duration if duration
end 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) update_message_status(status, duration)
end end
@@ -105,6 +109,7 @@ module Voice
return 'Call ended' if status == 'ended' return 'Call ended' if status == 'ended'
return 'Missed call' if status == 'missed' return 'Missed call' if status == 'missed'
return 'No answer' if status == 'no_answer' return 'No answer' if status == 'no_answer'
'Call ended' 'Call ended'
end end
@@ -120,24 +125,6 @@ module Voice
additional_attributes: additional_attributes additional_attributes: additional_attributes
) )
end 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 end
end end

View File

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

View File

@@ -78,31 +78,8 @@ module Voice
end end
def broadcast_agent_notification(conversation, info) def broadcast_agent_notification(conversation, info)
contact = conversation.contact # This method is no longer needed since conversation.created events
inbox = conversation.inbox # will handle incoming call notifications
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'
}
}
)
end end
end end
end end

View File

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

View File

@@ -16,7 +16,6 @@ module Voice
# Add the activity message separately, after the voice call message # Add the activity message separately, after the voice call message
create_activity_message create_activity_message
broadcast_to_agent
@conversation @conversation
end end
@@ -43,7 +42,7 @@ module Voice
@conversation.reload @conversation.reload
# Log the conversation ID and display_id for debugging # 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 # The conference_sid should be set by the ConversationFinderService, but we double-check
@conference_name = @conversation.additional_attributes['conference_sid'] @conference_name = @conversation.additional_attributes['conference_sid']
@@ -57,30 +56,30 @@ module Voice
@conversation.additional_attributes['conference_sid'] = @conference_name @conversation.additional_attributes['conference_sid'] = @conference_name
@conversation.save! @conversation.save!
Rails.logger.info("🔧 OUTGOING CALL: Fixed conference name to #{@conference_name}") # Logging removed
else else
Rails.logger.info("✅ OUTGOING CALL: Using existing conference name #{@conference_name}") # Logging removed
end end
end end
def initiate_call def initiate_call
# Double-check that we have a valid conference name before calling # Double-check that we have a valid conference name before calling
if @conference_name.blank? || !@conference_name.match?(/^conf_account_\d+_conv_\d+$/) 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 # Re-generate the conference name as a last resort
@conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}" @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 # Update the conversation with the new conference name
@conversation.additional_attributes['conference_sid'] = @conference_name @conversation.additional_attributes['conference_sid'] = @conference_name
@conversation.save! @conversation.save!
else else
Rails.logger.info("✅ OUTGOING CALL: Valid conference name: #{@conference_name}") # Logging removed
end end
# Log that we're about to initiate the call # 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 # Initiate the call using the channel's implementation
@call_details = @voice_inbox.channel.initiate_call( @call_details = @voice_inbox.channel.initiate_call(
@@ -90,7 +89,7 @@ module Voice
) )
# Log the returned call details for debugging # 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 # Update conversation with call details, but don't set status
# Status will be properly set by CallStatusManager # Status will be properly set by CallStatusManager
@@ -109,7 +108,7 @@ module Voice
@conversation.update!(additional_attributes: updated_attributes) @conversation.update!(additional_attributes: updated_attributes)
# Log the final conversation state # 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 end
def create_voice_call_message def create_voice_call_message
@@ -172,37 +171,5 @@ module Voice
status_manager.process_status_update('initiated', nil, true, custom_message) status_manager.process_status_update('initiated', nil, true, custom_message)
end 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
end end

View File

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

View File

@@ -104,6 +104,7 @@ Rails.application.routes.draw do
collection do collection do
post :incoming post :incoming
match :conference_status, via: [:post, :options] # Allow both POST and OPTIONS 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 match :incoming, via: [:post, :options] # Allow both POST and OPTIONS
end end
end end