diff --git a/.gitignore b/.gitignore index 53deb62a8..7ebd3a8b4 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,4 @@ yarn-debug.log* # Claude.ai config file CLAUDE.md +**/.claude/settings.local.json diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index e1087b19f..b16bd3bb5 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -33,11 +33,6 @@ class Messages::MessageBuilder def content_attributes params = convert_to_hash(@params) content_attributes = params.fetch(:content_attributes, {}) - - return parse_json(content_attributes) if content_attributes.is_a?(String) - return content_attributes if content_attributes.is_a?(Hash) - - {} end # Converts the given object to a hash. @@ -105,8 +100,9 @@ class Messages::MessageBuilder end def message_type - if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' - raise StandardError, 'Incoming messages are only allowed in Api inboxes' + # Allow incoming messages in both API and Voice channels + if !['Channel::Api', 'Channel::Voice'].include?(@conversation.inbox.channel_type) && @message_type == 'incoming' + raise StandardError, 'Incoming messages are only allowed in Api and Voice inboxes' end @message_type @@ -139,7 +135,7 @@ class Messages::MessageBuilder end def message_params - { + message_attrs = { account_id: @conversation.account_id, inbox_id: @conversation.inbox_id, message_type: message_type, @@ -152,5 +148,12 @@ class Messages::MessageBuilder echo_id: @params[:echo_id], source_id: @params[:source_id] }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) + + # Directly add content_attributes from params if present + if @params[:content_attributes].present? + message_attrs[:content_attributes] = content_attributes + end + + message_attrs end -end +end \ No newline at end of file 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 8abab1df2..629fd907c 100644 --- a/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/channels/voice/webhooks_controller.rb @@ -23,15 +23,41 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts # Handle incoming calls from Twilio def incoming - # 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 + # Set CORS headers first to ensure they're included + set_cors_headers - # 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.") + # 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 + + # Validate required parameters + validate_incoming_params + + # Process the call + service = Voice::IncomingCallService.new( + account: Current.account, + params: params.to_unsafe_h.merge(host_with_port: request.host_with_port) + ) + 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")}") + + # 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 conference status updates @@ -44,19 +70,31 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts 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 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 + + # Validate required parameters + 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 service = Voice::ConferenceStatusService.new(account: Current.account, params: params) service.process - rescue => e + + Rails.logger.info("✅ CONFERENCE STATUS: Successfully processed") + rescue StandardError => e # Log errors but don't affect the response - Rails.logger.error("Error processing conference status: #{e.message[0..100]}") + Rails.logger.error("❌ CONFERENCE STATUS ERROR: #{e.message}") + Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}") end # Always return a successful response for Twilio @@ -65,19 +103,44 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts private - def validate_twilio_signature - validator = Voice::TwilioValidatorService.new( - account: Current.account, - params: params, - request: request - ) - - if !validator.valid? - render_error('Invalid Twilio signature') - return false + def validate_incoming_params + if params['CallSid'].blank? + raise "Missing required parameter: CallSid" end - true + if params['From'].blank? + raise "Missing required parameter: From" + end + + if params['To'].blank? + raise "Missing required parameter: To" + end + + if Current.account.nil? + raise "Current account not set" + end + end + + def validate_twilio_signature + begin + 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') + return false + end end def render_error(message) @@ -86,4 +149,4 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts response.hangup render xml: response.to_s end -end +end \ No newline at end of file diff --git a/app/controllers/api/v1/accounts/voice_controller.rb b/app/controllers/api/v1/accounts/voice_controller.rb index e665ba39e..c2345189c 100644 --- a/app/controllers/api/v1/accounts/voice_controller.rb +++ b/app/controllers/api/v1/accounts/voice_controller.rb @@ -51,6 +51,34 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController # Update conversation call status @conversation.additional_attributes['call_status'] = 'completed' + @conversation.additional_attributes['call_ended_at'] = Time.now.to_i + + # Calculate call duration if we have a start time + if @conversation.additional_attributes['call_started_at'] + @conversation.additional_attributes['call_duration'] = Time.now.to_i - @conversation.additional_attributes['call_started_at'].to_i + end + + # Mark conversation as resolved + @conversation.status = :resolved + + # Update the voice call message status + if call_message = find_voice_call_message + content_attributes = call_message.content_attributes || {} + content_attributes['data'] ||= {} + content_attributes['data']['status'] = 'completed' + content_attributes['data']['status_updated'] = Time.now.to_i + content_attributes['data']['meta'] ||= {} + content_attributes['data']['meta']['completed_at'] = Time.now.to_i + content_attributes['data']['ended_at'] = Time.now.to_i + + # Add duration if available + if @conversation.additional_attributes['call_duration'] + content_attributes['data']['duration'] = @conversation.additional_attributes['call_duration'] + end + + call_message.update(content_attributes: content_attributes) + end + @conversation.save! # Create an activity message noting the call has ended @@ -115,13 +143,32 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController # Agent joining call via WebRTC - # Update conversation to show agent joined + # Update conversation to show agent joined and set call status to active @conversation.additional_attributes['agent_joined'] = true @conversation.additional_attributes['joined_at'] = Time.now.to_i @conversation.additional_attributes['joined_by'] = { id: current_user.id, name: current_user.name } + + # CRITICAL: Update call status to 'in-progress' to ensure UI updates properly + # This is especially important for incoming calls where the status might not get updated otherwise + @conversation.additional_attributes['call_status'] = 'in-progress' + + # Also record started_at timestamp if not already set + @conversation.additional_attributes['call_started_at'] = Time.now.to_i unless @conversation.additional_attributes['call_started_at'] + + # Update the call data in the voice call message + if call_message = find_voice_call_message + content_attributes = call_message.content_attributes || {} + content_attributes['data'] ||= {} + content_attributes['data']['status'] = 'in-progress' + content_attributes['data']['status_updated'] = Time.now.to_i + content_attributes['data']['meta'] ||= {} + content_attributes['data']['meta']['active_at'] = Time.now.to_i + call_message.update(content_attributes: content_attributes) + end + @conversation.save! # Create an activity message @@ -454,6 +501,29 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController @conversation = Current.account.conversations.find(params[:id] || params[:conversation_id]) end + # Helper method to find the voice call message for the current call + # Similar to the one in Voice::MessageUpdateService but simplified + def find_voice_call_message + return nil unless @conversation.present? + + # Try to find by call_sid first + if call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid') + message = @conversation.messages + .where(content_type: 'voice_call') + .where("content_attributes->'data'->>'call_sid' = ?", call_sid) + .first + + # If found, return it + return message if message + end + + # Fall back to the most recent voice call message + @conversation.messages + .where(content_type: 'voice_call') + .order(created_at: :desc) + .first + end + # Helper method to get base URL with extra resilience def base_url # Try several methods to determine the base URL, with detailed logging diff --git a/app/controllers/twilio/voice_controller.rb b/app/controllers/twilio/voice_controller.rb index 4fbccd0a8..1df26a8b9 100644 --- a/app/controllers/twilio/voice_controller.rb +++ b/app/controllers/twilio/voice_controller.rb @@ -91,76 +91,15 @@ class Twilio::VoiceController < ActionController::Base contact_number = is_outbound ? to_number : from_number conversation = find_or_create_conversation(inbox, contact_number, call_sid) - contact = conversation.contact - - # Create a single feedback message for this recording - return unless contact.present? - - existing_msg = conversation.messages.where('additional_attributes @> ?', { recording_sid: recording_sid }.to_json).first - return if existing_msg + # Process the recording using RecordingService begin - message_params = { - content: 'Feedback about recent signup', - message_type: :incoming, - additional_attributes: { - call_sid: call_sid, - recording_url: recording_url, - recording_sid: recording_sid - } - } - - message = Messages::MessageBuilder.new(contact, conversation, message_params).perform - - # Download and attach the recording if we have a valid URL - if message.present? && recording_url.present? - begin - # Validate that the recording URL is accessible - uri = URI.parse(recording_url) - if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) - # Only create an attachment if we have a valid Twilio recording URL - # Twilio recording URL format: https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Recordings/{RecordingSid} - if recording_url.present? && recording_url.include?('/Recordings/') && recording_sid.present? - # Get authentication details from the channel config to access the recording - config = inbox.channel.provider_config_hash - account_sid = config['account_sid'] - auth_token = config['auth_token'] - - # Download the recording and attach via ActiveStorage - recording_mp3_url = "#{recording_url}.mp3" - download_file = Down.download( - recording_mp3_url, - http_basic_authentication: [account_sid, auth_token] - ) - attachment = message.attachments.new( - file_type: :audio, - account_id: inbox.account_id, - extension: 'mp3', - fallback_title: 'Voice Recording', - meta: { - recording_sid: recording_sid, - twilio_account_sid: account_sid, - auth_required: true - } - ) - attachment.file.attach( - io: download_file, - filename: "#{recording_sid}.mp3", - content_type: 'audio/mpeg' - ) - attachment.save! - Rails.logger.info("Successfully downloaded and attached voice recording: #{recording_url}") - else - Rails.logger.error("Invalid Twilio recording URL format or missing SID: #{recording_url}") - end - else - Rails.logger.error("Invalid recording URL format: #{recording_url}") - end - rescue StandardError => e - # Log error but continue - Rails.logger.error("Error processing recording: #{e.message}") - end - end + Voice::RecordingService.new( + conversation: conversation, + recording_url: recording_url, + recording_sid: recording_sid, + call_sid: call_sid + ).process # End the call after a single recording response = Twilio::TwiML::VoiceResponse.new do |r| @@ -171,6 +110,13 @@ class Twilio::VoiceController < ActionController::Base rescue StandardError => e # Log the error but don't crash Rails.logger.error("Error processing recording: #{e.message}") + + # Return a simple TwiML in case of error + response = Twilio::TwiML::VoiceResponse.new do |r| + r.say(message: 'We encountered an issue processing your feedback. Goodbye.') + r.hangup + end + render xml: response.to_s, status: :ok end end @@ -195,63 +141,38 @@ class Twilio::VoiceController < ActionController::Base is_outbound = direction == 'outbound-api' from_number = params['From'] to_number = params['To'] + duration = params['CallDuration'] ? params['CallDuration'].to_i : nil Rails.logger.info("Twilio status callback: CallSid=#{call_sid}, Status=#{call_status}, Direction=#{direction}") # Find the inbox inbox = find_inbox(is_outbound ? from_number : to_number) + return head :ok unless inbox.present? - if inbox.present? - # Find or create the conversation - conversation = find_or_create_conversation(inbox, is_outbound ? to_number : from_number, call_sid) - - # Add activity for the status change - track_call_activity(conversation, call_status, false, is_outbound) - - # If call is completed/failed, update conversation status and notify frontend - if %w[completed busy failed no-answer canceled].include?(call_status) - # Update conversation with call status - conversation.additional_attributes ||= {} - conversation.additional_attributes['call_status'] = call_status - conversation.additional_attributes['call_ended_at'] = Time.now.to_i - conversation.status = :resolved - conversation.save! - - # Publish update to frontend via ActionCable - ActionCable.server.broadcast( - "#{conversation.account_id}_#{conversation.inbox_id}", - { - event_name: 'call_status_changed', - data: { - call_sid: call_sid, - status: call_status, - conversation_id: conversation.id - } - } - ) - - # Create an activity message for call ending if it's a user hangup - if call_status == 'completed' - end_reason = params['CallDuration'] ? 'Call ended by hangup' : 'Call ended' - call_duration = params['CallDuration'] ? params['CallDuration'].to_i : nil - - Messages::MessageBuilder.new( - nil, - conversation, - { - content: end_reason, - message_type: :activity, - additional_attributes: { - call_sid: call_sid, - call_status: call_status, - call_direction: is_outbound ? 'outbound' : 'inbound', - call_duration: call_duration - } - } - ).perform - end - end + # Use ConversationFinderService to find or create the conversation + contact_number = is_outbound ? to_number : from_number + # Ensuring contact_number is not blank + if contact_number.blank? + Rails.logger.error("Missing phone number in Twilio status callback: CallSid=#{call_sid}") + return head :ok end + + conversation = Voice::ConversationFinderService.new( + account: inbox.account, + call_sid: call_sid, + phone_number: contact_number, + is_outbound: is_outbound, + inbox: inbox + ).perform + + # Use TwilioCallStatusService to handle status update + Voice::TwilioCallStatusService.new( + conversation: conversation, + call_sid: call_sid, + call_status: call_status, + is_outbound: is_outbound, + duration: duration + ).process(params['IsFirstResponseForStatus'] == 'true') # Return an empty response head :ok @@ -278,66 +199,84 @@ class Twilio::VoiceController < ActionController::Base inbox = find_inbox(inbox_number) if inbox.present? - # Find or create conversation + # Find or create conversation using the service contact_number = is_outbound ? to_number : from_number - conversation = find_or_create_conversation(inbox, contact_number, call_sid) - # Add call activity message - track_call_activity(conversation, 'in-progress', true, is_outbound) + # Log contact information + Rails.logger.info("Creating conversation with contact_number=#{contact_number}, call_sid=#{call_sid}") - # IMPORTANT: Use the provided conference_name if available, otherwise create one - account_id = inbox.account_id - - if conference_name_param.present? - # Use the provided conference name - conference_name = conference_name_param - Rails.logger.info("🚨 USING PROVIDED CONFERENCE NAME: '#{conference_name}'") - else - # Create a new conference name - conference_name = "conf_account_#{account_id}_conv_#{conversation.display_id}" - Rails.logger.info("🚨 CREATED NEW CONFERENCE NAME: '#{conference_name}'") - end - - # Store the conference name in the conversation for the agent to join - conversation.additional_attributes ||= {} - conversation.additional_attributes['conference_sid'] = conference_name - conversation.additional_attributes['call_direction'] = 'outbound' - conversation.additional_attributes['requires_agent_join'] = true - - # Log this critical information - Rails.logger.info("🚨🚨🚨 OUTBOUND CALL: Setting conference_sid=#{conference_name} and requires_agent_join=true") - - # Save the conversation - conversation.save! - - # Log the conference creation - Rails.logger.info("🎧🎧🎧 OUTBOUND CALL: Created conference: #{conference_name} for account: #{account_id}, conversation: #{conversation.display_id}") + begin + conversation = Voice::ConversationFinderService.new( + account: inbox.account, + call_sid: call_sid, + phone_number: contact_number, + is_outbound: is_outbound, + inbox: inbox + ).perform + + # Add call activity message + Voice::TwilioCallStatusService.new( + conversation: conversation, + call_sid: call_sid, + call_status: 'in-progress', + is_outbound: is_outbound, + duration: nil + ).process(true) + + # IMPORTANT: Use the provided conference_name if available, otherwise use the one from conversation + if conference_name_param.present? + # Use the provided conference name + conference_name = conference_name_param + Rails.logger.info("🚨 USING PROVIDED CONFERENCE NAME: '#{conference_name}'") + else + # Use the conference name from the conversation + conference_name = conversation.additional_attributes['conference_sid'] + Rails.logger.info("🚨 USING EXISTING CONFERENCE NAME: '#{conference_name}'") + end + + # Store the conference name and other required attributes + conversation.additional_attributes['conference_sid'] = conference_name + conversation.additional_attributes['call_direction'] = is_outbound ? 'outbound' : 'inbound' + conversation.additional_attributes['requires_agent_join'] = true + + # Log this critical information + Rails.logger.info("🚨🚨🚨 CALL: Setting conference_sid=#{conference_name} and requires_agent_join=true") + + # Save the conversation + conversation.save! + + # Log the conference creation + Rails.logger.info("🎧🎧🎧 CALL: Created conference: #{conference_name} for account: #{inbox.account_id}, conversation: #{conversation.display_id}") - # Generate TwiML that connects the caller to a conference - response = Twilio::TwiML::VoiceResponse.new - - # Simple greeting - response.say(message: 'Please wait while we connect you to an agent') - - # Connect to conference - CRITICAL: Make parameters match the agent side in voice_controller.rb - 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 - earlyMedia: true, # Enable early media for faster connection - ADDED THIS PARAMETER - statusCallback: "#{base_url}/api/v1/accounts/#{account_id}/channels/voice/webhooks/conference_status", - statusCallbackMethod: 'POST', - statusCallbackEvent: 'start end join leave', - participantLabel: "caller-#{call_sid.last(8)}" - ) + # Generate TwiML that connects the caller to a conference + response = Twilio::TwiML::VoiceResponse.new + + # Simple greeting + response.say(message: 'Please wait while we connect you to an agent') + + # Connect to conference - CRITICAL: Make parameters match the agent side in voice_controller.rb + 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 + earlyMedia: true, # Enable early media for faster connection + statusCallback: "#{base_url}/api/v1/accounts/#{inbox.account_id}/channels/voice/webhooks/conference_status", + statusCallbackMethod: 'POST', + statusCallbackEvent: 'start end join leave', + participantLabel: "caller-#{call_sid.last(8)}" + ) + end + + render xml: response.to_s, status: :ok + return + rescue StandardError => e + Rails.logger.error("Error creating conversation for voice call: #{e.message}") + # Continue to fallback TwiML end - - render xml: response.to_s, status: :ok - return end end @@ -369,96 +308,30 @@ class Twilio::VoiceController < ActionController::Base end def find_inbox(phone_number) + return nil if phone_number.blank? + Inbox.joins('INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id') .where('channel_voice.phone_number = ?', phone_number) .first end + # Legacy method for backward compatibility def find_or_create_conversation(inbox, phone_number, call_sid) - account = inbox.account - - # Reuse if existing conversation for this call SID - existing = account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first + # Extra validation to avoid passing blank phone numbers + return nil if phone_number.blank? || inbox.nil? - # If we found an existing conversation, check if it has a conference_sid - if existing - # For outbound calls, we need to ensure it has a conference_sid - if existing.additional_attributes['conference_sid'].blank? - # Create a conference name in the same format as inbound calls - conference_name = "conf_account_#{account.id}_conv_#{existing.display_id}" - existing.additional_attributes['conference_sid'] = conference_name - existing.save! - Rails.logger.info("🎧🎧🎧 ADDED CONFERENCE_SID to existing conversation: #{conference_name}") - end - return existing + begin + Voice::ConversationFinderService.new( + account: inbox.account, + call_sid: call_sid, + phone_number: phone_number, + is_outbound: false, # Default to inbound for compatibility + inbox: inbox + ).perform + rescue StandardError => e + Rails.logger.error("Error in find_or_create_conversation: #{e.message}") + nil end - - # Ensure contact and inbox - contact = account.contacts.find_or_create_by(phone_number: phone_number) do |c| - c.name = "Contact from #{phone_number}" - end - contact_inbox = ContactInbox.find_or_initialize_by(contact_id: contact.id, inbox_id: inbox.id) - contact_inbox.source_id ||= phone_number - contact_inbox.save! - - # Create new conversation for this call - convo = account.conversations.create!(contact_inbox_id: contact_inbox.id, inbox_id: inbox.id, status: :open) - - # Create a conference name using the same format for consistency - conference_name = "conf_account_#{account.id}_conv_#{convo.display_id}" - - convo.additional_attributes = { - 'call_sid' => call_sid, - 'call_status' => 'in-progress', - 'conference_sid' => conference_name - } - convo.save! - - Rails.logger.info("🎧🎧🎧 Created new conversation with conference_sid: #{conference_name}") - convo - end - - def track_call_activity(conversation, call_status, is_first_response, is_outbound) - return unless conversation.present? - - # Only create status messages when status changes or on first response - prev_status = conversation.additional_attributes&.dig('call_status') - return if !is_first_response && prev_status == call_status - - # Update conversation with call status - conversation.additional_attributes ||= {} - conversation.additional_attributes['call_status'] = call_status - conversation.save! - - # Create an appropriate activity message based on status - activity_message = case call_status - when 'ringing' - is_outbound ? 'Outbound call initiated' : 'Phone ringing' - when 'in-progress' - if is_first_response - is_outbound ? 'Call connected' : 'Call answered' - else - 'Call in progress' - end - when 'completed', 'busy', 'failed', 'no-answer', 'canceled' - "Call #{call_status}" - else - "Call status: #{call_status}" - end - - Messages::MessageBuilder.new( - nil, - conversation, - { - content: activity_message, - message_type: :activity, - additional_attributes: { - call_sid: conversation.additional_attributes&.dig('call_sid'), - call_status: call_status, - call_direction: is_outbound ? 'outbound' : 'inbound' - } - } - ).perform end def get_one_message(call_sid) @@ -486,4 +359,4 @@ class Twilio::VoiceController < ActionController::Base additional_attributes[:voice_delivery_status] = 'delivered' message.update(additional_attributes: additional_attributes) end -end +end \ No newline at end of file diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreview.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreview.vue index a2b3ad7fb..560d471b4 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreview.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreview.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; +import { useVoiceCallHelpers } from 'dashboard/composables/useVoiceCallHelpers'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; @@ -13,13 +14,212 @@ const props = defineProps({ }); const { t } = useI18n(); - const { getPlainText } = useMessageFormatter(); +// Use our shared voice call helper +const { + isVoiceChannelConversation, + hasArrow: checkHasArrow, + isIncomingCall: checkIsIncoming, + normalizeCallStatus, + getCallIconName, + getStatusText, + processArrowContent, +} = useVoiceCallHelpers(props, { t }); + +// Utility function to find the last voice call message in conversation +const findVoiceCallMessage = (conversation) => { + // If conversation has messages property, look for voice call messages + if (conversation && conversation.messages && Array.isArray(conversation.messages)) { + // Look through messages in reverse to find the latest voice call + for (let i = conversation.messages.length - 1; i >= 0; i--) { + const msg = conversation.messages[i]; + if (msg.content_type === 'voice_call' || msg.content_type === 'voice') { + return msg; + } + } + } + + // If no voice call found in messages or messages not available, check lastNonActivityMessage + const { lastNonActivityMessage } = conversation || {}; + + if (lastNonActivityMessage?.content_type === 'voice_call' || + lastNonActivityMessage?.content_type === 'voice') { + return lastNonActivityMessage; + } + + // Check if conversation has a call_status in additional_attributes + // This is a strong indicator of a voice call conversation + if (conversation?.additional_attributes?.call_status) { + // If we have a call status but no voice call message, the lastNonActivityMessage + // might still be related to the call (even if it doesn't have the right content_type) + if (lastNonActivityMessage) { + return lastNonActivityMessage; + } + } + + // As a fallback, check if last message content includes "Voice Call" or common call-related terms + if (lastNonActivityMessage?.content && + typeof lastNonActivityMessage.content === 'string') { + const content = lastNonActivityMessage.content.toLowerCase(); + if (content.includes('voice call') || + content.includes('call ') || + content.includes('missed call') || + content.includes('incoming call') || + content.includes('outgoing call') || + content.startsWith('←') || + content.startsWith('→')) { + return lastNonActivityMessage; + } + } + + return null; +}; + +const voiceCallMessage = computed(() => { + return findVoiceCallMessage(props.conversation); +}); + +const isVoiceCall = computed(() => { + // Force voice call view for voice channel conversations + if (isVoiceChannelConversation.value) { + return true; + } + + // Check if conversation has a call_status in additional_attributes + if (props.conversation?.additional_attributes?.call_status) { + return true; + } + + // Check for voice call message + return !!voiceCallMessage.value; +}); + +const callData = computed(() => { + if (!isVoiceCall.value) return {}; + + // First check for data directly in conversation attributes + const conversationAttributes = props.conversation?.custom_attributes || + props.conversation?.additional_attributes || {}; + if (conversationAttributes.call_data) { + return conversationAttributes.call_data; + } + + // Then check message content attributes + if (voiceCallMessage.value?.content_attributes?.data) { + return voiceCallMessage.value.content_attributes.data; + } + + return {}; +}); + +const hasArrow = computed(() => { + return checkHasArrow(voiceCallMessage.value); +}); + +const isIncomingCall = computed(() => { + if (!isVoiceCall.value) return null; + + // Get the conversation call_status + const conversationCallStatus = props.conversation?.additional_attributes?.call_status; + + return checkIsIncoming(callData.value, voiceCallMessage.value); +}); + +const normalizedCallStatus = computed(() => { + if (!isVoiceCall.value) return ''; + + // First check for direct call_status in the conversation additional_attributes + // This is the most authoritative source for call status + const conversationCallStatus = props.conversation?.additional_attributes?.call_status; + if (conversationCallStatus) { + return normalizeCallStatus(conversationCallStatus, isIncomingCall.value); + } + + // If there's an arrow in the message, this is a legacy format message + if (hasArrow.value) { + const content = voiceCallMessage.value?.content || ''; + if (content.includes('ended') || content.includes('Call ended')) { + return 'ended'; + } + if (content.includes('missed') || content.includes('Missed call') || content.includes('no answer')) { + return isIncomingCall.value ? 'missed' : 'no-answer'; + } + if (content.includes('in progress') || content.includes('active') || content.includes('answered')) { + return 'active'; + } + + // For voice channel conversations, default to ended for better display + if (isVoiceChannelConversation.value) { + return 'ended'; + } + + // Default to ended for legacy messages + return 'ended'; + } + + // Apply the same status mapping logic as VoiceCall component + const callStatus = callData.value?.status; + if (callStatus) { + return normalizeCallStatus(callStatus, isIncomingCall.value); + } + + // Determine status from timestamps + if (callData.value?.ended_at) { + return 'ended'; + } + if (callData.value?.missed) { + return isIncomingCall.value ? 'missed' : 'no-answer'; + } + if (callData.value?.started_at || props.conversation?.additional_attributes?.call_started_at) { + return 'active'; + } + + // For voice channel conversations, default to ended for better display + if (isVoiceChannelConversation.value) { + return 'ended'; + } + + // Default to ended for any remaining cases to avoid showing incorrect status + return 'ended'; +}); + +const callIconName = computed(() => { + return getCallIconName(normalizedCallStatus.value, isIncomingCall.value); +}); + +const callStatusText = computed(() => { + if (!isVoiceCall.value) return ''; + + // For voice channel conversations, force more descriptive text + if (isVoiceChannelConversation.value) { + return getStatusText(normalizedCallStatus.value, isIncomingCall.value); + } + + // For legacy messages with arrows, just use a cleaner version of the content + if (hasArrow.value && voiceCallMessage.value?.content) { + return processArrowContent( + voiceCallMessage.value.content, + isIncomingCall.value, + normalizedCallStatus.value + ); + } + + // Generate the correct status text based on call status and direction + return getStatusText(normalizedCallStatus.value, isIncomingCall.value); +}); + +// Return proper message content based on message type const lastNonActivityMessageContent = computed(() => { const { lastNonActivityMessage = {}, customAttributes = {} } = props.conversation; const { email: { subject } = {} } = customAttributes; + + // Return special formatting for voice calls + if (isVoiceCall.value) { + return callStatusText.value; + } + return getPlainText( subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT') ); @@ -42,9 +242,48 @@ const unreadMessagesCount = computed(() => { - + + + + + + + + + + + + + {{ callStatusText }} + + ({{ t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}) + + + + + {{ lastNonActivityMessageContent }} + { + + diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue index f1ad9d09b..86827395e 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue @@ -2,6 +2,7 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; +import { useVoiceCallHelpers } from 'dashboard/composables/useVoiceCallHelpers'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue'; @@ -24,7 +25,175 @@ const slaCardLabelRef = ref(null); const { getPlainText } = useMessageFormatter(); +// Use our voice call helpers composable +const { + isVoiceChannelConversation, + hasArrow, + isIncomingCall: checkIsIncoming, + normalizeCallStatus, + getCallIconName, + getStatusText, + processArrowContent, +} = useVoiceCallHelpers(props, { t }); + +// Force voice call view for voice channel conversations +const isVoiceCall = computed(() => { + if (isVoiceChannelConversation.value) { + return true; + } + + // Check if conversation has a call_status in additional_attributes + if (props.conversation?.additional_attributes?.call_status) { + return true; + } + + // Check for voice call in last message + const { lastNonActivityMessage } = props.conversation || {}; + if (lastNonActivityMessage?.content_type === 'voice_call' || + lastNonActivityMessage?.content_type === 'voice') { + return true; + } + + // Look for voice call content with expanded terms + if (lastNonActivityMessage?.content && + typeof lastNonActivityMessage.content === 'string') { + const content = lastNonActivityMessage.content.toLowerCase(); + if (content.includes('voice call') || + content.includes('call ') || + content.includes('missed call') || + content.includes('incoming call') || + content.includes('outgoing call') || + content.startsWith('←') || + content.startsWith('→')) { + return true; + } + } + + return false; +}); + +// Check if content has arrow prefix using our helper +const messageHasArrow = computed(() => { + const { lastNonActivityMessage } = props.conversation || {}; + return hasArrow(lastNonActivityMessage); +}); + +// Get call data from multiple sources +const callData = computed(() => { + // First check for data directly in conversation attributes + const conversationAttributes = props.conversation?.custom_attributes || {}; + if (conversationAttributes.call_data) { + return conversationAttributes.call_data; + } + + // Then check message content attributes + const { lastNonActivityMessage } = props.conversation || {}; + if (lastNonActivityMessage?.content_attributes?.data) { + return lastNonActivityMessage.content_attributes.data; + } + + return {}; +}); + +const isIncomingCall = computed(() => { + if (!isVoiceCall.value) return null; + + const { lastNonActivityMessage } = props.conversation || {}; + return checkIsIncoming(callData.value, lastNonActivityMessage); +}); + +const normalizedCallStatus = computed(() => { + if (!isVoiceCall.value) return ''; + + // First check for direct call_status in the conversation additional_attributes + // This is the most authoritative source for call status + const conversationCallStatus = props.conversation?.additional_attributes?.call_status; + if (conversationCallStatus) { + return normalizeCallStatus(conversationCallStatus, isIncomingCall.value); + } + + // If there's an arrow in the message, this is a legacy format message + if (messageHasArrow.value) { + const { lastNonActivityMessage } = props.conversation || {}; + const content = lastNonActivityMessage?.content || ''; + + if (content.includes('ended') || content.includes('Call ended')) { + return 'ended'; + } + if (content.includes('missed') || content.includes('Missed call') || content.includes('no answer')) { + return isIncomingCall.value ? 'missed' : 'no-answer'; + } + if (content.includes('in progress') || content.includes('active') || content.includes('answered')) { + return 'active'; + } + + // For voice channel conversations, default to ended for better display + if (isVoiceChannelConversation.value) { + return 'ended'; + } + + // Default to 'ended' for any legacy messages without clear status + return 'ended'; + } + + // Apply status mapping logic to call data status + const callStatus = callData.value?.status; + if (callStatus) { + return normalizeCallStatus(callStatus, isIncomingCall.value); + } + + // Determine status from timestamps + if (callData.value?.ended_at) { + return 'ended'; + } + if (callData.value?.missed) { + return isIncomingCall.value ? 'missed' : 'no-answer'; + } + if (callData.value?.started_at || props.conversation?.additional_attributes?.call_started_at) { + return 'active'; + } + + // For voice channel conversations, default to ended for better display + if (isVoiceChannelConversation.value) { + return 'ended'; + } + + // Default to ended for any remaining cases to avoid showing incorrect status + return 'ended'; +}); + +const callIconName = computed(() => { + return getCallIconName(normalizedCallStatus.value, isIncomingCall.value); +}); + +const callStatusText = computed(() => { + if (!isVoiceCall.value) return ''; + + // For legacy messages with arrows, process using our helper + if (messageHasArrow.value) { + const { lastNonActivityMessage } = props.conversation || {}; + const content = lastNonActivityMessage?.content || ''; + + // Process arrow content with our helper + return processArrowContent(content, isIncomingCall.value, normalizedCallStatus.value); + } + + // For voice channel conversations, use our helper for descriptive text + if (isVoiceChannelConversation.value) { + return getStatusText(normalizedCallStatus.value, isIncomingCall.value); + } + + // Generate the correct status text based on call status and direction + return getStatusText(normalizedCallStatus.value, isIncomingCall.value); +}); + const lastNonActivityMessageContent = computed(() => { + // If it's a voice call, use the voice call text with icon + if (isVoiceCall.value) { + return callStatusText.value; + } + + // Otherwise use the regular message content const { lastNonActivityMessage = {}, customAttributes = {} } = props.conversation; const { email: { subject } = {} } = customAttributes; @@ -61,7 +230,40 @@ defineExpose({ - + + + + + + + + + + + + + + {{ callStatusText }} + + + + {{ lastNonActivityMessageContent }} @@ -105,3 +307,22 @@ defineExpose({ + + diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue index a5996d04a..0d406fa9c 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue @@ -57,6 +57,72 @@ const lastActivityAt = computed(() => { return timestamp ? shortTimestamp(dynamicTime(timestamp)) : ''; }); +const lastNonActivityMessage = computed(() => { + return props.conversation?.lastNonActivityMessage || {}; +}); + +const isVoiceCall = computed(() => { + return lastNonActivityMessage.value?.content_type === 'voice_call' || + lastNonActivityMessage.value?.content_type === 'voice'; +}); + +const callData = computed(() => { + if (!isVoiceCall.value) return null; + return lastNonActivityMessage.value?.content_attributes?.data || {}; +}); + +const isIncomingCall = computed(() => { + if (!isVoiceCall.value) return false; + + const direction = callData.value?.call_direction; + if (direction) { + return direction === 'inbound'; + } + + return lastNonActivityMessage.value?.message_type === 0; +}); + +const normalizedCallStatus = computed(() => { + if (!isVoiceCall.value) return null; + + // Apply the same status mapping as in VoiceCall component + const callStatus = callData.value?.status; + if (callStatus) { + const statusMap = { + 'in-progress': 'active', + 'completed': 'ended', + 'canceled': 'ended', + 'failed': 'ended', + 'busy': 'no-answer', + 'no-answer': isIncomingCall.value ? 'missed' : 'no-answer' + }; + + return statusMap[callStatus] || callStatus; + } + + // Determine status from timestamps + if (callData.value?.ended_at) { + return 'ended'; + } + if (callData.value?.missed) { + return isIncomingCall.value ? 'missed' : 'no-answer'; + } + if (callData.value?.started_at) { + return 'active'; + } + + // Default to ringing + return 'ringing'; +}); + +const isRingingCall = computed(() => { + return normalizedCallStatus.value === 'ringing'; +}); + +const isActiveCall = computed(() => { + return normalizedCallStatus.value === 'active'; +}); + const showMessagePreviewWithoutMeta = computed(() => { const { labels = [] } = props.conversation; return ( @@ -87,9 +153,21 @@ const onCardClick = e => { + + + { { + + diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 9d6b30f81..ad607cf5b 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -36,6 +36,7 @@ import DyteBubble from './bubbles/Dyte.vue'; import LocationBubble from './bubbles/Location.vue'; import CSATBubble from './bubbles/CSAT.vue'; import FormBubble from './bubbles/Form.vue'; +import VoiceCallBubble from './bubbles/VoiceCall.vue'; import MessageError from './MessageError.vue'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue'; @@ -288,6 +289,15 @@ const componentToRender = computed(() => { return InstagramStoryBubble; } + // Handle voice call bubble + if ( + props.contentType === 'voice_call' || + props.contentAttributes?.type === 'voice_call' || + props.contentAttributes?.data?.callType === 'voice_call' + ) { + return VoiceCallBubble; + } + if (Array.isArray(props.attachments) && props.attachments.length === 1) { const fileType = props.attachments[0].fileType; @@ -487,10 +497,11 @@ provideMessageContext({ :class="{ 'ltr:pl-9 rtl:pl-0 justify-end': orientation === ORIENTATION.RIGHT, 'min-w-0': variant === MESSAGE_VARIANTS.EMAIL, + 'min-w-0 max-w-full': componentToRender === VoiceCallBubble, }" @contextmenu="openContextMenu($event)" > - + + + + + + + + + + + + {{ labelText }} + + + {{ subtextWithDuration }} + + + + + + + + + + + \ No newline at end of file diff --git a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue index 3c47e95c1..8afe2d856 100644 --- a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue @@ -61,15 +61,15 @@ export default { // Define local fallback translations in case i18n fails const translations = { - 'CONVERSATION.END_CALL': 'End call', - 'CONVERSATION.JOIN_CALL': 'Join call', - 'CONVERSATION.REJECT_CALL': 'Reject', - 'CONVERSATION.CALL_ENDED': 'Call ended', - 'CONVERSATION.CALL_END_ERROR': 'Failed to end call', - 'CONVERSATION.CALL_ACCEPTED': 'Joining call...', - 'CONVERSATION.CALL_REJECTED': 'Call rejected', - 'CONVERSATION.CALL_JOIN_ERROR': 'Failed to join call', - 'CONVERSATION.INCOMING_CALL': 'Incoming call', + 'CONVERSATION.VOICE_CALL.END_CALL': 'End call', + 'CONVERSATION.VOICE_CALL.JOIN_CALL': 'Join call', + 'CONVERSATION.VOICE_CALL.REJECT_CALL': 'Reject', + 'CONVERSATION.VOICE_CALL.CALL_ENDED': 'Call ended', + 'CONVERSATION.VOICE_CALL.CALL_END_ERROR': 'Failed to end call', + 'CONVERSATION.VOICE_CALL.CALL_ACCEPTED': 'Joining call...', + 'CONVERSATION.VOICE_CALL.CALL_REJECTED': 'Call rejected', + 'CONVERSATION.VOICE_CALL.CALL_JOIN_ERROR': 'Failed to join call', + 'CONVERSATION.VOICE_CALL.INCOMING_CALL': 'Incoming call', }; // Computed properties @@ -402,7 +402,7 @@ export default { const { callSid, conversationId } = incomingCall.value; // Show user feedback - useAlert(safeTranslate('CONVERSATION.CALL_REJECTED')); + useAlert(safeTranslate('CONVERSATION.VOICE_CALL.CALL_REJECTED')); // Make API call to reject the call (optional, the caller will stay in the queue) await VoiceAPI.rejectCall(callSid, conversationId); @@ -1602,12 +1602,12 @@ export default { {{ displayContactName }} - {{ isIncoming ? $t('CONVERSATION.INCOMING_CALL') : (callInfo.inboxName || 'Voice Call') }} + {{ isIncoming ? $t('CONVERSATION.VOICE_CALL.INCOMING_CALL') : $t('CONVERSATION.VOICE_CALL.OUTGOING_CALL') }} - {{ formattedCallDuration }} + {{ formattedCallDuration }} @@ -1617,27 +1617,27 @@ export default { v-if="isIncoming" class="control-button accept-call-button" @click="acceptCall" - :title="$t('CONVERSATION.JOIN_CALL')" + :title="$t('CONVERSATION.VOICE_CALL.JOIN_CALL')" > - {{ $t('CONVERSATION.JOIN_CALL') }} + {{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }} - {{ $t('CONVERSATION.REJECT_CALL') }} + {{ $t('CONVERSATION.VOICE_CALL.REJECT_CALL') }} diff --git a/app/javascript/dashboard/components/widgets/conversation/Message.vue b/app/javascript/dashboard/components/widgets/conversation/Message.vue index 92c72a1c2..745c6d940 100644 --- a/app/javascript/dashboard/components/widgets/conversation/Message.vue +++ b/app/javascript/dashboard/components/widgets/conversation/Message.vue @@ -9,7 +9,6 @@ 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'; @@ -44,7 +43,6 @@ export default { InstagramStoryReply, Spinner, NextButton, - VoiceCall, }, props: { data: { @@ -496,15 +494,11 @@ export default { - - - - - - - {{ callStatusText }} - {{ statusSubtext }} - - - - - - - - \ No newline at end of file diff --git a/app/javascript/dashboard/composables/useVoiceCallHelpers.js b/app/javascript/dashboard/composables/useVoiceCallHelpers.js new file mode 100644 index 000000000..98a417cdd --- /dev/null +++ b/app/javascript/dashboard/composables/useVoiceCallHelpers.js @@ -0,0 +1,151 @@ +import { computed } from 'vue'; + +export const useVoiceCallHelpers = (props, { t }) => { + // Check if the conversation is from a voice channel + const isVoiceChannelConversation = computed(() => { + return props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice'; + }); + + // Helper function to find call information from various sources + const getCallData = (conversation) => { + if (!conversation) return {}; + + // First check for data directly in conversation attributes + const conversationAttributes = conversation.custom_attributes || conversation.additional_attributes || {}; + if (conversationAttributes.call_data) { + return conversationAttributes.call_data; + } + + return {}; + }; + + // Check if a message has an arrow prefix + const hasArrow = (message) => { + if (!message?.content) return false; + + return ( + typeof message.content === 'string' && + (message.content.startsWith('←') || + message.content.startsWith('→') || + message.content.startsWith('↔️')) + ); + }; + + // Determine if it's an incoming call + const isIncomingCall = (callData, message) => { + if (!message) return null; + + // Check for arrow in content + if (hasArrow(message)) { + return message.content.startsWith('←'); + } + + // Try to use the direction stored in the call data + if (callData?.call_direction) { + return callData.call_direction === 'inbound'; + } + + // Fall back to message_type + return message.message_type === 0; + }; + + // Get normalized call status from multiple sources + const normalizeCallStatus = (status, isIncoming) => { + // Map from Twilio status to our UI status + const statusMap = { + 'in-progress': 'active', + 'completed': 'ended', + 'canceled': 'ended', + 'failed': 'ended', + 'busy': 'no-answer', + 'no-answer': isIncoming ? 'missed' : 'no-answer', + 'active': 'active', + 'missed': 'missed', + 'ended': 'ended', + 'ringing': 'ringing' + }; + + return statusMap[status] || status; + }; + + // Get the appropriate icon for a call status + const getCallIconName = (status, isIncoming) => { + if (status === 'missed' || status === 'no-answer') { + return 'i-ph-phone-x-fill'; + } + + if (status === 'active') { + return 'i-ph-phone-call-fill'; + } + + if (status === 'ended' || status === 'completed') { + return 'i-ph-phone-fill'; + } + + // Default phone icon for ringing state + return isIncoming + ? 'i-ph-phone-incoming-fill' + : 'i-ph-phone-outgoing-fill'; + }; + + // Get the appropriate text for a call status + const getStatusText = (status, isIncoming) => { + if (status === 'active') { + return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS'); + } + + if (isIncoming) { + if (status === 'ringing') { + return t('CONVERSATION.VOICE_CALL.INCOMING_CALL'); + } + + if (status === 'missed') { + return t('CONVERSATION.VOICE_CALL.MISSED_CALL'); + } + + if (status === 'ended') { + return t('CONVERSATION.VOICE_CALL.CALL_ENDED'); + } + } else { + if (status === 'ringing') { + return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL'); + } + + if (status === 'no-answer') { + return t('CONVERSATION.VOICE_CALL.NO_ANSWER'); + } + + if (status === 'ended') { + return t('CONVERSATION.VOICE_CALL.CALL_ENDED'); + } + } + + return isIncoming + ? t('CONVERSATION.VOICE_CALL.INCOMING_CALL') + : t('CONVERSATION.VOICE_CALL.OUTGOING_CALL'); + }; + + // Process message content with arrow prefix + const processArrowContent = (content, isIncoming, normalizedStatus) => { + // Remove arrows and clean up the text + let text = content.replace(/^[←→↔️]/, '').trim(); + + // If it only says "Voice Call" or "jo", add more descriptive status info + if (text === 'Voice Call' || text === 'jo' || text === '') { + return getStatusText(normalizedStatus, isIncoming); + } + + return text; + }; + + return { + isVoiceChannelConversation, + getCallData, + hasArrow, + isIncomingCall, + normalizeCallStatus, + getCallIconName, + getStatusText, + processArrowContent, + }; +}; \ No newline at end of file diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index a753f4609..e69cd1906 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -237,19 +237,47 @@ "CONTACT": "Contact", "COPILOT": "Copilot" }, - "INCOMING_CALL": "Incoming call", - "JOIN_CALL": "Join call", - "REJECT_CALL": "Reject call", - "END_CALL": "End call", - "MINIMIZE_CALL": "Minimize call", - "EXPAND_CALL": "Expand call", - "CALL_STATUS": { - "CONNECTING": "Connecting...", - "RINGING": "Ringing...", - "CONNECTED": "Connected", - "ENDED": "Call ended", - "FAILED": "Call failed", - "REJECTED": "Call rejected" + "VOICE_CALL": { + "TITLE": "Call", + "RINGING": "Ringing", + "ACTIVE": "Call in progress", + "MISSED": "Missed Call", + "ENDED": "Call Ended", + "INCOMING": "Incoming call...", + "OUTGOING": "Call started...", + "INCOMING_CALL": "Incoming call...", + "OUTGOING_CALL": "Outgoing call", + "CALL_IN_PROGRESS": "Call in progress...", + "NO_ANSWER": "No answer", + "MISSED_CALL": "Missed call", + "CALL_ENDED": "Call ended", + "DURATION": "{duration}", + "UNKNOWN": "Unknown", + "UNKNOWN_CALLER": "Unknown caller", + "UNKNOWN_NUMBER": "Unknown number", + "CALL_ERROR": "Failed to initiate call. Please try again.", + "CALL_INITIATED": "Call initiated successfully.", + "NOT_ANSWERED_YET": "Not answered yet", + "YOU_CALLED": "You called", + "THEY_ANSWERED": "They answered", + "YOU_ANSWERED": "You answered", + "YOU_DIDNT_ANSWER": "You didn't answer", + "RINGING_STATUS": "Ringing", + "ACTIVE_STATUS": "Call in progress", + "MISSED_STATUS": "Missed Call", + "ENDED_STATUS": "Call Ended", + "CALL_DURATION": "Duration: {duration}", + "INCOMING_FROM": "Incoming from {name}", + "OUTGOING_TO": "Outgoing to {name}", + "JOIN_CALL": "Join call", + "REJECT_CALL": "Reject call", + "END_CALL": "End call" + }, + "COPILOT": { + "TRY_THESE_PROMPTS": "Try these prompts" + }, + "GALLERY_VIEW": { + "ERROR_DOWNLOADING": "Unable to download attachment. Please try again" } }, "EMAIL_TRANSCRIPT": { @@ -394,11 +422,6 @@ "TWO": "{user} and {secondUser} are typing", "MULTIPLE": "{user} and {count} others are typing" }, - "VOICE_CALL": "Call", - "CALL_ERROR": "Failed to initiate call. Please try again.", - "CALL_INITIATED": "Call initiated successfully.", - "AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback", - "TRANSCRIPTION": "Call transcription", "COPILOT": { "TRY_THESE_PROMPTS": "Try these prompts" }, diff --git a/app/javascript/shared/components/FluentIcon/Icon.vue b/app/javascript/shared/components/FluentIcon/Icon.vue index f09e4d3f4..4749a0f4d 100644 --- a/app/javascript/shared/components/FluentIcon/Icon.vue +++ b/app/javascript/shared/components/FluentIcon/Icon.vue @@ -30,10 +30,30 @@ export default { computed: { pathSource() { // To support icons with multiple paths - const path = this.icons[`${this.icon}-${this.type}`]; - if (path.constructor === Array) { + const key = `${this.icon}-${this.type}`; + const path = this.icons[key]; + + // If not found, try default icon + if (path === undefined) { + const defaultKey = `call-${this.type}`; + const defaultPath = this.icons[defaultKey]; + + // If default icon also not found, return empty array to prevent errors + if (defaultPath === undefined) { + return []; + } + + if (Array.isArray(defaultPath)) { + return defaultPath; + } + + return [defaultPath]; + } + + if (Array.isArray(path)) { return path; } + return [path]; }, }, diff --git a/app/models/message.rb b/app/models/message.rb index 3250c4d0b..29a416428 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -102,9 +102,10 @@ class Message < ApplicationRecord # [:deleted] : Used to denote whether the message was deleted by the agent # [:external_created_at] : Can specify if the message was created at a different timestamp externally # [:external_error : Can specify if the message creation failed due to an error at external API + # [:data] : Used for structured content types such as voice_call store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted, :external_created_at, :story_sender, :story_id, :external_error, - :translations, :in_reply_to_external_id, :is_unsupported], coder: JSON + :translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id @@ -112,6 +113,7 @@ class Message < ApplicationRecord scope :chat, -> { where.not(message_type: :activity).where(private: false) } scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('id desc') } scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) } + scope :voice_calls, -> { where(content_type: :voice_call) } # TODO: Get rid of default scope # https://stackoverflow.com/a/1834250/939299 @@ -219,6 +221,16 @@ class Message < ApplicationRecord save! end + # For voice calls - convenience method to get status from content attributes + def voice_call_status + content_attributes.dig('data', 'status') + end + + # For voice calls - check if this is an active call + def active_voice_call? + voice_call? && !%w[completed failed busy no-answer canceled missed ended].include?(voice_call_status) + end + private def prevent_message_flooding @@ -400,4 +412,4 @@ class Message < ApplicationRecord end end -Message.prepend_mod_with('Message') +Message.prepend_mod_with('Message') \ No newline at end of file diff --git a/app/services/voice/conference_manager_service.rb b/app/services/voice/conference_manager_service.rb new file mode 100644 index 000000000..3bb259cf9 --- /dev/null +++ b/app/services/voice/conference_manager_service.rb @@ -0,0 +1,274 @@ +module Voice + class ConferenceManagerService + pattr_initialize [:conversation!, :event!, :call_sid!, :conference_sid, :participant_sid, :participant_label] + + def process + # Process the conference event + case event + when 'conference-start' + handle_conference_start + when 'conference-end' + handle_conference_end + when 'participant-join' + handle_participant_join + when 'participant-leave' + handle_participant_leave + end + + # Create activity message for the event + create_activity_message + + # Save conversation changes + conversation.save! + end + + private + + def message_service + @message_service ||= Voice::MessageUpdateService.new( + conversation: conversation, + call_sid: call_sid + ) + end + + def handle_conference_start + conversation.additional_attributes ||= {} + conversation.additional_attributes['conference_status'] = 'started' + conversation.additional_attributes['conference_started_at'] = Time.now.to_i + + # Log conference start + Rails.logger.info("🎧 CONFERENCE STARTED: conference_sid=#{conference_sid}") + + # Update call status to ringing if not already in a more advanced state + current_status = conversation.additional_attributes['call_status'] + Rails.logger.info("📞 CURRENT CALL STATUS: '#{current_status}'") + + if !%w[active in-progress completed].include?(current_status) + Rails.logger.info("📞 UPDATING CALL TO RINGING ON CONFERENCE START") + message_service.update_call_status('ringing') + message_service.update_voice_call_status('ringing') + + # Ensure we have metadata for debugging + conversation.additional_attributes['meta'] ||= {} + conversation.additional_attributes['meta']['ringing_at'] = Time.now.to_i + conversation.additional_attributes['meta']['conference_started_at'] = Time.now.to_i + conversation.save! + end + end + + def handle_conference_end + conversation.additional_attributes ||= {} + conversation.additional_attributes['conference_status'] = 'ended' + conversation.additional_attributes['conference_ended_at'] = Time.now.to_i + + # Log conference end + Rails.logger.info("🎧 CONFERENCE ENDED: conference_sid=#{conference_sid}") + + # Determine the final call status based on the current state + current_status = conversation.additional_attributes['call_status'] + Rails.logger.info("📞 CURRENT CALL STATUS AT CONFERENCE END: '#{current_status}'") + + if current_status == 'active' || current_status == 'in-progress' + # Call was active, mark as completed + duration = nil + if conversation.additional_attributes['call_started_at'] + duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i + Rails.logger.info("⏱️ CALCULATED CALL DURATION: #{duration} seconds") + end + + Rails.logger.info("📞 MARKING ACTIVE CALL AS COMPLETED") + message_service.update_call_status('completed', duration) + message_service.update_voice_call_status('ended', duration) + elsif current_status == 'ringing' + # Call never connected, mark as missed + Rails.logger.info("📞 MARKING RINGING CALL AS MISSED") + message_service.update_call_status('missed') + message_service.update_voice_call_status('missed') + else + # Default to completed status + Rails.logger.info("📞 MARKING CALL AS COMPLETED (DEFAULT)") + message_service.update_call_status('completed') + message_service.update_voice_call_status('ended') + end + + # Ensure metadata is updated + conversation.additional_attributes['meta'] ||= {} + conversation.additional_attributes['meta']['conference_ended_at'] = Time.now.to_i + + # Force update UI to show the change + ActionCable.server.broadcast( + "#{conversation.account_id}_#{conversation.inbox_id}", + { + event_name: 'call_status_changed', + data: { + call_sid: call_sid, + status: conversation.additional_attributes['call_status'], + conversation_id: conversation.id, + force_refresh: true + } + } + ) + + Rails.logger.info("📢 BROADCAST: Sent conference end notification") + end + + def handle_participant_join + # Track the participant + update_participant_info('joined') + + # Log the participant joining + Rails.logger.info("👥 PARTICIPANT JOINED: #{participant_label || 'unknown'} (#{participant_sid})") + + # Store participant join time based on type + if participant_label&.start_with?('agent') + conversation.additional_attributes['agent_joined_at'] = Time.now.to_i + Rails.logger.info("👤 AGENT JOINED AT: #{Time.now.to_i}") + + # If call is ringing when agent joins, mark as active + if conversation.additional_attributes['call_status'] == 'ringing' + Rails.logger.info("📞 UPDATING RINGING CALL TO ACTIVE (agent joined)") + message_service.update_call_status('active') + message_service.update_voice_call_status('active') + end + elsif participant_label&.start_with?('caller') + conversation.additional_attributes['caller_joined_at'] = Time.now.to_i + Rails.logger.info("👤 CALLER JOINED AT: #{Time.now.to_i}") + + # For outbound calls + if conversation.additional_attributes['call_direction'] == 'outbound' + # Mark call as active as soon as caller joins an outbound call + # This ensures the call doesn't get stuck in ringing + if conversation.additional_attributes['call_status'] == 'ringing' + Rails.logger.info("📞 UPDATING RINGING OUTBOUND CALL TO ACTIVE (caller joined)") + message_service.update_call_status('active') + message_service.update_voice_call_status('active') + end + end + else + # Generic participant (no label) + Rails.logger.info("👤 GENERIC PARTICIPANT JOINED") + + # If we're stuck in ringing, try to move forward + if conversation.additional_attributes['call_status'] == 'ringing' && + (Time.now.to_i - conversation.additional_attributes.dig('meta', 'ringing_at').to_i > 10) + Rails.logger.info("📞 UPDATING LONG-RINGING CALL TO ACTIVE (participant joined)") + message_service.update_call_status('active') + message_service.update_voice_call_status('active') + end + end + + # Check if both caller and agent have joined + if conversation.additional_attributes['agent_joined_at'] && + conversation.additional_attributes['caller_joined_at'] + # Ensure call is marked as active when both parties are present + if conversation.additional_attributes['call_status'] != 'active' + Rails.logger.info("📞 UPDATING CALL STATUS TO ACTIVE (both parties present)") + message_service.update_call_status('active') + message_service.update_voice_call_status('active') + end + end + end + + def handle_participant_leave + # Update participant tracking + update_participant_info('left') + + # Record leave time based on participant type + if participant_label&.start_with?('agent') + conversation.additional_attributes['agent_left_at'] = Time.now.to_i + elsif participant_label&.start_with?('caller') + conversation.additional_attributes['caller_left_at'] = Time.now.to_i + end + + # Handle caller leaving during ringing phase + if participant_label&.start_with?('caller') && + conversation.additional_attributes['call_status'] == 'ringing' + + # Check if any agent has joined + has_agent_joined = participant_has_joined?('agent') + + unless has_agent_joined + message_service.update_call_status('missed') + message_service.update_voice_call_status('missed') + end + end + + # Handle case where all participants have left but conference is still active + if all_participants_left? && + conversation.additional_attributes['conference_status'] != 'ended' && + conversation.additional_attributes['call_status'] == 'active' + + # Calculate duration if we can + duration = nil + if conversation.additional_attributes['call_started_at'] + duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i + end + + message_service.update_call_status('completed', duration) + message_service.update_voice_call_status('ended', duration) + end + end + + def update_participant_info(status) + # Initialize participants tracking + conversation.additional_attributes ||= {} + conversation.additional_attributes['participants'] ||= {} + + # Determine participant type from label + participant_type = participant_label&.start_with?('agent') ? 'agent' : 'caller' + + if status == 'joined' + # Add or update participant + conversation.additional_attributes['participants'][participant_sid] = { + 'joined_at' => Time.now.to_i, + 'type' => participant_type, + 'call_sid' => call_sid, + 'status' => 'joined' + } + elsif status == 'left' && conversation.additional_attributes['participants'].key?(participant_sid) + # Update existing participant + conversation.additional_attributes['participants'][participant_sid]['status'] = 'left' + conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i + end + end + + def participant_has_joined?(type) + participants = conversation.additional_attributes['participants'] || {} + + if participants.is_a?(Hash) + return participants.values.any? { |p| p['type'] == type && p['status'] == 'joined' } + end + + false + end + + def all_participants_left? + participants = conversation.additional_attributes['participants'] || {} + + if participants.is_a?(Hash) + return !participants.values.any? { |p| p['status'] == 'joined' } + end + + true # Default to true if no participants structure exists + end + + def create_activity_message + 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 + + message_service.create_activity_message(content) + end + end +end \ No newline at end of file diff --git a/app/services/voice/conference_status_service.rb b/app/services/voice/conference_status_service.rb index a833920d5..32765fe3f 100644 --- a/app/services/voice/conference_status_service.rb +++ b/app/services/voice/conference_status_service.rb @@ -3,15 +3,66 @@ module Voice pattr_initialize [:account!, :params!] def process + # Log all incoming parameters for debugging + Rails.logger.info("🎤 CONFERENCE STATUS PARAMS: #{params.to_unsafe_h.except('controller', 'action').to_json}") + find_conversation queue_status_processing if @conversation end def status_info + # Normalize the event name to match our expected format + raw_event = params['StatusCallbackEvent'] + + # Log the raw event to help with debugging + Rails.logger.info("🎧 RAW EVENT RECEIVED: '#{raw_event}'") + + # Convert Twilio's event formats to our standardized kebab-case + normalized_event = if raw_event.present? + # Clean up the string first - convert to lowercase and remove spaces + event_text = raw_event.downcase.gsub(/\s+/, '') + + # Handle all possible format variations from Twilio + case event_text + # Participant join events (camelCase, kebab-case, no dash) + when 'participant-join', 'participantjoin', 'participantjoined', 'participantjoin', 'participant_join' + 'participant-join' + + # Participant leave events (camelCase, kebab-case, no dash) + when 'participant-leave', 'participantleave', 'participantleft', 'participantleave', 'participant_leave' + 'participant-leave' + + # Conference start events (camelCase, kebab-case, no dash) + when 'conference-start', 'conferencestart', 'conferencestarted', 'conferencestart', 'conference_start' + 'conference-start' + + # Conference end events (camelCase, kebab-case, no dash) + when 'conference-end', 'conferenceend', 'conferenceended', 'conferenceend', 'conference_end' + 'conference-end' + + # Add other Twilio event variations if needed + + # For any other event, standardize to kebab-case + else + # Convert camelCase to kebab-case + kebab_case = event_text.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase + # Convert snake_case to kebab-case + kebab_case = kebab_case.gsub('_', '-') + # Remove any extra dashes + kebab_case = kebab_case.gsub(/--+/, '-') + kebab_case + end + else + # Default if no event is provided + 'unknown' + end + + Rails.logger.info("🎧 NORMALIZED EVENT: '#{raw_event}' -> '#{normalized_event}'") + { call_sid: params['CallSid'], conference_sid: params['ConferenceSid'], - event: params['StatusCallbackEvent'], + event: normalized_event, participant_sid: params['ParticipantSid'], participant_label: params['ParticipantLabel'], call_sid_ending_with: params['CallSidEndingWith'], @@ -29,6 +80,8 @@ module Voice @conversation = account.conversations .where("additional_attributes->>'conference_sid' = ?", status_info[:conference_sid]) .first + + Rails.logger.info("🔍 SEARCHING BY CONFERENCE_SID: #{status_info[:conference_sid]}") if @conversation.nil? end # If not found and conference_sid looks like our format, extract conversation ID @@ -46,6 +99,15 @@ module Voice @conversation = account.conversations .where("additional_attributes->>'call_sid' = ?", status_info[:call_sid]) .first + + Rails.logger.info("🔍 SEARCHING BY CALL_SID: #{status_info[:call_sid]}") if @conversation.nil? + end + + if @conversation + Rails.logger.info("✅ FOUND CONVERSATION: id=#{@conversation.id} display_id=#{@conversation.display_id}") + else + Rails.logger.error("❌ CONVERSATION NOT FOUND for call_sid=#{status_info[:call_sid]} conference_sid=#{status_info[:conference_sid]}") + return end # Update participant info if conversation found @@ -55,38 +117,39 @@ module Voice def update_participant_info # Initialize or get current participants list @conversation.additional_attributes ||= {} - @conversation.additional_attributes['participants'] ||= [] + @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 + participant_sid = status_info[:participant_sid] + return unless participant_sid.present? + + # Log the received event + Rails.logger.info("👥 PARTICIPANT EVENT: #{status_info[:event]} for #{participant_sid} (#{status_info[:participant_label]})") # 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 - } + case status_info[:event] + when 'participant-join' + # Add participant + @conversation.additional_attributes['participants'][participant_sid] = { + 'joined_at' => Time.now.to_i, + 'type' => status_info[:participant_label]&.start_with?('agent') ? 'agent' : 'caller', + 'call_sid' => status_info[:call_sid], + 'status' => 'joined' + } + + # Flag outbound calls that need agent join if this is a caller + if @conversation.additional_attributes['call_direction'] == 'outbound' && + status_info[:participant_label]&.start_with?('caller-') + # 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 + when 'participant-leave' + # 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 - 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 @@ -94,6 +157,27 @@ module Voice end def broadcast_agent_join_notification + # Get the contact, ensuring we still have one + contact = @conversation.contact + unless contact + # If contact is missing, try to find or create one based on info available + # This shouldn't normally happen but is a safeguard + phone_numbers = @conversation.messages.where(content_type: 'voice_call') + .map { |m| m.content_attributes.dig('data', 'to_number') }.compact.first + + if phone_numbers + contact = account.contacts.find_or_create_by(phone_number: phone_numbers) do |c| + c.name = "Contact from #{phone_numbers}" + end + # Update conversation with the new contact + @conversation.update(contact_id: contact.id) + else + # If we can't find a phone number, create a generic contact + contact = account.contacts.create!(phone_number: "unknown-#{Time.now.to_i}") + @conversation.update(contact_id: contact.id) + end + end + ActionCable.server.broadcast( "account_#{account.id}", { @@ -103,17 +187,22 @@ module Voice 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, + contact_name: contact.name || 'Outbound Call', + contact_id: contact.id, is_outbound: true, - account_id: account.id + account_id: account.id, + conference_sid: status_info[:conference_sid] } } ) + + Rails.logger.info("📣 BROADCAST: Sent agent join notification") end def queue_status_processing # Process the status update directly using the service + Rails.logger.info("📊 PROCESSING CONFERENCE EVENT: #{status_info[:event]}") + Voice::ConferenceStatusUpdateService.new( conversation: @conversation, event: status_info[:event], diff --git a/app/services/voice/conference_status_update_service.rb b/app/services/voice/conference_status_update_service.rb index 3c25b5374..96bcf2cbf 100644 --- a/app/services/voice/conference_status_update_service.rb +++ b/app/services/voice/conference_status_update_service.rb @@ -3,158 +3,15 @@ module Voice 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 + # Use the ConferenceManagerService to handle all conference events + Voice::ConferenceManagerService.new( + conversation: conversation, + event: event, + call_sid: call_sid, + conference_sid: conference_sid, + participant_sid: participant_sid, + participant_label: participant_label + ).process 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/conversation_finder_service.rb b/app/services/voice/conversation_finder_service.rb new file mode 100644 index 000000000..5a29629ae --- /dev/null +++ b/app/services/voice/conversation_finder_service.rb @@ -0,0 +1,93 @@ +module Voice + class ConversationFinderService + pattr_initialize [:account!, :phone_number!, :inbox, :call_sid, :is_outbound] + + def perform + # Ensure we have a phone number + validate_and_normalize_phone_number + + # First try to find existing conversation by call_sid if available + conversation = find_by_call_sid if call_sid.present? + return conversation if conversation + + # If not found, create a new conversation + create_new_conversation + end + + private + + def validate_and_normalize_phone_number + # Simple validation to ensure we have something to work with + raise "Phone number cannot be blank" if phone_number.blank? + + # Normalize the phone number (strip any whitespace) + @phone_number = phone_number.strip + end + + def find_by_call_sid + account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first + end + + def find_or_create_contact + # Always find or create a contact based on the phone number + account.contacts.find_or_create_by!(phone_number: phone_number) do |c| + c.name = "Contact from #{phone_number}" + end + end + + def create_new_conversation + # First ensure we have a contact + contact = find_or_create_contact + + # Find or initialize the contact inbox + contact_inbox = ContactInbox.find_or_initialize_by( + contact_id: contact.id, + inbox_id: inbox.id + ) + + # Set source_id if not set - needed for properly mapping the conversation + contact_inbox.source_id ||= phone_number + contact_inbox.save! + + # Create the conversation + conversation = account.conversations.create!( + contact_inbox_id: contact_inbox.id, + inbox_id: inbox.id, + contact_id: contact.id, # Explicitly set the contact_id to avoid any validation issues + status: :open, + additional_attributes: initial_attributes + ) + + # Add conference_sid to attributes + conference_name = generate_conference_name(conversation) + conversation.additional_attributes['conference_sid'] = conference_name + conversation.save! + + conversation + end + + def initial_attributes + attributes = { + 'call_status' => 'in-progress', + 'call_initiated_at' => Time.now.to_i + } + + # Add call_sid if available + attributes['call_sid'] = call_sid if call_sid.present? + + if is_outbound + attributes['call_direction'] = 'outbound' + attributes['call_type'] = 'outbound' + else + attributes['call_direction'] = 'inbound' + attributes['call_type'] = 'inbound' + end + + attributes + end + + def generate_conference_name(conversation) + "conf_account_#{account.id}_conv_#{conversation.display_id}" + 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 index cc81fec88..a6f63d5b5 100644 --- a/app/services/voice/incoming_call_service.rb +++ b/app/services/voice/incoming_call_service.rb @@ -3,10 +3,25 @@ module Voice pattr_initialize [:account!, :params!] def process - create_contact - create_conversation - create_conversation_messages - generate_twiml_response + Rails.logger.info("🔍 INCOMING CALL: Starting processing for call_sid=#{caller_info[:call_sid]}") + Rails.logger.info("📞 CALL DETAILS: From=#{caller_info[:from_number]} To=#{caller_info[:to_number]}") + + begin + find_inbox + create_contact + create_conversation + create_voice_call_message + twiml = generate_twiml_response + + Rails.logger.info("✅ INCOMING CALL: Successfully processed for call_sid=#{caller_info[:call_sid]}") + return twiml + rescue StandardError => e + Rails.logger.error("❌ INCOMING CALL ERROR: #{e.message}") + Rails.logger.error("❌ INCOMING CALL BACKTRACE: #{e.backtrace[0..5].join("\n")}") + + # Return a simple error TwiML + return error_twiml(e.message) + end end def caller_info @@ -19,25 +34,50 @@ module Voice private + def find_inbox + # Find the inbox for this phone number + @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 + + raise "Inbox not found for phone number #{caller_info[:to_number]}" unless @inbox.present? + + Rails.logger.info("📥 FOUND INBOX: inbox_id=#{@inbox.id} for phone=#{caller_info[:to_number]}") + end + 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]}" + # Normalize the phone number + phone_number = caller_info[:from_number].strip + + # Find or create the contact + @contact = account.contacts.find_or_create_by!(phone_number: phone_number) do |c| + c.name = "Contact from #{phone_number}" end + + Rails.logger.info("👤 CONTACT: contact_id=#{@contact.id} name=#{@contact.name} phone=#{@contact.phone_number}") 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 + @contact_inbox = ContactInbox.find_or_initialize_by( + contact_id: @contact.id, + inbox_id: @inbox.id + ) + + # Set source_id if not already set + @contact_inbox.source_id ||= caller_info[:from_number] + @contact_inbox.save! + + Rails.logger.info("📬 CONTACT INBOX: id=#{@contact_inbox.id} source_id=#{@contact_inbox.source_id}") # Create a new conversation with call details @conversation = account.conversations.create!( - contact_inbox_id: contact_inbox.id, + contact_inbox_id: @contact_inbox.id, inbox_id: @inbox.id, + contact_id: @contact.id, status: :open, - contact: @contact, additional_attributes: { 'call_sid' => caller_info[:call_sid], 'call_status' => 'ringing', @@ -52,47 +92,53 @@ module Voice @conversation.additional_attributes['conference_sid'] = conference_name @conversation.save! - Rails.logger.info("🎧 Creating conference: #{conference_name} for account: #{account.id}, conversation: #{@conversation.display_id}") + Rails.logger.info("💬 CONVERSATION: id=#{@conversation.id} display_id=#{@conversation.display_id} conference=#{conference_name}") 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 - } + def create_voice_call_message + # Create a single voice call message from contact for this call + message_params = { + content: 'Voice Call', + message_type: 'incoming', + content_type: 'voice_call', + content_attributes: { + data: { + call_sid: caller_info[:call_sid], + status: 'ringing', + conversation_id: @conversation.id, + call_direction: 'inbound', + conference_sid: @conversation.additional_attributes['conference_sid'], + from_number: caller_info[:from_number], + to_number: caller_info[:to_number], + meta: { + created_at: Time.now.to_i, + ringing_at: Time.now.to_i } } } + } + + # Create the message + @voice_call_message = Messages::MessageBuilder.new( + @contact, + @conversation, + message_params ).perform - - # Create a simple activity message (no sender needed) - Messages::MessageBuilder.new( - nil, # Activity messages don't need a sender + + Rails.logger.info("✉️ VOICE CALL MESSAGE: id=#{@voice_call_message.id} content_type=#{@voice_call_message.content_type}") + + # Create an activity message for the incoming call + activity_message = Messages::MessageBuilder.new( + nil, @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' - } + message_type: :activity } ).perform - + + Rails.logger.info("📝 ACTIVITY MESSAGE: id=#{activity_message.id}") + # Broadcast call notification broadcast_call_status end @@ -113,6 +159,8 @@ module Voice } } ) + + Rails.logger.info("📢 BROADCAST: Sent incoming_call notification") end def generate_twiml_response @@ -121,6 +169,9 @@ module Voice response = Twilio::TwiML::VoiceResponse.new response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.') + callback_url = "#{base_url}/api/v1/accounts/#{account.id}/channels/voice/webhooks/conference_status" + Rails.logger.info("🔗 CONFERENCE CALLBACK URL: #{callback_url}") + response.dial do |dial| dial.conference( conference_name, @@ -129,35 +180,30 @@ module Voice beep: false, muted: false, waitUrl: '', - statusCallback: "#{base_url.gsub(/\/$/, '')}/api/v1/accounts/#{account.id}/channels/voice/webhooks/conference_status", + statusCallback: callback_url, statusCallbackMethod: 'POST', statusCallbackEvent: 'start end join leave', participantLabel: "caller-#{caller_info[:call_sid].last(8)}" ) end + Rails.logger.info("📞 TWIML: Generated conference TwiML for #{conference_name}") 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 + def error_twiml(message) + response = Twilio::TwiML::VoiceResponse.new + response.say(message: 'We are experiencing technical difficulties with our phone system. Please try again later.') + response.hangup + + Rails.logger.info("❌ ERROR TWIML: Generated error TwiML due to: #{message}") + response.to_s end def base_url - ENV.fetch('FRONTEND_URL', "https://#{params['host_with_port']}") + url = ENV.fetch('FRONTEND_URL', "https://#{params['host_with_port']}") + Rails.logger.info("🌐 BASE URL: Using #{url}") + url.gsub(/\/$/, '') # Remove trailing slash if present end end end \ No newline at end of file diff --git a/app/services/voice/message_update_service.rb b/app/services/voice/message_update_service.rb new file mode 100644 index 000000000..ccc5a8347 --- /dev/null +++ b/app/services/voice/message_update_service.rb @@ -0,0 +1,212 @@ +module Voice + class MessageUpdateService + pattr_initialize [:conversation!, :call_sid] + + def update_voice_call_status(status, duration = nil) + message = find_voice_call_message + return unless message + + # Log message found for debugging + Rails.logger.info("📱 UPDATE VOICE CALL STATUS: Found message: #{message.id}, updating status: #{status}") + + # Get current content attributes, initialize if needed + content_attributes = message.content_attributes || {} + content_attributes['data'] ||= {} + + # Log previous status + previous_status = content_attributes['data']['status'] + Rails.logger.info("📱 PREVIOUS STATUS: #{previous_status} -> NEW STATUS: #{status}") + + # Update fields + content_attributes['data']['status'] = status + content_attributes['data']['duration'] = duration if duration + content_attributes['data']['meta'] ||= {} + content_attributes['data']['meta']["#{status}_at"] = Time.now.to_i + content_attributes['data']['updated_at'] = Time.now.to_i + + # Add a flag to force the UI to refresh + content_attributes['data']['status_updated'] = Time.now.to_i + + # Save the message with a rescue to ensure we get error details if it fails + begin + result = message.update(content_attributes: content_attributes) + if result + Rails.logger.info("✅ VOICE CALL STATUS UPDATED: Message #{message.id} status: #{status}") + else + Rails.logger.error("❌ VOICE CALL STATUS UPDATE FAILED: #{message.errors.full_messages.join(', ')}") + end + rescue => e + Rails.logger.error("❌ VOICE CALL STATUS UPDATE ERROR: #{e.message}") + Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..3].join("\n")}") + end + + message + end + + def find_voice_call_message + # First try to find by call_sid + message = nil + + if call_sid.present? + # Try to find by exact call_sid match + Rails.logger.info("🔍 SEARCHING FOR VOICE CALL MESSAGE BY CALL_SID: #{call_sid}") + message = conversation.messages + .where(content_type: 'voice_call') + .where("content_attributes->'data'->>'call_sid' = ?", call_sid) + .first + end + + # If not found, try by looking for a call_sid that contains our call_sid (Twilio sometimes sends partial SIDs) + if message.nil? && call_sid.present? + Rails.logger.info("🔍 SEARCHING FOR VOICE CALL MESSAGE BY PARTIAL CALL_SID MATCH: #{call_sid}") + # Look for messages where call_sid is a substring + last_few_chars = call_sid.last(8) + messages = conversation.messages + .where(content_type: 'voice_call') + .order(created_at: :desc) + + # Manually check for partial matches in content_attributes + message = messages.find do |msg| + stored_call_sid = msg.content_attributes.dig('data', 'call_sid') + stored_call_sid.present? && (stored_call_sid.include?(call_sid) || call_sid.include?(stored_call_sid)) + end + end + + # If still not found, get the most recent voice call message + if message.nil? + Rails.logger.info("🔍 USING MOST RECENT VOICE CALL MESSAGE AS FALLBACK") + message = conversation.messages + .where(content_type: 'voice_call') + .order(created_at: :desc) + .first + end + + if message + Rails.logger.info("✅ FOUND VOICE CALL MESSAGE: #{message.id}") + else + Rails.logger.error("❌ NO VOICE CALL MESSAGE FOUND FOR CONVERSATION: #{conversation.id}") + end + + message + end + + def create_activity_message(content) + # Create a simple activity message without additional attributes + Messages::MessageBuilder.new( + nil, + conversation, + { + content: content, + message_type: :activity + } + ).perform + end + + def update_call_status(status, duration = nil) + # Update conversation attributes + conversation.additional_attributes ||= {} + + # Only update if status is changing + previous_status = conversation.additional_attributes['call_status'] + if previous_status == status + Rails.logger.info("🔄 CALL STATUS UNCHANGED: Already in state '#{status}', no update needed") + return + end + + # Log the status change + Rails.logger.info("📞 CALL STATUS UPDATE: '#{previous_status}' -> '#{status}'") + + # Update the status + conversation.additional_attributes['call_status'] = status + + # Add timestamps and metadata based on status + if status == 'in-progress' || status == 'active' + # Record the start time if not already set + if !conversation.additional_attributes['call_started_at'] + conversation.additional_attributes['call_started_at'] = Time.now.to_i + Rails.logger.info("⏱️ CALL STARTED AT: #{Time.now.to_i}") + end + + # Ensure we have call meta data + conversation.additional_attributes['meta'] ||= {} + conversation.additional_attributes['meta']['active_at'] = Time.now.to_i + + # For active calls, update the UI immediately + notify_call_status_change(status) + elsif call_ended?(status) + # Record end time + conversation.additional_attributes['call_ended_at'] = Time.now.to_i + Rails.logger.info("⏱️ CALL ENDED AT: #{Time.now.to_i}") + + # Calculate and record duration + if duration + conversation.additional_attributes['call_duration'] = duration + Rails.logger.info("⏱️ CALL DURATION (provided): #{duration} seconds") + elsif conversation.additional_attributes['call_started_at'] + conversation.additional_attributes['call_duration'] = + Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i + Rails.logger.info("⏱️ CALL DURATION (calculated): #{conversation.additional_attributes['call_duration']} seconds") + end + + # Add call end metadata + conversation.additional_attributes['meta'] ||= {} + conversation.additional_attributes['meta']["#{status}_at"] = Time.now.to_i + + # Mark conversation as resolved for ended calls + conversation.status = :resolved + Rails.logger.info("✅ MARKING CONVERSATION AS RESOLVED: conversation_id=#{conversation.id}") + end + + # Save the conversation + begin + result = conversation.save! + Rails.logger.info("💾 SAVED CONVERSATION SUCCESSFULLY: conversation_id=#{conversation.id}") + rescue => e + Rails.logger.error("❌ FAILED TO SAVE CONVERSATION: #{e.message}") + Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..3].join("\n")}") + end + + # Broadcast status update for active and ended calls + notify_call_status_change(status) if call_ended?(status) || status == 'active' + end + + def call_ended?(status) + %w[completed busy failed no-answer canceled missed].include?(status) + end + + def notify_call_status_change(status) + # For consistency, ensure the conversation values match the notification + # Sometimes we might have multiple events coming in and want to ensure the final state + # is reflected correctly in the UI + conversation.reload + + # If the conversation has a different status than what we're notifying about, + # use the conversation's status (it may have been updated in another operation) + final_status = status + if conversation.additional_attributes['call_status'] != status + final_status = conversation.additional_attributes['call_status'] + Rails.logger.info("⚠️ STATUS MISMATCH: Notifying: '#{status}', Conversation: '#{final_status}', using conversation value") + end + + # Construct the notification payload + notification = { + event_name: 'call_status_changed', + data: { + call_sid: call_sid, + status: final_status, + conversation_id: conversation.id, + timestamp: Time.now.to_i + } + } + + # Log the notification for debugging + Rails.logger.info("📢 BROADCASTING CALL STATUS: '#{final_status}' for conversation_id=#{conversation.id}") + + # Send the notification + ActionCable.server.broadcast( + "#{conversation.account_id}_#{conversation.inbox_id}", + notification + ) + 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 index a22d321ae..1fc584646 100644 --- a/app/services/voice/outgoing_call_service.rb +++ b/app/services/voice/outgoing_call_service.rb @@ -6,7 +6,7 @@ module Voice find_voice_inbox create_conversation initiate_call - create_conversation_messages + create_voice_call_message broadcast_to_agent @conversation end @@ -20,35 +20,17 @@ module Voice 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 - ) + # Use the ConversationFinderService to create the conversation + @conversation = Voice::ConversationFinderService.new( + account: account, + phone_number: contact.phone_number, + is_outbound: true, + inbox: @voice_inbox, + call_sid: nil # This will be set after call is initiated + ).perform - # 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}" + @conference_name = @conversation.additional_attributes['conference_sid'] end def initiate_call @@ -59,51 +41,55 @@ module Voice 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 + updated_attributes = @conversation.additional_attributes.merge({ + 'call_sid' => @call_details[:call_sid], + 'call_status' => 'in-progress', + 'requires_agent_join' => true, + '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 - } + def create_voice_call_message + # Create a voice call message + message_params = { + content: 'Voice Call', + message_type: 'outgoing', + content_type: 'voice_call', + content_attributes: { + data: { + call_sid: @call_details[:call_sid], + status: 'ringing', + conversation_id: @conversation.id, + call_direction: 'outbound', + conference_sid: @conference_name, + from_number: @voice_inbox.channel.phone_number, + to_number: contact.phone_number, + agent_id: user.id, + meta: { + created_at: Time.now.to_i, + ringing_at: Time.now.to_i } - }, - sender: user + } } + } + + # Create the message + @widget_message = Messages::MessageBuilder.new( + user, + @conversation, + message_params ).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 + # Create an activity message for the outgoing call + message_service = Voice::MessageUpdateService.new( + conversation: @conversation, + call_sid: @call_details[:call_sid] + ) + + message_service.create_activity_message("Outgoing call to #{contact.name || contact.phone_number}") # Update last activity timestamp @conversation.update(last_activity_at: Time.current) diff --git a/app/services/voice/recording_service.rb b/app/services/voice/recording_service.rb new file mode 100644 index 000000000..0355a0ccd --- /dev/null +++ b/app/services/voice/recording_service.rb @@ -0,0 +1,91 @@ +module Voice + class RecordingService + pattr_initialize [:conversation!, :recording_url!, :recording_sid!, :call_sid] + + def process + # Skip if already processed + return if recording_already_processed? + + # Create message from the recording + message = create_recording_message + + # Download and attach the recording + attach_recording_to_message(message) + + message + end + + private + + def recording_already_processed? + conversation.messages.where('additional_attributes @> ?', { recording_sid: recording_sid }.to_json).exists? + end + + def create_recording_message + contact = conversation.contact + return nil unless contact + + message_params = { + content: 'Voice Recording', + message_type: :incoming, + additional_attributes: { + call_sid: call_sid, + recording_url: recording_url, + recording_sid: recording_sid + } + } + + Messages::MessageBuilder.new(contact, conversation, message_params).perform + end + + def attach_recording_to_message(message) + return unless message && valid_recording_url? + + begin + # Get authentication details from the inbox channel + config = conversation.inbox.channel.provider_config_hash + account_sid = config['account_sid'] + auth_token = config['auth_token'] + + # Download the MP3 version of the recording + recording_mp3_url = "#{recording_url}.mp3" + download_file = Down.download( + recording_mp3_url, + http_basic_authentication: [account_sid, auth_token] + ) + + # Create the attachment + attachment = message.attachments.new( + file_type: :audio, + account_id: conversation.account_id, + extension: 'mp3', + fallback_title: 'Voice Recording', + meta: { + recording_sid: recording_sid, + twilio_account_sid: account_sid, + auth_required: true + } + ) + + # Attach the file + attachment.file.attach( + io: download_file, + filename: "#{recording_sid}.mp3", + content_type: 'audio/mpeg' + ) + + attachment.save! + rescue StandardError => e + Rails.logger.error("Error attaching recording: #{e.message}") + end + end + + def valid_recording_url? + # Validate that the URL is a proper Twilio recording URL + uri = URI.parse(recording_url) + return false unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + + recording_url.include?('/Recordings/') && recording_sid.present? + end + end +end \ No newline at end of file diff --git a/app/services/voice/twilio_call_status_service.rb b/app/services/voice/twilio_call_status_service.rb new file mode 100644 index 000000000..54e559285 --- /dev/null +++ b/app/services/voice/twilio_call_status_service.rb @@ -0,0 +1,61 @@ +module Voice + class TwilioCallStatusService + pattr_initialize [:conversation!, :call_sid!, :call_status!, :is_outbound, :duration] + + CALL_STATUS_MESSAGES = { + 'initiated' => { outbound: 'Outbound call initiated', inbound: 'Initiating call' }, + 'ringing' => { outbound: 'Phone ringing', inbound: 'Phone ringing' }, + 'in-progress' => { + outbound: { first: 'Call connected', next: 'Call in progress' }, + inbound: { first: 'Call answered', next: 'Call in progress' } + }, + 'completed' => { outbound: 'Call completed', inbound: 'Call completed' }, + 'busy' => { outbound: 'Call busy', inbound: 'Call busy' }, + 'failed' => { outbound: 'Call failed', inbound: 'Call failed' }, + 'no-answer' => { outbound: 'Call not answered', inbound: 'Call not answered' }, + 'canceled' => { outbound: 'Call canceled', inbound: 'Call canceled' } + }.freeze + + def process(is_first_response = false) + # Skip if no changes needed + prev_status = conversation.additional_attributes&.dig('call_status') + return if !is_first_response && prev_status == call_status + + # Update conversation status using shared service + message_service.update_call_status(call_status, duration) + + # Create activity message + create_activity_message(is_first_response) + + # Update voice call message + message_service.update_voice_call_status(call_status, duration) + end + + private + + def message_service + @message_service ||= Voice::MessageUpdateService.new( + conversation: conversation, + call_sid: call_sid + ) + end + + def create_activity_message(is_first_response) + activity_message = activity_message_for_status(is_first_response) + message_service.create_activity_message(activity_message) + end + + def activity_message_for_status(is_first_response) + call_direction = is_outbound ? :outbound : :inbound + + if call_status == 'in-progress' + message_type = is_first_response ? :first : :next + return CALL_STATUS_MESSAGES[call_status][call_direction][message_type] + elsif CALL_STATUS_MESSAGES.key?(call_status) + return CALL_STATUS_MESSAGES[call_status][call_direction] + else + return "Call status: #{call_status}" + end + 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 index 2f04db301..b614fbee9 100644 --- a/app/services/voice/twilio_validator_service.rb +++ b/app/services/voice/twilio_validator_service.rb @@ -7,50 +7,84 @@ module Voice return true if request.method == "OPTIONS" # Skip validation for local development - return true if Rails.env.development? + if Rails.env.development? + Rails.logger.info("🔑 TWILIO VALIDATION: Skipping in development environment") + return true + end + + # Skip if we're missing account information + if account.blank? + Rails.logger.warn("⚠️ TWILIO VALIDATION: No account provided, allowing request") + return true + end # Skip if no To param (happens in some callback scenarios) to_number = params['To'] - return true if to_number.blank? + if to_number.blank? + Rails.logger.warn("⚠️ TWILIO VALIDATION: No 'To' parameter in request, allowing for callbacks") + return true + end 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") + Rails.logger.warn("⚠️ TWILIO VALIDATION: No inbox found for phone number #{to_number}, allowing request") 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") + Rails.logger.warn("⚠️ TWILIO VALIDATION: Channel is not a voice channel, allowing request") return true end - auth_token = channel.provider_config_hash['auth_token'] - + provider_config = channel.provider_config_hash + + # Check for auth token presence + if provider_config.blank? || provider_config['auth_token'].blank? + Rails.logger.warn("⚠️ TWILIO VALIDATION: No auth token available in provider config, allowing request") + return true + end + + auth_token = provider_config['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") + Rails.logger.warn("⚠️ TWILIO VALIDATION: 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}" + + # Log validation attempt + Rails.logger.info("🔐 TWILIO VALIDATION: Validating signature for URL: #{url}") + is_valid = validator.validate(url, params.to_unsafe_h, signature) - unless is_valid - Rails.logger.error("⚠️ Invalid Twilio signature detected") + if is_valid + Rails.logger.info("✅ TWILIO VALIDATION: Valid signature confirmed") + else + Rails.logger.error("⚠️ TWILIO VALIDATION: Invalid signature detected") + + # For debugging, log details about the validation + Rails.logger.error("📋 TWILIO VALIDATION DETAILS:") + Rails.logger.error("URL: #{url}") + Rails.logger.error("Signature: #{signature}") + Rails.logger.error("Auth Token: #{auth_token[0..3]}...") # Only log first few chars for security + + # Still return false for invalid signatures return false end - rescue => e - Rails.logger.error("Error validating Twilio signature: #{e.message}") + rescue StandardError => e + Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}") # Always allow callbacks even if validation fails return true end @@ -61,11 +95,21 @@ module Voice 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 + return nil if to_number.blank? + + 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 = ?', to_number) + .first + + if inbox + Rails.logger.info("📥 TWILIO VALIDATION: Found inbox id=#{inbox.id} for phone=#{to_number}") + else + Rails.logger.warn("⚠️ TWILIO VALIDATION: No inbox found for phone=#{to_number}") + end + + inbox end end end \ No newline at end of file
+ +
{{ lastNonActivityMessageContent }}