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