mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-20 21:15:01 +00:00
chore: floating call button
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
|
||||||
|
require 'securerandom'
|
||||||
before_action :fetch_contact
|
before_action :fetch_contact
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -16,12 +17,37 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
|||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
# Initiate the call using the channel's implementation
|
# Create a new conversation for this call
|
||||||
voice_inbox.channel.initiate_call(to: @contact.phone_number)
|
|
||||||
|
|
||||||
# Create a new conversation for this call if needed
|
|
||||||
conversation = find_or_create_conversation(voice_inbox)
|
conversation = find_or_create_conversation(voice_inbox)
|
||||||
|
|
||||||
|
# Initiate the call using the channel's implementation - this returns the call details
|
||||||
|
call_details = voice_inbox.channel.initiate_call(to: @contact.phone_number)
|
||||||
|
|
||||||
|
# Create a message for this call with call details
|
||||||
|
params = {
|
||||||
|
content: "Outgoing voice call initiated to #{@contact.phone_number}",
|
||||||
|
message_type: :activity,
|
||||||
|
additional_attributes: call_details,
|
||||||
|
source_id: call_details[:call_sid] # Use call SID as source_id
|
||||||
|
}
|
||||||
|
|
||||||
|
message = Messages::MessageBuilder.new(Current.user, conversation, params).perform
|
||||||
|
|
||||||
|
# Make sure the conversation has the latest activity timestamp
|
||||||
|
conversation.update(last_activity_at: Time.current)
|
||||||
|
# Store call SID and status for front-end
|
||||||
|
conversation.update!(additional_attributes: (conversation.additional_attributes || {}).merge(call_details))
|
||||||
|
|
||||||
|
# Broadcast the conversation and message to the appropriate ActionCable channels
|
||||||
|
ActionCableBroadcastJob.perform_later(
|
||||||
|
conversation.account_id,
|
||||||
|
'conversation.created',
|
||||||
|
conversation.push_event_data.merge(
|
||||||
|
message: message.push_event_data,
|
||||||
|
status: 'open'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
render json: conversation
|
render json: conversation
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error("Error initiating call: #{e.message}")
|
Rails.logger.error("Error initiating call: #{e.message}")
|
||||||
@@ -40,11 +66,19 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
|||||||
|
|
||||||
if conversation.nil? || !conversation.open?
|
if conversation.nil? || !conversation.open?
|
||||||
# Find or create a contact_inbox for this contact and inbox
|
# Find or create a contact_inbox for this contact and inbox
|
||||||
contact_inbox = ContactInbox.find_or_create_by!(
|
contact_inbox = ContactInbox.find_or_initialize_by(
|
||||||
contact_id: @contact.id,
|
contact_id: @contact.id,
|
||||||
inbox_id: inbox.id
|
inbox_id: inbox.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set the source_id if it's a new record
|
||||||
|
if contact_inbox.new_record?
|
||||||
|
# For voice channels, use the phone number as the source_id
|
||||||
|
contact_inbox.source_id = @contact.phone_number
|
||||||
|
end
|
||||||
|
|
||||||
|
contact_inbox.save!
|
||||||
|
|
||||||
conversation = ::Conversation.create!(
|
conversation = ::Conversation.create!(
|
||||||
account_id: Current.account.id,
|
account_id: Current.account.id,
|
||||||
inbox_id: inbox.id,
|
inbox_id: inbox.id,
|
||||||
@@ -54,12 +88,13 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Add a note about the call being initiated
|
# Add a note about the call being initiated
|
||||||
Messages::MessageBuilder.new(
|
params = {
|
||||||
user: Current.user,
|
|
||||||
conversation: conversation,
|
|
||||||
message_type: :activity,
|
message_type: :activity,
|
||||||
content: "Voice call initiated to #{@contact.phone_number}"
|
content: "Voice call initiated to #{@contact.phone_number}",
|
||||||
).perform
|
source_id: "voice_call_#{SecureRandom.uuid}" # Generate a unique source_id
|
||||||
|
}
|
||||||
|
|
||||||
|
Messages::MessageBuilder.new(Current.user, conversation, params).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
conversation
|
conversation
|
||||||
|
|||||||
94
app/controllers/api/v1/accounts/voice_controller.rb
Normal file
94
app/controllers/api/v1/accounts/voice_controller.rb
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :fetch_conversation, only: [:end_call, :call_status]
|
||||||
|
|
||||||
|
def end_call
|
||||||
|
call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid')
|
||||||
|
return render json: { error: 'No active call found' }, status: :not_found unless call_sid
|
||||||
|
|
||||||
|
# Get the inbox and channel information
|
||||||
|
inbox = @conversation.inbox
|
||||||
|
channel = inbox&.channel
|
||||||
|
|
||||||
|
if channel.is_a?(Channel::Voice) && channel.provider == 'twilio'
|
||||||
|
config = channel.provider_config_hash
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Create a Twilio client and end the call
|
||||||
|
client = Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
||||||
|
call = client.calls(call_sid).fetch
|
||||||
|
|
||||||
|
# Only try to end the call if it's still in progress
|
||||||
|
if call.status == 'in-progress' || call.status == 'ringing'
|
||||||
|
client.calls(call_sid).update(status: 'completed')
|
||||||
|
|
||||||
|
# Update conversation call status
|
||||||
|
@conversation.additional_attributes ||= {}
|
||||||
|
@conversation.additional_attributes['call_status'] = 'completed'
|
||||||
|
@conversation.save!
|
||||||
|
|
||||||
|
# Create an activity message noting the call has ended
|
||||||
|
Messages::MessageBuilder.new(
|
||||||
|
nil,
|
||||||
|
@conversation,
|
||||||
|
{
|
||||||
|
content: 'Call ended by agent',
|
||||||
|
message_type: :activity,
|
||||||
|
additional_attributes: {
|
||||||
|
call_sid: call_sid,
|
||||||
|
call_status: 'completed',
|
||||||
|
ended_by: current_user.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).perform
|
||||||
|
|
||||||
|
render json: { status: 'success', message: 'Call successfully ended' }
|
||||||
|
else
|
||||||
|
render json: { status: 'success', message: "Call already in '#{call.status}' state" }
|
||||||
|
end
|
||||||
|
rescue Twilio::REST::RestError => e
|
||||||
|
render json: { error: "Failed to end call: #{e.message}" }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
else
|
||||||
|
render json: { error: 'Unsupported channel provider for call control' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def call_status
|
||||||
|
call_sid = @conversation.additional_attributes&.dig('call_sid')
|
||||||
|
return render json: { error: 'No call found' }, status: :not_found unless call_sid
|
||||||
|
|
||||||
|
# Get the inbox and channel information
|
||||||
|
inbox = @conversation.inbox
|
||||||
|
channel = inbox&.channel
|
||||||
|
|
||||||
|
if channel.is_a?(Channel::Voice) && channel.provider == 'twilio'
|
||||||
|
config = channel.provider_config_hash
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Create a Twilio client and fetch the call status
|
||||||
|
client = Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
||||||
|
call = client.calls(call_sid).fetch
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
status: call.status,
|
||||||
|
duration: call.duration,
|
||||||
|
direction: call.direction,
|
||||||
|
from: call.from,
|
||||||
|
to: call.to,
|
||||||
|
start_time: call.start_time,
|
||||||
|
end_time: call.end_time
|
||||||
|
}
|
||||||
|
rescue Twilio::REST::RestError => e
|
||||||
|
render json: { error: "Failed to fetch call status: #{e.message}" }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
else
|
||||||
|
render json: { error: 'Unsupported channel provider for call status' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_conversation
|
||||||
|
@conversation = Current.account.conversations.find(params[:id] || params[:conversation_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
437
app/controllers/twilio/voice_controller.rb
Normal file
437
app/controllers/twilio/voice_controller.rb
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
class Twilio::VoiceController < ActionController::Base
|
||||||
|
skip_forgery_protection
|
||||||
|
|
||||||
|
def twiml
|
||||||
|
# ULTRA minimal TwiML - just a simple greeting and record
|
||||||
|
response = Twilio::TwiML::VoiceResponse.new
|
||||||
|
|
||||||
|
# Just a simple message about recent signup and feedback
|
||||||
|
response.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup. We would love to hear any feedback or questions you might have about your experience so far. Please share your thoughts after the beep.')
|
||||||
|
|
||||||
|
# Record their feedback
|
||||||
|
response.record(
|
||||||
|
action: '/twilio/voice/handle_recording',
|
||||||
|
method: 'POST',
|
||||||
|
maxLength: 30,
|
||||||
|
timeout: 2,
|
||||||
|
statusCallback: '/twilio/voice/status_callback',
|
||||||
|
statusCallbackMethod: 'POST',
|
||||||
|
statusCallbackEvent: ['completed']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always end the call to avoid any complexity
|
||||||
|
response.hangup
|
||||||
|
|
||||||
|
# Render the response immediately
|
||||||
|
render xml: response.to_s, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_user_input
|
||||||
|
call_sid = params['CallSid']
|
||||||
|
digits = params['Digits']
|
||||||
|
speech_result = params['SpeechResult']
|
||||||
|
from_number = params['From']
|
||||||
|
to_number = params['To']
|
||||||
|
direction = params['Direction']
|
||||||
|
is_outbound = direction == 'outbound-api'
|
||||||
|
|
||||||
|
# Find the inbox for this voice call based on the direction
|
||||||
|
inbox = find_inbox(is_outbound ? from_number : to_number)
|
||||||
|
|
||||||
|
if inbox.present?
|
||||||
|
# Create or find the conversation for this call
|
||||||
|
conversation = find_or_create_conversation(inbox, is_outbound ? to_number : from_number, call_sid)
|
||||||
|
|
||||||
|
# Create an activity message showing the user input
|
||||||
|
input_text = if digits.present?
|
||||||
|
"Caller pressed #{digits}"
|
||||||
|
elsif speech_result.present?
|
||||||
|
"Caller said: \"#{speech_result}\""
|
||||||
|
else
|
||||||
|
"Caller responded"
|
||||||
|
end
|
||||||
|
|
||||||
|
Messages::MessageBuilder.new(
|
||||||
|
nil,
|
||||||
|
conversation,
|
||||||
|
{
|
||||||
|
content: input_text,
|
||||||
|
message_type: :activity,
|
||||||
|
additional_attributes: {
|
||||||
|
call_sid: call_sid,
|
||||||
|
call_status: 'in-progress',
|
||||||
|
user_input: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
# Redirect back to the main TwiML to continue the call flow
|
||||||
|
response = Twilio::TwiML::VoiceResponse.new do |r|
|
||||||
|
r.redirect(url: "/twilio/voice/twiml?ReturnCall=true&Direction=#{direction.to_s}&step=check_messages")
|
||||||
|
end
|
||||||
|
|
||||||
|
render xml: response.to_s, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_recording
|
||||||
|
call_sid = params['CallSid']
|
||||||
|
from_number = params['From']
|
||||||
|
to_number = params['To']
|
||||||
|
recording_url = params['RecordingUrl']
|
||||||
|
recording_sid = params['RecordingSid']
|
||||||
|
direction = params['Direction']
|
||||||
|
|
||||||
|
# Determine if outbound call
|
||||||
|
is_outbound = direction == 'outbound-api'
|
||||||
|
|
||||||
|
# Find inbox and save recording if available
|
||||||
|
if recording_url.present? && call_sid.present?
|
||||||
|
inbox_number = is_outbound ? from_number : to_number
|
||||||
|
inbox = find_inbox(inbox_number)
|
||||||
|
|
||||||
|
if inbox.present?
|
||||||
|
contact_number = is_outbound ? to_number : from_number
|
||||||
|
conversation = find_or_create_conversation(inbox, contact_number, call_sid)
|
||||||
|
contact = conversation.contact
|
||||||
|
|
||||||
|
# Create a message with the recording
|
||||||
|
if contact.present?
|
||||||
|
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']
|
||||||
|
|
||||||
|
# Create an authenticated URL that includes auth details
|
||||||
|
# This is needed because Twilio recording URLs require authentication
|
||||||
|
recording_mp3_url = "#{recording_url}.mp3"
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Create an attachment record with proper file type for audio
|
||||||
|
attachment = message.attachments.new(
|
||||||
|
file_type: :audio, # Use audio type for proper player rendering
|
||||||
|
account_id: inbox.account_id,
|
||||||
|
external_url: recording_mp3_url,
|
||||||
|
fallback_title: 'Voice Recording',
|
||||||
|
meta: {
|
||||||
|
recording_sid: recording_sid,
|
||||||
|
twilio_account_sid: account_sid,
|
||||||
|
auth_required: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the attachment
|
||||||
|
if attachment.save
|
||||||
|
Rails.logger.info("Successfully attached voice recording from #{recording_url}")
|
||||||
|
else
|
||||||
|
Rails.logger.error("Failed to save attachment: #{attachment.errors.full_messages.join(', ')}")
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error("Failed to handle recording: #{e.message}")
|
||||||
|
|
||||||
|
# If the audio attachment fails, try with a more generic file type
|
||||||
|
begin
|
||||||
|
fallback_attachment = message.attachments.new(
|
||||||
|
file_type: :file,
|
||||||
|
account_id: inbox.account_id,
|
||||||
|
external_url: recording_mp3_url,
|
||||||
|
fallback_title: 'Voice Recording (.mp3)',
|
||||||
|
meta: {
|
||||||
|
recording_sid: recording_sid,
|
||||||
|
twilio_account_sid: account_sid,
|
||||||
|
auth_required: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
fallback_attachment.save
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error("Failed to create fallback attachment: #{e.message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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 => e
|
||||||
|
# Log error but continue
|
||||||
|
Rails.logger.error("Error processing recording: #{e.message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Loop recording until caller hangs up
|
||||||
|
response = Twilio::TwiML::VoiceResponse.new
|
||||||
|
response.say(message: 'Segment recorded. Please leave more feedback after the beep, or hang up to finish.')
|
||||||
|
response.record(
|
||||||
|
action: '/twilio/voice/handle_recording',
|
||||||
|
method: 'POST',
|
||||||
|
maxLength: 30,
|
||||||
|
timeout: 2,
|
||||||
|
playBeep: true,
|
||||||
|
statusCallback: '/twilio/voice/status_callback',
|
||||||
|
statusCallbackMethod: 'POST',
|
||||||
|
statusCallbackEvent: ['completed', 'in-progress', 'absent']
|
||||||
|
)
|
||||||
|
render xml: response.to_s, status: :ok
|
||||||
|
rescue => e
|
||||||
|
# Log the error but don't crash
|
||||||
|
Rails.logger.error("Error processing recording: #{e.message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def transcription_callback
|
||||||
|
# Process the transcription asynchronously
|
||||||
|
if params['CallSid'].present?
|
||||||
|
# Queue the processing as a background job
|
||||||
|
CallTranscriptionJob.perform_later(params.permit!.to_h)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return an empty TwiML response to satisfy Twilio
|
||||||
|
response = Twilio::TwiML::VoiceResponse.new
|
||||||
|
render xml: response.to_s, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# This endpoint will be called by Twilio's StatusCallback
|
||||||
|
# parameter to notify of call status changes
|
||||||
|
def status_callback
|
||||||
|
call_sid = params['CallSid']
|
||||||
|
call_status = params['CallStatus']
|
||||||
|
direction = params['Direction']
|
||||||
|
is_outbound = direction == 'outbound-api'
|
||||||
|
from_number = params['From']
|
||||||
|
to_number = params['To']
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 ['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
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return an empty response
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Simple TwiML with signup follow-up message
|
||||||
|
def simple_twiml
|
||||||
|
call_sid = params['CallSid']
|
||||||
|
from_number = params['From']
|
||||||
|
to_number = params['To']
|
||||||
|
direction = params['Direction']
|
||||||
|
|
||||||
|
# Determine if outbound call
|
||||||
|
is_outbound = direction == 'outbound-api'
|
||||||
|
|
||||||
|
response = Twilio::TwiML::VoiceResponse.new
|
||||||
|
|
||||||
|
# The signup follow-up message
|
||||||
|
response.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup. We would love to hear any feedback or questions you might have about your experience so far. Please share your thoughts after the beep.')
|
||||||
|
|
||||||
|
# Record their feedback
|
||||||
|
response.record(
|
||||||
|
action: '/twilio/voice/handle_recording',
|
||||||
|
method: 'POST',
|
||||||
|
maxLength: 60,
|
||||||
|
timeout: 3,
|
||||||
|
statusCallback: '/twilio/voice/status_callback',
|
||||||
|
statusCallbackMethod: 'POST',
|
||||||
|
statusCallbackEvent: ['completed', 'in-progress', 'absent']
|
||||||
|
)
|
||||||
|
|
||||||
|
# End the call
|
||||||
|
response.hangup
|
||||||
|
|
||||||
|
# If we have call details, log them for the conversation
|
||||||
|
if call_sid.present?
|
||||||
|
# Find the inbox for this voice call
|
||||||
|
inbox_number = is_outbound ? from_number : to_number
|
||||||
|
inbox = find_inbox(inbox_number)
|
||||||
|
|
||||||
|
if inbox.present?
|
||||||
|
# Create or find conversation
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render xml: response.to_s, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def find_inbox(phone_number)
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
return existing if existing
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
convo.additional_attributes = { 'call_sid' => call_sid, 'call_status' => 'in-progress' }
|
||||||
|
convo.save!
|
||||||
|
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)
|
||||||
|
redis_key = "voice_message:#{call_sid}"
|
||||||
|
|
||||||
|
# Get just one message
|
||||||
|
redis_message = Redis::Alfred.lpop(redis_key)
|
||||||
|
return nil unless redis_message.present?
|
||||||
|
|
||||||
|
begin
|
||||||
|
message = JSON.parse(redis_message)
|
||||||
|
return message
|
||||||
|
rescue JSON::ParserError => e
|
||||||
|
Rails.logger.error("Failed to parse voice message from Redis: #{e.message}")
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_message_delivered(message_id)
|
||||||
|
# Find the message
|
||||||
|
message = Message.find_by(id: message_id)
|
||||||
|
return unless message.present?
|
||||||
|
|
||||||
|
# Update the message delivery status
|
||||||
|
additional_attributes = message.additional_attributes || {}
|
||||||
|
additional_attributes[:voice_delivery_status] = 'delivered'
|
||||||
|
message.update(additional_attributes: additional_attributes)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,6 +15,7 @@ class AsyncDispatcher < BaseDispatcher
|
|||||||
CsatSurveyListener.instance,
|
CsatSurveyListener.instance,
|
||||||
HookListener.instance,
|
HookListener.instance,
|
||||||
InstallationWebhookListener.instance,
|
InstallationWebhookListener.instance,
|
||||||
|
MessageListener.instance,
|
||||||
NotificationListener.instance,
|
NotificationListener.instance,
|
||||||
ParticipationListener.instance,
|
ParticipationListener.instance,
|
||||||
ReportingEventListener.instance,
|
ReportingEventListener.instance,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import NetworkNotification from './components/NetworkNotification.vue';
|
|||||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||||
|
import FloatingCallWidget from './components/widgets/FloatingCallWidget.vue';
|
||||||
import vueActionCable from './helper/actionCable';
|
import vueActionCable from './helper/actionCable';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
@@ -14,6 +15,8 @@ import { setColorTheme } from './helper/themeHelper';
|
|||||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useFontSize } from 'dashboard/composables/useFontSize';
|
import { useFontSize } from 'dashboard/composables/useFontSize';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||||
import {
|
import {
|
||||||
registerSubscription,
|
registerSubscription,
|
||||||
verifyServiceWorkerExistence,
|
verifyServiceWorkerExistence,
|
||||||
@@ -25,6 +28,7 @@ export default {
|
|||||||
|
|
||||||
components: {
|
components: {
|
||||||
AddAccountModal,
|
AddAccountModal,
|
||||||
|
FloatingCallWidget,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
NetworkNotification,
|
NetworkNotification,
|
||||||
UpdateBanner,
|
UpdateBanner,
|
||||||
@@ -51,6 +55,7 @@ export default {
|
|||||||
showAddAccountModal: false,
|
showAddAccountModal: false,
|
||||||
latestChatwootVersion: null,
|
latestChatwootVersion: null,
|
||||||
reconnectService: null,
|
reconnectService: null,
|
||||||
|
showCallWidget: false, // Set to true for testing, false for production
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -60,6 +65,8 @@ export default {
|
|||||||
currentUser: 'getCurrentUser',
|
currentUser: 'getCurrentUser',
|
||||||
authUIFlags: 'getAuthUIFlags',
|
authUIFlags: 'getAuthUIFlags',
|
||||||
accountUIFlags: 'accounts/getUIFlags',
|
accountUIFlags: 'accounts/getUIFlags',
|
||||||
|
activeCall: 'calls/getActiveCall',
|
||||||
|
hasActiveCall: 'calls/hasActiveCall',
|
||||||
}),
|
}),
|
||||||
hasAccounts() {
|
hasAccounts() {
|
||||||
const { accounts = [] } = this.currentUser || {};
|
const { accounts = [] } = this.currentUser || {};
|
||||||
@@ -86,6 +93,13 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
// Make app instance available globally for debugging and cross-component access
|
||||||
|
window.app = this;
|
||||||
|
|
||||||
|
// Set up global force end call mechanism
|
||||||
|
window.forceEndCall = () => this.forceEndCall();
|
||||||
|
window.forceEndCallHandlers = [];
|
||||||
|
|
||||||
this.initializeColorTheme();
|
this.initializeColorTheme();
|
||||||
this.listenToThemeChanges();
|
this.listenToThemeChanges();
|
||||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||||
@@ -106,6 +120,84 @@ export default {
|
|||||||
setLocale(locale) {
|
setLocale(locale) {
|
||||||
this.$root.$i18n.locale = locale;
|
this.$root.$i18n.locale = locale;
|
||||||
},
|
},
|
||||||
|
handleCallEnded() {
|
||||||
|
console.log('Call ended event received in App.vue');
|
||||||
|
// Update our local state first for immediate UI update
|
||||||
|
this.showCallWidget = false;
|
||||||
|
// Then update the store
|
||||||
|
this.$store.dispatch('calls/clearActiveCall');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Public method that can be called from anywhere
|
||||||
|
forceEndCall() {
|
||||||
|
console.log('Force end call triggered in App.vue');
|
||||||
|
|
||||||
|
// 1. Update UI immediately
|
||||||
|
this.showCallWidget = false;
|
||||||
|
|
||||||
|
// 2. Try to notify any other components
|
||||||
|
if (window.forceEndCallHandlers) {
|
||||||
|
window.forceEndCallHandlers.forEach(handler => {
|
||||||
|
try {
|
||||||
|
handler();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in end call handler:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. CRITICAL: Make API call to actually end the call on the server
|
||||||
|
if (this.activeCall && this.activeCall.callSid) {
|
||||||
|
const { callSid, conversationId } = this.activeCall;
|
||||||
|
|
||||||
|
// Save references before clearing the store
|
||||||
|
const savedCallSid = callSid;
|
||||||
|
const savedConversationId = conversationId;
|
||||||
|
|
||||||
|
// Now clear the store
|
||||||
|
this.$store.dispatch('calls/clearActiveCall');
|
||||||
|
|
||||||
|
// Make API call if we have a conversation ID
|
||||||
|
if (savedConversationId) {
|
||||||
|
console.log(
|
||||||
|
'App.vue making API call to end call with SID:',
|
||||||
|
savedCallSid,
|
||||||
|
'for conversation:',
|
||||||
|
savedConversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make the API call to end the call on the server with both parameters
|
||||||
|
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||||
|
.then(response => {
|
||||||
|
console.log('Call ended successfully via API:', response);
|
||||||
|
useAlert({ message: 'Call ended successfully', type: 'success' });
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error ending call via API:', error);
|
||||||
|
|
||||||
|
// If first attempt fails, try one more time with additional logging
|
||||||
|
console.log('Retrying end call with more debugging...');
|
||||||
|
setTimeout(() => {
|
||||||
|
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||||
|
.then(retryResponse => {
|
||||||
|
console.log('Retry successful:', retryResponse);
|
||||||
|
})
|
||||||
|
.catch(retryError => {
|
||||||
|
console.error('Retry also failed:', retryError);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
useAlert({ message: 'Call UI has been reset', type: 'info' });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('App.vue: Not making API call because conversation ID is missing');
|
||||||
|
useAlert({ message: 'Call ended', type: 'success' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No active call data, just clear the store
|
||||||
|
this.$store.dispatch('calls/clearActiveCall');
|
||||||
|
}
|
||||||
|
},
|
||||||
async initializeAccount() {
|
async initializeAccount() {
|
||||||
await this.$store.dispatch('accounts/get');
|
await this.$store.dispatch('accounts/get');
|
||||||
this.$store.dispatch('setActiveAccount', {
|
this.$store.dispatch('setActiveAccount', {
|
||||||
@@ -153,6 +245,15 @@ export default {
|
|||||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||||
<WootSnackbarBox />
|
<WootSnackbarBox />
|
||||||
<NetworkNotification />
|
<NetworkNotification />
|
||||||
|
<!-- Floating call widget that appears during active calls -->
|
||||||
|
<FloatingCallWidget
|
||||||
|
v-if="showCallWidget || (activeCall && activeCall.callSid)"
|
||||||
|
:key="`call-${Date.now()}`"
|
||||||
|
:call-sid="activeCall ? activeCall.callSid : 'test-call'"
|
||||||
|
:inbox-name="activeCall ? (activeCall.inboxName || 'Primary') : 'Primary'"
|
||||||
|
:conversation-id="activeCall ? activeCall.conversationId : null"
|
||||||
|
@call-ended="handleCallEnded"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LoadingState v-else />
|
<LoadingState v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,6 +16,63 @@ class VoiceAPI extends ApiClient {
|
|||||||
`/api/v1/accounts/${accountId}/contacts/${contactId}/call`
|
`/api/v1/accounts/${accountId}/contacts/${contactId}/call`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// End an active call
|
||||||
|
endCall(callSid, conversationId) {
|
||||||
|
if (!conversationId) {
|
||||||
|
console.error('VoiceAPI: Cannot end call - conversation ID is required');
|
||||||
|
return Promise.reject(
|
||||||
|
new Error('Conversation ID is required to end a call')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!callSid) {
|
||||||
|
console.error('VoiceAPI: Cannot end call - call SID is required');
|
||||||
|
return Promise.reject(new Error('Call SID is required to end a call'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate call SID format - Twilio call SID starts with 'CA' followed by alphanumeric characters
|
||||||
|
if (!callSid.startsWith('CA') && !callSid.startsWith('TJ')) {
|
||||||
|
console.error('VoiceAPI: Invalid call SID format:', callSid);
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the account ID from the current URL
|
||||||
|
const accountId = this.accountIdFromRoute;
|
||||||
|
console.log(
|
||||||
|
`VoiceAPI: Ending call with SID ${callSid} for conversation ${conversationId} in account ${accountId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make the actual API call with conversation ID as a parameter
|
||||||
|
// Using the route structure that matches the Rails routes.rb definition
|
||||||
|
return axios
|
||||||
|
.post(`/api/v1/accounts/${accountId}/voice/end_call`, {
|
||||||
|
call_sid: callSid,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
id: conversationId, // Also include as 'id' as the controller may check for it
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
console.log('VoiceAPI: End call API succeeded:', response);
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('VoiceAPI: End call API failed:', error);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get call status
|
||||||
|
getCallStatus(callSid) {
|
||||||
|
// Get the account ID from the current URL
|
||||||
|
const accountId = this.accountIdFromRoute;
|
||||||
|
return axios.get(`/api/v1/accounts/${accountId}/voice/call_status`, {
|
||||||
|
params: { call_sid: callSid },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new VoiceAPI();
|
export default new VoiceAPI();
|
||||||
|
|||||||
@@ -272,11 +272,11 @@ defineExpose({
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
@input="
|
@input="
|
||||||
isValidationField(item.key) &&
|
isValidationField(item.key) &&
|
||||||
v$[getValidationKey(item.key)].$touch()
|
v$[getValidationKey(item.key)].$touch()
|
||||||
"
|
"
|
||||||
@blur="
|
@blur="
|
||||||
isValidationField(item.key) &&
|
isValidationField(item.key) &&
|
||||||
v$[getValidationKey(item.key)].$touch()
|
v$[getValidationKey(item.key)].$touch()
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -108,7 +108,13 @@ const onCardClick = e => {
|
|||||||
v-tooltip.left="inboxName"
|
v-tooltip.left="inboxName"
|
||||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
|
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
|
||||||
>
|
>
|
||||||
|
<!-- Special handling for voice channel -->
|
||||||
|
<span
|
||||||
|
v-if="inbox.channelType === 'Channel::Voice'"
|
||||||
|
class="i-ph-phone-fill text-n-slate-11 size-3"
|
||||||
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
|
v-else
|
||||||
:icon="inboxIcon"
|
:icon="inboxIcon"
|
||||||
class="flex-shrink-0 text-n-slate-11 size-3"
|
class="flex-shrink-0 text-n-slate-11 size-3"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -60,38 +60,47 @@ const filteredAttrs = computed(() => {
|
|||||||
const computedVariant = computed(() => {
|
const computedVariant = computed(() => {
|
||||||
if (props.variant) return props.variant;
|
if (props.variant) return props.variant;
|
||||||
// The useAttrs method returns attributes values an empty string (not boolean value as in props).
|
// The useAttrs method returns attributes values an empty string (not boolean value as in props).
|
||||||
if (attrs.solid || attrs.solid === '') return 'solid';
|
// Add defensive checks for undefined attrs
|
||||||
if (attrs.outline || attrs.outline === '') return 'outline';
|
const attrObj = attrs || {};
|
||||||
if (attrs.faded || attrs.faded === '') return 'faded';
|
if (attrObj.solid || attrObj.solid === '') return 'solid';
|
||||||
if (attrs.link || attrs.link === '') return 'link';
|
if (attrObj.outline || attrObj.outline === '') return 'outline';
|
||||||
if (attrs.ghost || attrs.ghost === '') return 'ghost';
|
if (attrObj.faded || attrObj.faded === '') return 'faded';
|
||||||
|
if (attrObj.link || attrObj.link === '') return 'link';
|
||||||
|
if (attrObj.ghost || attrObj.ghost === '') return 'ghost';
|
||||||
return 'solid'; // Default variant
|
return 'solid'; // Default variant
|
||||||
});
|
});
|
||||||
|
|
||||||
const computedColor = computed(() => {
|
const computedColor = computed(() => {
|
||||||
if (props.color) return props.color;
|
if (props.color) return props.color;
|
||||||
if (attrs.blue || attrs.blue === '') return 'blue';
|
// Add defensive checks for undefined attrs
|
||||||
if (attrs.ruby || attrs.ruby === '') return 'ruby';
|
const attrObj = attrs || {};
|
||||||
if (attrs.amber || attrs.amber === '') return 'amber';
|
if (attrObj.blue || attrObj.blue === '') return 'blue';
|
||||||
if (attrs.slate || attrs.slate === '') return 'slate';
|
if (attrObj.ruby || attrObj.ruby === '') return 'ruby';
|
||||||
if (attrs.teal || attrs.teal === '') return 'teal';
|
if (attrObj.amber || attrObj.amber === '') return 'amber';
|
||||||
|
if (attrObj.slate || attrObj.slate === '') return 'slate';
|
||||||
|
if (attrObj.green || attrObj.green === '') return 'green';
|
||||||
|
if (attrObj.teal || attrObj.teal === '') return 'teal';
|
||||||
return 'blue'; // Default color
|
return 'blue'; // Default color
|
||||||
});
|
});
|
||||||
|
|
||||||
const computedSize = computed(() => {
|
const computedSize = computed(() => {
|
||||||
if (props.size) return props.size;
|
if (props.size) return props.size;
|
||||||
if (attrs.xs || attrs.xs === '') return 'xs';
|
// Add defensive checks for undefined attrs
|
||||||
if (attrs.sm || attrs.sm === '') return 'sm';
|
const attrObj = attrs || {};
|
||||||
if (attrs.md || attrs.md === '') return 'md';
|
if (attrObj.xs || attrObj.xs === '') return 'xs';
|
||||||
if (attrs.lg || attrs.lg === '') return 'lg';
|
if (attrObj.sm || attrObj.sm === '') return 'sm';
|
||||||
|
if (attrObj.md || attrObj.md === '') return 'md';
|
||||||
|
if (attrObj.lg || attrObj.lg === '') return 'lg';
|
||||||
return 'md';
|
return 'md';
|
||||||
});
|
});
|
||||||
|
|
||||||
const computedJustify = computed(() => {
|
const computedJustify = computed(() => {
|
||||||
if (props.justify) return props.justify;
|
if (props.justify) return props.justify;
|
||||||
if (attrs.start || attrs.start === '') return 'start';
|
// Add defensive checks for undefined attrs
|
||||||
if (attrs.center || attrs.center === '') return 'center';
|
const attrObj = attrs || {};
|
||||||
if (attrs.end || attrs.end === '') return 'end';
|
if (attrObj.start || attrObj.start === '') return 'start';
|
||||||
|
if (attrObj.center || attrObj.center === '') return 'center';
|
||||||
|
if (attrObj.end || attrObj.end === '') return 'end';
|
||||||
|
|
||||||
return 'center';
|
return 'center';
|
||||||
});
|
});
|
||||||
@@ -141,6 +150,17 @@ const STYLE_CONFIG = {
|
|||||||
ghost:
|
ghost:
|
||||||
'text-n-slate-12 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
'text-n-slate-12 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||||
},
|
},
|
||||||
|
green: {
|
||||||
|
solid:
|
||||||
|
'bg-green-600 text-white hover:enabled:bg-green-700 focus-visible:bg-green-700 outline-transparent',
|
||||||
|
faded:
|
||||||
|
'bg-green-600/10 text-green-700 hover:enabled:bg-green-600/20 focus-visible:bg-green-600/20 outline-transparent',
|
||||||
|
outline:
|
||||||
|
'text-green-700 hover:enabled:bg-green-600/10 focus-visible:bg-green-600/10 outline-green-600',
|
||||||
|
ghost:
|
||||||
|
'text-green-700 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||||
|
link: 'text-green-700 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||||
|
},
|
||||||
teal: {
|
teal: {
|
||||||
solid:
|
solid:
|
||||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const timeStampURL = computed(() => {
|
const timeStampURL = computed(() => {
|
||||||
return timeStampAppendedURL(attachment.dataUrl);
|
// Safely access the URL, providing a fallback if not available
|
||||||
|
const url = attachment?.dataUrl || attachment?.data_url || '';
|
||||||
|
return timeStampAppendedURL(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
const audioPlayer = useTemplateRef('audioPlayer');
|
const audioPlayer = useTemplateRef('audioPlayer');
|
||||||
@@ -91,8 +93,17 @@ const changePlaybackSpeed = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadAudio = async () => {
|
const downloadAudio = async () => {
|
||||||
const { fileType, dataUrl, extension } = attachment;
|
// Get the URL with fallback options
|
||||||
downloadFile({ url: dataUrl, type: fileType, extension });
|
const url = attachment?.dataUrl || attachment?.data_url || '';
|
||||||
|
if (!url) {
|
||||||
|
console.error('No valid URL found for download');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileType = attachment?.fileType || attachment?.file_type || 'file';
|
||||||
|
const extension = attachment?.extension || 'mp3';
|
||||||
|
|
||||||
|
downloadFile({ url, type: fileType, extension });
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export default {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.toggle-button {
|
.toggle-button {
|
||||||
@apply bg-slate-200 dark:bg-slate-600;
|
@apply bg-slate-200 dark:bg-slate-600;
|
||||||
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
|
--toggle-button-box-shadow:
|
||||||
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
|
rgb(255, 255, 255) 0px 0px 0px 0px, rgba(59, 130, 246, 0.5) 0px 0px 0px 0px,
|
||||||
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
|
rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
|
||||||
border-radius: var(--border-radius-large);
|
border-radius: var(--border-radius-large);
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -0,0 +1,521 @@
|
|||||||
|
<script>
|
||||||
|
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'FloatingCallWidget',
|
||||||
|
props: {
|
||||||
|
callSid: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
inboxName: {
|
||||||
|
type: String,
|
||||||
|
default: 'Primary',
|
||||||
|
},
|
||||||
|
conversationId: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['call-ended'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const callDuration = ref(0);
|
||||||
|
const durationTimer = ref(null);
|
||||||
|
const isCallActive = ref(!!props.callSid);
|
||||||
|
const isMuted = ref(false);
|
||||||
|
const showCallOptions = ref(false);
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
|
||||||
|
// Define local fallback translations in case i18n fails
|
||||||
|
const translations = {
|
||||||
|
'CONVERSATION.END_CALL': 'End call',
|
||||||
|
'CONVERSATION.CALL_ENDED': 'Call ended',
|
||||||
|
'CONVERSATION.CALL_END_ERROR': 'Failed to end call',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedCallDuration = computed(() => {
|
||||||
|
const minutes = Math.floor(callDuration.value / 60);
|
||||||
|
const seconds = callDuration.value % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const startDurationTimer = () => {
|
||||||
|
console.log('Starting duration timer');
|
||||||
|
if (durationTimer.value) clearInterval(durationTimer.value);
|
||||||
|
|
||||||
|
durationTimer.value = setInterval(() => {
|
||||||
|
callDuration.value += 1;
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDurationTimer = () => {
|
||||||
|
if (durationTimer.value) {
|
||||||
|
clearInterval(durationTimer.value);
|
||||||
|
durationTimer.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emergency force end call function - simpler and more direct
|
||||||
|
const forceEndCall = () => {
|
||||||
|
console.log('FORCE END CALL triggered from floating widget');
|
||||||
|
|
||||||
|
// Try all methods to ensure call ends
|
||||||
|
|
||||||
|
// 1. Local component state
|
||||||
|
stopDurationTimer();
|
||||||
|
isCallActive.value = false;
|
||||||
|
|
||||||
|
// Save the call data before potential reset
|
||||||
|
const savedCallSid = props.callSid;
|
||||||
|
const savedConversationId = props.conversationId;
|
||||||
|
|
||||||
|
// 2. First, make direct API call if we have a valid call SID and conversation ID
|
||||||
|
if (savedConversationId && savedCallSid && savedCallSid !== 'pending') {
|
||||||
|
// Check if it's a valid Twilio call SID (starts with CA or TJ)
|
||||||
|
const isValidTwilioSid =
|
||||||
|
savedCallSid.startsWith('CA') || savedCallSid.startsWith('TJ');
|
||||||
|
|
||||||
|
if (isValidTwilioSid) {
|
||||||
|
console.log(
|
||||||
|
'FloatingCallWidget: Making direct API call to end Twilio call with SID:',
|
||||||
|
savedCallSid,
|
||||||
|
'for conversation:',
|
||||||
|
savedConversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the direct API call without using global method
|
||||||
|
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||||
|
.then(response => {
|
||||||
|
console.log(
|
||||||
|
'FloatingCallWidget: Call ended successfully via API:',
|
||||||
|
response
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(
|
||||||
|
'FloatingCallWidget: Error ending call via API:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'FloatingCallWidget: Invalid Twilio call SID format:',
|
||||||
|
savedCallSid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (savedCallSid === 'pending') {
|
||||||
|
console.log(
|
||||||
|
'FloatingCallWidget: Call was still in pending state, no API call needed'
|
||||||
|
);
|
||||||
|
} else if (!savedConversationId) {
|
||||||
|
console.log(
|
||||||
|
'FloatingCallWidget: No conversation ID available for ending call'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('FloatingCallWidget: Missing required data for API call');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Also use global method to update UI states
|
||||||
|
if (window.forceEndCall) {
|
||||||
|
console.log('Using global forceEndCall method');
|
||||||
|
window.forceEndCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks if global method not available
|
||||||
|
|
||||||
|
// 4. Force App state update directly
|
||||||
|
if (window.app) {
|
||||||
|
console.log('Forcing app state update');
|
||||||
|
window.app.$data.showCallWidget = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Emit event
|
||||||
|
emit('call-ended');
|
||||||
|
|
||||||
|
// 6. Update store - using store from setup scope
|
||||||
|
store.dispatch('calls/clearActiveCall');
|
||||||
|
|
||||||
|
// 7. User feedback
|
||||||
|
useAlert({ message: 'Call ended', type: 'success' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Original more careful implementation
|
||||||
|
const endCall = async () => {
|
||||||
|
console.log('Attempting to end call with SID:', props.callSid);
|
||||||
|
|
||||||
|
// First, always hide the UI for immediate feedback
|
||||||
|
stopDurationTimer();
|
||||||
|
isCallActive.value = false;
|
||||||
|
|
||||||
|
// Force update the app's state
|
||||||
|
if (typeof window !== 'undefined' && window.app && window.app.$data) {
|
||||||
|
window.app.$data.showCallWidget = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit the event to parent components
|
||||||
|
emit('call-ended');
|
||||||
|
|
||||||
|
// Show success message to user
|
||||||
|
useAlert({ message: 'Call ended', type: 'success' });
|
||||||
|
|
||||||
|
// Now try the API call (after UI is updated)
|
||||||
|
try {
|
||||||
|
// Skip actual API call if it's a test or temp call SID
|
||||||
|
if (
|
||||||
|
props.callSid &&
|
||||||
|
!props.callSid.startsWith('test-') &&
|
||||||
|
!props.callSid.startsWith('temp-') &&
|
||||||
|
!props.callSid.startsWith('debug-')
|
||||||
|
) {
|
||||||
|
console.log('Ending real call with SID:', props.callSid);
|
||||||
|
await VoiceAPI.endCall(props.callSid);
|
||||||
|
} else {
|
||||||
|
console.log('Using fake/temp call SID, skipping API call');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in API call to end call:', error);
|
||||||
|
// Don't show error to user since UI is already updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear from store as last step
|
||||||
|
const store = useStore();
|
||||||
|
store.dispatch('calls/clearActiveCall');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
// This would typically connect to Twilio's mute functionality
|
||||||
|
// For now we'll just toggle the state
|
||||||
|
isMuted.value = !isMuted.value;
|
||||||
|
useAlert({
|
||||||
|
message: isMuted.value ? 'Call muted' : 'Call unmuted',
|
||||||
|
type: 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a real implementation, you'd call Twilio's API to mute the call
|
||||||
|
// Example: window.twilioDevice.activeConnection().mute(isMuted.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCallOptions = () => {
|
||||||
|
showCallOptions.value = !showCallOptions.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
isFullscreen.value = !isFullscreen.value;
|
||||||
|
// Would typically adjust UI accordingly
|
||||||
|
};
|
||||||
|
|
||||||
|
// Explicit debug handler for end call click
|
||||||
|
const handleEndCallClick = () => {
|
||||||
|
console.log('END CALL BUTTON CLICKED in FloatingCallWidget');
|
||||||
|
console.log(
|
||||||
|
'Current call SID:',
|
||||||
|
props.callSid,
|
||||||
|
'Conversation ID:',
|
||||||
|
props.conversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save the call data before UI updates
|
||||||
|
const savedCallSid = props.callSid;
|
||||||
|
const savedConversationId = props.conversationId;
|
||||||
|
|
||||||
|
// Always update UI immediately for better user experience
|
||||||
|
stopDurationTimer();
|
||||||
|
isCallActive.value = false;
|
||||||
|
|
||||||
|
// Update app state
|
||||||
|
if (window.app) {
|
||||||
|
window.app.$data.showCallWidget = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update store
|
||||||
|
store.dispatch('calls/clearActiveCall');
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
emit('call-ended');
|
||||||
|
|
||||||
|
// Make API call if we have a valid conversation ID and a real call SID (not pending)
|
||||||
|
if (savedConversationId && savedCallSid && savedCallSid !== 'pending') {
|
||||||
|
// Check if it's a valid Twilio call SID (starts with CA or TJ)
|
||||||
|
const isValidTwilioSid =
|
||||||
|
savedCallSid.startsWith('CA') || savedCallSid.startsWith('TJ');
|
||||||
|
|
||||||
|
if (isValidTwilioSid) {
|
||||||
|
console.log(
|
||||||
|
'handleEndCallClick: Making API call to end Twilio call with SID:',
|
||||||
|
savedCallSid,
|
||||||
|
'for conversation:',
|
||||||
|
savedConversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make the API call after UI is updated
|
||||||
|
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||||
|
.then(response => {
|
||||||
|
console.log(
|
||||||
|
'handleEndCallClick: Call ended successfully via API:',
|
||||||
|
response
|
||||||
|
);
|
||||||
|
useAlert({ message: 'Call ended', type: 'success' });
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(
|
||||||
|
'handleEndCallClick: Error ending call via API:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
useAlert({
|
||||||
|
message: 'Call ended (but server may still show as active)',
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'handleEndCallClick: Invalid Twilio call SID format:',
|
||||||
|
savedCallSid
|
||||||
|
);
|
||||||
|
useAlert({ message: 'Call ended', type: 'success' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (savedCallSid === 'pending') {
|
||||||
|
console.log(
|
||||||
|
'handleEndCallClick: Call was still in pending state, no API call needed'
|
||||||
|
);
|
||||||
|
} else if (!savedConversationId) {
|
||||||
|
console.log(
|
||||||
|
'handleEndCallClick: No conversation ID available for ending call'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('handleEndCallClick: Missing required data for API call');
|
||||||
|
}
|
||||||
|
|
||||||
|
useAlert({ message: 'Call ended', type: 'success' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safe translation helper with fallback
|
||||||
|
const safeTranslate = key => {
|
||||||
|
try {
|
||||||
|
return t(key);
|
||||||
|
} catch (error) {
|
||||||
|
return translations[key] || key;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('FloatingCallWidget mounted with callSid:', props.callSid);
|
||||||
|
// Always start the timer, regardless of callSid
|
||||||
|
startDurationTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopDurationTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for call SID changes
|
||||||
|
watch(
|
||||||
|
() => props.callSid,
|
||||||
|
newCallSid => {
|
||||||
|
isCallActive.value = !!newCallSid;
|
||||||
|
|
||||||
|
if (newCallSid) {
|
||||||
|
startDurationTimer();
|
||||||
|
} else {
|
||||||
|
stopDurationTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCallActive,
|
||||||
|
callDuration,
|
||||||
|
formattedCallDuration,
|
||||||
|
isMuted,
|
||||||
|
showCallOptions,
|
||||||
|
isFullscreen,
|
||||||
|
endCall,
|
||||||
|
forceEndCall,
|
||||||
|
handleEndCallClick,
|
||||||
|
toggleMute,
|
||||||
|
toggleCallOptions,
|
||||||
|
toggleFullscreen,
|
||||||
|
safeTranslate,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="floating-call-widget">
|
||||||
|
<div class="call-info">
|
||||||
|
<span class="inbox-name">{{ inboxName }}</span>
|
||||||
|
<span class="call-duration">{{ formattedCallDuration }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="call-controls">
|
||||||
|
<button
|
||||||
|
class="control-button mute-button"
|
||||||
|
:class="{ active: isMuted }"
|
||||||
|
:disabled="callSid === 'pending'"
|
||||||
|
@click="toggleMute"
|
||||||
|
>
|
||||||
|
<span :class="isMuted ? 'i-ph-microphone-slash' : 'i-ph-microphone'" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="control-button end-call-button"
|
||||||
|
title="End Call"
|
||||||
|
@click.prevent.stop="handleEndCallClick"
|
||||||
|
>
|
||||||
|
<span class="i-ph-phone-x" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="callSid === 'pending'" class="status-indicator">
|
||||||
|
Connecting...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="control-button settings-button"
|
||||||
|
@click="toggleCallOptions"
|
||||||
|
>
|
||||||
|
<span class="i-ph-dots-three" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCallOptions" class="call-options">
|
||||||
|
<button @click="toggleFullscreen">
|
||||||
|
{{ isFullscreen ? 'Minimize Call' : 'Expand Call' }}
|
||||||
|
</button>
|
||||||
|
<!-- Add more call options as needed -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.floating-call-widget {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background-color: #1f2937;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 12px 16px;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 220px;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
.call-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.inbox-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-duration {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.control-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #374151;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.end-call-button {
|
||||||
|
background: #dc2626;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #2563eb;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: white;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-options {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22,7 +22,13 @@ export default {
|
|||||||
<div
|
<div
|
||||||
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
|
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
|
||||||
>
|
>
|
||||||
|
<!-- Use i-ph- icons for phone specifically, and FluentIcon for others -->
|
||||||
|
<span
|
||||||
|
v-if="inbox.channel_type === 'Channel::Voice'"
|
||||||
|
class="mr-0.5 rtl:ml-0.5 rtl:mr-0 i-ph-phone text-sm"
|
||||||
|
></span>
|
||||||
<fluent-icon
|
<fluent-icon
|
||||||
|
v-else
|
||||||
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
|
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
|
||||||
:icon="computedInboxClass"
|
:icon="computedInboxClass"
|
||||||
size="12"
|
size="12"
|
||||||
|
|||||||
@@ -110,12 +110,23 @@ export const hasValidAvatarUrl = avatarUrl => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const timeStampAppendedURL = dataUrl => {
|
export const timeStampAppendedURL = dataUrl => {
|
||||||
const url = new URL(dataUrl);
|
try {
|
||||||
if (!url.searchParams.has('t')) {
|
// Make sure the URL is valid before trying to construct it
|
||||||
url.searchParams.append('t', Date.now());
|
if (!dataUrl || typeof dataUrl !== 'string') {
|
||||||
}
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
return url.toString();
|
const url = new URL(dataUrl);
|
||||||
|
if (!url.searchParams.has('t')) {
|
||||||
|
url.searchParams.append('t', Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
} catch (error) {
|
||||||
|
// If URL construction fails, just return the original URL
|
||||||
|
console.error('Invalid URL in timeStampAppendedURL:', error);
|
||||||
|
return dataUrl || '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getHostNameFromURL = url => {
|
export const getHostNameFromURL = url => {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
|||||||
|
|
||||||
case INBOX_TYPES.TWILIO:
|
case INBOX_TYPES.TWILIO:
|
||||||
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
|
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
|
||||||
|
|
||||||
case INBOX_TYPES.VOICE:
|
case INBOX_TYPES.VOICE:
|
||||||
return 'voice';
|
return 'voice';
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
|||||||
return phoneNumber?.startsWith('whatsapp')
|
return phoneNumber?.startsWith('whatsapp')
|
||||||
? 'brand-whatsapp'
|
? 'brand-whatsapp'
|
||||||
: 'brand-sms';
|
: 'brand-sms';
|
||||||
|
|
||||||
case INBOX_TYPES.VOICE:
|
case INBOX_TYPES.VOICE:
|
||||||
return 'phone';
|
return 'phone';
|
||||||
|
|
||||||
|
|||||||
@@ -382,6 +382,9 @@
|
|||||||
"VOICE_CALL": "Call",
|
"VOICE_CALL": "Call",
|
||||||
"CALL_ERROR": "Failed to initiate call. Please try again.",
|
"CALL_ERROR": "Failed to initiate call. Please try again.",
|
||||||
"CALL_INITIATED": "Call initiated successfully.",
|
"CALL_INITIATED": "Call initiated successfully.",
|
||||||
|
"END_CALL": "End call",
|
||||||
|
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback",
|
||||||
|
"TRANSCRIPTION": "Call transcription",
|
||||||
"COPILOT": {
|
"COPILOT": {
|
||||||
"TRY_THESE_PROMPTS": "Try these prompts"
|
"TRY_THESE_PROMPTS": "Try these prompts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
<script>
|
||||||
|
import { computed, ref, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import actionCableService from 'dashboard/helper/actionCable';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CallManager',
|
||||||
|
components: {
|
||||||
|
NextButton,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
conversation: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['callEnded'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const store = useStore();
|
||||||
|
const { accountId } = useAccount();
|
||||||
|
const callStatus = ref('');
|
||||||
|
const callSid = ref('');
|
||||||
|
const callDuration = ref(0);
|
||||||
|
const recordingUrl = ref('');
|
||||||
|
const transcription = ref('');
|
||||||
|
const durationTimer = ref(null);
|
||||||
|
const isCallActive = computed(
|
||||||
|
() => callStatus.value && callStatus.value !== 'completed'
|
||||||
|
);
|
||||||
|
|
||||||
|
const callStatusText = computed(() => {
|
||||||
|
switch (callStatus.value) {
|
||||||
|
case 'queued':
|
||||||
|
return 'Call queued';
|
||||||
|
case 'ringing':
|
||||||
|
return 'Phone ringing...';
|
||||||
|
case 'in-progress':
|
||||||
|
return 'Call in progress';
|
||||||
|
case 'completed':
|
||||||
|
return 'Call completed';
|
||||||
|
case 'failed':
|
||||||
|
return 'Call failed';
|
||||||
|
case 'busy':
|
||||||
|
return 'Phone was busy';
|
||||||
|
case 'no-answer':
|
||||||
|
return 'No answer';
|
||||||
|
default:
|
||||||
|
return 'Call initiated';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedCallDuration = computed(() => {
|
||||||
|
const minutes = Math.floor(callDuration.value / 60);
|
||||||
|
const seconds = callDuration.value % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const startDurationTimer = () => {
|
||||||
|
if (durationTimer.value) clearInterval(durationTimer.value);
|
||||||
|
|
||||||
|
durationTimer.value = setInterval(() => {
|
||||||
|
callDuration.value += 1;
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDurationTimer = () => {
|
||||||
|
if (durationTimer.value) {
|
||||||
|
clearInterval(durationTimer.value);
|
||||||
|
durationTimer.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCallStatus = status => {
|
||||||
|
callStatus.value = status;
|
||||||
|
|
||||||
|
if (status === 'in-progress') {
|
||||||
|
startDurationTimer();
|
||||||
|
} else if (
|
||||||
|
status === 'completed' ||
|
||||||
|
status === 'failed' ||
|
||||||
|
status === 'busy' ||
|
||||||
|
status === 'no-answer'
|
||||||
|
) {
|
||||||
|
stopDurationTimer();
|
||||||
|
emit('callEnded');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endCall = async () => {
|
||||||
|
if (!callSid.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await VoiceAPI.endCall(callSid.value, props.conversation.id);
|
||||||
|
updateCallStatus('completed');
|
||||||
|
useAlert('Call ended', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
useAlert('Failed to end call. Please try again.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupCall = () => {
|
||||||
|
// If there's an active conversation, check for call details
|
||||||
|
if (props.conversation) {
|
||||||
|
const messages = props.conversation.messages || [];
|
||||||
|
|
||||||
|
// Find the most recent call activity message
|
||||||
|
const callMessage = messages.find(
|
||||||
|
message =>
|
||||||
|
message.message_type === 10 && // activity message
|
||||||
|
message.additional_attributes?.call_sid
|
||||||
|
);
|
||||||
|
|
||||||
|
if (callMessage) {
|
||||||
|
const attrs = callMessage.additional_attributes;
|
||||||
|
callSid.value = attrs.call_sid;
|
||||||
|
updateCallStatus(attrs.status || 'initiated');
|
||||||
|
|
||||||
|
if (attrs.recording_url) {
|
||||||
|
recordingUrl.value = attrs.recording_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find transcription if available
|
||||||
|
const transcriptionMessage = messages.find(
|
||||||
|
message =>
|
||||||
|
message.message_type === 0 && // incoming message
|
||||||
|
message.additional_attributes?.is_transcription
|
||||||
|
);
|
||||||
|
|
||||||
|
if (transcriptionMessage) {
|
||||||
|
transcription.value = transcriptionMessage.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup WebSocket listener for call status updates
|
||||||
|
const setupWebSocket = () => {
|
||||||
|
if (!props.conversation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set up ActionCable to listen for call status changes
|
||||||
|
if (accountId.value && props.conversation?.inbox_id) {
|
||||||
|
const roomName = `${accountId.value}_${props.conversation.inbox_id}`;
|
||||||
|
console.log(
|
||||||
|
`Setting up ActionCable listener for call status in room: ${roomName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup ActionCable connection and handler for call status events
|
||||||
|
actionCableService.createConsumer();
|
||||||
|
actionCableService.addRoom(roomName);
|
||||||
|
|
||||||
|
const handleCallStatusChanged = ({ event_name, data }) => {
|
||||||
|
// Only handle call_status_changed events
|
||||||
|
if (event_name !== 'call_status_changed') return;
|
||||||
|
|
||||||
|
console.log('Received call status change via ActionCable:', data);
|
||||||
|
|
||||||
|
// Only update if it's for our current call
|
||||||
|
if (data.call_sid === callSid.value) {
|
||||||
|
console.log(
|
||||||
|
`Updating call status from ${callStatus.value} to ${data.status}`
|
||||||
|
);
|
||||||
|
updateCallStatus(data.status);
|
||||||
|
|
||||||
|
// If call is completed, refresh the conversation to get any recordings
|
||||||
|
if (data.status === 'completed' || data.status === 'canceled') {
|
||||||
|
// Notify parent component that call has ended
|
||||||
|
emit('callEnded');
|
||||||
|
|
||||||
|
// Refresh the conversation to get updated messages with recordings
|
||||||
|
if (props.conversation?.id) {
|
||||||
|
store.dispatch('fetchConversation', {
|
||||||
|
id: props.conversation.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register for events
|
||||||
|
actionCableService.onReceivedMessage = handleCallStatusChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also set up store watcher as backup method
|
||||||
|
if (
|
||||||
|
store.state.conversations &&
|
||||||
|
store.state.conversations.conversations
|
||||||
|
) {
|
||||||
|
const unwatch = store.watch(
|
||||||
|
state => {
|
||||||
|
if (!props.conversation || !props.conversation.id) return null;
|
||||||
|
const conversations = state.conversations?.conversations || {};
|
||||||
|
const conv = conversations[props.conversation.id];
|
||||||
|
return conv ? conv.messages : null;
|
||||||
|
},
|
||||||
|
messages => {
|
||||||
|
if (!messages) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check for call status messages
|
||||||
|
const callMessage = messages.find(
|
||||||
|
message =>
|
||||||
|
message.message_type === 10 && // activity message
|
||||||
|
message.additional_attributes &&
|
||||||
|
message.additional_attributes.call_sid === callSid.value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (callMessage?.additional_attributes) {
|
||||||
|
if (callMessage.additional_attributes.call_status) {
|
||||||
|
updateCallStatus(
|
||||||
|
callMessage.additional_attributes.call_status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callMessage.additional_attributes.recording_url) {
|
||||||
|
recordingUrl.value =
|
||||||
|
callMessage.additional_attributes.recording_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for transcription messages
|
||||||
|
const transcriptionMessage = messages.find(
|
||||||
|
message =>
|
||||||
|
message.message_type === 0 && // incoming message
|
||||||
|
message.additional_attributes &&
|
||||||
|
message.additional_attributes.is_transcription
|
||||||
|
);
|
||||||
|
|
||||||
|
if (transcriptionMessage) {
|
||||||
|
transcription.value = transcriptionMessage.content;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error processing message updates:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up the watcher when component is unmounted
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (unwatch) {
|
||||||
|
unwatch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('Conversations store not found or initialized');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting up message watcher:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Wrap in try/catch to prevent Vue errors if there's an issue
|
||||||
|
try {
|
||||||
|
if (props.conversation && props.conversation.id) {
|
||||||
|
// Proceed with setup, using props.conversation.messages or default inside setupCall
|
||||||
|
setupCall();
|
||||||
|
setupWebSocket();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in CallManager mounted:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopDurationTimer();
|
||||||
|
|
||||||
|
// Clean up the ActionCable connection
|
||||||
|
if (accountId.value && props.conversation?.inbox_id) {
|
||||||
|
try {
|
||||||
|
const roomName = `${accountId.value}_${props.conversation.inbox_id}`;
|
||||||
|
actionCableService.removeRoom(roomName);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cleaning up ActionCable:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
callStatus,
|
||||||
|
callStatusText,
|
||||||
|
isCallActive,
|
||||||
|
recordingUrl,
|
||||||
|
transcription,
|
||||||
|
callDuration,
|
||||||
|
formattedCallDuration,
|
||||||
|
endCall,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-show="isCallActive && callStatus"
|
||||||
|
v-if="isCallActive && callStatus"
|
||||||
|
class="relative p-4 mb-4 border border-solid rounded-md bg-n-slate-1 border-n-slate-4 flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-red-600 animate-pulse i-ph-phone-call text-xl" />
|
||||||
|
<h3 class="mb-0 text-base font-medium">{{ callStatusText }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div v-if="callDuration" class="text-sm text-n-slate-9">
|
||||||
|
{{ formattedCallDuration }}
|
||||||
|
</div>
|
||||||
|
<NextButton
|
||||||
|
v-tooltip.top-end="$t('CONVERSATION.END_CALL')"
|
||||||
|
icon="i-ph-phone-x"
|
||||||
|
sm
|
||||||
|
ruby
|
||||||
|
@click.stop.prevent="endCall"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="recordingUrl" class="w-full mt-2">
|
||||||
|
<audio controls class="w-full h-10">
|
||||||
|
<source :src="recordingUrl" type="audio/mpeg" />
|
||||||
|
{{ $t('CONVERSATION.AUDIO_NOT_SUPPORTED') }}
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="transcription"
|
||||||
|
class="mt-2 p-2 border border-solid rounded bg-n-slate-2 border-n-slate-5 text-sm"
|
||||||
|
>
|
||||||
|
<h4 class="mb-1 text-xs font-semibold text-n-slate-10">
|
||||||
|
{{ $t('CONVERSATION.TRANSCRIPTION') }}
|
||||||
|
</h4>
|
||||||
|
<p class="m-0">{{ transcription }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -13,6 +13,7 @@ import ComposeConversation from 'dashboard/components-next/NewConversation/Compo
|
|||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||||
|
import CallManager from './CallManager.vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isAConversationRoute,
|
isAConversationRoute,
|
||||||
@@ -30,6 +31,7 @@ export default {
|
|||||||
ComposeConversation,
|
ComposeConversation,
|
||||||
SocialIcons,
|
SocialIcons,
|
||||||
ContactMergeModal,
|
ContactMergeModal,
|
||||||
|
CallManager,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin],
|
mixins: [inboxMixin],
|
||||||
props: {
|
props: {
|
||||||
@@ -55,6 +57,8 @@ export default {
|
|||||||
showMergeModal: false,
|
showMergeModal: false,
|
||||||
showDeleteModal: false,
|
showDeleteModal: false,
|
||||||
isCallLoading: false,
|
isCallLoading: false,
|
||||||
|
activeCallConversation: null,
|
||||||
|
isHoveringCallButton: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -144,18 +148,255 @@ export default {
|
|||||||
},
|
},
|
||||||
async initiateVoiceCall() {
|
async initiateVoiceCall() {
|
||||||
if (!this.contact || !this.contact.id) return;
|
if (!this.contact || !this.contact.id) return;
|
||||||
|
|
||||||
this.isCallLoading = true;
|
this.isCallLoading = true;
|
||||||
try {
|
try {
|
||||||
const response = await VoiceAPI.initiateCall(this.contact.id);
|
const response = await VoiceAPI.initiateCall(this.contact.id);
|
||||||
useAlert('Call initiated successfully', 'success');
|
const conversation = response.data;
|
||||||
|
|
||||||
|
// First set local state for immediate UI update
|
||||||
|
this.activeCallConversation = conversation;
|
||||||
|
console.log('Call initiated, conversation data:', conversation);
|
||||||
|
|
||||||
|
// Always create a call SID even if it's not in the response
|
||||||
|
let callSid = conversation?.call_sid;
|
||||||
|
|
||||||
|
// If not directly available, try to find it in messages
|
||||||
|
if (!callSid) {
|
||||||
|
const messages = conversation?.messages || [];
|
||||||
|
const callMessage = messages.find(
|
||||||
|
message =>
|
||||||
|
message.message_type === 10 &&
|
||||||
|
message.additional_attributes &&
|
||||||
|
message.additional_attributes.call_sid
|
||||||
|
);
|
||||||
|
|
||||||
|
callSid = callMessage?.additional_attributes?.call_sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still not found, check conversation.additional_attributes
|
||||||
|
if (!callSid && conversation?.additional_attributes) {
|
||||||
|
callSid = conversation.additional_attributes.call_sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we don't have a call SID, log the error but continue
|
||||||
|
// This will allow the UI to show something while we wait for the real call SID
|
||||||
|
if (!callSid) {
|
||||||
|
console.log(
|
||||||
|
'No call SID found in response, waiting for server to assign one'
|
||||||
|
);
|
||||||
|
|
||||||
|
// We'll rely on WebSocket updates to get the real call SID when available
|
||||||
|
// For now just set a placeholder for UI purposes
|
||||||
|
callSid = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log for debugging
|
||||||
|
console.log('Voice call response:', conversation);
|
||||||
|
console.log('Using call SID:', callSid);
|
||||||
|
|
||||||
|
// Always set the global call state for the floating widget
|
||||||
|
const inbox = conversation.inbox_id
|
||||||
|
? this.$store.getters['inboxes/getInbox'](conversation.inbox_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.$store.dispatch('calls/setActiveCall', {
|
||||||
|
callSid,
|
||||||
|
inboxName: inbox?.name || 'Primary',
|
||||||
|
conversationId: conversation.id,
|
||||||
|
contactId: this.contact.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set App's showCallWidget to true
|
||||||
|
if (window.app && window.app.$data) {
|
||||||
|
window.app.$data.showCallWidget = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a brief delay, force update UI
|
||||||
|
setTimeout(() => {
|
||||||
|
this.$forceUpdate();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
useAlert('Voice call initiated successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error handled with useAlert
|
// Error handled with useAlert
|
||||||
useAlert('Failed to initiate call. Please try again.', 'error');
|
useAlert('Failed to initiate voice call');
|
||||||
} finally {
|
} finally {
|
||||||
this.isCallLoading = false;
|
this.isCallLoading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleCallEnded() {
|
||||||
|
this.activeCallConversation = null;
|
||||||
|
// Clear global call state
|
||||||
|
this.$store.dispatch('calls/clearActiveCall');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Simplified emergency end call function
|
||||||
|
forceEndActiveCall() {
|
||||||
|
console.log('FORCE END ACTIVE CALL triggered from ContactInfo');
|
||||||
|
|
||||||
|
// Important: Save a reference to the conversation before resetting it
|
||||||
|
const savedConversation = this.activeCallConversation;
|
||||||
|
|
||||||
|
// 1. Immediately update local state for immediate UI feedback
|
||||||
|
this.activeCallConversation = null;
|
||||||
|
this.isHoveringCallButton = false;
|
||||||
|
this.$forceUpdate();
|
||||||
|
|
||||||
|
// 2. Reset App global state
|
||||||
|
if (window.app) {
|
||||||
|
window.app.$data.showCallWidget = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reset store state
|
||||||
|
this.$store.dispatch('calls/clearActiveCall');
|
||||||
|
|
||||||
|
// 4. Get the call SID from the saved conversation
|
||||||
|
if (savedConversation) {
|
||||||
|
// Try to find the call SID
|
||||||
|
let callSid = null;
|
||||||
|
|
||||||
|
// Check all possible locations
|
||||||
|
if (savedConversation.call_sid) {
|
||||||
|
callSid = savedConversation.call_sid;
|
||||||
|
} else if (savedConversation.additional_attributes?.call_sid) {
|
||||||
|
callSid = savedConversation.additional_attributes.call_sid;
|
||||||
|
} else if (
|
||||||
|
savedConversation.messages &&
|
||||||
|
savedConversation.messages.length > 0
|
||||||
|
) {
|
||||||
|
// Look in messages
|
||||||
|
const callMessage = savedConversation.messages.find(
|
||||||
|
message =>
|
||||||
|
message.message_type === 10 &&
|
||||||
|
message.additional_attributes?.call_sid
|
||||||
|
);
|
||||||
|
|
||||||
|
if (callMessage) {
|
||||||
|
callSid = callMessage.additional_attributes.call_sid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ContactInfo: Found call SID for API call:', callSid);
|
||||||
|
|
||||||
|
// 5. Make direct API call to end the call if we have a valid call SID
|
||||||
|
if (callSid && callSid !== 'pending') {
|
||||||
|
// Check if it's a valid Twilio call SID
|
||||||
|
const isValidTwilioSid =
|
||||||
|
callSid.startsWith('CA') || callSid.startsWith('TJ');
|
||||||
|
|
||||||
|
if (isValidTwilioSid) {
|
||||||
|
console.log(
|
||||||
|
'ContactInfo: Making direct API call to end call with SID:',
|
||||||
|
callSid
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make API call with conversation ID
|
||||||
|
VoiceAPI.endCall(callSid, savedConversation.id)
|
||||||
|
.then(response => {
|
||||||
|
console.log(
|
||||||
|
'ContactInfo: Call ended successfully via API:',
|
||||||
|
response
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('ContactInfo: Error ending call via API:', error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'ContactInfo: Invalid Twilio call SID format:',
|
||||||
|
callSid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (callSid === 'pending') {
|
||||||
|
console.log(
|
||||||
|
'ContactInfo: Call was still in pending state, no API call needed'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('ContactInfo: No call SID available for API call');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. User feedback
|
||||||
|
useAlert({ message: 'Call ended successfully', type: 'success' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Original more careful implementation
|
||||||
|
async endActiveCall() {
|
||||||
|
console.log('End active call triggered from ContactInfo component');
|
||||||
|
|
||||||
|
// First, immediately update the UI for responsive feedback
|
||||||
|
const savedActiveCall = this.activeCallConversation;
|
||||||
|
this.activeCallConversation = null;
|
||||||
|
this.$forceUpdate();
|
||||||
|
|
||||||
|
// Reset app-level state
|
||||||
|
if (window.app && window.app.$data) {
|
||||||
|
window.app.$data.showCallWidget = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear global state
|
||||||
|
this.$store.dispatch('calls/clearActiveCall');
|
||||||
|
|
||||||
|
// Always give user success feedback
|
||||||
|
useAlert({ message: 'Call ended successfully', type: 'success' });
|
||||||
|
|
||||||
|
// Then try the API call (after UI is updated)
|
||||||
|
try {
|
||||||
|
if (savedActiveCall) {
|
||||||
|
// Try to find the call SID
|
||||||
|
let callSid = null;
|
||||||
|
|
||||||
|
// Check all possible locations
|
||||||
|
if (savedActiveCall.call_sid) {
|
||||||
|
callSid = savedActiveCall.call_sid;
|
||||||
|
} else if (savedActiveCall.additional_attributes?.call_sid) {
|
||||||
|
callSid = savedActiveCall.additional_attributes.call_sid;
|
||||||
|
} else {
|
||||||
|
// Look in messages
|
||||||
|
const messages = savedActiveCall.messages || [];
|
||||||
|
const callMessage = messages.find(
|
||||||
|
message =>
|
||||||
|
message.message_type === 10 &&
|
||||||
|
message.additional_attributes?.call_sid
|
||||||
|
);
|
||||||
|
|
||||||
|
if (callMessage) {
|
||||||
|
callSid = callMessage.additional_attributes.call_sid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Found call SID for API call:', callSid);
|
||||||
|
|
||||||
|
// Make the API call if we have a valid call SID
|
||||||
|
if (callSid && callSid !== 'pending') {
|
||||||
|
// Check if it's a valid Twilio call SID
|
||||||
|
const isValidTwilioSid =
|
||||||
|
callSid.startsWith('CA') || callSid.startsWith('TJ');
|
||||||
|
|
||||||
|
if (isValidTwilioSid) {
|
||||||
|
try {
|
||||||
|
console.log('Making API call to end call with SID:', callSid);
|
||||||
|
await VoiceAPI.endCall(callSid, savedActiveCall.id);
|
||||||
|
console.log('API call to end call succeeded');
|
||||||
|
} catch (apiError) {
|
||||||
|
console.error('API call to end call failed:', apiError);
|
||||||
|
// We've already updated UI, so don't show error to user
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Invalid Twilio call SID format:', callSid);
|
||||||
|
}
|
||||||
|
} else if (callSid === 'pending') {
|
||||||
|
console.log('Call was still in pending state, no API call needed');
|
||||||
|
} else {
|
||||||
|
console.log('No call SID available for API call');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in endActiveCall:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
async deleteContact({ id }) {
|
async deleteContact({ id }) {
|
||||||
try {
|
try {
|
||||||
await this.$store.dispatch('contacts/delete', id);
|
await this.$store.dispatch('contacts/delete', id);
|
||||||
@@ -189,6 +430,16 @@ export default {
|
|||||||
openMergeModal() {
|
openMergeModal() {
|
||||||
this.showMergeModal = true;
|
this.showMergeModal = true;
|
||||||
},
|
},
|
||||||
|
onCallButtonClick() {
|
||||||
|
if (this.activeCallConversation) {
|
||||||
|
useAlert('Call already ongoing', 'warning');
|
||||||
|
if (window.app && window.app.$data) {
|
||||||
|
window.app.$data.showCallWidget = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.initiateVoiceCall();
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -196,6 +447,14 @@ export default {
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative items-center w-full p-4">
|
<div class="relative items-center w-full p-4">
|
||||||
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
|
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
|
||||||
|
<!-- Call Manager Component - Shows only when a call is active -->
|
||||||
|
<CallManager
|
||||||
|
v-if="activeCallConversation && contact && contact.id"
|
||||||
|
:contact="contact"
|
||||||
|
:conversation="activeCallConversation"
|
||||||
|
@call-ended="handleCallEnded"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
v-if="showAvatar"
|
v-if="showAvatar"
|
||||||
@@ -296,13 +555,16 @@ export default {
|
|||||||
</ComposeConversation>
|
</ComposeConversation>
|
||||||
<NextButton
|
<NextButton
|
||||||
v-if="contact.phone_number"
|
v-if="contact.phone_number"
|
||||||
v-tooltip.top-end="'Call'"
|
v-tooltip.top-end="
|
||||||
|
activeCallConversation ? 'Call already ongoing' : 'Call'
|
||||||
|
"
|
||||||
icon="i-ph-phone"
|
icon="i-ph-phone"
|
||||||
slate
|
slate
|
||||||
faded
|
faded
|
||||||
sm
|
sm
|
||||||
:is-loading="isCallLoading"
|
:is-loading="!activeCallConversation && isCallLoading"
|
||||||
@click.stop.prevent="initiateVoiceCall"
|
:color="activeCallConversation ? 'teal' : undefined"
|
||||||
|
@click.stop.prevent="onCallButtonClick"
|
||||||
/>
|
/>
|
||||||
<NextButton
|
<NextButton
|
||||||
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
|
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default {
|
|||||||
icon="i-lucide-clipboard"
|
icon="i-lucide-clipboard"
|
||||||
@click="onCopy"
|
@click="onCopy"
|
||||||
/>
|
/>
|
||||||
<slot v-if="buttonSlot" name="button"></slot>
|
<slot v-if="buttonSlot" name="button" />
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +1,3 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<PageHeader
|
|
||||||
:header-title="$t('INBOX_MGMT.ADD.VOICE.TITLE')"
|
|
||||||
:header-content="$t('INBOX_MGMT.ADD.VOICE.DESC')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<form class="flex flex-wrap flex-col gap-4 p-2" @submit.prevent="createChannel">
|
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
|
||||||
<label>
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.PROVIDER.LABEL') }}
|
|
||||||
<select
|
|
||||||
v-model="provider"
|
|
||||||
class="p-2 bg-white border border-n-blue-100 rounded"
|
|
||||||
@change="onProviderChange"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="option in providerOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Twilio Provider Config -->
|
|
||||||
<div v-if="provider === 'twilio'" class="flex-shrink-0 flex-grow-0">
|
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
|
||||||
<label :class="{ error: v$.phoneNumber.$error }">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL') }}
|
|
||||||
<input
|
|
||||||
v-model.trim="phoneNumber"
|
|
||||||
type="text"
|
|
||||||
:placeholder="$t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
|
|
||||||
@blur="v$.phoneNumber.$touch"
|
|
||||||
/>
|
|
||||||
<span v-if="v$.phoneNumber.$error" class="message">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
|
||||||
<label :class="{ error: v$.accountSid.$error }">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL') }}
|
|
||||||
<input
|
|
||||||
v-model.trim="accountSid"
|
|
||||||
type="text"
|
|
||||||
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
|
|
||||||
@blur="v$.accountSid.$touch"
|
|
||||||
/>
|
|
||||||
<span v-if="v$.accountSid.$error" class="message">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
|
||||||
<label :class="{ error: v$.authToken.$error }">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL') }}
|
|
||||||
<input
|
|
||||||
v-model.trim="authToken"
|
|
||||||
type="text"
|
|
||||||
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
|
|
||||||
@blur="v$.authToken.$touch"
|
|
||||||
/>
|
|
||||||
<span v-if="v$.authToken.$error" class="message">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add other provider configs here -->
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<NextButton
|
|
||||||
:is-loading="uiFlags.isCreating"
|
|
||||||
:is-disabled="v$.$invalid"
|
|
||||||
:label="$t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
|
|
||||||
type="submit"
|
|
||||||
color="blue"
|
|
||||||
@click="createChannel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required } from '@vuelidate/validators';
|
import { required } from '@vuelidate/validators';
|
||||||
@@ -163,10 +72,10 @@ export default {
|
|||||||
if (this.v$.$invalid) {
|
if (this.v$.$invalid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const providerConfig = this.getProviderConfig();
|
const providerConfig = this.getProviderConfig();
|
||||||
|
|
||||||
const channel = await this.$store.dispatch(
|
const channel = await this.$store.dispatch(
|
||||||
'inboxes/createVoiceChannel',
|
'inboxes/createVoiceChannel',
|
||||||
{
|
{
|
||||||
@@ -178,7 +87,7 @@ export default {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
router.replace({
|
router.replace({
|
||||||
name: 'settings_inboxes_add_agents',
|
name: 'settings_inboxes_add_agents',
|
||||||
params: {
|
params: {
|
||||||
@@ -187,9 +96,110 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(error.response?.data?.message || this.$t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE'));
|
useAlert(
|
||||||
|
error.response?.data?.message ||
|
||||||
|
this.$t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
:header-title="$t('INBOX_MGMT.ADD.VOICE.TITLE')"
|
||||||
|
:header-content="$t('INBOX_MGMT.ADD.VOICE.DESC')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="flex flex-wrap flex-col gap-4 p-2"
|
||||||
|
@submit.prevent="createChannel"
|
||||||
|
>
|
||||||
|
<div class="flex-shrink-0 flex-grow-0">
|
||||||
|
<label>
|
||||||
|
{{ $t('INBOX_MGMT.ADD.VOICE.PROVIDER.LABEL') }}
|
||||||
|
<select
|
||||||
|
v-model="provider"
|
||||||
|
class="p-2 bg-white border border-n-blue-100 rounded"
|
||||||
|
@change="onProviderChange"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in providerOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Twilio Provider Config -->
|
||||||
|
<div v-if="provider === 'twilio'" class="flex-shrink-0 flex-grow-0">
|
||||||
|
<div class="flex-shrink-0 flex-grow-0">
|
||||||
|
<label :class="{ error: v$.phoneNumber.$error }">
|
||||||
|
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL') }}
|
||||||
|
<input
|
||||||
|
v-model.trim="phoneNumber"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
|
||||||
|
@blur="v$.phoneNumber.$touch"
|
||||||
|
/>
|
||||||
|
<span v-if="v$.phoneNumber.$error" class="message">
|
||||||
|
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR') }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-shrink-0 flex-grow-0">
|
||||||
|
<label :class="{ error: v$.accountSid.$error }">
|
||||||
|
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL') }}
|
||||||
|
<input
|
||||||
|
v-model.trim="accountSid"
|
||||||
|
type="text"
|
||||||
|
:placeholder="
|
||||||
|
$t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
@blur="v$.accountSid.$touch"
|
||||||
|
/>
|
||||||
|
<span v-if="v$.accountSid.$error" class="message">
|
||||||
|
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED') }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-shrink-0 flex-grow-0">
|
||||||
|
<label :class="{ error: v$.authToken.$error }">
|
||||||
|
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL') }}
|
||||||
|
<input
|
||||||
|
v-model.trim="authToken"
|
||||||
|
type="text"
|
||||||
|
:placeholder="
|
||||||
|
$t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
@blur="v$.authToken.$touch"
|
||||||
|
/>
|
||||||
|
<span v-if="v$.authToken.$error" class="message">
|
||||||
|
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED') }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add other provider configs here -->
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<NextButton
|
||||||
|
:is-loading="uiFlags.isCreating"
|
||||||
|
:is-disabled="v$.$invalid"
|
||||||
|
:label="$t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
|
||||||
|
type="submit"
|
||||||
|
color="blue"
|
||||||
|
@click="createChannel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import auditlogs from './modules/auditlogs';
|
|||||||
import auth from './modules/auth';
|
import auth from './modules/auth';
|
||||||
import automations from './modules/automations';
|
import automations from './modules/automations';
|
||||||
import bulkActions from './modules/bulkActions';
|
import bulkActions from './modules/bulkActions';
|
||||||
|
import calls from './modules/calls';
|
||||||
import campaigns from './modules/campaigns';
|
import campaigns from './modules/campaigns';
|
||||||
import cannedResponse from './modules/cannedResponse';
|
import cannedResponse from './modules/cannedResponse';
|
||||||
import categories from './modules/helpCenterCategories';
|
import categories from './modules/helpCenterCategories';
|
||||||
@@ -64,6 +65,7 @@ export default createStore({
|
|||||||
auth,
|
auth,
|
||||||
automations,
|
automations,
|
||||||
bulkActions,
|
bulkActions,
|
||||||
|
calls,
|
||||||
campaigns,
|
campaigns,
|
||||||
cannedResponse,
|
cannedResponse,
|
||||||
categories,
|
categories,
|
||||||
|
|||||||
46
app/javascript/dashboard/store/modules/calls.js
Normal file
46
app/javascript/dashboard/store/modules/calls.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const state = {
|
||||||
|
activeCall: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getters = {
|
||||||
|
getActiveCall: $state => $state.activeCall,
|
||||||
|
hasActiveCall: $state => !!$state.activeCall,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
setActiveCall({ commit }, callData) {
|
||||||
|
console.log('Setting active call in store:', callData);
|
||||||
|
commit('SET_ACTIVE_CALL', callData);
|
||||||
|
|
||||||
|
// If we're in a browser environment, try to set the app state
|
||||||
|
if (typeof window !== 'undefined' && window.app && window.app.$data) {
|
||||||
|
window.app.$data.showCallWidget = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearActiveCall({ commit }) {
|
||||||
|
console.log('Clearing active call in store');
|
||||||
|
commit('CLEAR_ACTIVE_CALL');
|
||||||
|
|
||||||
|
// If we're in a browser environment, try to clear the app state
|
||||||
|
if (typeof window !== 'undefined' && window.app && window.app.$data) {
|
||||||
|
window.app.$data.showCallWidget = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutations = {
|
||||||
|
SET_ACTIVE_CALL($state, callData) {
|
||||||
|
$state.activeCall = callData;
|
||||||
|
},
|
||||||
|
CLEAR_ACTIVE_CALL($state) {
|
||||||
|
$state.activeCall = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state,
|
||||||
|
getters,
|
||||||
|
actions,
|
||||||
|
mutations,
|
||||||
|
};
|
||||||
191
app/jobs/call_transcription_job.rb
Normal file
191
app/jobs/call_transcription_job.rb
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
class CallTranscriptionJob < ApplicationJob
|
||||||
|
queue_as :low
|
||||||
|
|
||||||
|
def perform(params)
|
||||||
|
call_sid = params['CallSid']
|
||||||
|
recording_url = params['RecordingUrl']
|
||||||
|
recording_sid = params['RecordingSid']
|
||||||
|
to_number = params['To']
|
||||||
|
from_number = params['From']
|
||||||
|
direction = params['Direction']
|
||||||
|
call_status = params['CallStatus']
|
||||||
|
|
||||||
|
# Determine if this is an outbound call
|
||||||
|
is_outbound = direction == 'outbound-api'
|
||||||
|
|
||||||
|
# Find the inbox for this voice call based on the direction
|
||||||
|
# For outbound calls, the "From" is the Chatwoot number
|
||||||
|
# For inbound calls, the "To" is the Chatwoot number
|
||||||
|
channel_number = is_outbound ? from_number : to_number
|
||||||
|
|
||||||
|
inbox = 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 = ?", channel_number)
|
||||||
|
.first
|
||||||
|
|
||||||
|
return if inbox.nil?
|
||||||
|
|
||||||
|
account = inbox.account
|
||||||
|
|
||||||
|
# For outbound calls, the contact is the person being called (To)
|
||||||
|
# For inbound calls, the contact is the caller (From)
|
||||||
|
contact_number = is_outbound ? to_number : from_number
|
||||||
|
|
||||||
|
# Find or create the contact
|
||||||
|
contact = account.contacts.find_or_create_by(phone_number: contact_number) do |c|
|
||||||
|
c.name = is_outbound ? "Customer at #{contact_number}" : "Caller from #{contact_number}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find or create the contact inbox
|
||||||
|
contact_inbox = ContactInbox.find_or_initialize_by(
|
||||||
|
contact_id: contact.id,
|
||||||
|
inbox_id: inbox.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set the source_id if it's a new record
|
||||||
|
if contact_inbox.new_record?
|
||||||
|
contact_inbox.source_id = from_number
|
||||||
|
end
|
||||||
|
|
||||||
|
contact_inbox.save!
|
||||||
|
|
||||||
|
# Find or create a conversation for this call
|
||||||
|
conversation = account.conversations.find_or_create_by(
|
||||||
|
contact_inbox_id: contact_inbox.id,
|
||||||
|
inbox_id: inbox.id
|
||||||
|
) do |conv|
|
||||||
|
conv.status = :open
|
||||||
|
end
|
||||||
|
|
||||||
|
# If the conversation was auto-resolved, reopen it
|
||||||
|
if conversation.resolved?
|
||||||
|
conversation.toggle_status
|
||||||
|
end
|
||||||
|
|
||||||
|
# If we have a transcription, add it to an existing message or create a new one
|
||||||
|
if params['TranscriptionText'].present?
|
||||||
|
# Look for a recent message from this recording
|
||||||
|
recording_message = conversation.messages
|
||||||
|
.where("additional_attributes @> ?", { recording_sid: recording_sid }.to_json)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.first
|
||||||
|
|
||||||
|
if recording_message
|
||||||
|
# Update existing message with transcription
|
||||||
|
recording_message.content = params['TranscriptionText']
|
||||||
|
recording_message.additional_attributes[:transcription] = params['TranscriptionText']
|
||||||
|
recording_message.save!
|
||||||
|
else
|
||||||
|
# Create a new message with the transcription
|
||||||
|
message_params = {
|
||||||
|
content: params['TranscriptionText'],
|
||||||
|
message_type: :incoming,
|
||||||
|
additional_attributes: {
|
||||||
|
is_transcription: true,
|
||||||
|
call_sid: call_sid,
|
||||||
|
recording_sid: recording_sid,
|
||||||
|
recording_url: recording_url
|
||||||
|
},
|
||||||
|
source_id: "transcription_#{recording_sid}"
|
||||||
|
}
|
||||||
|
|
||||||
|
message = Messages::MessageBuilder.new(contact, conversation, message_params).perform
|
||||||
|
|
||||||
|
# Attach the recording as an attachment if it's a valid Twilio recording URL
|
||||||
|
if recording_url.present? && recording_url.include?('/Recordings/') && recording_sid.present?
|
||||||
|
begin
|
||||||
|
# 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']
|
||||||
|
|
||||||
|
# Create an authenticated URL that includes auth details
|
||||||
|
# Format for MP3 playback
|
||||||
|
recording_mp3_url = "#{recording_url}.mp3"
|
||||||
|
|
||||||
|
attachment_params = {
|
||||||
|
file_type: :audio,
|
||||||
|
account_id: inbox.account_id,
|
||||||
|
external_url: recording_mp3_url,
|
||||||
|
fallback_title: 'Voice recording',
|
||||||
|
meta: {
|
||||||
|
recording_sid: recording_sid,
|
||||||
|
twilio_account_sid: account_sid,
|
||||||
|
auth_required: true,
|
||||||
|
transcription: params['TranscriptionText']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attachment = message.attachments.new(attachment_params)
|
||||||
|
attachment.save!
|
||||||
|
Rails.logger.info("Successfully attached voice recording from #{recording_url}")
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Error attaching voice recording: #{e.message}")
|
||||||
|
|
||||||
|
# Try with a more general file type if audio fails
|
||||||
|
begin
|
||||||
|
fallback_attachment = message.attachments.new(
|
||||||
|
file_type: :file,
|
||||||
|
account_id: inbox.account_id,
|
||||||
|
external_url: recording_mp3_url,
|
||||||
|
fallback_title: 'Voice Recording (.mp3)',
|
||||||
|
meta: {
|
||||||
|
recording_sid: recording_sid,
|
||||||
|
twilio_account_sid: account_sid,
|
||||||
|
auth_required: true,
|
||||||
|
transcription: params['TranscriptionText']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
fallback_attachment.save!
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Failed to create fallback attachment: #{e.message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Rails.logger.error("Invalid Twilio recording URL format or missing SID: #{recording_url}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create or update call status information
|
||||||
|
call_info_message = conversation.messages
|
||||||
|
.where("additional_attributes @> ?", { call_sid: call_sid, message_type: 'activity' }.to_json)
|
||||||
|
.first
|
||||||
|
|
||||||
|
message_attributes = {
|
||||||
|
call_sid: call_sid,
|
||||||
|
call_status: params['CallStatus'] || 'in-progress'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add recording details if available
|
||||||
|
if recording_url.present?
|
||||||
|
message_attributes[:recordings_count] = (call_info_message&.additional_attributes&.dig(:recordings_count) || 0) + 1
|
||||||
|
message_attributes[:latest_recording_url] = recording_url
|
||||||
|
message_attributes[:latest_recording_sid] = recording_sid
|
||||||
|
message_attributes[:call_duration] = params['RecordingDuration']
|
||||||
|
end
|
||||||
|
|
||||||
|
if call_info_message.present?
|
||||||
|
# Update the existing call info message
|
||||||
|
call_info_message.additional_attributes.merge!(message_attributes)
|
||||||
|
call_info_message.save!
|
||||||
|
else
|
||||||
|
# Create a new call info message
|
||||||
|
message_params = {
|
||||||
|
content: 'Voice call in progress',
|
||||||
|
message_type: :activity,
|
||||||
|
additional_attributes: message_attributes,
|
||||||
|
source_id: "voice_call_#{call_sid}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Messages::MessageBuilder.new(nil, conversation, message_params).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
# Notify the frontend about the update
|
||||||
|
ActionCableBroadcastJob.perform_later(
|
||||||
|
conversation.account_id,
|
||||||
|
'conversation.updated',
|
||||||
|
conversation.push_event_data.merge(
|
||||||
|
status: conversation.status
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
19
app/listeners/message_listener.rb
Normal file
19
app/listeners/message_listener.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
class MessageListener < BaseListener
|
||||||
|
def message_created(event)
|
||||||
|
message = extract_message_and_account(event)[0]
|
||||||
|
return if message.nil?
|
||||||
|
|
||||||
|
if message.conversation.inbox.channel.class.name == 'Channel::Voice'
|
||||||
|
# Deliver the message to voice call if it's a voice channel
|
||||||
|
Voice::MessageDeliveryService.new(message).perform
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def extract_message_and_account(event)
|
||||||
|
message = event.data[:message]
|
||||||
|
account = message.account
|
||||||
|
[message, account]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,7 +9,7 @@ class Channel::Voice < ApplicationRecord
|
|||||||
|
|
||||||
# Provider-specific configs stored in JSON
|
# Provider-specific configs stored in JSON
|
||||||
validates :provider_config, presence: true
|
validates :provider_config, presence: true
|
||||||
|
|
||||||
EDITABLE_ATTRS = [:phone_number, :provider, :provider_config].freeze
|
EDITABLE_ATTRS = [:phone_number, :provider, :provider_config].freeze
|
||||||
|
|
||||||
def name
|
def name
|
||||||
@@ -32,9 +32,32 @@ class Channel::Voice < ApplicationRecord
|
|||||||
|
|
||||||
def initiate_twilio_call(to)
|
def initiate_twilio_call(to)
|
||||||
config = provider_config_hash
|
config = provider_config_hash
|
||||||
callback_url = Rails.application.routes.url_helpers.twiml_twilio_voice_url(host: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'))
|
|
||||||
params = { from: phone_number, to: to, url: callback_url }
|
# Generate a full URL for Twilio to request TwiML
|
||||||
twilio_client(config).calls.create(**params)
|
host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||||
|
|
||||||
|
# Use the simplest possible TwiML endpoint
|
||||||
|
callback_url = "#{host}/twilio/voice/simple"
|
||||||
|
|
||||||
|
# Parameters including status callbacks for call progress tracking
|
||||||
|
params = {
|
||||||
|
from: phone_number,
|
||||||
|
to: to,
|
||||||
|
url: callback_url,
|
||||||
|
status_callback: "#{host}/twilio/voice/status_callback",
|
||||||
|
status_callback_event: %w[initiated ringing answered completed],
|
||||||
|
status_callback_method: 'POST'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create the call
|
||||||
|
call = twilio_client(config).calls.create(**params)
|
||||||
|
|
||||||
|
# Return the bare minimum
|
||||||
|
{
|
||||||
|
provider: 'twilio',
|
||||||
|
call_sid: call.sid,
|
||||||
|
status: call.status
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def twilio_client(config)
|
def twilio_client(config)
|
||||||
@@ -48,4 +71,6 @@ class Channel::Voice < ApplicationRecord
|
|||||||
JSON.parse(provider_config.to_s)
|
JSON.parse(provider_config.to_s)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
public :provider_config_hash
|
||||||
|
end
|
||||||
|
|||||||
59
app/services/voice/message_delivery_service.rb
Normal file
59
app/services/voice/message_delivery_service.rb
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
module Voice
|
||||||
|
class MessageDeliveryService
|
||||||
|
# This service will handle delivering messages from agents to active voice calls
|
||||||
|
# For now we'll store the messages in Redis with the call_sid as the key
|
||||||
|
# This way the TwiML controller can retrieve and read them out to the caller
|
||||||
|
|
||||||
|
attr_reader :message, :conversation
|
||||||
|
|
||||||
|
def initialize(message)
|
||||||
|
@message = message
|
||||||
|
@conversation = message.conversation
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return unless should_deliver_message?
|
||||||
|
|
||||||
|
call_sid = conversation.additional_attributes&.dig('call_sid')
|
||||||
|
return unless call_sid.present?
|
||||||
|
|
||||||
|
# Store the message in Redis to be read out in the next TwiML request
|
||||||
|
redis_key = "voice_message:#{call_sid}"
|
||||||
|
|
||||||
|
# Add the message to a Redis list
|
||||||
|
Redis::Alfred.lpush(redis_key, {
|
||||||
|
content: message.content,
|
||||||
|
message_id: message.id,
|
||||||
|
delivered: false
|
||||||
|
}.to_json)
|
||||||
|
|
||||||
|
# Set expiration so we don't keep messages forever
|
||||||
|
Redis::Alfred.expire(redis_key, 1.hour.to_i)
|
||||||
|
|
||||||
|
# Update the message with delivery status
|
||||||
|
update_message_delivery_status
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def should_deliver_message?
|
||||||
|
# Only deliver outgoing messages (from agents)
|
||||||
|
return false unless message.outgoing?
|
||||||
|
|
||||||
|
# Only deliver text messages, not attachments etc
|
||||||
|
return false unless message.content.present?
|
||||||
|
|
||||||
|
# Only deliver to active voice calls
|
||||||
|
return false unless conversation.additional_attributes&.dig('call_sid').present?
|
||||||
|
return false unless conversation.additional_attributes&.dig('call_status') == 'in-progress'
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_message_delivery_status
|
||||||
|
additional_attributes = message.additional_attributes || {}
|
||||||
|
additional_attributes[:voice_delivery_status] = 'queued'
|
||||||
|
message.update(additional_attributes: additional_attributes)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -55,6 +55,11 @@ en:
|
|||||||
VOICE_CALL: 'Voice Call'
|
VOICE_CALL: 'Voice Call'
|
||||||
CALL_ERROR: 'Failed to initiate voice call'
|
CALL_ERROR: 'Failed to initiate voice call'
|
||||||
CALL_INITIATED: 'Voice call initiated successfully'
|
CALL_INITIATED: 'Voice call initiated successfully'
|
||||||
|
END_CALL: 'End call'
|
||||||
|
CALL_ENDED: 'Call ended successfully'
|
||||||
|
CALL_END_ERROR: 'Failed to end call. Please try again.'
|
||||||
|
AUDIO_NOT_SUPPORTED: 'Your browser does not support audio playback'
|
||||||
|
TRANSCRIPTION: 'Transcription'
|
||||||
CONTACT_PANEL:
|
CONTACT_PANEL:
|
||||||
NEW_MESSAGE: 'New Message'
|
NEW_MESSAGE: 'New Message'
|
||||||
MERGE_CONTACT: 'Merge Contact'
|
MERGE_CONTACT: 'Merge Contact'
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ Rails.application.routes.draw do
|
|||||||
post :set_agent_bot, on: :member
|
post :set_agent_bot, on: :member
|
||||||
delete :avatar, on: :member
|
delete :avatar, on: :member
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Voice call management
|
||||||
|
post 'voice/end_call', to: 'voice#end_call'
|
||||||
|
get 'voice/call_status', to: 'voice#call_status'
|
||||||
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
||||||
collection do
|
collection do
|
||||||
delete :destroy
|
delete :destroy
|
||||||
@@ -479,10 +483,16 @@ Rails.application.routes.draw do
|
|||||||
namespace :twilio do
|
namespace :twilio do
|
||||||
resources :callback, only: [:create]
|
resources :callback, only: [:create]
|
||||||
resources :delivery_status, only: [:create]
|
resources :delivery_status, only: [:create]
|
||||||
resource :voice, only: [] do
|
|
||||||
get :twiml
|
# Define controller explicitly to avoid the plural/singular confusion
|
||||||
post :transcription_callback
|
get 'voice/twiml', to: 'voice#twiml'
|
||||||
end
|
post 'voice/twiml', to: 'voice#twiml'
|
||||||
|
get 'voice/simple', to: 'voice#simple_twiml'
|
||||||
|
post 'voice/simple', to: 'voice#simple_twiml'
|
||||||
|
post 'voice/handle_recording', to: 'voice#handle_recording'
|
||||||
|
post 'voice/handle_user_input', to: 'voice#handle_user_input'
|
||||||
|
post 'voice/transcription_callback', to: 'voice#transcription_callback'
|
||||||
|
post 'voice/status_callback', to: 'voice#status_callback'
|
||||||
end
|
end
|
||||||
|
|
||||||
get 'microsoft/callback', to: 'microsoft/callbacks#show'
|
get 'microsoft/callback', to: 'microsoft/callbacks#show'
|
||||||
|
|||||||
Reference in New Issue
Block a user