chore: clean up voice message components
This commit is contained in:
@@ -94,3 +94,4 @@ yarn-debug.log*
|
||||
|
||||
# Claude.ai config file
|
||||
CLAUDE.md
|
||||
**/.claude/settings.local.json
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+260
-2
@@ -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(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex items-end w-full gap-2 pb-1">
|
||||
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
<!-- Voice Call Message -->
|
||||
<div
|
||||
v-if="isVoiceCall"
|
||||
class="w-full mb-0 text-sm flex items-center gap-1 pt-0.5"
|
||||
:class="{
|
||||
'text-green-600 dark:text-green-400': normalizedCallStatus === 'ringing',
|
||||
'text-woot-600 dark:text-woot-400': normalizedCallStatus === 'active',
|
||||
'text-red-600 dark:text-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
|
||||
'text-slate-600 dark:text-slate-400': normalizedCallStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Explicit icon based on call status - force specific icons instead of computed properties -->
|
||||
<i v-if="normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer'"
|
||||
class="i-ph-phone-x-fill text-base inline-block flex-shrink-0 text-red-600 dark:text-red-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="normalizedCallStatus === 'active'"
|
||||
class="i-ph-phone-call-fill text-base inline-block flex-shrink-0 text-woot-600 dark:text-woot-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="normalizedCallStatus === 'ended' || normalizedCallStatus === 'completed'"
|
||||
class="i-ph-phone-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
<span class="text-current truncate">{{ callStatusText }}</span>
|
||||
<span
|
||||
v-if="normalizedCallStatus === 'ringing'"
|
||||
class="flex-shrink-0 text-xs font-medium text-green-600 dark:text-green-400"
|
||||
>
|
||||
({{ t('CONVERSATION.VOICE_CALL.JOIN_CALL') }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Regular Message -->
|
||||
<p v-else class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
@@ -64,3 +303,22 @@ const unreadMessagesCount = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Animation for ringing calls */
|
||||
.pulse-animation {
|
||||
animation: icon-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+222
-1
@@ -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({
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
|
||||
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
<!-- Voice Call Message display with icon -->
|
||||
<div
|
||||
v-if="isVoiceCall"
|
||||
class="flex items-center gap-1 mb-0 text-sm line-clamp-1"
|
||||
:class="{
|
||||
'text-green-600 dark:text-green-400': normalizedCallStatus === 'ringing',
|
||||
'text-woot-600 dark:text-woot-400': normalizedCallStatus === 'active',
|
||||
'text-red-600 dark:text-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
|
||||
'text-slate-600 dark:text-slate-400': normalizedCallStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Explicit icon based on call status - force specific icons instead of computed properties -->
|
||||
<i v-if="normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer'"
|
||||
class="i-ph-phone-x-fill text-base inline-block flex-shrink-0 text-red-600 dark:text-red-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="normalizedCallStatus === 'active'"
|
||||
class="i-ph-phone-call-fill text-base inline-block flex-shrink-0 text-woot-600 dark:text-woot-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="normalizedCallStatus === 'ended' || normalizedCallStatus === 'completed'"
|
||||
class="i-ph-phone-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<span class="text-current truncate">{{ callStatusText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Regular Message display -->
|
||||
<p v-else class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
@@ -105,3 +307,22 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Animation for ringing calls */
|
||||
.pulse-animation {
|
||||
animation: icon-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+98
-2
@@ -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 => {
|
||||
<template>
|
||||
<div
|
||||
role="button"
|
||||
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
|
||||
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer relative"
|
||||
:class="{
|
||||
'border-l-2 border-green-500 dark:border-green-400': isRingingCall,
|
||||
'border-l-2 border-woot-500 dark:border-woot-400': isActiveCall,
|
||||
'border-l-2 border-red-500 dark:border-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
|
||||
'conversation-ringing': isRingingCall
|
||||
}"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<!-- Ringing call indicator (pulse effect) -->
|
||||
<div
|
||||
v-if="isRingingCall"
|
||||
class="absolute left-0 top-0 bottom-0 w-0.5 bg-green-500 dark:bg-green-400 animate-pulse"
|
||||
></div>
|
||||
|
||||
<Avatar
|
||||
:name="currentContactName"
|
||||
:src="currentContactThumbnail"
|
||||
@@ -111,7 +189,7 @@ const onCardClick = e => {
|
||||
<!-- Special handling for voice channel -->
|
||||
<span
|
||||
v-if="inbox.channelType === 'Channel::Voice'"
|
||||
class="i-ph-phone-fill text-n-slate-11 size-3"
|
||||
class="i-ph-phone-fill text-n-slate-11 size-3 inline-block"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
@@ -137,3 +215,21 @@ const onCardClick = e => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conversation-ringing {
|
||||
animation: border-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes border-pulse {
|
||||
0% {
|
||||
border-color: rgba(34, 197, 94, 0.8); /* Green for ringing */
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
100% {
|
||||
border-color: rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
<Component :is="componentToRender" />
|
||||
<Component :is="componentToRender" :message="props" />
|
||||
</div>
|
||||
<MessageError
|
||||
v-if="contentAttributes.externalError"
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex-col border border-slate-100 dark:border-slate-700 rounded-lg overflow-hidden w-full max-w-xs"
|
||||
:class="statusClass"
|
||||
>
|
||||
<div class="flex items-center p-3 gap-3 w-full">
|
||||
<!-- Call Icon -->
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center size-10 rounded-full"
|
||||
:class="iconBgClass"
|
||||
>
|
||||
<span
|
||||
:class="[iconName, 'text-white text-xl']"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- Call Info -->
|
||||
<div class="flex flex-col flex-grow overflow-hidden">
|
||||
<span class="text-base font-medium" :class="labelTextClass">
|
||||
{{ labelText }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-500">
|
||||
{{ subtextWithDuration }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
:src="recordingUrl"
|
||||
preload="metadata"
|
||||
@ended="handlePlaybackEnd"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useVoiceCallHelpers } from 'dashboard/composables/useVoiceCallHelpers';
|
||||
|
||||
export default {
|
||||
name: 'VoiceCallBubble',
|
||||
components: {
|
||||
},
|
||||
inject: ['$emit'],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
internalStatus: '',
|
||||
refreshInterval: null,
|
||||
statusCheckInterval: null,
|
||||
isAnimating: false,
|
||||
recordingUrl: '',
|
||||
isPlaying: false,
|
||||
};
|
||||
},
|
||||
setup(props) {
|
||||
// Initialize our composable for use in methods
|
||||
const {
|
||||
normalizeCallStatus,
|
||||
isIncomingCall,
|
||||
getCallIconName,
|
||||
getStatusText
|
||||
} = useVoiceCallHelpers({ conversation: props.message?.conversation }, {
|
||||
t: (key) => {
|
||||
// This is a simple passthrough for the t function since we're in options API
|
||||
// In setup() we can't access this.$t directly
|
||||
return key;
|
||||
}
|
||||
});
|
||||
|
||||
// Expose these helpers to the component instance
|
||||
return {
|
||||
normalizeCallHelper: normalizeCallStatus,
|
||||
checkIsIncoming: isIncomingCall,
|
||||
getCallIconHelper: getCallIconName,
|
||||
getStatusTextHelper: getStatusText
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
callData() {
|
||||
return this.message?.contentAttributes?.data || {};
|
||||
},
|
||||
|
||||
directionalStatus() {
|
||||
const direction = this.callData?.call_direction;
|
||||
if (direction) {
|
||||
return direction === 'inbound' ? 'inbound' : 'outbound';
|
||||
}
|
||||
return this.message?.messageType === 0 ? 'inbound' : 'outbound';
|
||||
},
|
||||
|
||||
isIncoming() {
|
||||
return this.directionalStatus === 'inbound';
|
||||
},
|
||||
|
||||
isOutgoing() {
|
||||
return this.directionalStatus === 'outbound';
|
||||
},
|
||||
|
||||
status() {
|
||||
// Use internal status if we have one (from UI updates)
|
||||
if (this.internalStatus) {
|
||||
return this.internalStatus;
|
||||
}
|
||||
|
||||
// First check for direct call_status in the conversation additional_attributes
|
||||
// This is the most authoritative source for call status
|
||||
const conversationCallStatus = this.message?.conversation?.additional_attributes?.call_status;
|
||||
if (conversationCallStatus) {
|
||||
// Use our composable helper for status normalization
|
||||
return this.normalizeCallHelper(conversationCallStatus, this.isIncoming);
|
||||
}
|
||||
|
||||
// Use the status from call data if present
|
||||
const callStatus = this.callData?.status;
|
||||
if (callStatus) {
|
||||
// Use our composable helper for status normalization
|
||||
return this.normalizeCallHelper(callStatus, this.isIncoming);
|
||||
}
|
||||
|
||||
// Determine status from timestamps
|
||||
if (this.callData?.ended_at) {
|
||||
return 'ended';
|
||||
}
|
||||
if (this.callData?.missed) {
|
||||
return this.isIncoming ? 'missed' : 'no-answer';
|
||||
}
|
||||
|
||||
// Check both message data and conversation data for started_at
|
||||
if (this.callData?.started_at ||
|
||||
this.message?.conversation?.additional_attributes?.call_started_at) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// Default to ringing
|
||||
return 'ringing';
|
||||
},
|
||||
|
||||
formattedDuration() {
|
||||
if (
|
||||
this.callData?.started_at &&
|
||||
(this.status === 'active' || this.status === 'ended')
|
||||
) {
|
||||
const startTime = new Date(this.callData.started_at);
|
||||
const endTime = this.callData?.ended_at
|
||||
? new Date(this.callData.ended_at)
|
||||
: new Date();
|
||||
|
||||
const durationMs = endTime - startTime;
|
||||
return this.formatDuration(durationMs);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
statusClass() {
|
||||
return {
|
||||
'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100': !this.isInbox,
|
||||
'bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100': this.isInbox,
|
||||
'call-ringing': this.status === 'ringing',
|
||||
};
|
||||
},
|
||||
|
||||
iconName() {
|
||||
// Use our composable helper for icon selection
|
||||
return this.getCallIconHelper(this.status, this.isIncoming);
|
||||
},
|
||||
|
||||
iconBgClass() {
|
||||
// Icon background colors based on status
|
||||
if (this.status === 'active') {
|
||||
return 'bg-green-500'; // Green for calls in progress
|
||||
}
|
||||
|
||||
if (this.status === 'missed' || this.status === 'no-answer') {
|
||||
return 'bg-red-500'; // Red for missed calls
|
||||
}
|
||||
|
||||
if (this.status === 'ended') {
|
||||
return 'bg-purple-500'; // Purple for ended calls
|
||||
}
|
||||
|
||||
// Default green for ringing
|
||||
return 'bg-green-500 pulse-animation';
|
||||
},
|
||||
|
||||
labelText() {
|
||||
// Use our composable helper to get status text
|
||||
// We need to convert the key to the actual text since we're in options API
|
||||
const key = this.getStatusTextHelper(this.status, this.isIncoming);
|
||||
|
||||
// Special cases for floating widget compatibility
|
||||
if (this.status === 'ringing') {
|
||||
if (this.isIncoming) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.INCOMING');
|
||||
} else {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING');
|
||||
}
|
||||
}
|
||||
|
||||
// Map the key to the translated text
|
||||
return this.$t(key);
|
||||
},
|
||||
|
||||
labelTextClass() {
|
||||
if (this.status === 'missed' || this.status === 'no-answer') {
|
||||
return 'text-red-500';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
subtext() {
|
||||
// Checking call direction and status
|
||||
const direction = this.isIncoming ? 'incoming' : 'outgoing';
|
||||
|
||||
// Check if we have agent_joined flag to determine if agent answered
|
||||
const agentJoined = this.message?.conversation?.additional_attributes?.agent_joined === true;
|
||||
const callStarted = !!this.message?.conversation?.additional_attributes?.call_started_at;
|
||||
|
||||
// Special handling for incoming calls that were previously joined but now ended
|
||||
// This avoids showing "You didn't answer" when agent actually did answer
|
||||
if (this.isIncoming && this.status === 'missed' && (agentJoined || callStarted)) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
|
||||
// Common subtext for all statuses
|
||||
const subtextMap = {
|
||||
incoming: {
|
||||
ringing: this.$t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'),
|
||||
active: this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'),
|
||||
missed: this.$t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER'),
|
||||
ended: this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED')
|
||||
},
|
||||
outgoing: {
|
||||
ringing: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
active: this.$t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'),
|
||||
'no-answer': this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
ended: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
completed: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
canceled: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
failed: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
busy: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED')
|
||||
}
|
||||
};
|
||||
|
||||
// First check if we have a specific message for this status
|
||||
if (subtextMap[direction] && subtextMap[direction][this.status]) {
|
||||
return subtextMap[direction][this.status];
|
||||
}
|
||||
|
||||
// Default for missing statuses
|
||||
if (this.isIncoming) {
|
||||
if (this.status === 'ringing') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
} else if (agentJoined || callStarted) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
} else {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER');
|
||||
}
|
||||
} else {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED');
|
||||
}
|
||||
},
|
||||
|
||||
subtextWithDuration() {
|
||||
// Checking if we have start and end timestamps for duration calculation
|
||||
let durationToShow = this.formattedDuration;
|
||||
|
||||
// Check if we have explicit call duration from the content attributes
|
||||
if (!durationToShow && this.callData?.duration) {
|
||||
const durationSeconds = parseInt(this.callData.duration, 10);
|
||||
if (!isNaN(durationSeconds) && durationSeconds > 0) {
|
||||
const minutes = Math.floor(durationSeconds / 60);
|
||||
const seconds = durationSeconds % 60;
|
||||
durationToShow = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// For completed calls, always show the duration if we have it
|
||||
const shouldShowDuration =
|
||||
(this.status === 'ended' || this.status === 'completed') &&
|
||||
durationToShow;
|
||||
|
||||
if (shouldShowDuration) {
|
||||
return `${this.subtext} · ${durationToShow}`;
|
||||
}
|
||||
|
||||
return this.subtext;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
this.setupVoiceCall();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setupVoiceCall();
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up all intervals to prevent memory leaks
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
|
||||
if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval);
|
||||
this.statusCheckInterval = null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatDuration(milliseconds) {
|
||||
// Convert milliseconds to seconds
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
|
||||
// Calculate minutes and remaining seconds
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
// Format as MM:SS with leading zeros
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
setupVoiceCall() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
|
||||
// Create refresh interval for active calls to update duration
|
||||
if (this.status === 'active') {
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.$forceUpdate();
|
||||
}, 1000);
|
||||
|
||||
// Set animation flag
|
||||
this.isAnimating = true;
|
||||
} else {
|
||||
this.isAnimating = false;
|
||||
}
|
||||
|
||||
// Always check for call status changes, not just when ringing
|
||||
if (true) { // Always run status checks
|
||||
// Create a separate interval to check if the call status has changed
|
||||
this.statusCheckInterval = setInterval(() => {
|
||||
// Check if content_attributes has been updated with new status
|
||||
const updatedStatus = this.callData?.status;
|
||||
const statusUpdatedAt = this.callData?.status_updated;
|
||||
|
||||
// Also check the conversation's call status (which might be more authoritative)
|
||||
const conversationStatus = this.message?.conversation?.additional_attributes?.call_status;
|
||||
|
||||
// Check for any status changes from either source
|
||||
const hasMessageStatusChanged = updatedStatus &&
|
||||
updatedStatus !== this.internalStatus &&
|
||||
statusUpdatedAt;
|
||||
|
||||
const hasConversationStatusChanged = conversationStatus &&
|
||||
conversationStatus !== this.internalStatus;
|
||||
|
||||
// If either status has changed, update UI
|
||||
if (hasMessageStatusChanged || hasConversationStatusChanged) {
|
||||
// Prefer the conversation status if available (more reliable)
|
||||
const newStatus = conversationStatus || updatedStatus;
|
||||
|
||||
// Status has changed, update UI
|
||||
this.updateStatus(newStatus);
|
||||
this.$forceUpdate();
|
||||
|
||||
// If call is now active or ended, update UI
|
||||
if (newStatus === 'active' || newStatus === 'in-progress') {
|
||||
this.setupVoiceCall();
|
||||
}
|
||||
}
|
||||
}, 1000); // Check more frequently - every 1 second
|
||||
} else if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval);
|
||||
this.statusCheckInterval = null;
|
||||
}
|
||||
},
|
||||
updateStatus(newStatus) {
|
||||
if (
|
||||
newStatus &&
|
||||
newStatus !== this.status &&
|
||||
newStatus !== this.internalStatus
|
||||
) {
|
||||
this.internalStatus = newStatus;
|
||||
}
|
||||
},
|
||||
handlePlaybackEnd() {
|
||||
this.isPlaying = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Voice call styling */
|
||||
.pulse-animation {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.call-ringing {
|
||||
animation: border-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); /* Green for ringing */
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes border-pulse {
|
||||
0% {
|
||||
border-color: rgba(34, 197, 94, 0.8); /* Green for ringing */
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
100% {
|
||||
border-color: rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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 }}
|
||||
</h3>
|
||||
<div class="call-subtitle">
|
||||
{{ isIncoming ? $t('CONVERSATION.INCOMING_CALL') : (callInfo.inboxName || 'Voice Call') }}
|
||||
{{ isIncoming ? $t('CONVERSATION.VOICE_CALL.INCOMING_CALL') : $t('CONVERSATION.VOICE_CALL.OUTGOING_CALL') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="call-duration" v-if="!isIncoming">
|
||||
{{ formattedCallDuration }}
|
||||
<span class="call-duration-label">{{ formattedCallDuration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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')"
|
||||
>
|
||||
<span class="i-ph-phone" />
|
||||
<span class="button-text">{{ $t('CONVERSATION.JOIN_CALL') }}</span>
|
||||
<span class="button-text">{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="isIncoming"
|
||||
class="control-button reject-call-button"
|
||||
@click="rejectCall"
|
||||
:title="$t('CONVERSATION.REJECT_CALL')"
|
||||
:title="$t('CONVERSATION.VOICE_CALL.REJECT_CALL')"
|
||||
>
|
||||
<span class="i-ph-phone-x" />
|
||||
<span class="button-text">{{ $t('CONVERSATION.REJECT_CALL') }}</span>
|
||||
<span class="button-text">{{ $t('CONVERSATION.VOICE_CALL.REJECT_CALL') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!isIncoming"
|
||||
class="control-button end-call-button"
|
||||
@click="handleEndCallClick"
|
||||
:title="$t('CONVERSATION.END_CALL')"
|
||||
:title="$t('CONVERSATION.VOICE_CALL.END_CALL')"
|
||||
>
|
||||
<span class="i-ph-phone-x" />
|
||||
</button>
|
||||
|
||||
@@ -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 {
|
||||
</template>
|
||||
</div>
|
||||
<BubbleText
|
||||
v-else-if="data.content && !isVoiceCall"
|
||||
v-else-if="data.content"
|
||||
:message="message"
|
||||
:is-email="isEmailContentType"
|
||||
:display-quoted-button="displayQuotedButton"
|
||||
/>
|
||||
<VoiceCall
|
||||
v-else-if="isVoiceCall"
|
||||
:message="data"
|
||||
/>
|
||||
<BubbleIntegration
|
||||
v-else-if="isAnIntegrationMessage"
|
||||
:message-id="data.id"
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
<template>
|
||||
<div class="voice-call-widget" :class="statusClass">
|
||||
<div class="status-icon">
|
||||
<span :class="statusIcon"></span>
|
||||
</div>
|
||||
<div class="call-info">
|
||||
<div class="call-status">{{ callStatusText }}</div>
|
||||
<div v-if="statusSubtext" class="call-subtext">{{ statusSubtext }}</div>
|
||||
</div>
|
||||
<NextButton
|
||||
v-if="showJoinButton"
|
||||
size="sm"
|
||||
icon="i-lucide-phone"
|
||||
variant="success"
|
||||
:label="$t('CONVERSATION.VOICE_CALL.JOIN_CALL')"
|
||||
:is-loading="isLoading"
|
||||
@click="joinCall"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
status: this.message.content_attributes?.data?.status || 'ringing',
|
||||
joinedAt: null,
|
||||
duration: this.message.content_attributes?.data?.duration || null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
// Watch for changes to message content_attributes to update call status
|
||||
'message.content_attributes.data': {
|
||||
handler(newData) {
|
||||
if (newData) {
|
||||
if (newData.status && newData.status !== this.status) {
|
||||
const oldStatus = this.status;
|
||||
this.status = newData.status;
|
||||
|
||||
// If call status changes to 'ended' or 'missed', close the floating call widget
|
||||
if ((newData.status === 'ended' || newData.status === 'missed') &&
|
||||
['ringing', 'active'].includes(oldStatus)) {
|
||||
this.closeCallWidget();
|
||||
}
|
||||
}
|
||||
if (newData.duration && newData.duration !== this.duration) {
|
||||
this.duration = newData.duration;
|
||||
}
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentConversationId: 'getSelectedChatConversationId',
|
||||
}),
|
||||
hasActiveCall() {
|
||||
return this.$store.getters['calls/hasActiveCall'];
|
||||
},
|
||||
callData() {
|
||||
return this.message.content_attributes?.data || {};
|
||||
},
|
||||
callStatusText() {
|
||||
switch (this.status) {
|
||||
case 'ringing':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.RINGING');
|
||||
case 'active':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.ACTIVE');
|
||||
case 'missed':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.MISSED');
|
||||
case 'ended':
|
||||
return this.$t('CONVERSATION.VOICE_CALL.ENDED');
|
||||
default:
|
||||
return this.$t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
},
|
||||
statusIcon() {
|
||||
switch (this.status) {
|
||||
case 'ringing':
|
||||
return 'i-lucide-phone-incoming';
|
||||
case 'active':
|
||||
return 'i-lucide-phone';
|
||||
case 'missed':
|
||||
return 'i-lucide-phone-missed';
|
||||
case 'ended':
|
||||
return 'i-lucide-phone-off';
|
||||
default:
|
||||
return 'i-lucide-phone-incoming';
|
||||
}
|
||||
},
|
||||
statusClass() {
|
||||
return {
|
||||
'ringing': this.status === 'ringing',
|
||||
'active': this.status === 'active',
|
||||
'missed': this.status === 'missed',
|
||||
'ended': this.status === 'ended',
|
||||
};
|
||||
},
|
||||
callConversationId() {
|
||||
return this.callData.conversation_id;
|
||||
},
|
||||
showJoinButton() {
|
||||
// Show join button only if the call is ringing or active and we're on the same conversation
|
||||
return (['ringing', 'active'].includes(this.status)) &&
|
||||
(this.callConversationId === this.currentConversationId);
|
||||
},
|
||||
formattedDuration() {
|
||||
if (!this.duration) return '';
|
||||
|
||||
const minutes = Math.floor(this.duration / 60);
|
||||
const seconds = this.duration % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
statusSubtext() {
|
||||
if (this.status === 'ended' && this.formattedDuration) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.DURATION', { duration: this.formattedDuration });
|
||||
}
|
||||
if (this.status === 'missed') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
isIncoming() {
|
||||
return this.message.message_type === 0; // 0 = incoming
|
||||
},
|
||||
isOutgoing() {
|
||||
return this.message.message_type === 1; // 1 = outgoing
|
||||
},
|
||||
senderText() {
|
||||
if (this.isIncoming) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.INCOMING_FROM', { name: this.message.sender?.name || 'Unknown' });
|
||||
}
|
||||
if (this.isOutgoing) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING_FROM', { name: this.message.sender?.name || 'Unknown' });
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// We don't need custom subscriptions anymore as we'll
|
||||
// get updates through Chatwoot's standard message update system
|
||||
// Just check if we have an active call with the same call_sid
|
||||
this.checkActiveCall();
|
||||
},
|
||||
methods: {
|
||||
async joinCall() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
// Update UI immediately
|
||||
this.status = 'active';
|
||||
this.joinedAt = new Date();
|
||||
|
||||
// Get the current conversation
|
||||
const conversation = await this.$store.dispatch('getConversation', {
|
||||
conversationId: this.callConversationId,
|
||||
});
|
||||
|
||||
const callSid = this.callData.call_sid;
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('No call SID found');
|
||||
}
|
||||
|
||||
// Show floating call widget
|
||||
this.$store.dispatch('calls/setActiveCall', {
|
||||
callSid,
|
||||
conversationId: this.callConversationId,
|
||||
inboxId: this.message.inbox_id,
|
||||
contactId: conversation.contact_id,
|
||||
contactName: conversation.contact?.name || 'Unknown',
|
||||
messageId: this.message.id,
|
||||
});
|
||||
|
||||
// Join the call via API
|
||||
await VoiceAPI.joinCall({
|
||||
call_sid: callSid,
|
||||
conversation_id: this.callConversationId,
|
||||
});
|
||||
|
||||
// Success notification
|
||||
useAlert(this.$t('CONVERSATION.VOICE_CALL.CALL_JOINED'));
|
||||
} catch (err) {
|
||||
console.error('Failed to join call:', err);
|
||||
useAlert(this.$t('CONVERSATION.VOICE_CALL.JOIN_ERROR'));
|
||||
// Reset status if join failed
|
||||
this.status = 'ringing';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// We don't need custom subscription methods anymore as we'll use
|
||||
// Chatwoot's standard message update events
|
||||
|
||||
checkActiveCall() {
|
||||
// If there's an active call in the store with the same conversation ID
|
||||
// update our local state to match
|
||||
const activeCall = this.$store.getters['calls/getActiveCall'];
|
||||
if (activeCall && activeCall.conversationId === this.callConversationId) {
|
||||
this.status = 'active';
|
||||
this.joinedAt = new Date();
|
||||
}
|
||||
},
|
||||
|
||||
updateStatus(newStatus, duration = null) {
|
||||
this.status = newStatus;
|
||||
|
||||
if (newStatus === 'ended') {
|
||||
if (duration) {
|
||||
this.duration = duration;
|
||||
} else if (this.joinedAt) {
|
||||
this.duration = Math.floor((new Date() - this.joinedAt) / 1000);
|
||||
}
|
||||
// Close the floating call widget when call ends
|
||||
this.closeCallWidget();
|
||||
}
|
||||
},
|
||||
|
||||
closeCallWidget() {
|
||||
// Get the call SID from the message data
|
||||
const callSid = this.callData.call_sid;
|
||||
if (!callSid) return;
|
||||
|
||||
// Handle the call status change directly
|
||||
this.$store.dispatch('calls/handleCallStatusChanged', {
|
||||
callSid,
|
||||
status: 'ended'
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.voice-call-widget {
|
||||
@apply flex items-center gap-2 p-3 rounded-lg my-1;
|
||||
@apply bg-slate-50 dark:bg-slate-700;
|
||||
@apply border border-slate-200 dark:border-slate-600;
|
||||
@apply transition-all duration-200;
|
||||
|
||||
&.ringing {
|
||||
@apply border-green-500 dark:border-green-500;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply border-woot-500 dark:border-woot-500 bg-woot-50 dark:bg-woot-800;
|
||||
}
|
||||
|
||||
&.missed {
|
||||
@apply border-red-300 dark:border-slate-600 bg-red-50 dark:bg-slate-700;
|
||||
}
|
||||
|
||||
&.ended {
|
||||
@apply border-slate-300 dark:border-slate-600;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
@apply flex items-center justify-center;
|
||||
@apply w-10 h-10 rounded-full;
|
||||
@apply bg-slate-200 dark:bg-slate-600;
|
||||
@apply text-slate-700 dark:text-slate-200;
|
||||
|
||||
.i-lucide-phone-incoming {
|
||||
@apply text-green-600 dark:text-green-400;
|
||||
}
|
||||
|
||||
.i-lucide-phone {
|
||||
@apply text-woot-600 dark:text-woot-400;
|
||||
}
|
||||
|
||||
.i-lucide-phone-missed {
|
||||
@apply text-red-600 dark:text-red-400;
|
||||
}
|
||||
|
||||
.i-lucide-phone-off {
|
||||
@apply text-slate-600 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
|
||||
.call-info {
|
||||
@apply flex-1;
|
||||
|
||||
.call-status {
|
||||
@apply font-medium text-slate-800 dark:text-slate-200;
|
||||
}
|
||||
|
||||
.call-subtext {
|
||||
@apply text-xs text-slate-500 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
},
|
||||
|
||||
+14
-2
@@ -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')
|
||||
@@ -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
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user