mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-03 12:37:56 +00:00
chore: clean up
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 -----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user