chore: clean up voice message components

This commit is contained in:
Sojan
2025-05-04 05:00:20 -07:00
parent 196d7afccf
commit 4348c4ab87
27 changed files with 2652 additions and 985 deletions
+1
View File
@@ -94,3 +94,4 @@ yarn-debug.log*
# Claude.ai config file
CLAUDE.md
**/.claude/settings.local.json
+12 -9
View File
@@ -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
+129 -256
View File
@@ -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
@@ -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>
@@ -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>
@@ -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
View File
@@ -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
+121 -32
View File
@@ -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
+105 -59
View File
@@ -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
+51 -65
View File
@@ -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)
+91
View File
@@ -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
+60 -16
View File
@@ -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