Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64df27d8c9 | ||
|
|
bf4a596726 | ||
|
|
bb1a4fc466 | ||
|
|
4343ebde59 | ||
|
|
9d95576f21 | ||
|
|
3c7c460fb9 | ||
|
|
bd84d1b17e | ||
|
|
bca048fc69 | ||
|
|
43b952d486 | ||
|
|
e58f60c27b | ||
|
|
759fe0d3f6 | ||
|
|
ce6489b485 | ||
|
|
b8187ed8a7 | ||
|
|
b3af194894 | ||
|
|
1e9180d3cd | ||
|
|
e06525181b | ||
|
|
3d29962969 | ||
|
|
7328e636ac | ||
|
|
e2e68868d5 | ||
|
|
670cf689f5 | ||
|
|
ccdbc2c7f9 | ||
|
|
aa4ef28e0e | ||
|
|
2879a0cd42 | ||
|
|
ce468bac01 | ||
|
|
2b0c154bc5 | ||
|
|
ebabb69048 | ||
|
|
5b77618a43 | ||
|
|
d6dd8efe46 | ||
|
|
74cd639574 | ||
|
|
414daff4f1 | ||
|
|
4e8a39f358 | ||
|
|
5dc1735f69 | ||
|
|
2f7c8f6cfc | ||
|
|
e8d3679aba | ||
|
|
c9daf70655 | ||
|
|
4ea22f7f36 | ||
|
|
4348c4ab87 | ||
|
|
196d7afccf | ||
|
|
4c48a565f6 | ||
|
|
3692cde1a9 | ||
|
|
3f0c01e166 | ||
|
|
4c579bc71e | ||
|
|
a7ff808d01 | ||
|
|
8d660df4c4 |
@@ -89,7 +89,7 @@ gem 'wisper', '2.0.0'
|
||||
##--- gems for channels ---##
|
||||
gem 'facebook-messenger'
|
||||
gem 'line-bot-api'
|
||||
gem 'twilio-ruby', '~> 5.66'
|
||||
gem 'twilio-ruby'
|
||||
# twitty will handle subscription of twitter account events
|
||||
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
|
||||
gem 'twitty', '~> 0.1.5'
|
||||
|
||||
+10
-8
@@ -246,8 +246,10 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.9.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-mashify (0.1.1)
|
||||
@@ -255,8 +257,8 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
@@ -414,7 +416,7 @@ GEM
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.8.1)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -486,7 +488,7 @@ GEM
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
@@ -819,7 +821,7 @@ GEM
|
||||
i18n
|
||||
timeout (0.4.3)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (5.77.0)
|
||||
twilio-ruby (7.6.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
nokogiri (>= 1.6, < 2.0)
|
||||
@@ -1008,7 +1010,7 @@ DEPENDENCIES
|
||||
telephone_number
|
||||
test-prof
|
||||
time_diff
|
||||
twilio-ruby (~> 5.66)
|
||||
twilio-ruby
|
||||
twitty (~> 0.1.5)
|
||||
tzinfo-data
|
||||
uglifier
|
||||
|
||||
@@ -21,6 +21,8 @@ class ContactInboxBuilder
|
||||
email_source_id
|
||||
when 'Channel::Sms'
|
||||
phone_source_id
|
||||
when 'Channel::Voice'
|
||||
phone_source_id # Voice uses phone number as source ID
|
||||
when 'Channel::Api', 'Channel::WebWidget'
|
||||
SecureRandom.uuid
|
||||
else
|
||||
@@ -35,7 +37,12 @@ class ContactInboxBuilder
|
||||
end
|
||||
|
||||
def phone_source_id
|
||||
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
|
||||
unless @contact.phone_number.present?
|
||||
# For voice channels, we'll create a fallback source ID if phone number is missing
|
||||
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Voice'
|
||||
|
||||
raise ActionController::ParameterMissing, 'contact phone number'
|
||||
end
|
||||
|
||||
@contact.phone_number
|
||||
end
|
||||
@@ -100,6 +107,6 @@ class ContactInboxBuilder
|
||||
end
|
||||
|
||||
def allowed_channels?
|
||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? || @inbox.channel_type == 'Channel::Voice'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ class Messages::MessageBuilder
|
||||
@private = params[:private] || false
|
||||
@conversation = conversation
|
||||
@user = user
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@message_type = params[:message_type].to_s || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
@@ -33,11 +33,6 @@ class Messages::MessageBuilder
|
||||
def content_attributes
|
||||
params = convert_to_hash(@params)
|
||||
content_attributes = params.fetch(:content_attributes, {})
|
||||
|
||||
return parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||
return content_attributes if content_attributes.is_a?(Hash)
|
||||
|
||||
{}
|
||||
end
|
||||
|
||||
# Converts the given object to a hash.
|
||||
@@ -105,8 +100,9 @@ class Messages::MessageBuilder
|
||||
end
|
||||
|
||||
def message_type
|
||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||
# Allow incoming messages in both API and Voice channels
|
||||
if !['Channel::Api', 'Channel::Voice'].include?(@conversation.inbox.channel_type) && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api and Voice inboxes'
|
||||
end
|
||||
|
||||
@message_type
|
||||
@@ -139,7 +135,7 @@ class Messages::MessageBuilder
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
message_attrs = {
|
||||
account_id: @conversation.account_id,
|
||||
inbox_id: @conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
@@ -152,5 +148,12 @@ class Messages::MessageBuilder
|
||||
echo_id: @params[:echo_id],
|
||||
source_id: @params[:source_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
|
||||
# Directly add content_attributes from params if present
|
||||
if @params[:content_attributes].present?
|
||||
message_attrs[:content_attributes] = content_attributes
|
||||
end
|
||||
|
||||
message_attrs
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,152 @@
|
||||
class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
|
||||
skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
|
||||
protect_from_forgery with: :null_session, only: [:incoming, :conference_status]
|
||||
before_action :validate_twilio_signature, only: [:incoming]
|
||||
before_action :handle_options_request, only: [:incoming, :conference_status]
|
||||
|
||||
# Handle CORS preflight OPTIONS requests
|
||||
def handle_options_request
|
||||
if request.method == "OPTIONS"
|
||||
set_cors_headers
|
||||
head :ok
|
||||
return true
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
def set_cors_headers
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
|
||||
headers['Access-Control-Max-Age'] = '86400' # 24 hours
|
||||
end
|
||||
|
||||
# Handle incoming calls from Twilio
|
||||
def incoming
|
||||
# Set CORS headers first to ensure they're included
|
||||
set_cors_headers
|
||||
|
||||
# Log basic request info
|
||||
Rails.logger.info("🔔 INCOMING CALL WEBHOOK: CallSid=#{params['CallSid']} From=#{params['From']} To=#{params['To']}")
|
||||
|
||||
# Process incoming call using service
|
||||
begin
|
||||
# Ensure account is set properly
|
||||
if !Current.account && params[:account_id].present?
|
||||
Current.account = Account.find(params[:account_id])
|
||||
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
|
||||
end
|
||||
|
||||
# Validate required parameters
|
||||
validate_incoming_params
|
||||
|
||||
# Process the call
|
||||
service = Voice::IncomingCallService.new(
|
||||
account: Current.account,
|
||||
params: params.to_unsafe_h.merge(host_with_port: request.host_with_port)
|
||||
)
|
||||
twiml_response = service.process
|
||||
|
||||
# Return TwiML response
|
||||
Rails.logger.info("✅ INCOMING CALL: Successfully processed")
|
||||
render xml: twiml_response
|
||||
rescue StandardError => e
|
||||
# Log the error with detailed information
|
||||
Rails.logger.error("❌ INCOMING CALL ERROR: #{e.message}")
|
||||
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
|
||||
|
||||
# Return friendly error message to caller
|
||||
render_error("We're sorry, but we're experiencing technical difficulties. Please try your call again later.")
|
||||
end
|
||||
end
|
||||
|
||||
# Handle conference status updates
|
||||
def conference_status
|
||||
# Set CORS headers first to ensure they're always included
|
||||
set_cors_headers
|
||||
|
||||
# Return immediately for OPTIONS requests
|
||||
if request.method == "OPTIONS"
|
||||
return head :ok
|
||||
end
|
||||
|
||||
# Log basic request info
|
||||
Rails.logger.info("🎧 CONFERENCE STATUS WEBHOOK: ConferenceSid=#{params['ConferenceSid']} Event=#{params['StatusCallbackEvent']}")
|
||||
|
||||
# Process conference status updates using service
|
||||
begin
|
||||
# Set account for local development if needed
|
||||
if !Current.account && params[:account_id].present?
|
||||
Current.account = Account.find(params[:account_id])
|
||||
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
|
||||
end
|
||||
|
||||
# Validate required parameters
|
||||
if params['ConferenceSid'].blank? && params['CallSid'].blank?
|
||||
Rails.logger.error("❌ MISSING REQUIRED PARAMS: Need either ConferenceSid or CallSid")
|
||||
end
|
||||
|
||||
# Use service to process conference status
|
||||
service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
|
||||
service.process
|
||||
|
||||
Rails.logger.info("✅ CONFERENCE STATUS: Successfully processed")
|
||||
rescue StandardError => e
|
||||
# Log errors but don't affect the response
|
||||
Rails.logger.error("❌ CONFERENCE STATUS ERROR: #{e.message}")
|
||||
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
|
||||
end
|
||||
|
||||
# Always return a successful response for Twilio
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_incoming_params
|
||||
if params['CallSid'].blank?
|
||||
raise "Missing required parameter: CallSid"
|
||||
end
|
||||
|
||||
if params['From'].blank?
|
||||
raise "Missing required parameter: From"
|
||||
end
|
||||
|
||||
if params['To'].blank?
|
||||
raise "Missing required parameter: To"
|
||||
end
|
||||
|
||||
if Current.account.nil?
|
||||
raise "Current account not set"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_twilio_signature
|
||||
begin
|
||||
validator = Voice::TwilioValidatorService.new(
|
||||
account: Current.account,
|
||||
params: params,
|
||||
request: request
|
||||
)
|
||||
|
||||
if !validator.valid?
|
||||
Rails.logger.error("❌ INVALID TWILIO SIGNATURE")
|
||||
render_error('Invalid Twilio signature')
|
||||
return false
|
||||
end
|
||||
|
||||
return true
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}")
|
||||
render_error('Error validating Twilio request')
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
def render_error(message)
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
response.say(message: message)
|
||||
response.hangup
|
||||
render xml: response.to_s
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_contact
|
||||
|
||||
def create
|
||||
# Validate that contact has a phone number
|
||||
if @contact.phone_number.blank?
|
||||
render json: { error: 'Contact has no phone number' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Use the outgoing call service to handle the entire process
|
||||
service = Voice::OutgoingCallService.new(
|
||||
account: Current.account,
|
||||
contact: @contact,
|
||||
user: Current.user
|
||||
)
|
||||
|
||||
# Process the call - this handles all the steps
|
||||
conversation = service.process
|
||||
|
||||
# Assign to @conversation so jbuilder template can access it
|
||||
@conversation = conversation
|
||||
|
||||
# Use the conversation jbuilder template to ensure consistent representation
|
||||
# This will ensure only display_id is used as the id, not the internal database id
|
||||
render 'api/v1/accounts/conversations/show'
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error initiating call: #{e.message}")
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_contact
|
||||
@contact = Current.account.contacts.find(params[:contact_id])
|
||||
end
|
||||
end
|
||||
@@ -163,7 +163,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
'line' => Channel::Line,
|
||||
'telegram' => Channel::Telegram,
|
||||
'whatsapp' => Channel::Whatsapp,
|
||||
'sms' => Channel::Sms
|
||||
'sms' => Channel::Sms,
|
||||
'voice' => Channel::Voice
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
class Api::V1::Accounts::Voice::TokensController < Api::V1::Accounts::BaseController
|
||||
before_action :set_voice_inbox
|
||||
|
||||
def create
|
||||
render json: build_response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Voice::TokensController#create: #{e.class} - #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
||||
render json: { error: 'Failed to generate token', details: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_response
|
||||
{
|
||||
token: twilio_token.to_jwt,
|
||||
identity: client_identity,
|
||||
voice_enabled: true,
|
||||
account_sid: twilio_config[:account_sid],
|
||||
agent_id: Current.user.id,
|
||||
account_id: Current.account.id,
|
||||
inbox_id: @voice_inbox.id,
|
||||
phone_number: twilio_config[:phone_number],
|
||||
twiml_endpoint: twilio_config[:twiml_url],
|
||||
has_twiml_app: twilio_config[:outgoing_app_sid].present?
|
||||
}
|
||||
end
|
||||
|
||||
def twilio_token
|
||||
Twilio::JWT::AccessToken.new(
|
||||
*twilio_credentials,
|
||||
identity: client_identity,
|
||||
ttl: 1.hour.to_i
|
||||
).tap { |t| t.add_grant(voice_grant) }
|
||||
end
|
||||
|
||||
def twilio_credentials
|
||||
twilio_config.values_at(:account_sid, :api_key_sid, :api_key_secret)
|
||||
end
|
||||
|
||||
def voice_grant
|
||||
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
|
||||
grant.incoming_allow = true
|
||||
grant.outgoing_application_sid = twilio_config[:outgoing_app_sid]
|
||||
grant.outgoing_application_params = outgoing_params
|
||||
end
|
||||
end
|
||||
|
||||
def outgoing_params
|
||||
{
|
||||
account_id: Current.account.id,
|
||||
agent_id: Current.user.id,
|
||||
identity: client_identity,
|
||||
client_name: client_identity,
|
||||
accountSid: twilio_config[:account_sid],
|
||||
is_agent: 'true'
|
||||
}
|
||||
end
|
||||
|
||||
def twilio_config
|
||||
@twilio_config ||= begin
|
||||
cfg = @voice_inbox.channel.provider_config_hash || {}
|
||||
{
|
||||
account_sid: cfg['account_sid'],
|
||||
api_key_sid: cfg['api_key_sid'],
|
||||
api_key_secret: cfg['api_key_secret'],
|
||||
outgoing_app_sid: cfg['outgoing_application_sid'],
|
||||
phone_number: @voice_inbox.channel.phone_number,
|
||||
twiml_url: "#{ENV.fetch('FRONTEND_URL', '')}/api/v1/accounts/#{Current.account.id}/voice/twiml_for_client"
|
||||
}.with_indifferent_access.merge(client_identity:)
|
||||
end
|
||||
end
|
||||
|
||||
def client_identity
|
||||
@client_identity ||= "agent-#{Current.user.id}-account-#{Current.account.id}"
|
||||
end
|
||||
|
||||
def set_voice_inbox
|
||||
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,230 @@
|
||||
require 'twilio-ruby'
|
||||
|
||||
class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_conversation, only: %i[end_call join_call reject_call]
|
||||
skip_before_action :authenticate_user!, only: :twiml_for_client
|
||||
protect_from_forgery with: :null_session, only: :twiml_for_client
|
||||
|
||||
before_action :render_options, if: -> { request.options? }
|
||||
after_action :set_cors_headers, if: -> { action_name == 'twiml_for_client' }
|
||||
|
||||
# ---------- PUBLIC ACTIONS --------------------------------------------------
|
||||
|
||||
def end_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
return render_not_found('active call') unless call_sid
|
||||
|
||||
twilio_client.calls(call_sid).update(status: 'completed') if in_progress?(call_sid)
|
||||
|
||||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||||
call_sid: call_sid,
|
||||
provider: :twilio)
|
||||
.process_status_update('completed', nil, false, "Call ended by #{current_user.name}")
|
||||
|
||||
broadcast_status(call_sid, 'completed')
|
||||
render_success('Call successfully ended')
|
||||
rescue StandardError => e
|
||||
render_error("Failed to end call: #{e.message}")
|
||||
end
|
||||
|
||||
def join_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
outbound = convo_attr('requires_agent_join') == true
|
||||
|
||||
return render_not_found('active call') unless call_sid || outbound
|
||||
|
||||
conference_sid = convo_attr('conference_sid') || create_conference_sid!
|
||||
update_join_metadata!(call_sid)
|
||||
broadcast_status(call_sid, 'in-progress')
|
||||
|
||||
render json: {
|
||||
status: 'success',
|
||||
message: 'Agent joining call via WebRTC',
|
||||
conference_sid: conference_sid,
|
||||
using_webrtc: true,
|
||||
conversation_id: @conversation.display_id,
|
||||
account_id: Current.account.id
|
||||
}
|
||||
rescue StandardError => e
|
||||
render_error("Failed to join call: #{e.message}")
|
||||
end
|
||||
|
||||
def reject_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
return render_not_found('active call') unless call_sid
|
||||
|
||||
@conversation.update!(additional_attributes: convo_attrs.merge(
|
||||
'agent_rejected' => true,
|
||||
'rejected_at' => Time.current.to_i,
|
||||
'rejected_by' => user_meta
|
||||
))
|
||||
|
||||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||||
call_sid: call_sid,
|
||||
provider: :twilio)
|
||||
.create_activity_message("#{current_user.name} declined to answer",
|
||||
rejected_by: current_user.name,
|
||||
rejected_at: Time.current.to_i)
|
||||
|
||||
render_success('Call rejected by agent')
|
||||
end
|
||||
|
||||
def call_status
|
||||
call_sid = params[:call_sid]
|
||||
return render_not_found('active call') unless call_sid
|
||||
|
||||
call = twilio_client.calls(call_sid).fetch
|
||||
render json: call.slice(:status, :duration, :direction, :from, :to, :start_time, :end_time)
|
||||
rescue StandardError => e
|
||||
render_error("Failed to fetch call status: #{e.message}")
|
||||
end
|
||||
|
||||
# TwiML for agent WebRTC dial‑in
|
||||
def twiml_for_client
|
||||
to = params[:To] || params[:to]
|
||||
return render_twiml_error('Missing conference ID parameter') if to.blank?
|
||||
|
||||
render xml: build_twiml(to), content_type: 'text/xml'
|
||||
rescue StandardError => e
|
||||
render_twiml_error(e.message)
|
||||
end
|
||||
|
||||
# ---------- PRIVATE ---------------------------------------------------------
|
||||
|
||||
private
|
||||
|
||||
# ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
def render_options
|
||||
head :ok
|
||||
end
|
||||
|
||||
def set_cors_headers
|
||||
headers['Content-Type'] ||= 'text/xml; charset=utf-8'
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
|
||||
headers['Access-Control-Max-Age'] = '86400'
|
||||
end
|
||||
|
||||
def render_success(msg) = render json: { status: 'success', message: msg }
|
||||
def render_not_found(resource) = render json: { error: "No #{resource} found" }, status: :not_found
|
||||
def render_error(msg) = render json: { error: msg }, status: :internal_server_error
|
||||
|
||||
def fetch_conversation
|
||||
@conversation = Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= begin
|
||||
cfg = @conversation.inbox.channel.provider_config_hash
|
||||
Twilio::REST::Client.new(cfg['account_sid'], cfg['auth_token'])
|
||||
end
|
||||
end
|
||||
|
||||
def in_progress?(call_sid)
|
||||
%w[in-progress ringing].include?(twilio_client.calls(call_sid).fetch.status)
|
||||
end
|
||||
|
||||
def convo_attrs
|
||||
@conversation.additional_attributes || {}
|
||||
end
|
||||
|
||||
def convo_attr(key)
|
||||
convo_attrs[key]
|
||||
end
|
||||
|
||||
def user_meta
|
||||
{ id: current_user.id, name: current_user.name }
|
||||
end
|
||||
|
||||
def create_conference_sid!
|
||||
sid = "conf_account_#{Current.account.id}_conv_#{@conversation.display_id}"
|
||||
@conversation.update!(additional_attributes: convo_attrs.merge('conference_sid' => sid))
|
||||
sid
|
||||
end
|
||||
|
||||
def update_join_metadata!(call_sid)
|
||||
@conversation.update!(additional_attributes: convo_attrs.merge(
|
||||
'agent_joined' => true,
|
||||
'joined_at' => Time.current.to_i,
|
||||
'joined_by' => user_meta,
|
||||
'call_status' => 'in-progress'
|
||||
))
|
||||
|
||||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||||
call_sid: call_sid,
|
||||
provider: :twilio)
|
||||
.process_status_update('in-progress', nil, false, "#{current_user.name} joined the call")
|
||||
end
|
||||
|
||||
def broadcast_status(call_sid, status)
|
||||
ActionCable.server.broadcast "account_#{@conversation.account_id}", {
|
||||
event_name: 'call_status_changed',
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: status,
|
||||
conversation_id: @conversation.display_id,
|
||||
inbox_id: @conversation.inbox_id,
|
||||
timestamp: Time.current.to_i
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
# ---- TwiML -----------------------------------------------------------------
|
||||
|
||||
def build_twiml(conference_name)
|
||||
# For agent legs, we need to add transcription too
|
||||
account_id = params[:account_id] || Current.account&.id
|
||||
agent_id = params[:agent_id] || current_user&.id
|
||||
transcription_url = "#{base_url}/twilio/transcription_callback?account_id=#{account_id}&conference_sid=#{conference_name}&speaker_type=agent&agent_id=#{agent_id}"
|
||||
|
||||
Twilio::TwiML::VoiceResponse.new do |r|
|
||||
# Add transcription for the agent leg too
|
||||
r.start do |start|
|
||||
start.transcription(
|
||||
status_callback_url: transcription_url,
|
||||
status_callback_method: 'POST',
|
||||
track: 'inbound_track', # Use inbound_track consistently for conference calls
|
||||
language_code: 'en-US'
|
||||
)
|
||||
end
|
||||
|
||||
r.dial do |dial|
|
||||
dial.conference(
|
||||
conference_name,
|
||||
startConferenceOnEnter: true,
|
||||
endConferenceOnExit: true,
|
||||
muted: false,
|
||||
beep: false,
|
||||
waitUrl: '',
|
||||
earlyMedia: true,
|
||||
statusCallback: conference_callback_url,
|
||||
statusCallbackEvent: 'start end join leave',
|
||||
statusCallbackMethod: 'POST',
|
||||
participantLabel: "agent-#{params[:agent_id] || current_user&.id}"
|
||||
)
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def conference_callback_url
|
||||
account_id = params[:account_id] || Current.account&.id
|
||||
"#{base_url.chomp('/')}/api/v1/accounts/#{account_id}/channels/voice/webhooks/conference_status"
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', '')
|
||||
end
|
||||
|
||||
# ---- TwiML Error -----------------------------------------------------------
|
||||
|
||||
def render_twiml_error(message)
|
||||
response = Twilio::TwiML::VoiceResponse.new do |r|
|
||||
r.say(message: "Error: #{message}")
|
||||
r.hangup
|
||||
end
|
||||
set_cors_headers
|
||||
render xml: response.to_s, content_type: 'text/xml'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Twilio::RecordingController < ActionController::Base
|
||||
skip_forgery_protection
|
||||
|
||||
# POST /twilio/recording_callback
|
||||
# This endpoint is called by Twilio when a call recording is available
|
||||
def recording_callback
|
||||
conference_sid = params['conference_sid']
|
||||
call_sid = params['CallSid']
|
||||
recording_url = params['RecordingUrl']
|
||||
recording_sid = params['RecordingSid']
|
||||
account_id = params['account_id']
|
||||
Rails.logger.info("[Twilio::RecordingController] Incoming recording_callback with params: #{params.inspect}")
|
||||
unless recording_url && account_id && (conference_sid || call_sid)
|
||||
Rails.logger.warn("[Twilio::RecordingController] Missing required params. recording_url: #{recording_url}, account_id: #{account_id}, conference_sid: #{conference_sid}, call_sid: #{call_sid}")
|
||||
return head :bad_request
|
||||
end
|
||||
|
||||
# Find the account
|
||||
account = Account.find_by(id: account_id)
|
||||
unless account
|
||||
Rails.logger.warn("[Twilio::RecordingController] Account not found for id: #{account_id}")
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
# Prefer lookup by conference_sid (most robust for conference recordings)
|
||||
conversation = if conference_sid
|
||||
account.conversations.find_by("additional_attributes ->> 'conference_sid' = ?", conference_sid)
|
||||
elsif call_sid
|
||||
account.conversations.find_by("additional_attributes ->> 'call_sid' = ?", call_sid)
|
||||
end
|
||||
unless conversation
|
||||
Rails.logger.warn("[Twilio::RecordingController] Conversation not found for conference_sid: #{conference_sid} or call_sid: #{call_sid}")
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
# Find the original voice call message (should be unique per conference)
|
||||
message = conversation.messages.voice_call.order(:created_at).first
|
||||
unless message
|
||||
Rails.logger.warn("[Twilio::RecordingController] No voice_call message found in conversation_id: #{conversation.id}")
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
# Download the recording from Twilio
|
||||
begin
|
||||
Rails.logger.info("[Twilio::RecordingController] Downloading recording from: #{recording_url}.mp3")
|
||||
file = URI.open(recording_url + '.mp3')
|
||||
rescue => e
|
||||
Rails.logger.error("[Twilio::RecordingController] Failed to download recording: #{e.message}")
|
||||
return head :internal_server_error
|
||||
end
|
||||
|
||||
# Attach the audio file to the message as an audio attachment
|
||||
begin
|
||||
att = message.attachments.create!(
|
||||
account_id: account.id,
|
||||
file: {
|
||||
io: file,
|
||||
filename: "twilio_recording_#{recording_sid}.mp3",
|
||||
content_type: 'audio/mpeg'
|
||||
},
|
||||
file_type: :audio,
|
||||
external_url: recording_url + '.mp3',
|
||||
meta: { recording_sid: recording_sid, conference_sid: conference_sid, call_sid: call_sid }
|
||||
)
|
||||
Rails.logger.info("[Twilio::RecordingController] Successfully attached recording to message_id: #{message.id}, attachment_id: #{att.id}")
|
||||
rescue => e
|
||||
Rails.logger.error("[Twilio::RecordingController] Failed to attach recording: #{e.message}")
|
||||
return head :internal_server_error
|
||||
end
|
||||
|
||||
# Optionally, update message content_attributes to indicate recording is attached
|
||||
content_attributes = message.content_attributes || {}
|
||||
content_attributes['recording_attached'] = true
|
||||
content_attributes['conference_sid'] = conference_sid if conference_sid
|
||||
message.update!(content_attributes: content_attributes)
|
||||
|
||||
head :ok
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
class Twilio::TranscriptionController < ActionController::Base
|
||||
skip_forgery_protection
|
||||
|
||||
# Receives real-time transcription updates from Twilio
|
||||
def transcription_callback
|
||||
# Set Current.account
|
||||
Current.account = Account.find_by(id: params[:account_id])
|
||||
|
||||
# Only process transcription content events
|
||||
if params['TranscriptionEvent'] == 'transcription-content'
|
||||
process_transcription_content
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_transcription_content
|
||||
# Extract transcript content from JSON
|
||||
data = JSON.parse(params['TranscriptionData'])
|
||||
transcript_content = data['transcript']
|
||||
confidence = data['confidence']
|
||||
|
||||
|
||||
# Find conversation by conference_sid from our standard format
|
||||
display_id = params[:conference_sid].match(/^conf_account_\d+_conv_(\d+)$/)[1]
|
||||
conversation = Current.account.conversations.find_by(display_id: display_id)
|
||||
|
||||
# Create message based on speaker_type
|
||||
create_message(conversation, transcript_content, confidence)
|
||||
end
|
||||
|
||||
def create_message(conversation, content, confidence)
|
||||
if params[:speaker_type] == 'contact'
|
||||
# Contact message (incoming)
|
||||
sender = conversation.contact
|
||||
message_type = :incoming
|
||||
else
|
||||
# Agent message (outgoing)
|
||||
sender = User.find_by(id: params[:agent_id])
|
||||
message_type = :outgoing
|
||||
end
|
||||
|
||||
# Create the message
|
||||
Messages::MessageBuilder.new(
|
||||
sender,
|
||||
conversation,
|
||||
content: content,
|
||||
message_type: message_type,
|
||||
private: false,
|
||||
additional_attributes: {
|
||||
transcription: true,
|
||||
call_sid: params['CallSid'],
|
||||
conference_sid: params[:conference_sid],
|
||||
speaker_type: params[:speaker_type],
|
||||
confidence: confidence,
|
||||
track: params['Track']
|
||||
}
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,189 @@
|
||||
class Twilio::VoiceController < ActionController::Base
|
||||
skip_forgery_protection
|
||||
|
||||
before_action :set_call_details, only: %i[status_callback simple_twiml]
|
||||
before_action :set_inbox, only: %i[status_callback simple_twiml]
|
||||
|
||||
|
||||
def status_callback
|
||||
return head :ok unless @inbox
|
||||
|
||||
conversation = Voice::ConversationFinderService.new(
|
||||
account: @inbox.account,
|
||||
call_sid: @call_sid,
|
||||
phone_number: incoming_number,
|
||||
is_outbound: outbound?,
|
||||
inbox: @inbox
|
||||
).perform
|
||||
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: @call_sid,
|
||||
provider: :twilio
|
||||
).process_status_update(params[:CallStatus], params[:CallDuration]&.to_i, first_status_response?)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
def simple_twiml
|
||||
return fallback_twiml unless @inbox
|
||||
|
||||
conversation = Voice::ConversationFinderService.new(
|
||||
account: @inbox.account,
|
||||
call_sid: @call_sid,
|
||||
phone_number: incoming_number,
|
||||
is_outbound: outbound?,
|
||||
inbox: @inbox
|
||||
).perform
|
||||
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: @call_sid,
|
||||
provider: :twilio
|
||||
).process_status_update('in-progress', nil, true)
|
||||
|
||||
conference_name = ensure_conference_name(conversation, params[:conference_name])
|
||||
|
||||
conversation.update!(
|
||||
additional_attributes: conversation.additional_attributes.merge(
|
||||
'conference_sid' => conference_name,
|
||||
'call_direction' => outbound? ? 'outbound' : 'inbound',
|
||||
'requires_agent_join' => true
|
||||
)
|
||||
)
|
||||
|
||||
render_twiml do |r|
|
||||
r.say(message: 'Please wait while we connect you to an agent')
|
||||
|
||||
# Enable real-time transcription for this call leg
|
||||
# For outbound calls, we're connecting to the contact, so this track is for the contact
|
||||
contact_id = conversation.contact_id
|
||||
callback_url = "#{base_url}/twilio/transcription_callback?account_id=#{@inbox.account_id}&conference_sid=#{conference_name}&speaker_type=contact&contact_id=#{contact_id}"
|
||||
Rails.logger.info("📞 VoiceController: Setting transcription callback to: #{callback_url}")
|
||||
|
||||
r.start do |start|
|
||||
start.transcription(
|
||||
status_callback_url: callback_url,
|
||||
status_callback_method: 'POST',
|
||||
track: 'inbound_track',
|
||||
language_code: 'en-US'
|
||||
)
|
||||
end
|
||||
|
||||
# Set up the conference
|
||||
conference_callback_url = "#{base_url}/api/v1/accounts/#{@inbox.account_id}/channels/voice/webhooks/conference_status"
|
||||
Rails.logger.info("📞 VoiceController: Setting conference callback to: #{conference_callback_url}")
|
||||
|
||||
r.dial do |d|
|
||||
d.conference(
|
||||
conference_name,
|
||||
startConferenceOnEnter: false,
|
||||
endConferenceOnExit: true,
|
||||
beep: false,
|
||||
muted: false,
|
||||
waitUrl: '',
|
||||
earlyMedia: true,
|
||||
statusCallback: conference_callback_url,
|
||||
statusCallbackMethod: 'POST',
|
||||
statusCallbackEvent: 'start end join leave',
|
||||
participantLabel: "caller-#{@call_sid.last(8)}",
|
||||
record: 'record-from-start',
|
||||
recording_status_callback: "#{base_url}/twilio/recording_callback?account_id=#{@inbox.account_id}&conference_sid=#{conference_name}",
|
||||
recording_status_callback_method: 'POST'
|
||||
)
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error creating voice conversation: #{e.message}")
|
||||
fallback_twiml
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_call_details
|
||||
@call_sid = params[:CallSid]
|
||||
@direction = params[:Direction]
|
||||
end
|
||||
|
||||
def set_inbox
|
||||
@inbox = find_inbox(outbound? ? params[:From] : params[:To])
|
||||
end
|
||||
|
||||
def outbound?
|
||||
@direction == 'outbound-api'
|
||||
end
|
||||
|
||||
def incoming_number
|
||||
outbound? ? params[:To] : params[:From]
|
||||
end
|
||||
|
||||
def first_status_response?
|
||||
params[:IsFirstResponseForStatus] == 'true'
|
||||
end
|
||||
|
||||
def render_twiml(status: :ok)
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
yield response
|
||||
render xml: response.to_s, status: status
|
||||
end
|
||||
|
||||
def build_message(conversation, content)
|
||||
Messages::MessageBuilder.new(
|
||||
nil,
|
||||
conversation,
|
||||
content: content,
|
||||
message_type: :activity,
|
||||
additional_attributes: { call_sid: @call_sid, call_status: 'in-progress', user_input: true }
|
||||
).perform
|
||||
end
|
||||
|
||||
def input_text
|
||||
return "Caller pressed #{params[:Digits]}" if params[:Digits].present?
|
||||
return "Caller said: \"#{params[:SpeechResult]}\"" if params[:SpeechResult].present?
|
||||
|
||||
'Caller responded'
|
||||
end
|
||||
|
||||
def ensure_conference_name(conversation, supplied)
|
||||
name = supplied.presence ||
|
||||
conversation.additional_attributes['conference_sid'] ||
|
||||
conversation.additional_attributes['conference_name']
|
||||
|
||||
return name if name&.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
|
||||
"conf_account_#{@inbox.account_id}_conv_#{conversation.display_id}"
|
||||
end
|
||||
|
||||
def fallback_twiml
|
||||
render_twiml do |r|
|
||||
r.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup.')
|
||||
r.pause(length: 1)
|
||||
r.say(message: 'We will connect you with an agent shortly.')
|
||||
r.hangup
|
||||
end
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
|
||||
def find_inbox(phone_number)
|
||||
return nil if phone_number.blank?
|
||||
|
||||
Inbox.joins('INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id')
|
||||
.find_by('channel_voice.phone_number = ?', phone_number)
|
||||
end
|
||||
|
||||
def find_or_create_conversation(inbox, phone_number, call_sid)
|
||||
Voice::ConversationFinderService.new(
|
||||
account: inbox.account,
|
||||
call_sid: call_sid,
|
||||
phone_number: phone_number,
|
||||
is_outbound: false,
|
||||
inbox: inbox
|
||||
).perform
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("find_or_create_conversation error: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -15,6 +15,7 @@ class AsyncDispatcher < BaseDispatcher
|
||||
CsatSurveyListener.instance,
|
||||
HookListener.instance,
|
||||
InstallationWebhookListener.instance,
|
||||
MessageListener.instance,
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
ReportingEventListener.instance,
|
||||
|
||||
@@ -107,7 +107,8 @@ module Api::V1::InboxesHelper
|
||||
'line' => Current.account.line_channels,
|
||||
'telegram' => Current.account.telegram_channels,
|
||||
'whatsapp' => Current.account.whatsapp_channels,
|
||||
'sms' => Current.account.sms_channels
|
||||
'sms' => Current.account.sms_channels,
|
||||
'voice' => Current.account.voice_channels
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import FloatingCallWidget from './components/widgets/FloatingCallWidget.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
@@ -14,6 +15,8 @@ import { setColorTheme } from './helper/themeHelper';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useFontSize } from 'dashboard/composables/useFontSize';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
@@ -25,6 +28,7 @@ export default {
|
||||
|
||||
components: {
|
||||
AddAccountModal,
|
||||
FloatingCallWidget,
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
@@ -51,6 +55,7 @@ export default {
|
||||
showAddAccountModal: false,
|
||||
latestChatwootVersion: null,
|
||||
reconnectService: null,
|
||||
showCallWidget: false, // Will be set to true when calls are active
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -60,6 +65,10 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
activeCall: 'calls/getActiveCall',
|
||||
hasActiveCall: 'calls/hasActiveCall',
|
||||
incomingCall: 'calls/getIncomingCall',
|
||||
hasIncomingCall: 'calls/hasIncomingCall',
|
||||
}),
|
||||
hasAccounts() {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
@@ -84,11 +93,34 @@ export default {
|
||||
}
|
||||
},
|
||||
},
|
||||
hasIncomingCall: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.showCallWidget = true;
|
||||
} else {
|
||||
this.showCallWidget = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
hasActiveCall: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.showCallWidget = true;
|
||||
} else {
|
||||
this.showCallWidget = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeColorTheme();
|
||||
this.listenToThemeChanges();
|
||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||
|
||||
// Make app instance available globally for direct call widget updates
|
||||
window.app = this;
|
||||
},
|
||||
unmounted() {
|
||||
if (this.reconnectService) {
|
||||
@@ -106,6 +138,77 @@ export default {
|
||||
setLocale(locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
},
|
||||
handleCallEnded() {
|
||||
this.showCallWidget = false;
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
this.$store.dispatch('calls/clearIncomingCall');
|
||||
|
||||
// Clear the activeCallConversation state in all ContactInfo components
|
||||
this.$nextTick(() => {
|
||||
const clearContactInfoCallState = (components) => {
|
||||
if (!components) return;
|
||||
|
||||
components.forEach(component => {
|
||||
if (component.$options && component.$options.name === 'ContactInfo') {
|
||||
if (component.activeCallConversation) {
|
||||
component.activeCallConversation = null;
|
||||
component.$forceUpdate();
|
||||
}
|
||||
}
|
||||
if (component.$children && component.$children.length) {
|
||||
clearContactInfoCallState(component.$children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
clearContactInfoCallState(this.$children);
|
||||
});
|
||||
},
|
||||
handleCallJoined() {
|
||||
this.showCallWidget = true;
|
||||
},
|
||||
handleCallRejected() {
|
||||
this.showCallWidget = false;
|
||||
this.$store.dispatch('calls/clearIncomingCall');
|
||||
},
|
||||
forceEndCall() {
|
||||
this.showCallWidget = false;
|
||||
if (window.forceEndCallHandlers) {
|
||||
window.forceEndCallHandlers.forEach(handler => {
|
||||
try {
|
||||
handler();
|
||||
} catch (e) {
|
||||
// Optionally log error in production
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.activeCall && this.activeCall.callSid) {
|
||||
const { callSid, conversationId } = this.activeCall;
|
||||
const savedCallSid = callSid;
|
||||
const savedConversationId = conversationId;
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
if (savedConversationId) {
|
||||
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||
.then(() => {
|
||||
useAlert({ message: 'Call ended successfully', type: 'success' });
|
||||
})
|
||||
.catch(() => {
|
||||
setTimeout(() => {
|
||||
VoiceAPI.endCall(savedCallSid, savedConversationId)
|
||||
.then(() => {
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
}, 1000);
|
||||
useAlert({ message: 'Call UI has been reset', type: 'info' });
|
||||
});
|
||||
} else {
|
||||
useAlert({ message: 'Call ended', type: 'success' });
|
||||
}
|
||||
} else {
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
}
|
||||
},
|
||||
async initializeAccount() {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
@@ -153,6 +256,25 @@ export default {
|
||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||
<WootSnackbarBox />
|
||||
<NetworkNotification />
|
||||
<!-- Floating call widget that appears during active calls -->
|
||||
<FloatingCallWidget
|
||||
v-if="showCallWidget || hasActiveCall || hasIncomingCall"
|
||||
:key="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : 'no-call')"
|
||||
:call-sid="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : '')"
|
||||
:inbox-name="activeCall ? (activeCall.inboxName || 'Primary') : (incomingCall ? incomingCall.inboxName : 'Primary')"
|
||||
:conversation-id="activeCall ? activeCall.conversationId : (incomingCall ? incomingCall.conversationId : null)"
|
||||
:contact-name="activeCall ? activeCall.contactName : (incomingCall ? incomingCall.contactName : '')"
|
||||
:contact-id="activeCall ? activeCall.contactId : (incomingCall ? incomingCall.contactId : null)"
|
||||
:inbox-id="activeCall ? activeCall.inboxId : (incomingCall ? incomingCall.inboxId : null)"
|
||||
:inbox-avatar-url="activeCall ? activeCall.inboxAvatarUrl : (incomingCall ? incomingCall.inboxAvatarUrl : '')"
|
||||
:inbox-phone-number="activeCall ? activeCall.inboxPhoneNumber : (incomingCall ? incomingCall.inboxPhoneNumber : '')"
|
||||
:avatar-url="activeCall ? activeCall.avatarUrl : (incomingCall ? incomingCall.avatarUrl : '')"
|
||||
:phone-number="activeCall ? activeCall.phoneNumber : (incomingCall ? incomingCall.phoneNumber : '')"
|
||||
use-web-rtc
|
||||
@callEnded="handleCallEnded"
|
||||
@callJoined="handleCallJoined"
|
||||
@callRejected="handleCallRejected"
|
||||
/>
|
||||
</div>
|
||||
<LoadingState v-else />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class VoiceAPI extends ApiClient {
|
||||
constructor() {
|
||||
// Use 'voice' as the resource with accountScoped: true
|
||||
super('voice', { accountScoped: true });
|
||||
|
||||
// Client-side Twilio device
|
||||
this.device = null;
|
||||
this.activeConnection = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
// Initiate a call to a contact
|
||||
initiateCall(contactId) {
|
||||
if (!contactId) {
|
||||
throw new Error('Contact ID is required to initiate a call');
|
||||
}
|
||||
|
||||
// Based on the route definition, the correct URL path is /api/v1/accounts/{accountId}/contacts/{contactId}/call
|
||||
// The endpoint is defined in the contacts namespace, not voice namespace
|
||||
return axios.post(`${this.baseUrl().replace('/voice', '')}/contacts/${contactId}/call`);
|
||||
}
|
||||
|
||||
// End an active call
|
||||
endCall(callSid, conversationId) {
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to end a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to end a call');
|
||||
}
|
||||
|
||||
// Validate call SID format - Twilio call SID starts with 'CA' or 'TJ'
|
||||
if (!callSid.startsWith('CA') && !callSid.startsWith('TJ')) {
|
||||
throw new Error(
|
||||
'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.'
|
||||
);
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/end_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Get call status
|
||||
getCallStatus(callSid) {
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to get call status');
|
||||
}
|
||||
|
||||
return axios.get(`${this.url}/call_status`, {
|
||||
params: { call_sid: callSid },
|
||||
});
|
||||
}
|
||||
|
||||
// Join an incoming call as an agent (join the conference)
|
||||
// This is used for the WebRTC client-side setup, not for phone calls anymore
|
||||
joinCall(params) {
|
||||
// Check if we have individual parameters or a params object
|
||||
const conversationId = params.conversation_id || params.conversationId;
|
||||
const callSid = params.call_sid || params.callSid;
|
||||
const accountId = params.account_id;
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to join a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to join a call');
|
||||
}
|
||||
|
||||
// Build request payload with proper naming convention
|
||||
const payload = {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
};
|
||||
|
||||
// Add account_id if provided
|
||||
if (accountId) {
|
||||
payload.account_id = accountId;
|
||||
}
|
||||
|
||||
console.log('Calling join_call API endpoint with payload:', payload);
|
||||
|
||||
return axios.post(`${this.url}/join_call`, payload);
|
||||
}
|
||||
|
||||
// Reject an incoming call as an agent (don't join the conference)
|
||||
rejectCall(callSid, conversationId) {
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation ID is required to reject a call');
|
||||
}
|
||||
|
||||
if (!callSid) {
|
||||
throw new Error('Call SID is required to reject a call');
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/reject_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Client SDK methods
|
||||
|
||||
// Get a capability token for the Twilio Client
|
||||
getToken(inboxId) {
|
||||
console.log(`Requesting token for inbox ID: ${inboxId} at URL: ${this.url}/tokens`);
|
||||
|
||||
// Log the base URL for debugging
|
||||
console.log(`Base URL: ${this.baseUrl()}`);
|
||||
|
||||
// Check if inboxId is valid
|
||||
if (!inboxId) {
|
||||
console.error('No inbox ID provided for token request');
|
||||
return Promise.reject(new Error('Inbox ID is required'));
|
||||
}
|
||||
|
||||
// Add more request details to help debugging
|
||||
return axios.post(`${this.url}/tokens`, { inbox_id: inboxId }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).catch(error => {
|
||||
// Extract useful error details for debugging
|
||||
const errorInfo = {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
url: `${this.url}/tokens`,
|
||||
inboxId,
|
||||
};
|
||||
|
||||
console.error('Token request error details:', errorInfo);
|
||||
|
||||
// Try to extract a more useful error message from the HTML response if it's a 500 error
|
||||
if (error.response?.status === 500 && typeof error.response.data === 'string') {
|
||||
// Look for specific error patterns in the HTML
|
||||
const htmlData = error.response.data;
|
||||
|
||||
// Check for common Ruby/Rails error patterns
|
||||
const nameMatchResult = htmlData.match(/<h2>(.*?)<\/h2>/);
|
||||
const detailsMatchResult = htmlData.match(/<pre>([\s\S]*?)<\/pre>/);
|
||||
|
||||
const errorName = nameMatchResult ? nameMatchResult[1] : null;
|
||||
const errorDetails = detailsMatchResult ? detailsMatchResult[1] : null;
|
||||
|
||||
if (errorName || errorDetails) {
|
||||
const enhancedError = new Error(`Server error: ${errorName || 'Internal Server Error'}`);
|
||||
enhancedError.details = errorDetails;
|
||||
enhancedError.originalError = error;
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize the Twilio Device
|
||||
async initializeDevice(inboxId) {
|
||||
// If already initialized, return the existing device after checking its health
|
||||
if (this.initialized && this.device) {
|
||||
const deviceState = this.device.state;
|
||||
console.log('Device already initialized, current state:', deviceState);
|
||||
|
||||
// If the device is in a bad state, destroy and reinitialize
|
||||
if (deviceState === 'error' || deviceState === 'unregistered') {
|
||||
console.log('Device is in a bad state, destroying and reinitializing...');
|
||||
try {
|
||||
this.device.destroy();
|
||||
} catch (e) {
|
||||
console.log('Error destroying device:', e);
|
||||
}
|
||||
this.device = null;
|
||||
this.initialized = false;
|
||||
} else {
|
||||
// Device is in a good state, return it
|
||||
return this.device;
|
||||
}
|
||||
}
|
||||
|
||||
// Device needs to be initialized or reinitialized
|
||||
try {
|
||||
console.log(`Starting Twilio Device initialization for inbox: ${inboxId}`);
|
||||
|
||||
// Import the Twilio Voice SDK
|
||||
let Device;
|
||||
try {
|
||||
// We know the package is installed via package.json
|
||||
const { Device: TwilioDevice } = await import('@twilio/voice-sdk');
|
||||
Device = TwilioDevice;
|
||||
console.log('✓ Twilio Voice SDK imported successfully');
|
||||
} catch (importError) {
|
||||
console.error('✗ Failed to import Twilio Voice SDK:', importError);
|
||||
throw new Error(`Failed to load Twilio Voice SDK: ${importError.message}`);
|
||||
}
|
||||
|
||||
// Validate inbox ID
|
||||
if (!inboxId) {
|
||||
throw new Error('Inbox ID is required to initialize the Twilio Device');
|
||||
}
|
||||
|
||||
// Step 1: Get a token from the server
|
||||
console.log(`Requesting Twilio token for inbox: ${inboxId}`);
|
||||
let response;
|
||||
try {
|
||||
response = await this.getToken(inboxId);
|
||||
console.log(`✓ Token response received with status: ${response.status}`);
|
||||
} catch (tokenError) {
|
||||
console.error('✗ Token request failed:', tokenError);
|
||||
|
||||
// Enhanced error handling for token requests
|
||||
if (tokenError.details) {
|
||||
// If we already have extracted details from the error, include those
|
||||
console.error('Token error details:', tokenError.details);
|
||||
throw new Error(`Failed to get token: ${tokenError.message}`);
|
||||
}
|
||||
|
||||
// Check for specific HTTP error status codes
|
||||
if (tokenError.response) {
|
||||
const status = tokenError.response.status;
|
||||
const data = tokenError.response.data;
|
||||
|
||||
if (status === 401) {
|
||||
throw new Error('Authentication error: Please check your Twilio credentials');
|
||||
} else if (status === 403) {
|
||||
throw new Error('Permission denied: You don\'t have access to this inbox');
|
||||
} else if (status === 404) {
|
||||
throw new Error('Inbox not found or does not have voice capability');
|
||||
} else if (status === 500) {
|
||||
throw new Error('Server error: The server encountered an error processing your request. Check your Twilio configuration.');
|
||||
} else if (data && data.error) {
|
||||
throw new Error(`Server error: ${data.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to get token: ${tokenError.message}`);
|
||||
}
|
||||
|
||||
// Validate token response
|
||||
if (!response.data || !response.data.token) {
|
||||
console.error('✗ Invalid token response data:', response.data);
|
||||
|
||||
// Check if we have an error message in the response
|
||||
if (response.data && response.data.error) {
|
||||
throw new Error(`Server did not return a valid token: ${response.data.error}`);
|
||||
} else {
|
||||
throw new Error('Server did not return a valid token');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for warnings about missing TwiML App SID
|
||||
if (response.data.warning) {
|
||||
console.warn('⚠️ Twilio Voice Warning:', response.data.warning);
|
||||
|
||||
if (!response.data.has_twiml_app) {
|
||||
console.error(
|
||||
'🚨 IMPORTANT: Missing TwiML App SID. Browser-based calling requires a ' +
|
||||
'TwiML App configured in Twilio Console. Set the Voice Request URL to: ' +
|
||||
response.data.twiml_endpoint
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract token data
|
||||
const { token, identity, voice_enabled, account_sid } = response.data;
|
||||
|
||||
// Log diagnostic information
|
||||
console.log(`✓ Token data received for identity: ${identity}`);
|
||||
console.log(`✓ Voice enabled: ${voice_enabled}`);
|
||||
console.log(`✓ Twilio Account SID available: ${!!account_sid}`);
|
||||
|
||||
// Log the TwiML endpoint that will be used
|
||||
if (response.data.twiml_endpoint) {
|
||||
console.log(`✓ TwiML endpoint: ${response.data.twiml_endpoint}`);
|
||||
} else {
|
||||
console.warn('⚠️ No TwiML endpoint found in token response');
|
||||
}
|
||||
|
||||
// Check if voice is enabled
|
||||
if (!voice_enabled) {
|
||||
throw new Error('Voice is not enabled for this inbox. Check your Twilio configuration.');
|
||||
}
|
||||
|
||||
// Store the TwiML endpoint URL for later use
|
||||
this.twimlEndpoint = response.data.twiml_endpoint;
|
||||
|
||||
// Step 2: Create Twilio Device with better options
|
||||
const deviceOptions = {
|
||||
// Use absolute minimal options - less is more for audio compatibility
|
||||
allowIncomingWhileBusy: true, // Allow incoming calls while already on a call
|
||||
debug: true, // Enable debug logging
|
||||
warnings: true, // Show warnings in console
|
||||
disableAudioContextSounds: true, // Disable browser audio context for sounds
|
||||
// Add explicit edge parameter - this helps avoid connectivity issues
|
||||
edge: ['ashburn', 'sydney', 'roaming'],
|
||||
// Explicitly set codec preferences
|
||||
codecPreferences: ['opus', 'pcmu'],
|
||||
// Add the account ID to any calls made by this device
|
||||
appParams: {
|
||||
account_id: response.data.account_id,
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Creating Twilio Device with options:', deviceOptions);
|
||||
|
||||
try {
|
||||
this.device = new Device(token, deviceOptions);
|
||||
console.log('✓ Twilio Device created successfully');
|
||||
} catch (deviceError) {
|
||||
console.error('✗ Failed to create Twilio Device:', deviceError);
|
||||
throw new Error(`Failed to create Twilio Device: ${deviceError.message}`);
|
||||
}
|
||||
|
||||
// Step 3: Set up event listeners with enhanced error handling
|
||||
this._setupDeviceEventListeners(inboxId);
|
||||
|
||||
// Step 4: Register the device with Twilio
|
||||
console.log('Registering Twilio Device...');
|
||||
try {
|
||||
await this.device.register();
|
||||
console.log('✓ Twilio Device registered successfully');
|
||||
this.initialized = true;
|
||||
return this.device;
|
||||
} catch (registerError) {
|
||||
console.error('✗ Failed to register Twilio Device:', registerError);
|
||||
|
||||
// Handle specific registration errors
|
||||
if (registerError.message && registerError.message.includes('token')) {
|
||||
throw new Error('Invalid Twilio token. Check your account credentials.');
|
||||
} else if (registerError.message && registerError.message.includes('permission')) {
|
||||
throw new Error('Missing microphone permission. Please allow microphone access.');
|
||||
}
|
||||
|
||||
throw new Error(`Failed to register device: ${registerError.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Clear device and initialized flag in case of error
|
||||
this.device = null;
|
||||
this.initialized = false;
|
||||
|
||||
console.error('Failed to initialize Twilio Device:', error);
|
||||
|
||||
// Create a detailed error with context for debugging
|
||||
const enhancedError = new Error(`Twilio Device initialization failed: ${error.message}`);
|
||||
enhancedError.originalError = error;
|
||||
enhancedError.inboxId = inboxId;
|
||||
enhancedError.timestamp = new Date().toISOString();
|
||||
enhancedError.browserInfo = {
|
||||
userAgent: navigator.userAgent,
|
||||
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
};
|
||||
|
||||
// Add specific advice for known error cases
|
||||
if (error.message.includes('permission')) {
|
||||
enhancedError.advice = 'Please ensure your browser allows microphone access.';
|
||||
} else if (error.message.includes('token')) {
|
||||
enhancedError.advice = 'Check your Twilio credentials in the Voice channel settings.';
|
||||
} else if (error.message.includes('TwiML')) {
|
||||
enhancedError.advice = 'Set up a valid TwiML app in your Twilio console and configure it in the inbox settings.';
|
||||
} else if (error.message.includes('configuration')) {
|
||||
enhancedError.advice = 'Review your Voice inbox configuration to ensure all required fields are completed.';
|
||||
}
|
||||
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to set up device event listeners
|
||||
_setupDeviceEventListeners(inboxId) {
|
||||
if (!this.device) return;
|
||||
|
||||
// Remove any existing listeners to prevent duplicates
|
||||
this.device.removeAllListeners();
|
||||
|
||||
// Add standard event listeners
|
||||
this.device.on('registered', () => {
|
||||
console.log('✓ Twilio Device registered with Twilio servers');
|
||||
});
|
||||
|
||||
this.device.on('unregistered', () => {
|
||||
console.log('⚠️ Twilio Device unregistered from Twilio servers');
|
||||
});
|
||||
|
||||
this.device.on('tokenWillExpire', () => {
|
||||
console.log('⚠️ Twilio token is about to expire, refreshing...');
|
||||
this.getToken(inboxId)
|
||||
.then(newTokenResponse => {
|
||||
if (newTokenResponse.data && newTokenResponse.data.token) {
|
||||
console.log('✓ Successfully obtained new token');
|
||||
this.device.updateToken(newTokenResponse.data.token);
|
||||
} else {
|
||||
console.error('✗ Failed to get a valid token for renewal');
|
||||
}
|
||||
})
|
||||
.catch(tokenError => {
|
||||
console.error('✗ Error refreshing token:', tokenError);
|
||||
});
|
||||
});
|
||||
|
||||
this.device.on('incoming', connection => {
|
||||
console.log('📞 Incoming call received via Twilio Device');
|
||||
this.activeConnection = connection;
|
||||
|
||||
// Set up connection-specific events
|
||||
this._setupConnectionEventListeners(connection);
|
||||
});
|
||||
|
||||
this.device.on('error', error => {
|
||||
// Enhanced error logging with full details
|
||||
const errorDetails = {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
description: error.description || 'No description',
|
||||
twilioErrorObject: error,
|
||||
connectionInfo: this.activeConnection ? {
|
||||
parameters: this.activeConnection.parameters,
|
||||
status: this.activeConnection.status && this.activeConnection.status(),
|
||||
direction: this.activeConnection.direction,
|
||||
} : 'No active connection',
|
||||
deviceState: this.device.state,
|
||||
browserInfo: {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
console.error('❌ DETAILED Twilio Device Error:', errorDetails);
|
||||
|
||||
// Provide helpful troubleshooting tips based on error code
|
||||
switch (error.code) {
|
||||
case 31000:
|
||||
console.error('⚠️ Error 31000: General Error. This could be an authentication, configuration, or network issue.');
|
||||
console.error('31000 Error Details:', {
|
||||
sdp: error.sdp || 'No SDP data',
|
||||
callState: error.call ? error.call.state : 'No call state',
|
||||
connectionState: error.connection ? error.connection.state : 'No connection state',
|
||||
peerConnectionState: error.peerConnection ? error.peerConnection.iceConnectionState : 'No ICE state',
|
||||
message: error.message,
|
||||
twilioError: error,
|
||||
info: error.info || 'No additional info',
|
||||
solution: 'Check Twilio account status, SDP negotiations, and network connectivity'
|
||||
});
|
||||
|
||||
// Create a network diagnostic to check connectivity
|
||||
fetch('https://status.twilio.com/api/v2/status.json')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Twilio service status check:', data);
|
||||
})
|
||||
.catch(statusError => {
|
||||
console.error('Failed to check Twilio status:', statusError);
|
||||
});
|
||||
break;
|
||||
case 31002:
|
||||
console.error('⚠️ Error 31002: Permission Denied. Your browser microphone is blocked or unavailable.');
|
||||
break;
|
||||
case 31003:
|
||||
console.error('⚠️ Error 31003: TwiML App Error. Your TwiML application does not exist or is misconfigured.');
|
||||
break;
|
||||
case 31005:
|
||||
console.error('⚠️ Error 31005: Error sent from gateway in HANGUP. This usually means the TwiML endpoint is not reachable or returning invalid TwiML.');
|
||||
console.error('Additional details for 31005:', {
|
||||
activeConnection: this.activeConnection ? 'Yes' : 'No',
|
||||
deviceState: this.device ? this.device.state : 'No device',
|
||||
params: this.activeConnection ? this.activeConnection.parameters : 'No params',
|
||||
twimlEndpoint: this.activeConnection && this.activeConnection.parameters ?
|
||||
this.activeConnection.parameters.To : 'Unknown endpoint',
|
||||
hangupReason: error.hangupReason || 'Unknown', // Capture hangup reason
|
||||
message: error.message,
|
||||
description: error.description,
|
||||
customMessage: error.customMessage,
|
||||
originalError: error.originalError ? JSON.stringify(error.originalError) : 'None'
|
||||
});
|
||||
break;
|
||||
case 31008:
|
||||
console.error('⚠️ Error 31008: Connection Error. The call could not be established.');
|
||||
break;
|
||||
case 31204:
|
||||
console.error('⚠️ Error 31204: ICE Connection Failed. WebRTC connection failure, check firewall settings.');
|
||||
break;
|
||||
default:
|
||||
console.error(`⚠️ Unspecified error with code ${error.code}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.device.on('connect', connection => {
|
||||
console.log('📞 Call connected');
|
||||
this.activeConnection = connection;
|
||||
this._setupConnectionEventListeners(connection);
|
||||
});
|
||||
|
||||
this.device.on('disconnect', () => {
|
||||
console.log('📞 Call disconnected');
|
||||
this.activeConnection = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for the active connection with enhanced audio diagnostic logging
|
||||
_setupConnectionEventListeners(connection) {
|
||||
if (!connection) return;
|
||||
|
||||
// Add advanced audio debug data
|
||||
const getAudioDiagnostics = () => {
|
||||
const audioContext = window.AudioContext || window.webkitAudioContext;
|
||||
let audioInfo = { supported: !!audioContext };
|
||||
|
||||
try {
|
||||
if (audioContext) {
|
||||
const context = new audioContext();
|
||||
audioInfo = {
|
||||
...audioInfo,
|
||||
sampleRate: context.sampleRate,
|
||||
state: context.state,
|
||||
baseLatency: context.baseLatency,
|
||||
outputLatency: context.outputLatency,
|
||||
destination: {
|
||||
maxChannelCount: context.destination.maxChannelCount,
|
||||
numberOfInputs: context.destination.numberOfInputs,
|
||||
numberOfOutputs: context.destination.numberOfOutputs
|
||||
}
|
||||
};
|
||||
context.close();
|
||||
}
|
||||
} catch (e) {
|
||||
audioInfo.error = e.message;
|
||||
}
|
||||
|
||||
// Check if microphone is accessible
|
||||
let microphoneInfo = { detected: false, active: false, tracks: [] };
|
||||
if (window.activeAudioStream) {
|
||||
const tracks = window.activeAudioStream.getAudioTracks();
|
||||
microphoneInfo = {
|
||||
detected: true,
|
||||
active: tracks.some(track => track.enabled && track.readyState === 'live'),
|
||||
tracks: tracks.map(track => ({
|
||||
id: track.id,
|
||||
label: track.label,
|
||||
enabled: track.enabled,
|
||||
muted: track.muted,
|
||||
readyState: track.readyState,
|
||||
constraints: track.getConstraints()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
audioContext: audioInfo,
|
||||
microphone: microphoneInfo,
|
||||
speakersMuted: typeof window.speechSynthesis !== 'undefined' ?
|
||||
window.speechSynthesis.speaking === false : 'unknown'
|
||||
};
|
||||
};
|
||||
|
||||
connection.on('error', error => {
|
||||
// Significantly enhanced connection error logging with audio diagnostics
|
||||
const diagnostics = getAudioDiagnostics();
|
||||
|
||||
const connectionErrorDetails = {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
description: error.description || 'No description',
|
||||
twilioErrorObject: error,
|
||||
connectionInfo: {
|
||||
parameters: connection.parameters,
|
||||
status: connection.status && connection.status(),
|
||||
direction: connection.direction,
|
||||
},
|
||||
deviceState: this.device ? this.device.state : 'No device',
|
||||
timestamp: new Date().toISOString(),
|
||||
// Audio diagnostics for troubleshooting
|
||||
audioDiagnostics: diagnostics,
|
||||
// Browser media permissions
|
||||
mediaPermissions: {
|
||||
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
activeAudioStream: !!window.activeAudioStream,
|
||||
activeAudioTracks: window.activeAudioStream ?
|
||||
window.activeAudioStream.getAudioTracks().length : 0
|
||||
}
|
||||
};
|
||||
|
||||
console.error('❌ DETAILED Connection Error with Audio Diagnostics:', connectionErrorDetails);
|
||||
});
|
||||
|
||||
connection.on('mute', isMuted => {
|
||||
console.log(`📞 Call ${isMuted ? 'muted' : 'unmuted'}`);
|
||||
});
|
||||
|
||||
connection.on('accept', () => {
|
||||
// Enhanced logging for accept event with audio diagnostics
|
||||
const diagnostics = getAudioDiagnostics();
|
||||
|
||||
console.log('📞 Call accepted with audio diagnostics:', {
|
||||
connectionParameters: connection.parameters,
|
||||
status: connection.status && connection.status(),
|
||||
audioDiagnostics: diagnostics,
|
||||
activeAudioStream: window.activeAudioStream ? {
|
||||
active: window.activeAudioStream.active,
|
||||
id: window.activeAudioStream.id,
|
||||
trackCount: window.activeAudioStream.getTracks().length
|
||||
} : 'No active stream'
|
||||
});
|
||||
|
||||
// AUDIO HEALTH CHECK AFTER CONNECTION
|
||||
setTimeout(() => {
|
||||
console.log('🔊 AUDIO HEALTH CHECK:', {
|
||||
connectionActive: this.activeConnection === connection,
|
||||
connectionState: connection.status && connection.status(),
|
||||
audioTracks: window.activeAudioStream ?
|
||||
window.activeAudioStream.getAudioTracks().map(track => ({
|
||||
label: track.label,
|
||||
enabled: track.enabled,
|
||||
readyState: track.readyState,
|
||||
muted: track.muted
|
||||
})) : 'No active stream',
|
||||
// Device state after 5 seconds
|
||||
deviceState: this.device ? this.device.state : 'No device'
|
||||
});
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
connection.on('disconnect', () => {
|
||||
console.log('📞 Call disconnected', {
|
||||
disconnectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
|
||||
finalStatus: connection.status && connection.status(),
|
||||
audioDiagnostics: getAudioDiagnostics()
|
||||
});
|
||||
this.activeConnection = null;
|
||||
});
|
||||
|
||||
connection.on('reject', () => {
|
||||
console.log('📞 Call rejected', {
|
||||
rejectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
|
||||
audioDiagnostics: getAudioDiagnostics()
|
||||
});
|
||||
this.activeConnection = null;
|
||||
});
|
||||
|
||||
// Additional event for warning messages
|
||||
connection.on('warning', warning => {
|
||||
console.warn('⚠️ Connection Warning:', warning);
|
||||
});
|
||||
|
||||
// Listen for TwiML processing events
|
||||
connection.on('twiml-processing', twiml => {
|
||||
console.log('📄 Processing TwiML:', twiml);
|
||||
});
|
||||
|
||||
// Enhanced audio events for debugging
|
||||
if (typeof connection.on === 'function') {
|
||||
try {
|
||||
// Check for volume events
|
||||
connection.on('volume', (inputVolume, outputVolume) => {
|
||||
// Log only significant volume changes to avoid console spam
|
||||
if (Math.abs(inputVolume) > 50 || Math.abs(outputVolume) > 50) {
|
||||
console.log(`🔊 Volume change - Input: ${inputVolume}, Output: ${outputVolume}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check for media stream events if supported
|
||||
if (typeof connection.getRemoteStream === 'function') {
|
||||
const remoteStream = connection.getRemoteStream();
|
||||
if (remoteStream) {
|
||||
console.log('✅ Remote audio stream available:', {
|
||||
active: remoteStream.active,
|
||||
id: remoteStream.id,
|
||||
tracks: remoteStream.getTracks().map(t => ({
|
||||
kind: t.kind,
|
||||
enabled: t.enabled,
|
||||
readyState: t.readyState
|
||||
}))
|
||||
});
|
||||
} else {
|
||||
console.warn('⚠️ No remote audio stream available');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error setting up enhanced audio events:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make a call using the Twilio Client
|
||||
makeClientCall(params) {
|
||||
if (!this.device || !this.initialized) {
|
||||
throw new Error('Twilio Device not initialized');
|
||||
}
|
||||
|
||||
this.activeConnection = this.device.connect(params);
|
||||
return this.activeConnection;
|
||||
}
|
||||
|
||||
// Join a conference call using the Twilio Client
|
||||
joinClientCall(conferenceParams) {
|
||||
if (!this.device || !this.initialized) {
|
||||
throw new Error('Twilio Device not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// IMPORTANT: Do NOT try to register if already registered
|
||||
// Only check state is ready
|
||||
if (this.device.state !== 'ready' && this.device.state !== 'registered') {
|
||||
// Don't try to register again if already registered
|
||||
}
|
||||
|
||||
// This is CRITICAL for Twilio - params must be formatted exactly right
|
||||
// and passed directly in the format Twilio expects
|
||||
const params = {
|
||||
// REQUIRED: Twilio Voice JS SDK expects 'To' parameter to be a properly formatted string
|
||||
To: `${conferenceParams.To}`,
|
||||
|
||||
// Additional params for our server
|
||||
account_id: conferenceParams.account_id,
|
||||
is_agent: 'true'
|
||||
};
|
||||
|
||||
// Check To parameter exists - fail if missing
|
||||
if (!params.To) {
|
||||
throw new Error('Missing To parameter for conference');
|
||||
}
|
||||
|
||||
// Make sure 'To' is explicitly a string
|
||||
const stringifiedTo = String(params.To);
|
||||
console.log('🎯 CRITICAL CONFERENCE CONNECTION: Connecting agent to conference with To=', stringifiedTo);
|
||||
|
||||
// Follow Twilio documentation format - params should be nested under 'params' property
|
||||
console.log('🎯 TRYING CONNECTION: Using documented format with params property');
|
||||
|
||||
// Just use the minimal required parameters
|
||||
const connection = this.device.connect({
|
||||
params: {
|
||||
To: stringifiedTo, // Conference ID
|
||||
is_agent: 'true' // Flag to indicate agent is joining
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🎯 CONFERENCE CONNECTION RESULT:', connection ? 'Success' : 'Failed');
|
||||
this.activeConnection = connection;
|
||||
|
||||
if (connection && typeof connection.then === 'function') {
|
||||
// It's a Promise - newer Twilio SDK version
|
||||
connection.then(resolvedConnection => {
|
||||
this.activeConnection = resolvedConnection;
|
||||
try {
|
||||
if (typeof resolvedConnection.on === 'function') {
|
||||
resolvedConnection.on('accept', () => {
|
||||
// Connection accepted
|
||||
});
|
||||
}
|
||||
} catch (listenerError) {
|
||||
// Could not add listeners to Promise connection
|
||||
}
|
||||
}).catch(connError => {
|
||||
// WebRTC Promise connection error
|
||||
});
|
||||
} else {
|
||||
// It's a synchronous connection - older Twilio SDK
|
||||
}
|
||||
return connection;
|
||||
} catch (error) {
|
||||
// Error joining conference
|
||||
}
|
||||
}
|
||||
|
||||
// Get the status of the device with additional diagnostic info
|
||||
getDeviceStatus() {
|
||||
if (!this.device) {
|
||||
return 'not_initialized';
|
||||
}
|
||||
|
||||
const deviceState = this.device.state;
|
||||
|
||||
// Append a recommended action based on the state
|
||||
switch (deviceState) {
|
||||
case 'registered':
|
||||
return 'ready';
|
||||
case 'unregistered':
|
||||
return 'disconnected';
|
||||
case 'destroyed':
|
||||
return 'terminated';
|
||||
case 'busy':
|
||||
return 'busy';
|
||||
case 'error':
|
||||
return 'error';
|
||||
default:
|
||||
return deviceState;
|
||||
}
|
||||
}
|
||||
|
||||
// Get comprehensive diagnostic information about the device and connection
|
||||
getDiagnosticInfo() {
|
||||
const browserInfo = {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
vendor: navigator.vendor,
|
||||
hasMediaDevices: !!navigator.mediaDevices,
|
||||
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||
};
|
||||
|
||||
const deviceInfo = this.device ? {
|
||||
state: this.device.state,
|
||||
isInitialized: this.initialized,
|
||||
capabilities: this.device.capabilities || {},
|
||||
isBusy: this.device.isBusy || false,
|
||||
audio: {
|
||||
isAudioSelectionSupported: this.device.isAudioSelectionSupported || false
|
||||
}
|
||||
} : { state: 'not_initialized' };
|
||||
|
||||
const connectionInfo = this.activeConnection ? {
|
||||
status: this.activeConnection.status(),
|
||||
isMuted: this.activeConnection.isMuted(),
|
||||
direction: this.activeConnection.direction,
|
||||
parameters: this.activeConnection.parameters,
|
||||
} : { status: 'no_connection' };
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
browser: browserInfo,
|
||||
device: deviceInfo,
|
||||
connection: connectionInfo
|
||||
};
|
||||
}
|
||||
|
||||
// Get the status of the active connection
|
||||
getConnectionStatus() {
|
||||
if (!this.activeConnection) {
|
||||
return 'no_connection';
|
||||
}
|
||||
|
||||
const status = this.activeConnection.status();
|
||||
|
||||
// Translate connection statuses to more user-friendly terms
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'connecting';
|
||||
case 'open':
|
||||
return 'connected';
|
||||
case 'connecting':
|
||||
return 'connecting';
|
||||
case 'ringing':
|
||||
return 'ringing';
|
||||
case 'closed':
|
||||
return 'ended';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new VoiceAPI();
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div>
|
||||
<Button
|
||||
icon="i-ri-phone-fill"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:tooltip="$t('CALL_BUTTON.TOOLTIP')"
|
||||
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
||||
@click="openCallModal"
|
||||
/>
|
||||
<div v-if="showCallModal" class="fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0">
|
||||
<CallModal @close="showCallModal = false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CallModal from './CallModal.vue';
|
||||
|
||||
const showCallModal = ref(false);
|
||||
|
||||
const openCallModal = () => {
|
||||
showCallModal.value = true;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<div class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl">
|
||||
<div class="px-4 py-3 flex items-center">
|
||||
<h3 class="text-base font-medium">{{ $t('CALL_MODAL.START_CALL') }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Inbox Selector (First) -->
|
||||
<div class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap">
|
||||
{{ $t('CALL_MODAL.VIA') }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="selectedInbox"
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
|
||||
>
|
||||
<span class="text-sm truncate text-n-slate-12 flex items-center gap-2">
|
||||
<span class="i-ri-phone-fill text-n-slate-11"></span>
|
||||
{{ selectedInbox.name }} - {{ selectedInbox.phoneNumber }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="flex-shrink-0"
|
||||
@click="selectedInbox = null"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-on-click-outside="() => showInboxDropdown = false"
|
||||
class="relative flex items-center h-7"
|
||||
>
|
||||
<Button
|
||||
:label="$t('CALL_MODAL.SELECT_INBOX')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="slate"
|
||||
class="hover:!no-underline"
|
||||
@click="showInboxDropdown = !showInboxDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="voiceInboxesList.length > 0 && showInboxDropdown"
|
||||
:menu-items="voiceInboxesList"
|
||||
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||
@action="selectInbox($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Selector -->
|
||||
<ContactSelector
|
||||
:contacts="contacts"
|
||||
:selected-contact="selectedContact"
|
||||
:show-contacts-dropdown="showContactsDropdown"
|
||||
:is-loading="isSearching"
|
||||
:is-creating-contact="false"
|
||||
:contact-id="null"
|
||||
:contactable-inboxes-list="[]"
|
||||
:show-inboxes-dropdown="false"
|
||||
:has-errors="false"
|
||||
@search-contacts="handleContactSearch"
|
||||
@set-selected-contact="handleSelectedContact"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@update-dropdown="handleDropdownUpdate"
|
||||
/>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center justify-end w-full h-[3.25rem] gap-2 px-4 py-3">
|
||||
<Button
|
||||
:label="$t('CALL_MODAL.CANCEL')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CALL_MODAL.CALL')"
|
||||
icon="i-ri-phone-fill"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
:disabled="!selectedInbox || !selectedContact || isLoading"
|
||||
:is-loading="isLoading"
|
||||
@click="makeCall"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import ContactSelector from 'dashboard/components-next/NewConversation/components/ContactSelector.vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const selectedContact = ref(null);
|
||||
const selectedInbox = ref(null);
|
||||
const showContactsDropdown = ref(false);
|
||||
const showInboxDropdown = ref(false);
|
||||
const contacts = ref([]);
|
||||
const isSearching = ref(false);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const voiceInboxesList = computed(() => {
|
||||
return inboxes.value
|
||||
.filter(inbox => inbox.channel_type === INBOX_TYPES.VOICE)
|
||||
.map(inbox => ({
|
||||
id: inbox.id,
|
||||
title: `${inbox.name}`,
|
||||
subtitle: inbox.phone_number,
|
||||
label: `${inbox.name} - ${inbox.phone_number}`,
|
||||
action: 'select-inbox',
|
||||
value: inbox.id,
|
||||
sourceId: inbox.id,
|
||||
phoneNumber: inbox.phone_number,
|
||||
name: inbox.name,
|
||||
icon: 'i-ri-phone-fill',
|
||||
}));
|
||||
});
|
||||
|
||||
// Auto-select the first available voice inbox
|
||||
watch(voiceInboxesList, (newList) => {
|
||||
if (newList.length > 0 && !selectedInbox.value) {
|
||||
selectedInbox.value = newList[0];
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const selectInbox = item => {
|
||||
const inbox = voiceInboxesList.value.find(i => i.value === item.value);
|
||||
if (inbox) {
|
||||
selectedInbox.value = inbox;
|
||||
showInboxDropdown.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectedContact = ({ value, action, ...rest }) => {
|
||||
// If this is a direct call to a phone number
|
||||
if (action === 'create' && value.match(/^\+?[0-9\s\-()]+$/)) {
|
||||
selectedContact.value = {
|
||||
id: 'direct-call',
|
||||
name: t('CALL_MODAL.CALL_DIRECTLY'),
|
||||
sourceId: 'direct-call',
|
||||
phoneNumber: value,
|
||||
action: 'contact',
|
||||
};
|
||||
} else {
|
||||
// For existing contacts, make sure we're capturing their ID properly
|
||||
console.log('Contact selected from dropdown:', { value, action, ...rest });
|
||||
selectedContact.value = {
|
||||
...rest,
|
||||
sourceId: rest.id || rest.value || value // Make sure we have the ID in sourceId
|
||||
};
|
||||
}
|
||||
showContactsDropdown.value = false;
|
||||
};
|
||||
|
||||
const handleDropdownUpdate = (type, value) => {
|
||||
showContactsDropdown.value = value;
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
selectedContact.value = null;
|
||||
};
|
||||
|
||||
// This function gets called from the ContactSelector component
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
// Pass all the needed keys for search when using the value sent directly
|
||||
debouncedSearchContacts(value);
|
||||
};
|
||||
|
||||
const debouncedSearchContacts = debounce(async query => {
|
||||
if (!query || query.length < 2) {
|
||||
contacts.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching.value = true;
|
||||
try {
|
||||
// Use the simple search endpoint since it's more reliable for this use case
|
||||
const { data } = await ContactAPI.search(query);
|
||||
|
||||
console.log('Search response:', data); // Log the search response
|
||||
|
||||
// Ensure contacts.value is an array and convert to camelCase
|
||||
const searchResults = data?.payload ? camelcaseKeys(data.payload, { deep: true }) : [];
|
||||
|
||||
// Filter to only include contacts with phone numbers
|
||||
const contactsWithPhone = searchResults.filter(contact => contact.phoneNumber);
|
||||
|
||||
// Map the contacts to ensure they have sourceId set to ID for consistency
|
||||
contacts.value = contactsWithPhone.map(contact => ({
|
||||
...contact,
|
||||
sourceId: contact.id, // Make sure sourceId is set
|
||||
value: contact.id // Make sure value is set for TagInput
|
||||
}));
|
||||
|
||||
// If it looks like a phone number, add option to call directly
|
||||
if (query.match(/^\+?[0-9\s\-()]+$/) && !contacts.value.some(c => c.phoneNumber === query)) {
|
||||
contacts.value.push({
|
||||
id: 'direct-call',
|
||||
name: t('CALL_MODAL.CALL_DIRECTLY'),
|
||||
phoneNumber: query,
|
||||
sourceId: 'direct-call',
|
||||
value: 'direct-call'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Processed contacts for dropdown:', contacts.value);
|
||||
} catch (error) {
|
||||
console.error('Error searching contacts:', error);
|
||||
contacts.value = []; // Ensure this is always an array
|
||||
useAlert(t('CALL_MODAL.CONTACT_SEARCH_ERROR'));
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const makeCall = async () => {
|
||||
if (!selectedInbox.value || !selectedContact.value) {
|
||||
useAlert(t('CALL_MODAL.VALIDATION_ERROR'));
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const isDirect = selectedContact.value.sourceId === 'direct-call';
|
||||
const contactId = isDirect ? null : (selectedContact.value.sourceId || selectedContact.value.id);
|
||||
|
||||
if (contactId) {
|
||||
console.log('Making call to contact ID:', contactId, 'with full contact:', selectedContact.value);
|
||||
// Use VoiceAPI.initiateCall instead of direct axios call
|
||||
await VoiceAPI.initiateCall(contactId);
|
||||
} else {
|
||||
// For direct phone number calls
|
||||
const phoneNumber = selectedContact.value.phoneNumber;
|
||||
|
||||
if (!phoneNumber) {
|
||||
throw new Error('Phone number is required for direct calls');
|
||||
}
|
||||
|
||||
// First create a contact with this phone number
|
||||
console.log('Creating new contact with phone number:', phoneNumber);
|
||||
const contactPayload = {
|
||||
phone_number: phoneNumber,
|
||||
inbox_id: selectedInbox.value.sourceId,
|
||||
name: `Phone: ${phoneNumber}`,
|
||||
};
|
||||
|
||||
const contactResponse = await ContactAPI.create(contactPayload);
|
||||
console.log('Created contact:', contactResponse.data);
|
||||
|
||||
// Then initiate call to the newly created contact
|
||||
if (contactResponse.data && contactResponse.data.payload && contactResponse.data.payload.contact) {
|
||||
const newContactId = contactResponse.data.payload.contact.id;
|
||||
console.log('Using new contact ID:', newContactId);
|
||||
await VoiceAPI.initiateCall(newContactId);
|
||||
} else {
|
||||
throw new Error('Failed to create contact for direct call');
|
||||
}
|
||||
}
|
||||
|
||||
useAlert(t('CALL_MODAL.SUCCESS_MESSAGE'));
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
console.error('Error making call:', error);
|
||||
|
||||
let errorMessage = t('CALL_MODAL.ERROR_MESSAGE');
|
||||
|
||||
// Simple error handling - just show server message if available
|
||||
if (error.response && error.response.data && error.response.data.error) {
|
||||
errorMessage = error.response.data.error;
|
||||
}
|
||||
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// The first inbox will be selected automatically via the watch
|
||||
// This ensures it works even if voiceInboxesList is populated after mounting
|
||||
});
|
||||
</script>
|
||||
@@ -8,6 +8,7 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue';
|
||||
import SMSCampaignDetails from './SMSCampaignDetails.vue';
|
||||
import VoiceCampaignDetails from './VoiceCampaignDetails.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@@ -22,6 +23,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isVoiceType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -67,6 +72,12 @@ const campaignStatus = computed(() => {
|
||||
? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED')
|
||||
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
|
||||
}
|
||||
|
||||
if (props.isVoiceType) {
|
||||
return props.status === STATUS_COMPLETED
|
||||
? t('CAMPAIGN.VOICE.CARD.STATUS.COMPLETED')
|
||||
: t('CAMPAIGN.VOICE.CARD.STATUS.SCHEDULED');
|
||||
}
|
||||
|
||||
return props.status === STATUS_COMPLETED
|
||||
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
|
||||
@@ -108,6 +119,12 @@ const inboxIcon = computed(() => {
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
/>
|
||||
<VoiceCampaignDetails
|
||||
v-else-if="isVoiceType"
|
||||
:sender="sender"
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
/>
|
||||
<SMSCampaignDetails
|
||||
v-else
|
||||
:inbox-name="inboxName"
|
||||
@@ -118,7 +135,7 @@ const inboxIcon = computed(() => {
|
||||
</div>
|
||||
<div class="flex items-center justify-end w-20 gap-2">
|
||||
<Button
|
||||
v-if="isLiveChatType"
|
||||
v-if="isLiveChatType || isVoiceType"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
color="slate"
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
sender: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
inboxName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inboxIcon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
campaign: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const senderName = computed(() =>
|
||||
props.sender?.name || t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.BOT')
|
||||
);
|
||||
|
||||
const senderThumbnail = computed(() => props.sender?.thumbnail || '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 w-full overflow-hidden">
|
||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
||||
{{ t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Avatar
|
||||
:name="senderName"
|
||||
:src="senderThumbnail"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ senderName }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
||||
{{ t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.FROM') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+116
-4
@@ -37,7 +37,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
id: 1,
|
||||
name: 'Jamie Lee',
|
||||
},
|
||||
message: 'Hello! 👋 Any questions on pricing? I’m here to help!',
|
||||
message: 'Hello! 👋 Any questions on pricing? I am here to help!',
|
||||
campaign_status: 'active',
|
||||
enabled: false,
|
||||
campaign_type: 'ongoing',
|
||||
@@ -60,7 +60,8 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Chatwoot',
|
||||
name: 'Alexa Rivera',
|
||||
thumbnail: 'AR',
|
||||
},
|
||||
message: 'Hi! Chatwoot here. Need help setting up? Let me know!',
|
||||
campaign_status: 'active',
|
||||
@@ -88,7 +89,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
name: 'Chris Barlow',
|
||||
},
|
||||
message:
|
||||
'Hi there! 👋 I’m here for any questions you may have. Let’s chat!',
|
||||
'Hi there! 👋 I am here for any questions you may have. Let us chat!',
|
||||
campaign_status: 'active',
|
||||
enabled: true,
|
||||
campaign_type: 'ongoing',
|
||||
@@ -166,7 +167,7 @@ export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
phone_number: '+29818373149903',
|
||||
provider: 'default',
|
||||
},
|
||||
message: 'Hello! We’re excited to have your business with us!',
|
||||
message: 'Hello! We are excited to have your business with us!',
|
||||
campaign_status: 'active',
|
||||
enabled: true,
|
||||
campaign_type: 'one_off',
|
||||
@@ -210,3 +211,114 @@ export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
updated_at: '2024-10-30T16:15:03.157Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const VOICE_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Signup Confirmation Call',
|
||||
inbox: {
|
||||
id: 10,
|
||||
name: 'PaperLayer Phone Support',
|
||||
channel_type: 'Channel::Voice',
|
||||
avatar_url: '',
|
||||
phone_number: '+14155552671',
|
||||
},
|
||||
message: 'Hello! 👋 Thanks for signing up with PaperLayer. I am calling to confirm your account setup and see if you have any questions about getting started.',
|
||||
campaign_status: 'scheduled',
|
||||
enabled: true,
|
||||
campaign_type: 'voice',
|
||||
scheduled_at: new Date('2024-11-16T20:43:08.000Z').getTime(),
|
||||
audience: [
|
||||
{ id: 4, type: 'Label', title: 'Support Customers' },
|
||||
{ id: 5, type: 'Label', title: 'Active Users' },
|
||||
],
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Chris Barlow',
|
||||
thumbnail: 'CB'
|
||||
},
|
||||
created_at: '2024-11-15T13:13:08.496Z',
|
||||
updated_at: '2024-11-15T13:15:38.698Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Support Ticket Follow-Up',
|
||||
inbox: {
|
||||
id: 10,
|
||||
name: 'PaperLayer Phone Support',
|
||||
channel_type: 'Channel::Voice',
|
||||
avatar_url: '',
|
||||
phone_number: '+14155552671',
|
||||
},
|
||||
message: 'Hi, this is PaperLayer support calling to follow up on your recent ticket #12345. Has your issue been resolved to your satisfaction? If not, I can connect you with a specialist right away.',
|
||||
campaign_status: 'completed',
|
||||
enabled: true,
|
||||
campaign_type: 'voice',
|
||||
scheduled_at: new Date('2024-11-10T15:30:00.000Z').getTime(),
|
||||
audience: [
|
||||
{ id: 1, type: 'Label', title: 'Enterprise' },
|
||||
{ id: 6, type: 'Label', title: 'Premium' },
|
||||
],
|
||||
sender: {
|
||||
id: 2,
|
||||
name: 'Sarah Wilson',
|
||||
thumbnail: 'SW'
|
||||
},
|
||||
created_at: '2024-11-10T13:14:00.168Z',
|
||||
updated_at: '2024-11-10T13:15:38.707Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Appointment Reminder',
|
||||
inbox: {
|
||||
id: 10,
|
||||
name: 'PaperLayer Phone Support',
|
||||
channel_type: 'Channel::Voice',
|
||||
avatar_url: '',
|
||||
phone_number: '+14155552671',
|
||||
},
|
||||
message: 'Hello, this is a reminder about your upcoming consultation scheduled for tomorrow at 2:00 PM. Would you like to confirm this appointment or would you prefer to reschedule?',
|
||||
campaign_status: 'scheduled',
|
||||
enabled: true,
|
||||
campaign_type: 'voice',
|
||||
scheduled_at: new Date('2024-11-20T18:00:00.000Z').getTime(),
|
||||
audience: [
|
||||
{ id: 7, type: 'Label', title: 'Consultation Clients' },
|
||||
{ id: 8, type: 'Label', title: 'New Customers' },
|
||||
],
|
||||
sender: {
|
||||
id: 3,
|
||||
name: 'Michael Thompson',
|
||||
thumbnail: 'MT'
|
||||
},
|
||||
created_at: '2024-11-12T09:30:45.123Z',
|
||||
updated_at: '2024-11-12T09:30:45.123Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Customer Feedback Survey',
|
||||
inbox: {
|
||||
id: 10,
|
||||
name: 'PaperLayer Phone Support',
|
||||
channel_type: 'Channel::Voice',
|
||||
avatar_url: '',
|
||||
phone_number: '+14155552671',
|
||||
},
|
||||
message: 'Hello, this is PaperLayer reaching out for your valuable feedback. We noticed you\'ve been using our service for 30 days now. I\'d like to ask a few quick questions about your experience. Your feedback helps us improve our service. Would you have a moment to share your thoughts?',
|
||||
campaign_status: 'scheduled',
|
||||
enabled: true,
|
||||
campaign_type: 'voice',
|
||||
scheduled_at: new Date('2024-11-25T14:15:00.000Z').getTime(),
|
||||
audience: [
|
||||
{ id: 9, type: 'Label', title: 'Active 30+ Days' },
|
||||
{ id: 10, type: 'Label', title: 'Product Users' },
|
||||
],
|
||||
sender: {
|
||||
id: 4,
|
||||
name: 'Jessica Rivera',
|
||||
thumbnail: 'JR'
|
||||
},
|
||||
created_at: '2024-11-18T10:45:23.789Z',
|
||||
updated_at: '2024-11-18T10:45:23.789Z',
|
||||
}
|
||||
];
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
actionPerms: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[36rem]"
|
||||
>
|
||||
<div
|
||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||
>
|
||||
<slot name="empty-state-item" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full bg-gradient-to-t from-n-background from-0% via-n-background/95 via-25% to-transparent"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-6 w-full max-w-4xl mx-auto px-4">
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<h2
|
||||
class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
class="max-w-2xl mx-auto text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<Policy :permissions="actionPerms">
|
||||
<slot name="actions" />
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { VOICE_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import EmptyStateLayout from './CustomEmptyStateLayout.vue';
|
||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="flex flex-col gap-4 p-px">
|
||||
<div
|
||||
v-for="(campaign, index) in VOICE_CAMPAIGN_EMPTY_STATE_CONTENT"
|
||||
:key="campaign.id"
|
||||
:style="{
|
||||
opacity: index === 0 ? 1 : index === 1 ? 0.7 : index === 2 ? 0.4 : 0.2
|
||||
}"
|
||||
>
|
||||
<CampaignCard
|
||||
:title="campaign.title"
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
:is-voice-type="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="mt-10 py-5 px-8 rounded-lg bg-slate-800/5 border border-slate-300/10 max-w-[32rem] mx-auto text-center shadow-sm">
|
||||
<p class="mb-4 text-sm text-slate-700 dark:text-slate-300 font-medium">
|
||||
{{ t('CAMPAIGN.VOICE.EMPTY_STATE.JS_API_DESCRIPTION') }}
|
||||
</p>
|
||||
<code class="block px-5 py-4 overflow-auto text-xs rounded bg-slate-800 text-slate-200 text-left">
|
||||
chatwoot.triggerVoiceCampaign({
|
||||
campaignId: 'CAMPAIGN_ID',
|
||||
user: {
|
||||
name: 'John Doe',
|
||||
phone: '+1234567890'
|
||||
}
|
||||
});
|
||||
</code>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
@@ -10,6 +10,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isVoiceType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
@@ -31,6 +35,7 @@ const handleDelete = campaign => emit('delete', campaign);
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
:is-live-chat-type="isLiveChatType"
|
||||
:is-voice-type="isVoiceType"
|
||||
@edit="handleEdit(campaign)"
|
||||
@delete="handleDelete(campaign)"
|
||||
/>
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
|
||||
|
||||
import VoiceCampaignForm from './VoiceCampaignForm.vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
store.dispatch('labels/get');
|
||||
});
|
||||
|
||||
const addCampaign = async campaignDetails => {
|
||||
try {
|
||||
await store.dispatch('campaigns/create', {
|
||||
...campaignDetails,
|
||||
campaign_type: CAMPAIGN_TYPES.VOICE,
|
||||
});
|
||||
|
||||
// tracking this here instead of the store to track the type of campaign
|
||||
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
|
||||
type: CAMPAIGN_TYPES.VOICE,
|
||||
});
|
||||
|
||||
useAlert(t('CAMPAIGN.VOICE.CREATE.FORM.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage = error?.response?.message || t('CAMPAIGN.VOICE.CREATE.FORM.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => emit('close');
|
||||
|
||||
const handleSubmit = campaignDetails => {
|
||||
addCampaign(campaignDetails);
|
||||
handleClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
|
||||
>
|
||||
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||
{{ t(`CAMPAIGN.VOICE.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<VoiceCampaignForm
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
import { reactive, computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||
labels: useMapGetter('labels/getLabels'),
|
||||
inboxes: useMapGetter('inboxes/getVoiceInboxes'),
|
||||
};
|
||||
|
||||
const senderList = ref([]);
|
||||
|
||||
const initialState = {
|
||||
title: '',
|
||||
message: '',
|
||||
inboxId: null,
|
||||
senderId: null,
|
||||
scheduledAt: null,
|
||||
selectedAudience: [],
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
|
||||
const rules = {
|
||||
title: { required, minLength: minLength(1) },
|
||||
message: { required, minLength: minLength(1) },
|
||||
inboxId: { required },
|
||||
senderId: { required },
|
||||
scheduledAt: { required },
|
||||
selectedAudience: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
|
||||
const isCreating = computed(() => formState.uiFlags.value.isCreating);
|
||||
|
||||
const currentDateTime = computed(() => {
|
||||
// Added to disable the scheduled at field from being set to the current time
|
||||
const now = new Date();
|
||||
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||
return localTime.toISOString().slice(0, 16);
|
||||
});
|
||||
|
||||
const mapToOptions = (items, valueKey, labelKey) =>
|
||||
items?.map(item => ({
|
||||
value: item[valueKey],
|
||||
label: item[labelKey],
|
||||
})) ?? [];
|
||||
|
||||
const audienceList = computed(() =>
|
||||
mapToOptions(formState.labels.value, 'id', 'title')
|
||||
);
|
||||
|
||||
const inboxOptions = computed(() =>
|
||||
mapToOptions(formState.inboxes.value, 'id', 'name')
|
||||
);
|
||||
|
||||
const sendersAndBotList = computed(() => [
|
||||
{ value: 0, label: t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.BOT') },
|
||||
...mapToOptions(senderList.value, 'id', 'name'),
|
||||
]);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
const baseKey = 'CAMPAIGN.VOICE.CREATE.FORM';
|
||||
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
title: getErrorMessage('title', 'TITLE'),
|
||||
message: getErrorMessage('message', 'MESSAGE'),
|
||||
inbox: getErrorMessage('inboxId', 'INBOX'),
|
||||
sender: getErrorMessage('senderId', 'SENT_BY'),
|
||||
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
|
||||
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
|
||||
}));
|
||||
|
||||
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
||||
|
||||
const formatToUTCString = localDateTime =>
|
||||
localDateTime ? new Date(localDateTime).toISOString() : null;
|
||||
|
||||
const handleInboxChange = async inboxId => {
|
||||
if (!inboxId) {
|
||||
senderList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('inboxMembers/get', { inboxId });
|
||||
senderList.value = response?.data?.payload ?? [];
|
||||
} catch (error) {
|
||||
senderList.value = [];
|
||||
useAlert(
|
||||
error?.response?.message ??
|
||||
t('CAMPAIGN.VOICE.CREATE.FORM.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => state.inboxId,
|
||||
newInboxId => {
|
||||
if (newInboxId) {
|
||||
handleInboxChange(newInboxId);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
Object.assign(state, initialState);
|
||||
};
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const prepareCampaignDetails = () => ({
|
||||
title: state.title,
|
||||
message: state.message,
|
||||
inbox_id: state.inboxId,
|
||||
sender_id: state.senderId || null,
|
||||
scheduled_at: formatToUTCString(state.scheduledAt),
|
||||
audience: state.selectedAudience?.map(id => ({
|
||||
id,
|
||||
type: 'Label',
|
||||
})),
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
emit('submit', prepareCampaignDetails());
|
||||
resetState();
|
||||
handleCancel();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<Input
|
||||
v-model="state.title"
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.TITLE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.TITLE.PLACEHOLDER')"
|
||||
:message="formErrors.title"
|
||||
:message-type="formErrors.title ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="state.message"
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.MESSAGE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.MESSAGE.PLACEHOLDER')"
|
||||
show-character-count
|
||||
:message="formErrors.message"
|
||||
:message-type="formErrors.message ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.VOICE.CREATE.FORM.INBOX.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="inbox"
|
||||
v-model="state.inboxId"
|
||||
:options="inboxOptions"
|
||||
:has-error="!!formErrors.inbox"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.INBOX.PLACEHOLDER')"
|
||||
:message="formErrors.inbox"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="sender" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.VOICE.CREATE.FORM.SENT_BY.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="sender"
|
||||
v-model="state.senderId"
|
||||
:options="sendersAndBotList"
|
||||
:has-error="!!formErrors.sender"
|
||||
:disabled="!state.inboxId"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.SENT_BY.PLACEHOLDER')"
|
||||
:message="formErrors.sender"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.LABEL') }}
|
||||
</label>
|
||||
<TagMultiSelectComboBox
|
||||
v-model="state.selectedAudience"
|
||||
:options="audienceList"
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
|
||||
:has-error="!!formErrors.audience"
|
||||
:message="formErrors.audience"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="state.scheduledAt"
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.SCHEDULED_AT.LABEL')"
|
||||
type="datetime-local"
|
||||
:min="currentDateTime"
|
||||
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
|
||||
:message="formErrors.scheduledAt"
|
||||
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
type="button"
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAMPAIGN.VOICE.CREATE.FORM.BUTTONS.CREATE')"
|
||||
class="w-full"
|
||||
type="submit"
|
||||
:is-loading="isCreating"
|
||||
:disabled="isCreating || isSubmitDisabled"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -272,11 +272,11 @@ defineExpose({
|
||||
class="w-full"
|
||||
@input="
|
||||
isValidationField(item.key) &&
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
"
|
||||
@blur="
|
||||
isValidationField(item.key) &&
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+155
-2
@@ -13,13 +13,99 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
// Simple check: Is this a voice channel conversation?
|
||||
const isVoiceChannel = computed(() => {
|
||||
return props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice';
|
||||
});
|
||||
|
||||
// Get call direction: inbound or outbound
|
||||
const isIncomingCall = computed(() => {
|
||||
if (!isVoiceChannel.value) return false;
|
||||
|
||||
const direction = props.conversation?.additional_attributes?.call_direction;
|
||||
return direction === 'inbound';
|
||||
});
|
||||
|
||||
// Simple function to normalize call status
|
||||
const normalizedCallStatus = computed(() => {
|
||||
if (!isVoiceChannel.value) return null;
|
||||
|
||||
// Get the raw status directly from conversation
|
||||
const status = props.conversation?.additional_attributes?.call_status;
|
||||
|
||||
// Simple mapping of call statuses
|
||||
if (status === 'in-progress') return 'active';
|
||||
if (status === 'completed') return 'ended';
|
||||
if (status === 'canceled') return 'ended';
|
||||
if (status === 'failed') return 'ended';
|
||||
if (status === 'busy') return 'no-answer';
|
||||
if (status === 'no-answer') return isIncomingCall.value ? 'missed' : 'no-answer';
|
||||
|
||||
// Return the status as is for explicit values
|
||||
if (status === 'active') return 'active';
|
||||
if (status === 'missed') return 'missed';
|
||||
if (status === 'ended') return 'ended';
|
||||
if (status === 'ringing') return 'ringing';
|
||||
|
||||
// If no status is set, default to 'ended'
|
||||
return 'ended';
|
||||
});
|
||||
|
||||
// Get formatted call status text for voice channel conversations
|
||||
const callStatusText = computed(() => {
|
||||
if (!isVoiceChannel.value) return '';
|
||||
|
||||
const status = normalizedCallStatus.value;
|
||||
const isIncoming = isIncomingCall.value;
|
||||
|
||||
if (status === 'active') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (isIncoming) {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'missed') {
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
} else {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'no-answer') {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
}
|
||||
|
||||
return isIncoming
|
||||
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
});
|
||||
|
||||
// Return proper message content based on message type
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {}, customAttributes = {} } =
|
||||
props.conversation;
|
||||
const { email: { subject } = {} } = customAttributes;
|
||||
|
||||
// Return special formatting for voice calls
|
||||
if (isVoiceChannel.value) {
|
||||
return callStatusText.value;
|
||||
}
|
||||
|
||||
return getPlainText(
|
||||
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
|
||||
);
|
||||
@@ -42,9 +128,57 @@ const unreadMessagesCount = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex items-end w-full gap-2 pb-1">
|
||||
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
<!-- Voice Call Message -->
|
||||
<div
|
||||
v-if="isVoiceChannel"
|
||||
class="w-full mb-0 text-sm flex items-center gap-1 pt-0.5"
|
||||
:class="{
|
||||
'text-green-600 dark:text-green-400': normalizedCallStatus === 'ringing',
|
||||
'text-woot-600 dark:text-woot-400': normalizedCallStatus === 'active',
|
||||
'text-red-600 dark:text-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
|
||||
'text-slate-600 dark:text-slate-400': normalizedCallStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Explicit icon based on call status -->
|
||||
<!-- Missed call or no answer -->
|
||||
<i v-if="normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer'"
|
||||
class="i-ph-phone-x-fill text-base inline-block flex-shrink-0 text-red-600 dark:text-red-400 mr-1"></i>
|
||||
|
||||
<!-- Active call -->
|
||||
<i v-else-if="normalizedCallStatus === 'active'"
|
||||
class="i-ph-phone-call-fill text-base inline-block flex-shrink-0 text-woot-600 dark:text-woot-400 mr-1"></i>
|
||||
|
||||
<!-- Ended incoming call -->
|
||||
<i v-else-if="normalizedCallStatus === 'ended' && isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<!-- Ended outgoing call -->
|
||||
<i v-else-if="normalizedCallStatus === 'ended'"
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<!-- Ringing incoming call -->
|
||||
<i v-else-if="isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<!-- Ringing outgoing call -->
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
<span class="text-current truncate">{{ callStatusText }}</span>
|
||||
<span
|
||||
v-if="normalizedCallStatus === 'ringing'"
|
||||
class="flex-shrink-0 text-xs font-medium text-green-600 dark:text-green-400"
|
||||
>
|
||||
({{ t('CONVERSATION.VOICE_CALL.JOIN_CALL') }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Regular Message -->
|
||||
<p v-else class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
@@ -64,3 +198,22 @@ const unreadMessagesCount = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Animation for ringing calls */
|
||||
.pulse-animation {
|
||||
animation: icon-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+148
-1
@@ -24,7 +24,93 @@ const slaCardLabelRef = ref(null);
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
// Simple check: Is this a voice channel conversation?
|
||||
const isVoiceChannel = computed(() => {
|
||||
return props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice';
|
||||
});
|
||||
|
||||
// Get call direction: inbound or outbound
|
||||
const isIncomingCall = computed(() => {
|
||||
if (!isVoiceChannel.value) return false;
|
||||
|
||||
const direction = props.conversation?.additional_attributes?.call_direction;
|
||||
return direction === 'inbound';
|
||||
});
|
||||
|
||||
// Simple function to normalize call status
|
||||
const normalizedCallStatus = computed(() => {
|
||||
if (!isVoiceChannel.value) return null;
|
||||
|
||||
// Get the raw status directly from conversation
|
||||
const status = props.conversation?.additional_attributes?.call_status;
|
||||
|
||||
// Simple mapping of call statuses
|
||||
if (status === 'in-progress') return 'active';
|
||||
if (status === 'completed') return 'ended';
|
||||
if (status === 'canceled') return 'ended';
|
||||
if (status === 'failed') return 'ended';
|
||||
if (status === 'busy') return 'no-answer';
|
||||
if (status === 'no-answer') return isIncomingCall.value ? 'missed' : 'no-answer';
|
||||
|
||||
// Return the status as is for explicit values
|
||||
if (status === 'active') return 'active';
|
||||
if (status === 'missed') return 'missed';
|
||||
if (status === 'ended') return 'ended';
|
||||
if (status === 'ringing') return 'ringing';
|
||||
|
||||
// If no status is set, default to 'ended'
|
||||
return 'ended';
|
||||
});
|
||||
|
||||
// Get formatted call status text for voice channel conversations
|
||||
const callStatusText = computed(() => {
|
||||
if (!isVoiceChannel.value) return '';
|
||||
|
||||
const status = normalizedCallStatus.value;
|
||||
const isIncoming = isIncomingCall.value;
|
||||
|
||||
if (status === 'active') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (isIncoming) {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'missed') {
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
} else {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'no-answer') {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
}
|
||||
|
||||
return isIncoming
|
||||
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
});
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
// If it's a voice call, use the voice call text with icon
|
||||
if (isVoiceChannel.value) {
|
||||
return callStatusText.value;
|
||||
}
|
||||
|
||||
// Otherwise use the regular message content
|
||||
const { lastNonActivityMessage = {}, customAttributes = {} } =
|
||||
props.conversation;
|
||||
const { email: { subject } = {} } = customAttributes;
|
||||
@@ -61,7 +147,49 @@ defineExpose({
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
|
||||
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
<!-- Voice Call Message display with icon -->
|
||||
<div
|
||||
v-if="isVoiceChannel"
|
||||
class="flex items-center gap-1 mb-0 text-sm line-clamp-1"
|
||||
:class="{
|
||||
'text-green-600 dark:text-green-400': normalizedCallStatus === 'ringing',
|
||||
'text-woot-600 dark:text-woot-400': normalizedCallStatus === 'active',
|
||||
'text-red-600 dark:text-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
|
||||
'text-slate-600 dark:text-slate-400': normalizedCallStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Explicit icon based on call status -->
|
||||
<!-- Missed call or no answer -->
|
||||
<i v-if="normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer'"
|
||||
class="i-ph-phone-x-fill text-base inline-block flex-shrink-0 text-red-600 dark:text-red-400 mr-1"></i>
|
||||
|
||||
<!-- Active call -->
|
||||
<i v-else-if="normalizedCallStatus === 'active'"
|
||||
class="i-ph-phone-call-fill text-base inline-block flex-shrink-0 text-woot-600 dark:text-woot-400 mr-1"></i>
|
||||
|
||||
<!-- Ended incoming call -->
|
||||
<i v-else-if="normalizedCallStatus === 'ended' && isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<!-- Ended outgoing call -->
|
||||
<i v-else-if="normalizedCallStatus === 'ended'"
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<!-- Ringing incoming call -->
|
||||
<i v-else-if="isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<!-- Ringing outgoing call -->
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<span class="text-current truncate">{{ callStatusText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Regular Message display -->
|
||||
<p v-else class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
@@ -105,3 +233,22 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Animation for ringing calls */
|
||||
.pulse-animation {
|
||||
animation: icon-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+248
-11
@@ -4,6 +4,7 @@ import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
@@ -32,6 +33,7 @@ const props = defineProps({
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const cardMessagePreviewWithMetaRef = ref(null);
|
||||
|
||||
@@ -57,6 +59,123 @@ const lastActivityAt = computed(() => {
|
||||
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
|
||||
});
|
||||
|
||||
const lastNonActivityMessage = computed(() => {
|
||||
return props.conversation?.lastNonActivityMessage || {};
|
||||
});
|
||||
|
||||
// Simple check: Is this a voice channel conversation?
|
||||
const isVoiceChannel = computed(() => {
|
||||
return props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice';
|
||||
});
|
||||
|
||||
// Get call direction: inbound or outbound
|
||||
const isIncomingCall = computed(() => {
|
||||
if (!isVoiceChannel.value) return false;
|
||||
|
||||
const direction = props.conversation?.additional_attributes?.call_direction;
|
||||
return direction === 'inbound';
|
||||
});
|
||||
|
||||
// Simple function to normalize call status
|
||||
const normalizedCallStatus = computed(() => {
|
||||
if (!isVoiceChannel.value) return null;
|
||||
|
||||
// Get the raw status directly from conversation
|
||||
const status = props.conversation?.additional_attributes?.call_status;
|
||||
|
||||
// Simple mapping of call statuses
|
||||
if (status === 'in-progress') return 'active';
|
||||
if (status === 'completed') return 'ended';
|
||||
if (status === 'canceled') return 'ended';
|
||||
if (status === 'failed') return 'ended';
|
||||
if (status === 'busy') return 'no-answer';
|
||||
if (status === 'no-answer') return isIncomingCall.value ? 'missed' : 'no-answer';
|
||||
|
||||
// Return the status as is for explicit values
|
||||
if (status === 'active') return 'active';
|
||||
if (status === 'missed') return 'missed';
|
||||
if (status === 'ended') return 'ended';
|
||||
if (status === 'ringing') return 'ringing';
|
||||
|
||||
// If no status is set, default to 'ended'
|
||||
return 'ended';
|
||||
});
|
||||
|
||||
const isRingingCall = computed(() => {
|
||||
return normalizedCallStatus.value === 'ringing';
|
||||
});
|
||||
|
||||
const isActiveCall = computed(() => {
|
||||
return normalizedCallStatus.value === 'active';
|
||||
});
|
||||
|
||||
// Get formatted call status text for voice channel conversations
|
||||
const callStatusText = computed(() => {
|
||||
if (!isVoiceChannel.value) return '';
|
||||
|
||||
const status = normalizedCallStatus.value;
|
||||
const isIncoming = isIncomingCall.value;
|
||||
|
||||
if (status === 'active') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (isIncoming) {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'missed') {
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
} else {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'no-answer') {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
}
|
||||
|
||||
return isIncoming
|
||||
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
});
|
||||
|
||||
// Get icon class based on call status
|
||||
const callIconClass = computed(() => {
|
||||
if (!isVoiceChannel.value) return '';
|
||||
|
||||
const status = normalizedCallStatus.value;
|
||||
const isIncoming = isIncomingCall.value;
|
||||
|
||||
if (status === 'missed' || status === 'no-answer') {
|
||||
return 'i-ph-phone-x-fill';
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
return 'i-ph-phone-call-fill';
|
||||
}
|
||||
|
||||
if (status === 'ended' || status === 'completed') {
|
||||
return 'i-ph-phone-fill';
|
||||
}
|
||||
|
||||
// Default phone icon for ringing state
|
||||
return isIncoming
|
||||
? 'i-ph-phone-incoming-fill'
|
||||
: 'i-ph-phone-outgoing-fill';
|
||||
});
|
||||
|
||||
const showMessagePreviewWithoutMeta = computed(() => {
|
||||
const { labels = [] } = props.conversation;
|
||||
return (
|
||||
@@ -87,9 +206,21 @@ const onCardClick = e => {
|
||||
<template>
|
||||
<div
|
||||
role="button"
|
||||
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
|
||||
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer relative"
|
||||
:class="{
|
||||
'border-l-2 border-green-500 dark:border-green-400': isRingingCall,
|
||||
'border-l-2 border-woot-500 dark:border-woot-400': isActiveCall,
|
||||
'border-l-2 border-red-500 dark:border-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
|
||||
'conversation-ringing': isRingingCall
|
||||
}"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<!-- Ringing call indicator (pulse effect) -->
|
||||
<div
|
||||
v-if="isRingingCall"
|
||||
class="absolute left-0 top-0 bottom-0 w-0.5 bg-green-500 dark:bg-green-400 animate-pulse"
|
||||
></div>
|
||||
|
||||
<Avatar
|
||||
:name="currentContactName"
|
||||
:src="currentContactThumbnail"
|
||||
@@ -108,7 +239,35 @@ const onCardClick = e => {
|
||||
v-tooltip.left="inboxName"
|
||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
|
||||
>
|
||||
<!-- Special handling for voice channel with specific icons based on call status -->
|
||||
<span
|
||||
v-if="isVoiceChannel && normalizedCallStatus === 'missed'"
|
||||
class="i-ph-phone-x-fill text-red-600 dark:text-red-400 size-3 inline-block"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isVoiceChannel && normalizedCallStatus === 'active'"
|
||||
class="i-ph-phone-call-fill text-woot-600 dark:text-woot-400 size-3 inline-block"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isVoiceChannel && normalizedCallStatus === 'ended' && isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-n-slate-11 size-3 inline-block"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isVoiceChannel && normalizedCallStatus === 'ended'"
|
||||
class="i-ph-phone-outgoing-fill text-n-slate-11 size-3 inline-block"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isVoiceChannel && isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-green-600 dark:text-green-400 size-3 inline-block"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="isVoiceChannel"
|
||||
class="i-ph-phone-outgoing-fill text-green-600 dark:text-green-400 size-3 inline-block"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"
|
||||
></span>
|
||||
<Icon
|
||||
v-else
|
||||
:icon="inboxIcon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-3"
|
||||
/>
|
||||
@@ -118,16 +277,94 @@ const onCardClick = e => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardMessagePreview
|
||||
v-show="showMessagePreviewWithoutMeta"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<CardMessagePreviewWithMeta
|
||||
v-show="!showMessagePreviewWithoutMeta"
|
||||
ref="cardMessagePreviewWithMetaRef"
|
||||
:conversation="conversation"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
|
||||
<!-- Special preview for voice channel conversations -->
|
||||
<div
|
||||
v-if="isVoiceChannel"
|
||||
class="flex items-center py-1 h-7 gap-1 mb-0 text-sm line-clamp-1"
|
||||
:class="{
|
||||
'text-green-600 dark:text-green-400': normalizedCallStatus === 'ringing',
|
||||
'text-woot-600 dark:text-woot-400': normalizedCallStatus === 'active',
|
||||
'text-red-600 dark:text-red-400': normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer',
|
||||
'text-slate-600 dark:text-slate-400': normalizedCallStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Icon based on call status -->
|
||||
<i v-if="normalizedCallStatus === 'missed' || normalizedCallStatus === 'no-answer'"
|
||||
class="i-ph-phone-x-fill text-base inline-block flex-shrink-0 text-red-600 dark:text-red-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="normalizedCallStatus === 'active'"
|
||||
class="i-ph-phone-call-fill text-base inline-block flex-shrink-0 text-woot-600 dark:text-woot-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="normalizedCallStatus === 'ended'"
|
||||
class="i-ph-phone-fill text-base inline-block flex-shrink-0 text-slate-600 dark:text-slate-400 mr-1"></i>
|
||||
|
||||
<i v-else-if="isIncomingCall"
|
||||
class="i-ph-phone-incoming-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing-fill text-base inline-block flex-shrink-0 text-green-600 dark:text-green-400 mr-1"
|
||||
:class="{ 'pulse-animation': normalizedCallStatus === 'ringing' }"></i>
|
||||
|
||||
<span class="text-current truncate">{{ callStatusText }}</span>
|
||||
|
||||
<!-- Join now prompt for ringing calls -->
|
||||
<span
|
||||
v-if="normalizedCallStatus === 'ringing'"
|
||||
class="flex-shrink-0 text-xs font-medium text-green-600 dark:text-green-400"
|
||||
>
|
||||
({{ t('CONVERSATION.VOICE_CALL.JOIN_CALL') }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Regular message previews for non-voice channel conversations -->
|
||||
<template v-else>
|
||||
<CardMessagePreview
|
||||
v-show="showMessagePreviewWithoutMeta"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<CardMessagePreviewWithMeta
|
||||
v-show="!showMessagePreviewWithoutMeta"
|
||||
ref="cardMessagePreviewWithMetaRef"
|
||||
:conversation="conversation"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conversation-ringing {
|
||||
animation: border-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.pulse-animation {
|
||||
animation: icon-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes border-pulse {
|
||||
0% {
|
||||
border-color: rgba(34, 197, 94, 0.8); /* Green for ringing */
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
100% {
|
||||
border-color: rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+10
-2
@@ -15,6 +15,7 @@ import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isVoiceInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
messageTemplates: { type: Array, default: () => [] },
|
||||
@@ -113,6 +114,12 @@ const { onFileUpload } = useFileUpload({
|
||||
});
|
||||
|
||||
const sendButtonLabel = computed(() => {
|
||||
// For voice inboxes, show "Make a Call" instead of "Send"
|
||||
if (props.isVoiceInbox) {
|
||||
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.MAKE_CALL');
|
||||
}
|
||||
|
||||
// For all other inboxes, show the standard "Send" label
|
||||
const keyCode = isEditorHotKeyEnabled('cmd_enter') ? '⌘ + ↵' : '↵';
|
||||
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', {
|
||||
keyCode,
|
||||
@@ -157,7 +164,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="!isWhatsappInbox && !hasNoInbox"
|
||||
v-if="!isWhatsappInbox && !isVoiceInbox && !hasNoInbox"
|
||||
v-on-click-outside="() => (isEmojiPickerOpen = false)"
|
||||
class="relative"
|
||||
>
|
||||
@@ -197,7 +204,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox"
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox && !isVoiceInbox"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@@ -217,6 +224,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
<Button
|
||||
v-if="!isWhatsappInbox"
|
||||
:icon="isVoiceInbox ? 'i-ri-phone-fill' : undefined"
|
||||
:label="sendButtonLabel"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
|
||||
+5
-3
@@ -68,6 +68,7 @@ const inboxTypes = computed(() => ({
|
||||
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
|
||||
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
|
||||
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
|
||||
isVoice: props.targetInbox?.channelType === INBOX_TYPES.VOICE,
|
||||
isEmailOrWebWidget:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.EMAIL ||
|
||||
props.targetInbox?.channelType === INBOX_TYPES.WEB,
|
||||
@@ -87,7 +88,7 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
targetInbox: { required },
|
||||
message: { required: requiredIf(!inboxTypes.value.isWhatsapp) },
|
||||
message: { required: requiredIf(!inboxTypes.value.isWhatsapp && !inboxTypes.value.isVoice) },
|
||||
subject: { required: requiredIf(inboxTypes.value.isEmail) },
|
||||
}));
|
||||
|
||||
@@ -311,7 +312,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
/>
|
||||
|
||||
<MessageEditor
|
||||
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
|
||||
v-if="!inboxTypes.isWhatsapp && !inboxTypes.isVoice && !showNoInboxAlert"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@@ -321,7 +322,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
v-if="state.attachedFiles.length > 0"
|
||||
v-if="state.attachedFiles.length > 0 && !inboxTypes.isVoice"
|
||||
:attachments="state.attachedFiles"
|
||||
@update:attachments="state.attachedFiles = $event"
|
||||
/>
|
||||
@@ -329,6 +330,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
<ActionButtons
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-voice-inbox="inboxTypes.isVoice"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
|
||||
+5
-3
@@ -8,8 +8,9 @@ const CHANNEL_PRIORITY = {
|
||||
'Channel::Whatsapp': 2,
|
||||
'Channel::Sms': 3,
|
||||
'Channel::TwilioSms': 4,
|
||||
'Channel::WebWidget': 5,
|
||||
'Channel::Api': 6,
|
||||
'Channel::Voice': 5,
|
||||
'Channel::WebWidget': 6,
|
||||
'Channel::Api': 7,
|
||||
};
|
||||
|
||||
export const generateLabelForContactableInboxesList = ({
|
||||
@@ -23,7 +24,8 @@ export const generateLabelForContactableInboxesList = ({
|
||||
}
|
||||
if (
|
||||
channelType === INBOX_TYPES.TWILIO ||
|
||||
channelType === INBOX_TYPES.WHATSAPP
|
||||
channelType === INBOX_TYPES.WHATSAPP ||
|
||||
channelType === INBOX_TYPES.VOICE
|
||||
) {
|
||||
return `${name} (${phoneNumber})`;
|
||||
}
|
||||
|
||||
@@ -60,38 +60,47 @@ const filteredAttrs = computed(() => {
|
||||
const computedVariant = computed(() => {
|
||||
if (props.variant) return props.variant;
|
||||
// The useAttrs method returns attributes values an empty string (not boolean value as in props).
|
||||
if (attrs.solid || attrs.solid === '') return 'solid';
|
||||
if (attrs.outline || attrs.outline === '') return 'outline';
|
||||
if (attrs.faded || attrs.faded === '') return 'faded';
|
||||
if (attrs.link || attrs.link === '') return 'link';
|
||||
if (attrs.ghost || attrs.ghost === '') return 'ghost';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.solid || attrObj.solid === '') return 'solid';
|
||||
if (attrObj.outline || attrObj.outline === '') return 'outline';
|
||||
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
|
||||
});
|
||||
|
||||
const computedColor = computed(() => {
|
||||
if (props.color) return props.color;
|
||||
if (attrs.blue || attrs.blue === '') return 'blue';
|
||||
if (attrs.ruby || attrs.ruby === '') return 'ruby';
|
||||
if (attrs.amber || attrs.amber === '') return 'amber';
|
||||
if (attrs.slate || attrs.slate === '') return 'slate';
|
||||
if (attrs.teal || attrs.teal === '') return 'teal';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.blue || attrObj.blue === '') return 'blue';
|
||||
if (attrObj.ruby || attrObj.ruby === '') return 'ruby';
|
||||
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
|
||||
});
|
||||
|
||||
const computedSize = computed(() => {
|
||||
if (props.size) return props.size;
|
||||
if (attrs.xs || attrs.xs === '') return 'xs';
|
||||
if (attrs.sm || attrs.sm === '') return 'sm';
|
||||
if (attrs.md || attrs.md === '') return 'md';
|
||||
if (attrs.lg || attrs.lg === '') return 'lg';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.xs || attrObj.xs === '') return 'xs';
|
||||
if (attrObj.sm || attrObj.sm === '') return 'sm';
|
||||
if (attrObj.md || attrObj.md === '') return 'md';
|
||||
if (attrObj.lg || attrObj.lg === '') return 'lg';
|
||||
return 'md';
|
||||
});
|
||||
|
||||
const computedJustify = computed(() => {
|
||||
if (props.justify) return props.justify;
|
||||
if (attrs.start || attrs.start === '') return 'start';
|
||||
if (attrs.center || attrs.center === '') return 'center';
|
||||
if (attrs.end || attrs.end === '') return 'end';
|
||||
// Add defensive checks for undefined attrs
|
||||
const attrObj = attrs || {};
|
||||
if (attrObj.start || attrObj.start === '') return 'start';
|
||||
if (attrObj.center || attrObj.center === '') return 'center';
|
||||
if (attrObj.end || attrObj.end === '') return 'end';
|
||||
|
||||
return 'center';
|
||||
});
|
||||
@@ -141,6 +150,17 @@ const STYLE_CONFIG = {
|
||||
ghost:
|
||||
'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: {
|
||||
solid:
|
||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||
|
||||
@@ -36,6 +36,7 @@ import DyteBubble from './bubbles/Dyte.vue';
|
||||
import LocationBubble from './bubbles/Location.vue';
|
||||
import CSATBubble from './bubbles/CSAT.vue';
|
||||
import FormBubble from './bubbles/Form.vue';
|
||||
import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
@@ -296,6 +297,15 @@ const componentToRender = computed(() => {
|
||||
return InstagramStoryBubble;
|
||||
}
|
||||
|
||||
// Handle voice call bubble
|
||||
if (
|
||||
props.contentType === 'voice_call' ||
|
||||
props.contentAttributes?.type === 'voice_call' ||
|
||||
props.contentAttributes?.data?.callType === 'voice_call'
|
||||
) {
|
||||
return VoiceCallBubble;
|
||||
}
|
||||
|
||||
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
||||
const fileType = props.attachments[0].fileType;
|
||||
|
||||
@@ -509,10 +519,11 @@ provideMessageContext({
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
'min-w-0 max-w-full': componentToRender === VoiceCallBubble,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<Component :is="componentToRender" />
|
||||
<Component :is="componentToRender" :message="props" />
|
||||
</div>
|
||||
<MessageError
|
||||
v-if="contentAttributes.externalError"
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
isAWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isAVoiceChannel,
|
||||
} = useInbox();
|
||||
|
||||
const {
|
||||
@@ -41,6 +42,8 @@ const showStatusIndicator = computed(() => {
|
||||
if (status.value === MESSAGE_STATUS.FAILED) return false;
|
||||
// Don't show status for deleted messages
|
||||
if (contentAttributes.value?.deleted) return false;
|
||||
// Don't show status for transcription messages
|
||||
if (isAVoiceChannel.value) return false;
|
||||
|
||||
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
|
||||
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex-col border border-slate-100 dark:border-slate-700 rounded-lg overflow-hidden w-full max-w-xs"
|
||||
:class="statusClass"
|
||||
>
|
||||
<div class="flex items-center p-3 gap-3 w-full">
|
||||
<!-- Call Icon -->
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center size-10 rounded-full"
|
||||
:class="iconBgClass"
|
||||
>
|
||||
<span
|
||||
:class="[iconName, 'text-white text-xl']"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- Call Info -->
|
||||
<div class="flex flex-col flex-grow overflow-hidden">
|
||||
<span class="text-base font-medium" :class="labelTextClass">
|
||||
{{ labelText }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-500">
|
||||
{{ subtextWithDuration }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="hasAudioAttachment && recordingUrl">
|
||||
<div class="w-full m-0 p-1 min-w-[260px]">
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
:src="recordingUrl"
|
||||
preload="metadata"
|
||||
@ended="handlePlaybackEnd"
|
||||
controls
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useVoiceCallHelpers } from 'dashboard/composables/useVoiceCallHelpers';
|
||||
|
||||
export default {
|
||||
name: 'VoiceCallBubble',
|
||||
components: {
|
||||
},
|
||||
inject: ['$emit'],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
internalStatus: '',
|
||||
refreshInterval: null,
|
||||
statusCheckInterval: null,
|
||||
isAnimating: false,
|
||||
recordingUrl: '',
|
||||
isPlaying: false,
|
||||
hasAudioAttachment: false,
|
||||
};
|
||||
},
|
||||
setup(props) {
|
||||
// Initialize our composable for use in methods
|
||||
const {
|
||||
normalizeCallStatus,
|
||||
isIncomingCall,
|
||||
getCallIconName,
|
||||
getStatusText
|
||||
} = useVoiceCallHelpers({ conversation: props.message?.conversation }, {
|
||||
t: (key) => {
|
||||
// This is a simple passthrough for the t function since we're in options API
|
||||
// In setup() we can't access this.$t directly
|
||||
return key;
|
||||
}
|
||||
});
|
||||
|
||||
// Expose these helpers to the component instance
|
||||
return {
|
||||
normalizeCallHelper: normalizeCallStatus,
|
||||
checkIsIncoming: isIncomingCall,
|
||||
getCallIconHelper: getCallIconName,
|
||||
getStatusTextHelper: getStatusText
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
callData() {
|
||||
return this.message?.contentAttributes?.data || {};
|
||||
},
|
||||
|
||||
directionalStatus() {
|
||||
const direction = this.callData?.call_direction;
|
||||
if (direction) {
|
||||
return direction === 'inbound' ? 'inbound' : 'outbound';
|
||||
}
|
||||
return this.message?.messageType === 0 ? 'inbound' : 'outbound';
|
||||
},
|
||||
|
||||
isIncoming() {
|
||||
return this.directionalStatus === 'inbound';
|
||||
},
|
||||
|
||||
isOutgoing() {
|
||||
return this.directionalStatus === 'outbound';
|
||||
},
|
||||
|
||||
status() {
|
||||
// Use internal status if we have one (from UI updates)
|
||||
if (this.internalStatus) {
|
||||
return this.internalStatus;
|
||||
}
|
||||
|
||||
// First check for direct call_status in the conversation additional_attributes
|
||||
// This is the most authoritative source for call status
|
||||
const conversationCallStatus = this.message?.conversation?.additional_attributes?.call_status;
|
||||
if (conversationCallStatus) {
|
||||
// Use our composable helper for status normalization
|
||||
return this.normalizeCallHelper(conversationCallStatus, this.isIncoming);
|
||||
}
|
||||
|
||||
// Use the status from call data if present
|
||||
const callStatus = this.callData?.status;
|
||||
if (callStatus) {
|
||||
// Use our composable helper for status normalization
|
||||
return this.normalizeCallHelper(callStatus, this.isIncoming);
|
||||
}
|
||||
|
||||
// Determine status from timestamps
|
||||
if (this.callData?.ended_at) {
|
||||
return 'ended';
|
||||
}
|
||||
if (this.callData?.missed) {
|
||||
return this.isIncoming ? 'missed' : 'no-answer';
|
||||
}
|
||||
|
||||
// Check both message data and conversation data for started_at
|
||||
if (this.callData?.started_at ||
|
||||
this.message?.conversation?.additional_attributes?.call_started_at) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// Default to ringing
|
||||
return 'ringing';
|
||||
},
|
||||
|
||||
formattedDuration() {
|
||||
if (
|
||||
this.callData?.started_at &&
|
||||
(this.status === 'active' || this.status === 'ended')
|
||||
) {
|
||||
const startTime = new Date(this.callData.started_at);
|
||||
const endTime = this.callData?.ended_at
|
||||
? new Date(this.callData.ended_at)
|
||||
: new Date();
|
||||
|
||||
const durationMs = endTime - startTime;
|
||||
return this.formatDuration(durationMs);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
statusClass() {
|
||||
return {
|
||||
'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100': !this.isInbox,
|
||||
'bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100': this.isInbox,
|
||||
'call-ringing': this.status === 'ringing',
|
||||
};
|
||||
},
|
||||
|
||||
iconName() {
|
||||
// Use our composable helper for icon selection
|
||||
return this.getCallIconHelper(this.status, this.isIncoming);
|
||||
},
|
||||
|
||||
iconBgClass() {
|
||||
// Icon background colors based on status
|
||||
if (this.status === 'active') {
|
||||
return 'bg-green-500'; // Green for calls in progress
|
||||
}
|
||||
|
||||
if (this.status === 'missed' || this.status === 'no-answer') {
|
||||
return 'bg-red-500'; // Red for missed calls
|
||||
}
|
||||
|
||||
if (this.status === 'ended') {
|
||||
return 'bg-purple-500'; // Purple for ended calls
|
||||
}
|
||||
|
||||
// Default green for ringing
|
||||
return 'bg-green-500 pulse-animation';
|
||||
},
|
||||
|
||||
labelText() {
|
||||
// Use our composable helper to get status text
|
||||
// We need to convert the key to the actual text since we're in options API
|
||||
const key = this.getStatusTextHelper(this.status, this.isIncoming);
|
||||
|
||||
// Special cases for floating widget compatibility
|
||||
if (this.status === 'ringing') {
|
||||
if (this.isIncoming) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.INCOMING');
|
||||
} else {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING');
|
||||
}
|
||||
}
|
||||
|
||||
// Map the key to the translated text
|
||||
return this.$t(key);
|
||||
},
|
||||
|
||||
labelTextClass() {
|
||||
if (this.status === 'missed' || this.status === 'no-answer') {
|
||||
return 'text-red-500';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
subtext() {
|
||||
// Checking call direction and status
|
||||
const direction = this.isIncoming ? 'incoming' : 'outgoing';
|
||||
|
||||
// Check if we have agent_joined flag to determine if agent answered
|
||||
const agentJoined = this.message?.conversation?.additional_attributes?.agent_joined === true;
|
||||
const callStarted = !!this.message?.conversation?.additional_attributes?.call_started_at;
|
||||
|
||||
// Special handling for incoming calls that were previously joined but now ended
|
||||
// This avoids showing "You didn't answer" when agent actually did answer
|
||||
if (this.isIncoming && this.status === 'missed' && (agentJoined || callStarted)) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
|
||||
// Common subtext for all statuses
|
||||
const subtextMap = {
|
||||
incoming: {
|
||||
ringing: this.$t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'),
|
||||
active: this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'),
|
||||
missed: this.$t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER'),
|
||||
ended: this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED')
|
||||
},
|
||||
outgoing: {
|
||||
ringing: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
active: this.$t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'),
|
||||
'no-answer': this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
ended: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
completed: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
canceled: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
failed: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED'),
|
||||
busy: this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED')
|
||||
}
|
||||
};
|
||||
|
||||
// First check if we have a specific message for this status
|
||||
if (subtextMap[direction] && subtextMap[direction][this.status]) {
|
||||
return subtextMap[direction][this.status];
|
||||
}
|
||||
|
||||
// Default for missing statuses
|
||||
if (this.isIncoming) {
|
||||
if (this.status === 'ringing') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
} else if (agentJoined || callStarted) {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
} else {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_DIDNT_ANSWER');
|
||||
}
|
||||
} else {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.YOU_CALLED');
|
||||
}
|
||||
},
|
||||
|
||||
subtextWithDuration() {
|
||||
// Checking if we have start and end timestamps for duration calculation
|
||||
let durationToShow = this.formattedDuration;
|
||||
|
||||
// Check if we have explicit call duration from the content attributes
|
||||
if (!durationToShow && this.callData?.duration) {
|
||||
const durationSeconds = parseInt(this.callData.duration, 10);
|
||||
if (!isNaN(durationSeconds) && durationSeconds > 0) {
|
||||
const minutes = Math.floor(durationSeconds / 60);
|
||||
const seconds = durationSeconds % 60;
|
||||
durationToShow = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// For completed calls, always show the duration if we have it
|
||||
const shouldShowDuration =
|
||||
(this.status === 'ended' || this.status === 'completed') &&
|
||||
durationToShow;
|
||||
|
||||
if (shouldShowDuration) {
|
||||
return `${this.subtext} · ${durationToShow}`;
|
||||
}
|
||||
|
||||
return this.subtext;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
this.setupVoiceCall();
|
||||
this.setAudioAttachment();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setupVoiceCall();
|
||||
this.setAudioAttachment();
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up all intervals to prevent memory leaks
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
|
||||
if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval);
|
||||
this.statusCheckInterval = null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatDuration(milliseconds) {
|
||||
// Convert milliseconds to seconds
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
|
||||
// Calculate minutes and remaining seconds
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
// Format as MM:SS with leading zeros
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
setupVoiceCall() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
|
||||
// Create refresh interval for active calls to update duration
|
||||
if (this.status === 'active') {
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.$forceUpdate();
|
||||
}, 1000);
|
||||
|
||||
// Set animation flag
|
||||
this.isAnimating = true;
|
||||
} else {
|
||||
this.isAnimating = false;
|
||||
}
|
||||
|
||||
// Always check for call status changes, not just when ringing
|
||||
if (true) { // Always run status checks
|
||||
// Create a separate interval to check if the call status has changed
|
||||
this.statusCheckInterval = setInterval(() => {
|
||||
// Check if content_attributes has been updated with new status
|
||||
const updatedStatus = this.callData?.status;
|
||||
const statusUpdatedAt = this.callData?.status_updated;
|
||||
|
||||
// Also check the conversation's call status (which might be more authoritative)
|
||||
const conversationStatus = this.message?.conversation?.additional_attributes?.call_status;
|
||||
|
||||
// Check for any status changes from either source
|
||||
const hasMessageStatusChanged = updatedStatus &&
|
||||
updatedStatus !== this.internalStatus &&
|
||||
statusUpdatedAt;
|
||||
|
||||
const hasConversationStatusChanged = conversationStatus &&
|
||||
conversationStatus !== this.internalStatus;
|
||||
|
||||
// If either status has changed, update UI
|
||||
if (hasMessageStatusChanged || hasConversationStatusChanged) {
|
||||
// Prefer the conversation status if available (more reliable)
|
||||
const newStatus = conversationStatus || updatedStatus;
|
||||
|
||||
// Status has changed, update UI
|
||||
this.updateStatus(newStatus);
|
||||
this.$forceUpdate();
|
||||
|
||||
// If call is now active or ended, update UI
|
||||
if (newStatus === 'active' || newStatus === 'in-progress') {
|
||||
this.setupVoiceCall();
|
||||
}
|
||||
}
|
||||
}, 1000); // Check more frequently - every 1 second
|
||||
} else if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval);
|
||||
this.statusCheckInterval = null;
|
||||
}
|
||||
},
|
||||
updateStatus(newStatus) {
|
||||
if (
|
||||
newStatus &&
|
||||
newStatus !== this.status &&
|
||||
newStatus !== this.internalStatus
|
||||
) {
|
||||
this.internalStatus = newStatus;
|
||||
}
|
||||
},
|
||||
handlePlaybackEnd() {
|
||||
this.isPlaying = false;
|
||||
},
|
||||
setAudioAttachment() {
|
||||
// Look for audio attachment in message.attachments or message.contentAttributes.attachments
|
||||
let attachments = [];
|
||||
if (this.message?.attachments && Array.isArray(this.message.attachments)) {
|
||||
attachments = this.message.attachments;
|
||||
} else if (this.message?.contentAttributes?.attachments && Array.isArray(this.message.contentAttributes.attachments)) {
|
||||
attachments = this.message.contentAttributes.attachments;
|
||||
}
|
||||
// Find the first audio attachment, supporting both camelCase and snake_case fields
|
||||
const audio = attachments.find(att => {
|
||||
if (!att) return false;
|
||||
// Check file_type or fileType
|
||||
if ((att.file_type && att.file_type.startsWith('audio')) ||
|
||||
(att.fileType && att.fileType.startsWith('audio'))) return true;
|
||||
// Check content_type or contentType
|
||||
if ((att.content_type && att.content_type.startsWith('audio')) ||
|
||||
(att.contentType && att.contentType.startsWith('audio'))) return true;
|
||||
// Check data_url or dataUrl
|
||||
if ((att.data_url && att.data_url.match(/\.(mp3|wav|ogg|m4a)$/i)) ||
|
||||
(att.dataUrl && att.dataUrl.match(/\.(mp3|wav|ogg|m4a)$/i))) return true;
|
||||
// Check file_url or fileUrl
|
||||
if ((att.file_url && att.file_url.match(/\.(mp3|wav|ogg|m4a)$/i)) ||
|
||||
(att.fileUrl && att.fileUrl.match(/\.(mp3|wav|ogg|m4a)$/i))) return true;
|
||||
return false;
|
||||
});
|
||||
if (audio) {
|
||||
this.recordingUrl = audio.data_url || audio.file_url || audio.dataUrl || audio.fileUrl || '';
|
||||
this.hasAudioAttachment = true;
|
||||
} else {
|
||||
this.recordingUrl = '';
|
||||
this.hasAudioAttachment = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Voice call styling */
|
||||
.pulse-animation {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.call-ringing {
|
||||
animation: border-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); /* Green for ringing */
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes border-pulse {
|
||||
0% {
|
||||
border-color: rgba(34, 197, 94, 0.8); /* Green for ringing */
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
100% {
|
||||
border-color: rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,9 @@ defineOptions({
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -91,8 +93,16 @@ const changePlaybackSpeed = () => {
|
||||
};
|
||||
|
||||
const downloadAudio = async () => {
|
||||
const { fileType, dataUrl, extension } = attachment;
|
||||
downloadFile({ url: dataUrl, type: fileType, extension });
|
||||
// Get the URL with fallback options
|
||||
const url = attachment?.dataUrl || attachment?.data_url || '';
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType = attachment?.fileType || attachment?.file_type || 'file';
|
||||
const extension = attachment?.extension || 'mp3';
|
||||
|
||||
downloadFile({ url, type: fileType, extension });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
@@ -16,6 +17,7 @@ import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import CallButton from 'dashboard/components-next/CallModal/CallButton.vue';
|
||||
|
||||
const emit = defineEmits([
|
||||
'closeKeyShortcutModal',
|
||||
@@ -63,6 +65,11 @@ const conversationCustomViews = useMapGetter(
|
||||
'customViews/getConversationCustomViews'
|
||||
);
|
||||
|
||||
// Check if there are any voice inboxes
|
||||
const hasVoiceInbox = computed(() =>
|
||||
inboxes.value.some(inbox => inbox.channel_type === INBOX_TYPES.VOICE)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('labels/get');
|
||||
store.dispatch('inboxes/get');
|
||||
@@ -331,6 +338,11 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.SMS'),
|
||||
to: accountScopedRoute('campaigns_sms_index'),
|
||||
},
|
||||
{
|
||||
name: 'Voice',
|
||||
label: t('SIDEBAR.VOICE'),
|
||||
to: accountScopedRoute('campaigns_voice_index'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -511,6 +523,7 @@ const menuItems = computed(() => {
|
||||
{{ searchShortcut }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<CallButton v-if="hasVoiceInbox" class="flex-shrink-0" />
|
||||
<ComposeConversation align-position="right">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
@@ -541,4 +554,4 @@ const menuItems = computed(() => {
|
||||
/>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
</template>
|
||||
@@ -48,13 +48,13 @@ export default {
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
'voice',
|
||||
'api',
|
||||
'whatsapp',
|
||||
'sms',
|
||||
'telegram',
|
||||
'line',
|
||||
'instagram',
|
||||
'voice',
|
||||
].includes(key);
|
||||
},
|
||||
isComingSoon() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,13 @@ export default {
|
||||
<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"
|
||||
>
|
||||
<!-- 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
|
||||
v-else
|
||||
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
|
||||
:icon="computedInboxClass"
|
||||
size="12"
|
||||
|
||||
@@ -311,6 +311,7 @@ export default {
|
||||
<MessagePreview
|
||||
v-if="lastMessageInChat"
|
||||
:message="lastMessageInChat"
|
||||
:conversation="chat"
|
||||
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
|
||||
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,10 @@ export default {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
conversation: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showMessageType: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -26,6 +30,13 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
shouldShowCallStatus() {
|
||||
// Always show call status for voice channels if present
|
||||
return (
|
||||
this.conversation?.meta?.channel === 'Channel::Voice' &&
|
||||
!!this.conversation?.additional_attributes?.call_status
|
||||
);
|
||||
},
|
||||
messageByAgent() {
|
||||
const { message_type: messageType } = this.message;
|
||||
return messageType === MESSAGE_TYPE.OUTGOING;
|
||||
@@ -38,7 +49,125 @@ export default {
|
||||
const { private: isPrivate } = this.message;
|
||||
return isPrivate;
|
||||
},
|
||||
// Simple check: Is this a voice channel conversation?
|
||||
isVoiceChannel() {
|
||||
return this.conversation?.meta?.channel === 'Channel::Voice';
|
||||
},
|
||||
// Check if this is a voice call message
|
||||
isVoiceCall() {
|
||||
return (
|
||||
this.message?.content_type === 'voice_call'
|
||||
);
|
||||
},
|
||||
// Get call direction for voice calls
|
||||
isIncomingCall() {
|
||||
if (!this.isVoiceChannel) return false;
|
||||
|
||||
// First check conversation attributes
|
||||
const direction = this.conversation?.additional_attributes?.call_direction;
|
||||
if (direction) {
|
||||
return direction === 'inbound';
|
||||
}
|
||||
},
|
||||
// Get normalized call status
|
||||
callStatus() {
|
||||
if (!this.isVoiceChannel) return null;
|
||||
|
||||
// Get raw status from conversation
|
||||
const status = this.conversation?.additional_attributes?.call_status;
|
||||
|
||||
// Map status to normalized values
|
||||
if (status === 'in-progress') return 'active';
|
||||
if (status === 'completed') return 'ended';
|
||||
if (status === 'canceled') return 'ended';
|
||||
if (status === 'failed') return 'ended';
|
||||
if (status === 'busy') return 'no-answer';
|
||||
if (status === 'no-answer') return this.isIncomingCall ? 'missed' : 'no-answer';
|
||||
|
||||
// Return explicit status values as-is
|
||||
if (status === 'active') return 'active';
|
||||
if (status === 'missed') return 'missed';
|
||||
if (status === 'ended') return 'ended';
|
||||
if (status === 'ringing') return 'ringing';
|
||||
|
||||
// Default status
|
||||
return 'active';
|
||||
},
|
||||
// Voice call icon based on status
|
||||
voiceCallIcon() {
|
||||
if (!this.isVoiceChannel) return null;
|
||||
|
||||
const status = this.callStatus;
|
||||
const isIncoming = this.isIncomingCall;
|
||||
|
||||
if (status === 'missed' || status === 'no-answer') {
|
||||
return 'phone-missed-call';
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
return 'phone-in-talk';
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
|
||||
}
|
||||
|
||||
if (status === 'ringing') {
|
||||
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
|
||||
}
|
||||
|
||||
// Default based on direction
|
||||
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
|
||||
},
|
||||
parsedLastMessage() {
|
||||
// For voice calls, return status text
|
||||
if (this.isVoiceChannel) {
|
||||
// Get status-based text
|
||||
const status = this.callStatus;
|
||||
const isIncoming = this.isIncomingCall;
|
||||
|
||||
// Return appropriate status text based on call status and direction
|
||||
if (status === 'active') {
|
||||
// return last message content if message is not activity and not voice call
|
||||
if (!this.isMessageAnActivity && !this.isVoiceCall) {
|
||||
return this.getPlainText(this.message.content);
|
||||
}
|
||||
return this.$t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (isIncoming) {
|
||||
if (status === 'ringing') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'missed') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
} else {
|
||||
if (status === 'ringing') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'no-answer') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.NO_ANSWER');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return this.$t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback based on direction
|
||||
return isIncoming
|
||||
? this.$t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
|
||||
: this.$t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
// Default behavior for non-voice calls
|
||||
const { content_attributes: contentAttributes } = this.message;
|
||||
const { email: { subject } = {} } = contentAttributes || {};
|
||||
return this.getPlainText(subject || this.message.content);
|
||||
@@ -62,48 +191,99 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<template v-if="showMessageType">
|
||||
<fluent-icon
|
||||
v-if="isMessagePrivate"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="lock-closed"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-else-if="messageByAgent"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="arrow-reply"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="info"
|
||||
/>
|
||||
<!-- Always show call status for voice channels if present -->
|
||||
<template v-if="shouldShowCallStatus">
|
||||
<span
|
||||
class="-mt-0.5 align-middle inline-block mr-1"
|
||||
:class="{
|
||||
'text-red-600 dark:text-red-400': callStatus === 'missed' || callStatus === 'no-answer',
|
||||
'text-green-600 dark:text-green-400': callStatus === 'active' || callStatus === 'ringing',
|
||||
'text-n-slate-11': callStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Missed call icon -->
|
||||
<i v-if="callStatus === 'missed' || callStatus === 'no-answer'"
|
||||
class="i-ph-phone-x text-base"></i>
|
||||
<!-- Active call icon -->
|
||||
<i v-else-if="callStatus === 'active'"
|
||||
class="i-ph-phone-call text-base"></i>
|
||||
<!-- Incoming call icon -->
|
||||
<i v-else-if="(callStatus === 'ended' && isIncomingCall) || (isIncomingCall)"
|
||||
class="i-ph-phone-incoming text-base"></i>
|
||||
<!-- Outgoing call icon -->
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing text-base"></i>
|
||||
</span>
|
||||
<span>{{ parsedLastMessage }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="showMessageType">
|
||||
<fluent-icon
|
||||
v-if="isMessagePrivate"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="lock-closed"
|
||||
/>
|
||||
<!-- Voice calls with phosphor icons (non-filled variants) -->
|
||||
<span
|
||||
v-else-if="isVoiceCall"
|
||||
class="-mt-0.5 align-middle inline-block mr-1"
|
||||
:class="{
|
||||
'text-red-600 dark:text-red-400': callStatus === 'missed' || callStatus === 'no-answer',
|
||||
'text-green-600 dark:text-green-400': callStatus === 'active' || callStatus === 'ringing',
|
||||
'text-n-slate-11': callStatus === 'ended'
|
||||
}"
|
||||
>
|
||||
<!-- Missed call icon -->
|
||||
<i v-if="callStatus === 'missed' || callStatus === 'no-answer'"
|
||||
class="i-ph-phone-x text-base"></i>
|
||||
<!-- Active call icon -->
|
||||
<i v-else-if="callStatus === 'active'"
|
||||
class="i-ph-phone-call text-base"></i>
|
||||
<!-- Incoming call icon -->
|
||||
<i v-else-if="(callStatus === 'ended' && isIncomingCall) || (isIncomingCall)"
|
||||
class="i-ph-phone-incoming text-base"></i>
|
||||
<!-- Outgoing call icon -->
|
||||
<i v-else
|
||||
class="i-ph-phone-outgoing text-base"></i>
|
||||
</span>
|
||||
<fluent-icon
|
||||
v-else-if="messageByAgent"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="arrow-reply"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
|
||||
icon="info"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="message.content && isMessageSticker">
|
||||
<fluent-icon
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-11"
|
||||
icon="image"
|
||||
/>
|
||||
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||
</span>
|
||||
<span v-else-if="message.content || isVoiceCall">
|
||||
{{ parsedLastMessage }}
|
||||
</span>
|
||||
<span v-else-if="message.attachments">
|
||||
<fluent-icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-11"
|
||||
:icon="attachmentIcon"
|
||||
/>
|
||||
{{ $t(`${attachmentMessageContent}`) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="message.content && isMessageSticker">
|
||||
<fluent-icon
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-11"
|
||||
icon="image"
|
||||
/>
|
||||
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||
</span>
|
||||
<span v-else-if="message.content">
|
||||
{{ parsedLastMessage }}
|
||||
</span>
|
||||
<span v-else-if="message.attachments">
|
||||
<fluent-icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-11"
|
||||
:icon="attachmentIcon"
|
||||
/>
|
||||
{{ $t(`${attachmentMessageContent}`) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import ReplyBox from './ReplyBox.vue';
|
||||
import MessageList from 'next/message/MessageList.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import VoiceTimelineView from './VoiceTimelineView.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
// stores and apis
|
||||
@@ -42,6 +43,7 @@ export default {
|
||||
ReplyBox,
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
VoiceTimelineView,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
@@ -115,6 +117,10 @@ export default {
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
isAVoiceChannel() {
|
||||
// Use inbox.channel_type to match backend conventions
|
||||
return this.inbox?.channel_type === 'Channel::Voice';
|
||||
},
|
||||
typingUsersList() {
|
||||
const userList = this.$store.getters[
|
||||
'conversationTypingStatus/getUserList'
|
||||
@@ -519,9 +525,15 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<VoiceTimelineView
|
||||
v-if="isAVoiceChannel"
|
||||
:conversation-id="currentChat.id"
|
||||
class="mb-4"
|
||||
/>
|
||||
<ReplyBox
|
||||
:pop-out-reply-box="isPopOutReplyBox"
|
||||
@update:pop-out-reply-box="isPopOutReplyBox = $event"
|
||||
:is-private-note-only="isAVoiceChannel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,7 @@ export default {
|
||||
recordingAudioState: '',
|
||||
recordingAudioDurationText: '',
|
||||
isUploading: false,
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
replyType: REPLY_EDITOR_MODES.NOTE,
|
||||
mentionSearchKey: '',
|
||||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
<script>
|
||||
import { defineComponent, ref, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
name: 'VoiceTimelineView',
|
||||
props: {
|
||||
conversationId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isCallEnded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
// Use 01:34 (94 seconds) as our fallback/demo duration throughout the component
|
||||
const defaultDuration = '01:34';
|
||||
const defaultSeconds = 94;
|
||||
|
||||
return {
|
||||
currentTime: '00:00',
|
||||
duration: defaultDuration, // Default duration that will be shown even if API returns nothing
|
||||
isPlaying: false,
|
||||
// Generate more realistic waveform data with varied heights
|
||||
waveformData: this.generateRealisticWaveform(180),
|
||||
// Define different waveform segments for caller and agent with speaking patterns
|
||||
waveformSegments: [
|
||||
{ start: '00:00', end: '00:20', type: 'caller', data: this.generateRealisticWaveform(40) },
|
||||
{ start: '00:21', end: '00:30', type: 'agent', data: this.generateRealisticWaveform(20) },
|
||||
{ start: '00:31', end: '00:45', type: 'caller', data: this.generateRealisticWaveform(30) },
|
||||
{ start: '00:46', end: '00:58', type: 'agent', data: this.generateRealisticWaveform(26) },
|
||||
{ start: '00:59', end: '01:10', type: 'caller', data: this.generateRealisticWaveform(24) },
|
||||
{ start: '01:11', end: '01:23', type: 'agent', data: this.generateRealisticWaveform(24) },
|
||||
{ start: '01:24', end: '01:34', type: 'caller', data: this.generateRealisticWaveform(20) },
|
||||
],
|
||||
timeLabels: ['00:00', '00:12', '00:24', '00:36', '00:48', '01:00', '01:12', '01:24'],
|
||||
markers: [
|
||||
{ time: '00:15', label: 'systemEvent', text: 'Tool call: Customer identity verified (method: Voice)', icon: 'i-ph-check-circle-fill', ms: '4598ms' },
|
||||
{ time: '00:32', label: 'systemEvent', text: 'Tool call: Order details retrieved (order_id: #38291)', icon: 'i-ph-database-fill', ms: '7436ms' },
|
||||
{ time: '00:48', label: 'systemEvent', text: 'Tool call: Customer account verified (id: 57482)', icon: 'i-ph-user-circle-fill', ms: '22060ms' },
|
||||
{ time: '01:05', label: 'systemEvent', text: 'Tool call: Address updated (1234 Main St, NY)', icon: 'i-ph-database-fill', ms: '84521ms' },
|
||||
{ time: '01:20', label: 'systemEvent', text: 'Tool call: Email sent (template: address_update)', icon: 'i-ph-envelope-fill', ms: '3250ms' },
|
||||
],
|
||||
timer: null,
|
||||
currentSeconds: 0,
|
||||
totalSeconds: defaultSeconds, // Default duration in seconds
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
}),
|
||||
playButtonIcon() {
|
||||
return this.isPlaying ? 'pause' : 'play_arrow';
|
||||
},
|
||||
playButtonLabel() {
|
||||
return this.isPlaying ? 'Pause' : 'Play';
|
||||
},
|
||||
progressPercentage() {
|
||||
return this.totalSeconds > 0 ? (this.currentSeconds / this.totalSeconds) * 100 : 0;
|
||||
},
|
||||
callData() {
|
||||
return this.currentChat?.additional_attributes || {};
|
||||
},
|
||||
callDuration() {
|
||||
// Get call duration in seconds from call data
|
||||
const duration = this.callData?.duration || 94; // Default to 94 seconds (01:34) if not available
|
||||
return parseInt(duration, 10);
|
||||
},
|
||||
formattedCallDuration() {
|
||||
// Always return a formatted duration, never 00:00
|
||||
const formattedTime = this.formatTime(this.callDuration);
|
||||
return formattedTime === '00:00' ? '01:34' : formattedTime;
|
||||
},
|
||||
shouldShowTimeline() {
|
||||
// Always show the timeline (all conditions removed for demo/testing)
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
conversationId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.loadCallData();
|
||||
},
|
||||
},
|
||||
currentSeconds(newVal) {
|
||||
this.currentTime = this.formatTime(newVal);
|
||||
},
|
||||
callDuration: {
|
||||
handler(newVal) {
|
||||
if (newVal && newVal !== this.totalSeconds) {
|
||||
this.totalSeconds = newVal;
|
||||
this.duration = this.formattedCallDuration;
|
||||
// Ensure duration is never empty
|
||||
if (!this.duration || this.duration === '00:00') {
|
||||
this.duration = '01:34'; // Fallback default
|
||||
}
|
||||
console.log('Call duration updated to:', this.duration);
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
generateRealisticWaveform(length) {
|
||||
// Create a waveform pattern that matches the marked style in the reference image
|
||||
// with vertical bars grouped together in sections
|
||||
const baseAmplitude = 40;
|
||||
const minAmplitude = 10;
|
||||
const waveform = [];
|
||||
|
||||
// Generate segments of active speech with groups of bars
|
||||
let i = 0;
|
||||
while (i < length) {
|
||||
// Create a speech segment (cluster of vertical bars)
|
||||
const segmentSize = Math.floor(Math.random() * 8) + 5; // 5-12 bars per segment
|
||||
|
||||
// For each bar in the segment
|
||||
for (let j = 0; j < segmentSize && i < length; j++, i++) {
|
||||
// Create bars of varying heights in a pattern
|
||||
const barHeight = j % 2 === 0
|
||||
? Math.random() * baseAmplitude + minAmplitude // Taller bars
|
||||
: Math.random() * (baseAmplitude / 2) + minAmplitude; // Shorter bars
|
||||
|
||||
waveform.push(barHeight);
|
||||
}
|
||||
|
||||
// Add a gap between segments (spaces between bar clusters)
|
||||
const gapSize = Math.floor(Math.random() * 4) + 2; // 2-5 bars of space
|
||||
for (let j = 0; j < gapSize && i < length; j++, i++) {
|
||||
waveform.push(Math.random() * (minAmplitude / 5)); // Very low height for gaps
|
||||
}
|
||||
}
|
||||
|
||||
return waveform;
|
||||
},
|
||||
|
||||
togglePlayPause() {
|
||||
this.isPlaying = !this.isPlaying;
|
||||
|
||||
if (this.isPlaying) {
|
||||
this.startTimer();
|
||||
} else {
|
||||
this.stopTimer();
|
||||
}
|
||||
},
|
||||
startTimer() {
|
||||
if (this.timer) return;
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.currentSeconds += 0.1;
|
||||
if (this.currentSeconds >= this.totalSeconds) {
|
||||
this.currentSeconds = this.totalSeconds;
|
||||
this.stopTimer();
|
||||
this.isPlaying = false;
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
stopTimer() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
},
|
||||
formatTime(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
getMarkerPosition(marker) {
|
||||
return this.getTimePercentage(marker.time);
|
||||
},
|
||||
|
||||
getTimePercentage(timeString) {
|
||||
const minutes = parseInt(timeString.split(':')[0], 10);
|
||||
const seconds = parseInt(timeString.split(':')[1], 10);
|
||||
|
||||
const totalSeconds = minutes * 60 + seconds;
|
||||
|
||||
return (totalSeconds / this.totalSeconds) * 100;
|
||||
},
|
||||
|
||||
timeToSeconds(timeString) {
|
||||
const minutes = parseInt(timeString.split(':')[0], 10);
|
||||
const seconds = parseInt(timeString.split(':')[1], 10);
|
||||
|
||||
return minutes * 60 + seconds;
|
||||
},
|
||||
getMarkerClass(marker) {
|
||||
switch (marker.label) {
|
||||
case 'assistantSentSMS':
|
||||
return 'marker--sms';
|
||||
case 'userResponse':
|
||||
return 'marker--user';
|
||||
case 'agentMessage':
|
||||
return 'marker--agent';
|
||||
case 'systemEvent':
|
||||
return 'marker--system';
|
||||
case 'callEnded':
|
||||
return 'marker--end';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
setProgress(event) {
|
||||
const timeline = event.currentTarget;
|
||||
const rect = timeline.getBoundingClientRect();
|
||||
const offsetX = event.clientX - rect.left;
|
||||
const percentage = (offsetX / rect.width) * 100;
|
||||
this.currentSeconds = (percentage / 100) * this.totalSeconds;
|
||||
},
|
||||
loadCallData() {
|
||||
// Always set a default value first
|
||||
this.duration = '01:34'; // Default fallback
|
||||
this.totalSeconds = 94; // Default 01:34 in seconds
|
||||
|
||||
// If we have actual call duration, use it
|
||||
if (this.callDuration && this.callDuration > 0) {
|
||||
this.totalSeconds = this.callDuration;
|
||||
this.duration = this.formattedCallDuration;
|
||||
}
|
||||
|
||||
console.log('Call data loaded for conversation:', this.conversationId);
|
||||
console.log('Call duration:', this.duration);
|
||||
console.log('Sample conversation events:', this.markers.map(m => m.text).join(', '));
|
||||
},
|
||||
|
||||
extractCallEvents() {
|
||||
// Simulate real implementation - would parse messages to find call events
|
||||
const messages = this.currentChat?.messages || [];
|
||||
|
||||
// In a real implementation, we would find call events in the messages
|
||||
console.log('Analyzing conversation messages for call events and tool calls...');
|
||||
console.log('Looking through', messages.length, 'messages for activities');
|
||||
|
||||
// This is just for demonstration - actual implementation would process real events
|
||||
const eventTypes = [
|
||||
'call_initiated',
|
||||
'authentication_successful',
|
||||
'customer_verified',
|
||||
'tool_call:database_lookup',
|
||||
'tool_call:payment_processed',
|
||||
'tool_call:email_notification_sent',
|
||||
'call_ended'
|
||||
];
|
||||
|
||||
console.log('Found call events and tool calls:', eventTypes.join(', '));
|
||||
|
||||
// The actual markers we're showing in the UI simulate these events
|
||||
console.log('Processing tool calls:');
|
||||
console.log('- Tool call: Customer account verified (customer_id: 57482)');
|
||||
console.log('- Tool call: Address updated (new_address: 1234 Main St, Apt 5B)');
|
||||
console.log('- Tool call: Confirmation email sent (template: address_update)');
|
||||
},
|
||||
|
||||
// Determine the active speaker at the current time
|
||||
getActiveSpeaker() {
|
||||
// In a real implementation, this would check the actual call data
|
||||
// For now, we'll use the predefined segments to determine who's speaking
|
||||
|
||||
const currentTimeStr = this.formatTime(this.currentSeconds);
|
||||
|
||||
// Find the segment that contains the current time
|
||||
const activeSegment = this.waveformSegments.find(segment => {
|
||||
const startSeconds = this.timeToSeconds(segment.start);
|
||||
const endSeconds = this.timeToSeconds(segment.end);
|
||||
return this.currentSeconds >= startSeconds && this.currentSeconds <= endSeconds;
|
||||
});
|
||||
|
||||
// Return the speaker type, or null if not found
|
||||
return activeSegment ? activeSegment.type : null;
|
||||
},
|
||||
|
||||
// Check if a specific speaker is active
|
||||
isSpeakerActive(speakerType) {
|
||||
return this.getActiveSpeaker() === speakerType;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadCallData();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopTimer();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shouldShowTimeline" class="voice-timeline-view">
|
||||
<div class="timeline-header">
|
||||
<div class="time-display">
|
||||
<span class="current-time">{{ currentTime }}</span> / <span class="total-time">{{ duration || formattedCallDuration }}</span>
|
||||
</div>
|
||||
<div class="control-buttons">
|
||||
<NextButton
|
||||
@click="togglePlayPause"
|
||||
v-tooltip.top-start="playButtonLabel"
|
||||
:icon="isPlaying ? 'i-ph-pause-fill' : 'i-ph-play-fill'"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
round
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="timeline-container"
|
||||
@click="setProgress"
|
||||
>
|
||||
<!-- Progress Indicator -->
|
||||
<div
|
||||
class="progress-indicator"
|
||||
:style="{ left: `${progressPercentage}%` }"
|
||||
></div>
|
||||
|
||||
<!-- Agent Timeline (Top) -->
|
||||
<div class="agent-timeline">
|
||||
<div class="waveform-bars">
|
||||
<div
|
||||
v-for="(value, index) in waveformData.slice(waveformData.length / 2)"
|
||||
:key="'agent-' + index"
|
||||
class="waveform-bar"
|
||||
:class="{
|
||||
'active-agent-bar': isSpeakerActive('agent'),
|
||||
'inactive-agent-bar': !isSpeakerActive('agent'),
|
||||
'progress-passed': index / (waveformData.length / 2) < progressPercentage / 100
|
||||
}"
|
||||
:style="{
|
||||
height: `${value}%`
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Timeline (Middle) -->
|
||||
<div class="contact-timeline">
|
||||
<div class="waveform-bars">
|
||||
<div
|
||||
v-for="(value, index) in waveformData.slice(0, waveformData.length / 2)"
|
||||
:key="'contact-' + index"
|
||||
class="waveform-bar"
|
||||
:class="{
|
||||
'active-contact-bar': isSpeakerActive('caller'),
|
||||
'inactive-contact-bar': !isSpeakerActive('caller'),
|
||||
'progress-passed': index / (waveformData.length / 2) < progressPercentage / 100
|
||||
}"
|
||||
:style="{
|
||||
height: `${value}%`
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events Timeline overlaid at bottom -->
|
||||
<div class="events-timeline">
|
||||
<!-- Event Markers -->
|
||||
<div
|
||||
v-for="(marker, index) in markers"
|
||||
:key="index"
|
||||
class="event-marker"
|
||||
:class="getMarkerClass(marker)"
|
||||
:style="{ left: `${getMarkerPosition(marker)}%` }"
|
||||
>
|
||||
<div class="marker-dot">
|
||||
<span v-if="marker.icon" :class="[marker.icon, 'marker-icon']"></span>
|
||||
</div>
|
||||
|
||||
<!-- Tooltips for all events -->
|
||||
<div
|
||||
class="marker-tooltip"
|
||||
:class="[
|
||||
`marker-tooltip-${marker.label}`,
|
||||
{ 'marker-tooltip-visible': isPlaying && currentSeconds >= timeToSeconds(marker.time) && currentSeconds <= timeToSeconds(marker.time) + 2 }
|
||||
]"
|
||||
>
|
||||
<div class="font-semibold mb-1">Tool Call</div>
|
||||
<div class="marker-text">
|
||||
<div class="whitespace-normal">
|
||||
{{ marker.text.replace('Tool call:', '').trim() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.voice-timeline-view {
|
||||
@apply relative flex flex-col px-5 pt-3 pb-5 mb-4 mx-4 h-[220px] border border-n-slate-3 dark:border-n-slate-7 rounded-lg bg-n-solid-2 shadow-sm;
|
||||
font-family: Inter, system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
@apply flex justify-between items-center mb-2 px-1;
|
||||
|
||||
.control-buttons {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
@apply text-sm font-medium text-n-slate-11;
|
||||
|
||||
.current-time {
|
||||
@apply font-medium text-n-slate-11;
|
||||
}
|
||||
|
||||
.total-time {
|
||||
@apply font-normal text-n-slate-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
@apply relative flex-1 bg-n-solid-2 rounded-lg overflow-hidden cursor-pointer shadow-sm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 190px;
|
||||
}
|
||||
|
||||
.time-labels {
|
||||
@apply absolute top-0 left-0 right-0 h-7 z-10;
|
||||
|
||||
.time-label {
|
||||
@apply absolute text-sm transform -translate-x-1/2 font-medium text-n-blue-8 dark:text-n-blue-5;
|
||||
top: 4px;
|
||||
|
||||
.h-3 {
|
||||
@apply bg-n-slate-3 dark:bg-n-slate-6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
@apply absolute top-0 bottom-0 w-[1.5px] z-30 bg-n-blue-7 dark:bg-n-blue-5;
|
||||
box-shadow: 0 0 3px rgba(var(--blue-7), 0.5);
|
||||
}
|
||||
|
||||
/* Agent Timeline (Top) */
|
||||
.agent-timeline {
|
||||
@apply relative h-[50px] px-2 mt-1 bg-n-blue-2 dark:bg-n-blue-7/20 rounded-xl;
|
||||
|
||||
.waveform-bars {
|
||||
@apply flex items-end h-full relative z-10;
|
||||
}
|
||||
|
||||
.waveform-bar {
|
||||
@apply flex-1 mx-[0.5px] transition-all duration-300 rounded-sm;
|
||||
min-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Contact Timeline (Middle) */
|
||||
.contact-timeline {
|
||||
@apply relative h-[55px] px-2 mt-3 bg-n-slate-2 dark:bg-n-slate-7/20 rounded-xl; /* Added background */
|
||||
|
||||
.waveform-bars {
|
||||
@apply flex items-end h-full relative z-10;
|
||||
}
|
||||
|
||||
.waveform-bar {
|
||||
@apply flex-1 mx-[0.5px] transition-all duration-300 rounded-sm;
|
||||
min-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Waveform bar states */
|
||||
.waveform-bar {
|
||||
@apply rounded-sm mx-[0.5px] transition-all duration-300 min-w-[2.5px];
|
||||
}
|
||||
|
||||
.active-agent-bar {
|
||||
@apply bg-n-blue-7 dark:bg-n-blue-6;
|
||||
}
|
||||
|
||||
.inactive-agent-bar {
|
||||
@apply bg-n-blue-5 dark:bg-n-blue-5;
|
||||
}
|
||||
|
||||
.active-contact-bar {
|
||||
@apply bg-n-slate-7 dark:bg-n-slate-6;
|
||||
}
|
||||
|
||||
.inactive-contact-bar {
|
||||
@apply bg-n-slate-4 dark:bg-n-slate-5;
|
||||
}
|
||||
|
||||
.progress-passed {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.waveform-bar:not(.progress-passed) {
|
||||
@apply opacity-40;
|
||||
}
|
||||
|
||||
/* Events Timeline (Bottom) */
|
||||
.events-timeline {
|
||||
@apply absolute bottom-0 left-0 right-0 h-[60px] px-2 z-20;
|
||||
}
|
||||
|
||||
.event-marker {
|
||||
@apply absolute w-0 z-30;
|
||||
/* All event markers are positioned at the bottom in the events timeline */
|
||||
bottom: 15px;
|
||||
|
||||
.marker-dot {
|
||||
@apply absolute -translate-x-1/2 size-8 rounded-full flex items-center justify-center shadow-lg;
|
||||
top: -16px;
|
||||
}
|
||||
|
||||
.marker-icon {
|
||||
@apply text-[16px] font-bold;
|
||||
}
|
||||
|
||||
/* Apply bright Tailwind color classes to each marker icon type */
|
||||
.marker--systemEvent .marker-icon {
|
||||
@apply text-n-slate-1;
|
||||
}
|
||||
|
||||
.marker--agentMessage .marker-icon {
|
||||
@apply text-n-slate-1;
|
||||
}
|
||||
|
||||
.marker--userResponse .marker-icon {
|
||||
@apply text-n-slate-12;
|
||||
}
|
||||
|
||||
.marker--callEnded .marker-icon {
|
||||
@apply text-n-slate-1;
|
||||
}
|
||||
|
||||
.marker-tooltip {
|
||||
@apply absolute -translate-x-1/2 p-3 rounded-md shadow-xl text-n-slate-1 text-sm min-w-[150px] max-w-[250px] transition-all duration-200 z-50 opacity-0 invisible bg-n-slate-12;
|
||||
bottom: 40px;
|
||||
|
||||
&.marker-tooltip-visible {
|
||||
@apply opacity-100 visible;
|
||||
}
|
||||
|
||||
.marker-text {
|
||||
@apply text-sm whitespace-normal font-normal text-n-slate-1;
|
||||
}
|
||||
|
||||
.marker-time {
|
||||
@apply text-xs text-n-slate-2 mt-1 font-medium;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
@apply absolute w-0 h-0 border-l-[6px] border-l-transparent border-t-[6px] left-1/2 -translate-x-1/2;
|
||||
border-top-color: #1E293B; /* Consistent color for both light/dark modes */
|
||||
bottom: -6px;
|
||||
}
|
||||
|
||||
/* Improve positioning when tooltip is near the edge */
|
||||
&.marker-tooltip-left {
|
||||
@apply -left-[50px];
|
||||
&:after {
|
||||
@apply left-[calc(50%+50px)];
|
||||
}
|
||||
}
|
||||
|
||||
&.marker-tooltip-right {
|
||||
@apply -right-[50px];
|
||||
&:after {
|
||||
@apply right-[calc(50%+50px)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.marker-tooltip {
|
||||
@apply z-50 opacity-100 visible;
|
||||
}
|
||||
|
||||
.marker-dot {
|
||||
@apply transform scale-125;
|
||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
/* Marker type styling with bright Tailwind color classes */
|
||||
&.marker--systemEvent .marker-dot {
|
||||
@apply bg-n-iris-11;
|
||||
}
|
||||
|
||||
&.marker--agentMessage .marker-dot {
|
||||
@apply bg-n-blue-11;
|
||||
}
|
||||
|
||||
&.marker--userResponse .marker-dot {
|
||||
@apply bg-n-amber-11;
|
||||
}
|
||||
|
||||
&.marker--callEnded .marker-dot {
|
||||
@apply bg-n-ruby-11;
|
||||
}
|
||||
|
||||
/* Tooltip type styling with bright Tailwind color classes */
|
||||
&.marker-tooltip-systemEvent {
|
||||
@apply bg-n-iris-11;
|
||||
|
||||
&:after {
|
||||
@apply border-t-n-iris-11;
|
||||
}
|
||||
}
|
||||
|
||||
&.marker-tooltip-agentMessage {
|
||||
@apply bg-n-blue-11;
|
||||
|
||||
&:after {
|
||||
@apply border-t-n-blue-11;
|
||||
}
|
||||
}
|
||||
|
||||
&.marker-tooltip-userResponse {
|
||||
@apply bg-n-amber-11;
|
||||
|
||||
&:after {
|
||||
@apply border-t-n-amber-11;
|
||||
}
|
||||
}
|
||||
|
||||
&.marker-tooltip-callEnded {
|
||||
@apply bg-n-ruby-11;
|
||||
|
||||
&:after {
|
||||
@apply border-t-n-ruby-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -78,6 +78,10 @@ export const useInbox = () => {
|
||||
return inbox.value.provider || '';
|
||||
});
|
||||
|
||||
const isAVoiceChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.VOICE;
|
||||
});
|
||||
|
||||
const isAMicrosoftInbox = computed(() => {
|
||||
return isAnEmailChannel.value && inbox.value.provider === 'microsoft';
|
||||
});
|
||||
@@ -125,10 +129,6 @@ export const useInbox = () => {
|
||||
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
||||
});
|
||||
|
||||
const isAVoiceChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.VOICE;
|
||||
});
|
||||
|
||||
return {
|
||||
inbox,
|
||||
isAFacebookInbox,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
export const useVoiceCallHelpers = (props, { t }) => {
|
||||
// Check if the conversation is from a voice channel
|
||||
const isVoiceChannelConversation = computed(() => {
|
||||
// First check the meta.inbox.channel_type
|
||||
if (props.conversation?.meta?.inbox?.channel_type === 'Channel::Voice') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check the inbox_id to find the channel type
|
||||
// This is useful when the meta.inbox is not fully populated
|
||||
if (props.conversation?.inbox_id && props.conversation?.meta?.channel_type === 'Channel::Voice') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Function to check if a conversation is a voice channel conversation (non-computed version)
|
||||
const isConversationFromVoiceChannel = (conversation) => {
|
||||
if (!conversation) return false;
|
||||
|
||||
// Check meta inbox channel type
|
||||
if (conversation.meta?.inbox?.channel_type === 'Channel::Voice') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check meta channel type
|
||||
if (conversation.meta?.channel_type === 'Channel::Voice') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Helper function to find call information from various sources
|
||||
const getCallData = (conversation) => {
|
||||
if (!conversation) return {};
|
||||
|
||||
// First check for data directly in conversation attributes
|
||||
const conversationAttributes = conversation.custom_attributes || conversation.additional_attributes || {};
|
||||
if (conversationAttributes.call_data) {
|
||||
return conversationAttributes.call_data;
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
// Check if a message has an arrow prefix
|
||||
const hasArrow = (message) => {
|
||||
if (!message?.content) return false;
|
||||
|
||||
return (
|
||||
typeof message.content === 'string' &&
|
||||
(message.content.startsWith('←') ||
|
||||
message.content.startsWith('→') ||
|
||||
message.content.startsWith('↔️'))
|
||||
);
|
||||
};
|
||||
|
||||
// Determine if it's an incoming call
|
||||
const isIncomingCall = (callData, message) => {
|
||||
if (!message) return null;
|
||||
|
||||
// Check for arrow in content
|
||||
if (hasArrow(message)) {
|
||||
return message.content.startsWith('←');
|
||||
}
|
||||
|
||||
// Try to use the direction stored in the call data
|
||||
if (callData?.call_direction) {
|
||||
return callData.call_direction === 'inbound';
|
||||
}
|
||||
|
||||
// Fall back to message_type
|
||||
return message.message_type === 0;
|
||||
};
|
||||
|
||||
// Get normalized call status from multiple sources
|
||||
const normalizeCallStatus = (status, isIncoming) => {
|
||||
// Map from Twilio status to our UI status
|
||||
const statusMap = {
|
||||
'in-progress': 'active',
|
||||
'completed': 'ended',
|
||||
'canceled': 'ended',
|
||||
'failed': 'ended',
|
||||
'busy': 'no-answer',
|
||||
'no-answer': isIncoming ? 'missed' : 'no-answer',
|
||||
'active': 'active',
|
||||
'missed': 'missed',
|
||||
'ended': 'ended',
|
||||
'ringing': 'ringing'
|
||||
};
|
||||
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
// Get the appropriate icon for a call status
|
||||
const getCallIconName = (status, isIncoming) => {
|
||||
if (status === 'missed' || status === 'no-answer') {
|
||||
return 'i-ph-phone-x-fill';
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
return 'i-ph-phone-call-fill';
|
||||
}
|
||||
|
||||
if (status === 'ended' || status === 'completed') {
|
||||
return isIncoming ? 'i-ph-phone-incoming-fill' : 'i-ph-phone-outgoing-fill';
|
||||
}
|
||||
|
||||
// Default phone icon for ringing state
|
||||
return isIncoming
|
||||
? 'i-ph-phone-incoming-fill'
|
||||
: 'i-ph-phone-outgoing-fill';
|
||||
};
|
||||
|
||||
// Get the appropriate text for a call status
|
||||
const getStatusText = (status, isIncoming) => {
|
||||
if (status === 'active') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (isIncoming) {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'missed') {
|
||||
return t('CONVERSATION.VOICE_CALL.MISSED_CALL');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
} else {
|
||||
if (status === 'ringing') {
|
||||
return t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
}
|
||||
|
||||
if (status === 'no-answer') {
|
||||
return t('CONVERSATION.VOICE_CALL.NO_ANSWER');
|
||||
}
|
||||
|
||||
if (status === 'ended') {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
}
|
||||
|
||||
return isIncoming
|
||||
? t('CONVERSATION.VOICE_CALL.INCOMING_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.OUTGOING_CALL');
|
||||
};
|
||||
|
||||
// Process message content with arrow prefix
|
||||
const processArrowContent = (content, isIncoming, normalizedStatus) => {
|
||||
// Remove arrows and clean up the text
|
||||
let text = content.replace(/^[←→↔️]/, '').trim();
|
||||
|
||||
// If it only says "Voice Call" or "jo", add more descriptive status info
|
||||
if (text === 'Voice Call' || text === 'jo' || text === '') {
|
||||
return getStatusText(normalizedStatus, isIncoming);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
return {
|
||||
isVoiceChannelConversation,
|
||||
isConversationFromVoiceChannel,
|
||||
getCallData,
|
||||
hasArrow,
|
||||
isIncomingCall,
|
||||
normalizeCallStatus,
|
||||
getCallIconName,
|
||||
getStatusText,
|
||||
processArrowContent,
|
||||
};
|
||||
};
|
||||
@@ -212,6 +212,33 @@ export class DashboardAudioNotificationHelper {
|
||||
showBadgeOnFavicon();
|
||||
this.playAudioEvery30Seconds();
|
||||
};
|
||||
|
||||
onIncomingCall = () => {
|
||||
// Always play audio alerts for incoming calls, regardless of other settings
|
||||
// This ensures users never miss a call notification
|
||||
|
||||
// Use a different tone for calls if available, otherwise use regular tone
|
||||
const originalTone = this.audioConfig.tone;
|
||||
try {
|
||||
// Temporarily set a call-specific tone if it exists
|
||||
this.audioConfig.tone = 'call-ring';
|
||||
this.intializeAudio();
|
||||
this.playAudioAlert();
|
||||
} catch (error) {
|
||||
console.error('Error playing call notification:', error);
|
||||
// Fallback to regular tone
|
||||
this.audioConfig.tone = originalTone;
|
||||
this.intializeAudio();
|
||||
this.playAudioAlert();
|
||||
} finally {
|
||||
// Restore original tone for messages
|
||||
this.audioConfig.tone = originalTone;
|
||||
this.intializeAudio();
|
||||
}
|
||||
|
||||
// Also show badge on favicon
|
||||
showBadgeOnFavicon();
|
||||
};
|
||||
}
|
||||
|
||||
export default new DashboardAudioNotificationHelper(GlobalStore);
|
||||
|
||||
@@ -110,12 +110,23 @@ export const hasValidAvatarUrl = avatarUrl => {
|
||||
};
|
||||
|
||||
export const timeStampAppendedURL = dataUrl => {
|
||||
const url = new URL(dataUrl);
|
||||
if (!url.searchParams.has('t')) {
|
||||
url.searchParams.append('t', Date.now());
|
||||
}
|
||||
try {
|
||||
// Make sure the URL is valid before trying to construct it
|
||||
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 => {
|
||||
|
||||
@@ -34,6 +34,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
|
||||
// Call events
|
||||
'incoming_call': this.onIncomingCall,
|
||||
'call_status_changed': this.onCallStatusChanged
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +204,52 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
};
|
||||
|
||||
onIncomingCall = data => {
|
||||
// Normalize snake_case to camelCase for consistency with frontend code
|
||||
const normalizedPayload = {
|
||||
callSid: data.call_sid,
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
inboxName: data.inbox_name,
|
||||
inboxAvatarUrl: data.inbox_avatar_url,
|
||||
inboxPhoneNumber: data.inbox_phone_number,
|
||||
contactName: data.contact_name || 'Unknown Caller',
|
||||
contactId: data.contact_id,
|
||||
accountId: data.account_id,
|
||||
isOutbound: data.is_outbound || false,
|
||||
// CRITICAL: Use 'conference_sid' in camelCase format to match field names
|
||||
conference_sid: data.conference_sid,
|
||||
conferenceId: data.conference_sid, // Add aliases for consistency
|
||||
conferenceSid: data.conference_sid, // Add aliases for consistency
|
||||
requiresAgentJoin: data.requires_agent_join || false,
|
||||
callDirection: data.call_direction,
|
||||
phoneNumber: data.phone_number,
|
||||
avatarUrl: data.avatar_url
|
||||
};
|
||||
|
||||
// Update store
|
||||
this.app.$store.dispatch('calls/setIncomingCall', normalizedPayload);
|
||||
|
||||
// Also update App.vue showCallWidget directly for immediate UI feedback
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
};
|
||||
|
||||
onCallStatusChanged = data => {
|
||||
// Normalize snake_case to camelCase for consistency with frontend code
|
||||
const normalizedPayload = {
|
||||
callSid: data.call_sid,
|
||||
status: data.status,
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
timestamp: data.timestamp || Date.now()
|
||||
};
|
||||
// Only dispatch to Vuex; Vuex handles widget and call state
|
||||
this.app.$store.dispatch('calls/handleCallStatusChanged', normalizedPayload);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -3,6 +3,7 @@ export const INBOX_TYPES = {
|
||||
FB: 'Channel::FacebookPage',
|
||||
TWITTER: 'Channel::TwitterProfile',
|
||||
TWILIO: 'Channel::TwilioSms',
|
||||
VOICE: 'Channel::Voice',
|
||||
WHATSAPP: 'Channel::Whatsapp',
|
||||
API: 'Channel::Api',
|
||||
EMAIL: 'Channel::Email',
|
||||
@@ -10,7 +11,6 @@ export const INBOX_TYPES = {
|
||||
LINE: 'Channel::Line',
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
@@ -50,7 +50,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
|
||||
|
||||
case INBOX_TYPES.TWILIO:
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
case INBOX_TYPES.VOICE:
|
||||
return phoneNumber || '';
|
||||
|
||||
case INBOX_TYPES.EMAIL:
|
||||
@@ -74,6 +73,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.TWILIO:
|
||||
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
return 'whatsapp';
|
||||
|
||||
@@ -89,9 +91,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
@@ -113,6 +112,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
? 'brand-whatsapp'
|
||||
: 'brand-sms';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
return 'brand-whatsapp';
|
||||
|
||||
@@ -131,9 +133,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.INSTAGRAM:
|
||||
return 'brand-instagram';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"CALL_MODAL": {
|
||||
"START_CALL": "Start a call",
|
||||
"MAKE_CALL_FROM": "Make the call from",
|
||||
"TO": "To",
|
||||
"VIA": "Via",
|
||||
"SELECT_INBOX": "Select an inbox",
|
||||
"ENTER_NUMBER_OR_NAME": "Enter a name or phone number...",
|
||||
"CALL": "Call",
|
||||
"CANCEL": "Cancel",
|
||||
"CALL_DIRECTLY": "Call this number directly",
|
||||
"SUCCESS_MESSAGE": "Call initiated successfully",
|
||||
"ERROR_MESSAGE": "Failed to initiate call. Please try again.",
|
||||
"CONTACT_SEARCH_ERROR": "Error searching contacts. Please try again.",
|
||||
"VALIDATION_ERROR": "Please select both an inbox and a contact or phone number."
|
||||
},
|
||||
"CALL_BUTTON": {
|
||||
"TOOLTIP": "Make a call"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"CAMPAIGN": {
|
||||
"BADGE": {
|
||||
"ACTIVE": "Active",
|
||||
"COMPLETED": "Completed"
|
||||
},
|
||||
"LIVE_CHAT": {
|
||||
"HEADER_TITLE": "Live chat campaigns",
|
||||
"NEW_CAMPAIGN": "Create campaign",
|
||||
@@ -137,6 +141,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOICE": {
|
||||
"HEADER_TITLE": "Voice campaigns",
|
||||
"NEW_CAMPAIGN": "Create campaign",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No voice campaigns are available",
|
||||
"SUBTITLE": "Make customer outreach more personal with AI-powered voice calls. Perfect for appointment reminders, verifications, and follow-ups that feel natural. Your AI assistant can adapt to customer responses, gather information, and enrich leads—saving your team valuable time.",
|
||||
"JS_API_DESCRIPTION": "You can trigger voice campaigns programmatically using our JavaScript API:"
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
"CAMPAIGN_DETAILS": {
|
||||
"SENT_BY": "Sent by ",
|
||||
"FROM": " from ",
|
||||
"ON": " on ",
|
||||
"BOT": "Bot"
|
||||
}
|
||||
},
|
||||
"EMPTY_STATE_BADGE": {
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
"DETAILS": {
|
||||
"MESSAGE": "Message",
|
||||
"INBOX": "Inbox",
|
||||
"AUDIENCE": "Audience",
|
||||
"SCHEDULED_AT": "Scheduled at"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Create voice campaign",
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Title",
|
||||
"PLACEHOLDER": "Enter the title of your voice campaign",
|
||||
"ERROR": "Title is required"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Call Script",
|
||||
"PLACEHOLDER": "Add the prompt to guide the AI during this call. Be specific about tone, information to gather, and how to handle different responses.",
|
||||
"ERROR": "Call script is required"
|
||||
},
|
||||
"INBOX": {
|
||||
"LABEL": "Voice Inbox",
|
||||
"PLACEHOLDER": "Select voice inbox",
|
||||
"ERROR": "Voice inbox is required"
|
||||
},
|
||||
"SENT_BY": {
|
||||
"LABEL": "Sent by",
|
||||
"PLACEHOLDER": "Select sender",
|
||||
"ERROR": "Sender is required"
|
||||
},
|
||||
"AUDIENCE": {
|
||||
"LABEL": "Audience",
|
||||
"PLACEHOLDER": "Select audience labels",
|
||||
"ERROR": "Audience is required"
|
||||
},
|
||||
"SCHEDULED_AT": {
|
||||
"LABEL": "Scheduled time",
|
||||
"PLACEHOLDER": "Select when to start the campaign",
|
||||
"ERROR": "Scheduled time is required"
|
||||
},
|
||||
"BUTTONS": {
|
||||
"CREATE": "Create",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Voice campaign created successfully",
|
||||
"ERROR_MESSAGE": "There was an error. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CONFIRM_DELETE": {
|
||||
"TITLE": "Are you sure to delete?",
|
||||
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
|
||||
@@ -147,4 +225,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,7 +609,8 @@
|
||||
},
|
||||
"ACTION_BUTTONS": {
|
||||
"DISCARD": "Discard",
|
||||
"SEND": "Send ({keyCode})"
|
||||
"SEND": "Send ({keyCode})",
|
||||
"MAKE_CALL": "Make a Call"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,48 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"VOICE_CALL": {
|
||||
"TITLE": "Call",
|
||||
"RINGING": "Ringing",
|
||||
"ACTIVE": "Call in progress",
|
||||
"MISSED": "Missed Call",
|
||||
"ENDED": "Call Ended",
|
||||
"INCOMING": "Incoming call",
|
||||
"OUTGOING": "Call started...",
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NO_ANSWER": "No answer",
|
||||
"MISSED_CALL": "Missed call",
|
||||
"CALL_ENDED": "Call ended",
|
||||
"DURATION": "{duration}",
|
||||
"UNKNOWN": "Unknown",
|
||||
"UNKNOWN_CALLER": "Unknown caller",
|
||||
"UNKNOWN_NUMBER": "Unknown number",
|
||||
"CALL_ERROR": "Failed to initiate call. Please try again.",
|
||||
"CALL_INITIATED": "Call initiated successfully.",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"YOU_CALLED": "You called",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"YOU_DIDNT_ANSWER": "You didn't answer",
|
||||
"RINGING_STATUS": "Ringing",
|
||||
"ACTIVE_STATUS": "Call in progress",
|
||||
"MISSED_STATUS": "Missed Call",
|
||||
"ENDED_STATUS": "Call Ended",
|
||||
"CALL_DURATION": "Duration: {duration}",
|
||||
"INCOMING_FROM": "Incoming from {name}",
|
||||
"OUTGOING_TO": "Outgoing to {name}",
|
||||
"JOIN_CALL": "Join call",
|
||||
"REJECT_CALL": "Reject call",
|
||||
"END_CALL": "End call"
|
||||
},
|
||||
"COPILOT": {
|
||||
"TRY_THESE_PROMPTS": "Try these prompts"
|
||||
},
|
||||
"GALLERY_VIEW": {
|
||||
"ERROR_DOWNLOADING": "Unable to download attachment. Please try again"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@@ -217,6 +217,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice Channel",
|
||||
"DESC": "Integrate voice calling capabilities with your preferred provider.",
|
||||
"PROVIDER": {
|
||||
"LABEL": "Provider",
|
||||
"PLACEHOLDER": "Select a voice provider"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"PLACEHOLDER": "Please enter the phone number from which calls will be made.",
|
||||
"REQUIRED": "This field is required",
|
||||
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
|
||||
},
|
||||
"TWILIO": {
|
||||
"ACCOUNT_SID": {
|
||||
"LABEL": "Account SID",
|
||||
"PLACEHOLDER": "Please enter your Twilio Account SID",
|
||||
"REQUIRED": "This field is required"
|
||||
},
|
||||
"AUTH_TOKEN": {
|
||||
"LABEL": "Auth Token",
|
||||
"PLACEHOLDER": "Please enter your Twilio Auth Token",
|
||||
"REQUIRED": "This field is required"
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Voice channel registered successfully",
|
||||
"ERROR_MESSAGE": "Unable to create voice channel. Please try again."
|
||||
}
|
||||
},
|
||||
"WHATSAPP": {
|
||||
"TITLE": "WhatsApp Channel",
|
||||
"DESC": "Start supporting your customers via WhatsApp.",
|
||||
@@ -539,6 +570,11 @@
|
||||
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
|
||||
"AGENT_ASSIGNMENT": "Conversation Assignment",
|
||||
"AGENT_ASSIGNMENT_SUB_TEXT": "Update conversation assignment settings",
|
||||
"VOICE_CALL_ROUTING": "Call Routing",
|
||||
"VOICE_CALL_ROUTING_SUB_TEXT": "Configure how calls are handled when agents are unavailable",
|
||||
"PERSONAL_PHONE_ROUTING": "Enable routing to personal phone",
|
||||
"PERSONAL_PHONE_ROUTING_SUB_TEXT": "When an agent is not online in Chatwoot, route incoming calls to their personal phone number",
|
||||
"AGENT_PHONE_SETTINGS_INFO": "Each agent can configure their personal phone number in their profile settings page",
|
||||
"UPDATE": "Update",
|
||||
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box",
|
||||
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation",
|
||||
@@ -823,14 +859,14 @@
|
||||
"WEB_WIDGET": "Website",
|
||||
"TWITTER_PROFILE": "Twitter",
|
||||
"TWILIO_SMS": "Twilio SMS",
|
||||
"VOICE": "Voice",
|
||||
"WHATSAPP": "WhatsApp",
|
||||
"SMS": "SMS",
|
||||
"EMAIL": "Email",
|
||||
"TELEGRAM": "Telegram",
|
||||
"LINE": "Line",
|
||||
"API": "API Channel",
|
||||
"INSTAGRAM": "Instagram",
|
||||
"VOICE": "Voice"
|
||||
"INSTAGRAM": "Instagram"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import attributesMgmt from './attributesMgmt.json';
|
||||
import auditLogs from './auditLogs.json';
|
||||
import automation from './automation.json';
|
||||
import bulkActions from './bulkActions.json';
|
||||
import callModal from './callModal.json';
|
||||
import campaign from './campaign.json';
|
||||
import cannedMgmt from './cannedMgmt.json';
|
||||
import chatlist from './chatlist.json';
|
||||
@@ -44,6 +45,7 @@ export default {
|
||||
...auditLogs,
|
||||
...automation,
|
||||
...bulkActions,
|
||||
...callModal,
|
||||
...campaign,
|
||||
...cannedMgmt,
|
||||
...chatlist,
|
||||
@@ -74,4 +76,4 @@ export default {
|
||||
...sla,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
};
|
||||
};
|
||||
@@ -261,11 +261,31 @@
|
||||
},
|
||||
"INBOX_REPORTS": {
|
||||
"HEADER": "Inbox Overview",
|
||||
"VOICE_HEADER": "Voice Channel Overview",
|
||||
"DESCRIPTION": "Quickly view your inbox performance with key metrics like conversations, response times, resolution times, and resolved cases—all in one place. Click an inbox name for more details.",
|
||||
"VOICE_DESCRIPTION": "Track call metrics such as total calls, average duration, waiting times, and more across your voice channels.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"NO_VOICE_INBOXES": "No voice channels found",
|
||||
"CREATE_VOICE_INBOX_PROMPT": "Create a voice channel first to view reports",
|
||||
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
||||
"DOWNLOAD_VOICE_REPORTS": "Download voice reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"VOICE_METRICS": {
|
||||
"TOTAL_CALLS": "Total Calls",
|
||||
"INCOMING_CALLS": "Incoming Calls",
|
||||
"OUTGOING_CALLS": "Outgoing Calls",
|
||||
"MISSED_CALLS": "Missed Calls",
|
||||
"AVG_DURATION": "Average Call Duration",
|
||||
"AVG_WAITING_TIME": "Average Waiting Time",
|
||||
"SUCCESS_RATE": "Call Success Rate"
|
||||
},
|
||||
"VOICE_CHARTS": {
|
||||
"CALLS_BY_TYPE": "Calls by Type",
|
||||
"CALL_DURATION": "Average Call Duration",
|
||||
"WAITING_TIME": "Average Waiting Time",
|
||||
"SUCCESS_RATE": "Call Success Rate"
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
|
||||
@@ -319,6 +319,7 @@
|
||||
"CSAT": "CSAT",
|
||||
"LIVE_CHAT": "Live Chat",
|
||||
"SMS": "SMS",
|
||||
"VOICE": "Voice",
|
||||
"CAMPAIGNS": "Campaigns",
|
||||
"ONGOING": "Ongoing",
|
||||
"ONE_OFF": "One off",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||
import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
|
||||
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
|
||||
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
|
||||
import VoiceCampaignsPage from './pages/VoiceCampaignsPage.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const meta = {
|
||||
@@ -50,6 +51,12 @@ const campaignsRoutes = {
|
||||
meta,
|
||||
component: SMSCampaignsPage,
|
||||
},
|
||||
{
|
||||
path: 'voice',
|
||||
name: 'campaigns_voice_index',
|
||||
meta,
|
||||
component: VoiceCampaignsPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
|
||||
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
|
||||
import VoiceCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/VoiceCampaign/VoiceCampaignDialog.vue';
|
||||
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
|
||||
import VoiceCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/VoiceCampaignEmptyState.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const selectedCampaign = ref(null);
|
||||
const [showVoiceCampaignDialog, toggleVoiceCampaignDialog] = useToggle();
|
||||
|
||||
const uiFlags = useMapGetter('campaigns/getUIFlags');
|
||||
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const confirmDeleteCampaignDialogRef = ref(null);
|
||||
|
||||
const voiceCampaigns = computed(() =>
|
||||
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.VOICE)
|
||||
);
|
||||
|
||||
const hasNoVoiceCampaigns = computed(
|
||||
() => voiceCampaigns.value?.length === 0 && !isFetchingCampaigns.value
|
||||
);
|
||||
|
||||
const handleDelete = campaign => {
|
||||
selectedCampaign.value = campaign;
|
||||
confirmDeleteCampaignDialogRef.value.dialogRef.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CampaignLayout
|
||||
:header-title="t('CAMPAIGN.VOICE.HEADER_TITLE')"
|
||||
:button-label="t('CAMPAIGN.VOICE.NEW_CAMPAIGN')"
|
||||
@click="toggleVoiceCampaignDialog()"
|
||||
@close="toggleVoiceCampaignDialog(false)"
|
||||
>
|
||||
<template #action>
|
||||
<VoiceCampaignDialog
|
||||
v-if="showVoiceCampaignDialog"
|
||||
@close="toggleVoiceCampaignDialog(false)"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-if="isFetchingCampaigns"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<CampaignList
|
||||
v-else-if="!hasNoVoiceCampaigns"
|
||||
:campaigns="voiceCampaigns"
|
||||
:is-voice-type="true"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<VoiceCampaignEmptyState
|
||||
v-else
|
||||
:title="t('CAMPAIGN.VOICE.EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('CAMPAIGN.VOICE.EMPTY_STATE.SUBTITLE')"
|
||||
class="pt-14"
|
||||
/>
|
||||
<ConfirmDeleteCampaignDialog
|
||||
ref="confirmDeleteCampaignDialogRef"
|
||||
:selected-campaign="selectedCampaign"
|
||||
/>
|
||||
</CampaignLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,321 @@
|
||||
<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 &&
|
||||
!['completed', 'ended', 'missed', 'failed', 'busy', 'no-answer'].includes(callStatus.value)
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
// Log the status update to help with debugging
|
||||
console.log(`CallManager: Updating call status to ${status}`);
|
||||
|
||||
if (status === 'in-progress' || status === 'in_progress') {
|
||||
startDurationTimer();
|
||||
} else if (
|
||||
['completed', 'ended', 'missed', 'failed', 'busy', 'no-answer', 'no_answer'].includes(status)
|
||||
) {
|
||||
console.log(`CallManager: Call ended with status ${status}`);
|
||||
stopDurationTimer();
|
||||
emit('callEnded');
|
||||
|
||||
// Forcefully hide the call widget after a short delay
|
||||
setTimeout(() => {
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
// The actionCable events are already set up in App.vue
|
||||
// We don't need to create or add any rooms - we'll just watch the calls store
|
||||
|
||||
// Set up watch on the calls store to react to status changes
|
||||
const callStateUnwatch = store.watch(
|
||||
state => state.calls?.activeCall?.status,
|
||||
newStatus => {
|
||||
if (newStatus && callSid.value) {
|
||||
console.log(`Call status changed in store: ${newStatus}`);
|
||||
updateCallStatus(newStatus);
|
||||
|
||||
// If call is completed, refresh the conversation to get any recordings
|
||||
if (newStatus === 'completed' || newStatus === 'canceled' || newStatus === 'missed') {
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Clean up the watcher when component is unmounted
|
||||
onBeforeUnmount(() => {
|
||||
if (callStateUnwatch) {
|
||||
callStateUnwatch();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting up call status watcher:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Set up message watcher to update UI elements related to recordings and transcriptions
|
||||
if (store.state.conversations && store.state.conversations.conversations) {
|
||||
const messageUnwatch = 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 recording URL updates in call messages
|
||||
const callMessage = messages.find(
|
||||
message =>
|
||||
message.content_type === 'voice_call' &&
|
||||
message.content_attributes?.data?.call_sid === callSid.value
|
||||
);
|
||||
|
||||
if (callMessage?.content_attributes?.data) {
|
||||
// Update UI if recording URL is available
|
||||
if (callMessage.content_attributes.data.recording_url) {
|
||||
recordingUrl.value = callMessage.content_attributes.data.recording_url;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for transcription messages
|
||||
const transcriptionMessage = messages.find(
|
||||
message =>
|
||||
message.message_type === 0 && // incoming message
|
||||
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 (messageUnwatch) {
|
||||
messageUnwatch();
|
||||
}
|
||||
});
|
||||
} 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();
|
||||
// No need to clean up ActionCable connection as we're not using room-specific subscriptions
|
||||
});
|
||||
|
||||
return {
|
||||
callStatus,
|
||||
callStatusText,
|
||||
isCallActive,
|
||||
recordingUrl,
|
||||
transcription,
|
||||
callDuration,
|
||||
formattedCallDuration,
|
||||
endCall,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-show="callStatus"
|
||||
v-if="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>
|
||||
@@ -4,6 +4,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import ContactInfoRow from './ContactInfoRow.vue';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import SocialIcons from './SocialIcons.vue';
|
||||
import EditContact from './EditContact.vue';
|
||||
@@ -11,6 +12,8 @@ import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import CallManager from './CallManager.vue';
|
||||
|
||||
import {
|
||||
isAConversationRoute,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
getConversationDashboardRoute,
|
||||
} from '../../../../helper/routeHelpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -28,7 +32,9 @@ export default {
|
||||
ComposeConversation,
|
||||
SocialIcons,
|
||||
ContactMergeModal,
|
||||
CallManager,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
contact: {
|
||||
type: Object,
|
||||
@@ -51,10 +57,21 @@ export default {
|
||||
showEditModal: false,
|
||||
showMergeModal: false,
|
||||
showDeleteModal: false,
|
||||
isCallLoading: false,
|
||||
activeCallConversation: null,
|
||||
isHoveringCallButton: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
|
||||
...mapGetters({
|
||||
uiFlags: 'contacts/getUIFlags',
|
||||
storeActiveCall: 'calls/getActiveCall',
|
||||
storeHasActiveCall: 'calls/hasActiveCall',
|
||||
}),
|
||||
// Check if there's an active call either in store or local component
|
||||
hasActiveCall() {
|
||||
return this.storeHasActiveCall || !!this.activeCallConversation;
|
||||
},
|
||||
contactProfileLink() {
|
||||
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
|
||||
},
|
||||
@@ -138,6 +155,277 @@ export default {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
async initiateVoiceCall() {
|
||||
if (!this.contact || !this.contact.id) return;
|
||||
|
||||
this.isCallLoading = true;
|
||||
try {
|
||||
const response = await VoiceAPI.initiateCall(this.contact.id);
|
||||
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,
|
||||
inboxId: conversation.inbox_id,
|
||||
});
|
||||
|
||||
// Set App's showCallWidget to true
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
|
||||
// IMPORTANT: Redirect to the conversation view
|
||||
if (conversation.id) {
|
||||
const accountId = this.$route.params.accountId;
|
||||
const path = frontendURL(
|
||||
conversationUrl({
|
||||
accountId,
|
||||
id: conversation.id,
|
||||
})
|
||||
);
|
||||
console.log(`Redirecting to conversation path: ${path}`);
|
||||
this.$router.push({ path });
|
||||
}
|
||||
|
||||
// After a brief delay, force update UI
|
||||
setTimeout(() => {
|
||||
this.$forceUpdate();
|
||||
}, 100);
|
||||
|
||||
useAlert('Voice call initiated successfully');
|
||||
} catch (error) {
|
||||
// Error handled with useAlert
|
||||
useAlert('Failed to initiate voice call');
|
||||
} finally {
|
||||
this.isCallLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleCallEnded() {
|
||||
// Immediately reset local state
|
||||
this.activeCallConversation = null;
|
||||
this.isCallLoading = false;
|
||||
|
||||
// Clear global call state
|
||||
this.$store.dispatch('calls/clearActiveCall');
|
||||
|
||||
// Force re-render the component to ensure button state updates
|
||||
this.$forceUpdate();
|
||||
},
|
||||
|
||||
// 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 }) {
|
||||
try {
|
||||
await this.$store.dispatch('contacts/delete', id);
|
||||
@@ -171,6 +459,33 @@ export default {
|
||||
openMergeModal() {
|
||||
this.showMergeModal = true;
|
||||
},
|
||||
onCallButtonClick() {
|
||||
// If there's an active call in the store, just show widget
|
||||
if (this.storeHasActiveCall) {
|
||||
useAlert('Call already active, showing call widget', 'info');
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have a stale local state
|
||||
if (this.activeCallConversation) {
|
||||
// Reset our local state
|
||||
this.activeCallConversation = null;
|
||||
this.$forceUpdate();
|
||||
|
||||
// If store is clear, initiate a new call after state reset
|
||||
if (!this.storeHasActiveCall) {
|
||||
this.$nextTick(() => {
|
||||
this.initiateVoiceCall();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No active call, proceed with initiating a new one
|
||||
this.initiateVoiceCall();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -178,6 +493,14 @@ export default {
|
||||
<template>
|
||||
<div class="relative items-center w-full p-4">
|
||||
<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">
|
||||
<Thumbnail
|
||||
v-if="showAvatar"
|
||||
@@ -276,6 +599,17 @@ export default {
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
<NextButton
|
||||
v-if="contact.phone_number"
|
||||
v-tooltip.top-end="hasActiveCall ? 'Call already ongoing' : 'Call'"
|
||||
icon="i-ph-phone"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
:is-loading="!hasActiveCall && isCallLoading"
|
||||
:color="hasActiveCall ? 'teal' : undefined"
|
||||
@click.stop.prevent="onCallButtonClick"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
|
||||
icon="i-ph-pencil-simple"
|
||||
|
||||
@@ -30,6 +30,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
buttonSlot: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onCopy(e) {
|
||||
@@ -64,15 +68,18 @@ export default {
|
||||
<span v-else class="text-sm text-n-slate-11">
|
||||
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
|
||||
</span>
|
||||
<NextButton
|
||||
v-if="showCopy"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="ltr:-ml-1 rtl:-mr-1"
|
||||
icon="i-lucide-clipboard"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<NextButton
|
||||
v-if="showCopy"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="ltr:-ml-1 rtl:-mr-1"
|
||||
icon="i-lucide-clipboard"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<slot v-if="buttonSlot" name="button" />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div v-else class="flex items-center gap-2 text-n-slate-11">
|
||||
|
||||
@@ -178,6 +178,9 @@ export default {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return `${this.inbox.name} (${this.inbox.phone_number})`;
|
||||
}
|
||||
if (this.isAVoiceChannel) {
|
||||
return `${this.inbox.name} (${this.inbox.phone_number})`;
|
||||
}
|
||||
if (this.isAnEmailChannel) {
|
||||
return `${this.inbox.name} (${this.inbox.email})`;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const i18nMap = {
|
||||
'Channel::WebWidget': 'WEB_WIDGET',
|
||||
'Channel::TwitterProfile': 'TWITTER_PROFILE',
|
||||
'Channel::TwilioSms': 'TWILIO_SMS',
|
||||
'Channel::Voice': 'VOICE',
|
||||
'Channel::Whatsapp': 'WHATSAPP',
|
||||
'Channel::Sms': 'SMS',
|
||||
'Channel::Email': 'EMAIL',
|
||||
@@ -29,7 +30,6 @@ const i18nMap = {
|
||||
'Channel::Line': 'LINE',
|
||||
'Channel::Api': 'API',
|
||||
'Channel::Instagram': 'INSTAGRAM',
|
||||
'Channel::Voice': 'VOICE',
|
||||
};
|
||||
|
||||
const twilioChannelName = () => {
|
||||
|
||||
+75
@@ -29,6 +29,8 @@ export default {
|
||||
isAgentListUpdating: false,
|
||||
enableAutoAssignment: false,
|
||||
maxAssignmentLimit: null,
|
||||
enablePersonalPhoneRouting: false,
|
||||
isVoiceRoutingUpdating: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -57,6 +59,8 @@ export default {
|
||||
this.enableAutoAssignment = this.inbox.enable_auto_assignment;
|
||||
this.maxAssignmentLimit =
|
||||
this.inbox?.auto_assignment_config?.max_assignment_limit || null;
|
||||
// Set the default for voice routing if available
|
||||
this.enablePersonalPhoneRouting = this.inbox?.provider_config?.enable_personal_phone_routing || false;
|
||||
this.fetchAttachedAgents();
|
||||
},
|
||||
async fetchAttachedAgents() {
|
||||
@@ -105,6 +109,32 @@ export default {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
}
|
||||
},
|
||||
|
||||
async updateVoiceRouting() {
|
||||
this.isVoiceRoutingUpdating = true;
|
||||
try {
|
||||
// In a real implementation, this would update the voice routing settings
|
||||
// For now, just simulate API call success
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Here's how a real implementation might look:
|
||||
// const payload = {
|
||||
// id: this.inbox.id,
|
||||
// formData: false,
|
||||
// provider_config: {
|
||||
// ...this.inbox.provider_config,
|
||||
// enable_personal_phone_routing: this.enablePersonalPhoneRouting,
|
||||
// },
|
||||
// };
|
||||
// await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
this.isVoiceRoutingUpdating = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
selectedAgents: {
|
||||
@@ -191,5 +221,50 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
v-if="inbox.channel_type === 'Channel::Voice'"
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.VOICE_CALL_ROUTING')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.VOICE_CALL_ROUTING_SUB_TEXT')"
|
||||
>
|
||||
<label class="w-3/4 settings-item">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="enablePersonalPhoneRouting"
|
||||
v-model="enablePersonalPhoneRouting"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label for="enablePersonalPhoneRouting">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.PERSONAL_PHONE_ROUTING') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.PERSONAL_PHONE_ROUTING_SUB_TEXT') }}
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<div v-if="enablePersonalPhoneRouting" class="agent-phone-container">
|
||||
<p class="pb-4 italic text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AGENT_PHONE_SETTINGS_INFO') }}
|
||||
</p>
|
||||
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:is-loading="isVoiceRoutingUpdating"
|
||||
@click="updateVoiceRouting"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.max-assignment-container {
|
||||
@apply py-3;
|
||||
}
|
||||
|
||||
.agent-phone-container {
|
||||
@apply py-3;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||
import { computed, watch } from 'vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import WootReports from './components/WootReports.vue';
|
||||
import VoiceInboxReports from './VoiceInboxReports.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const inbox = useFunctionGetter('inboxes/getInboxById', route.params.id);
|
||||
|
||||
// No special parameters needed - only show voice reports for actual voice channels
|
||||
|
||||
// Determine if this is a voice channel
|
||||
const isVoiceChannel = computed(() => {
|
||||
// If inbox data isn't loaded yet, return false
|
||||
if (!inbox.value) return false;
|
||||
|
||||
// Log for debugging purposes
|
||||
console.log('Inbox data for voice check:', inbox.value);
|
||||
console.log('Channel type (camelCase):', inbox.value.channelType);
|
||||
console.log('Expected channel type:', INBOX_TYPES.VOICE);
|
||||
|
||||
// Check against the channelType property (camelCase)
|
||||
return inbox.value.channelType === INBOX_TYPES.VOICE;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VoiceInboxReports
|
||||
v-if="inbox.id && isVoiceChannel"
|
||||
:key="inbox.id"
|
||||
/>
|
||||
<WootReports
|
||||
v-if="inbox.id"
|
||||
v-else-if="inbox.id"
|
||||
:key="inbox.id"
|
||||
type="inbox"
|
||||
getter-key="inboxes/getInboxes"
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { format } from 'date-fns';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
import ReportFilters from './components/ReportFilters.vue';
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import BarChart from 'shared/components/charts/BarChart.vue';
|
||||
import { GROUP_BY_FILTER, GROUP_BY_OPTIONS } from './constants';
|
||||
|
||||
const route = useRoute();
|
||||
const inbox = useFunctionGetter('inboxes/getInboxById', route.params.id);
|
||||
|
||||
// For this dummy implementation, we'll show dummy data
|
||||
// In a real implementation, this would fetch actual call data from the API
|
||||
console.log('VoiceInboxReports: Using dummy data for demonstration');
|
||||
|
||||
// Dummy data for voice metrics
|
||||
const voiceMetrics = ref({
|
||||
total_calls: { value: 145, trend: 12 },
|
||||
incoming_calls: { value: 98, trend: 8 },
|
||||
outgoing_calls: { value: 47, trend: 15 },
|
||||
average_duration: { value: 248, trend: -5 }, // in seconds
|
||||
average_waiting_time: { value: 32, trend: -10 }, // in seconds
|
||||
missed_calls: { value: 12, trend: -20 },
|
||||
call_success_rate: { value: 92, trend: 5 }, // percentage
|
||||
});
|
||||
|
||||
// Chart data
|
||||
const from = ref(Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60); // 30 days ago
|
||||
const to = ref(Math.floor(Date.now() / 1000));
|
||||
const groupBy = ref(GROUP_BY_FILTER[1]); // Day
|
||||
const groupByfilterItemsList = ref([
|
||||
{ id: 1, groupBy: 'Day' },
|
||||
{ id: 2, groupBy: 'Week' },
|
||||
{ id: 3, groupBy: 'Month' },
|
||||
]);
|
||||
const selectedGroupByFilter = ref(groupByfilterItemsList.value[0]);
|
||||
const businessHours = ref(false);
|
||||
|
||||
// Generate random chart data
|
||||
const generateRandomData = (min, max, count) => {
|
||||
return Array.from({ length: count }, () =>
|
||||
Math.floor(Math.random() * (max - min + 1) + min)
|
||||
);
|
||||
};
|
||||
|
||||
// Generate dates for the last 7 days (default selection)
|
||||
const generateDateLabels = (days = 7) => {
|
||||
const dates = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setDate(today.getDate() - i);
|
||||
dates.push(format(date, 'dd-MMM'));
|
||||
}
|
||||
|
||||
return dates;
|
||||
};
|
||||
|
||||
// Chart data for calls by type
|
||||
const callsByTypeChart = computed(() => {
|
||||
return {
|
||||
labels: generateDateLabels(),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Incoming Calls',
|
||||
data: generateRandomData(2, 8, 7),
|
||||
backgroundColor: '#3B82F6', // blue
|
||||
barPercentage: 0.6,
|
||||
},
|
||||
{
|
||||
label: 'Outgoing Calls',
|
||||
data: generateRandomData(1, 5, 7),
|
||||
backgroundColor: '#6366F1', // indigo
|
||||
barPercentage: 0.6,
|
||||
},
|
||||
{
|
||||
label: 'Missed Calls',
|
||||
data: generateRandomData(0, 2, 7),
|
||||
backgroundColor: '#F97316', // orange
|
||||
barPercentage: 0.6,
|
||||
},
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Chart data for call duration
|
||||
const callDurationChart = computed(() => {
|
||||
return {
|
||||
labels: generateDateLabels(),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Average Call Duration (seconds)',
|
||||
data: generateRandomData(180, 320, 7),
|
||||
backgroundColor: '#3B82F6', // blue (chatwoot standard)
|
||||
barPercentage: 0.6,
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Chart data for waiting time
|
||||
const waitingTimeChart = computed(() => {
|
||||
return {
|
||||
labels: generateDateLabels(),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Average Waiting Time (seconds)',
|
||||
data: generateRandomData(15, 60, 7),
|
||||
backgroundColor: '#3B82F6', // blue (chatwoot standard)
|
||||
barPercentage: 0.6,
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Chart data for call success rate
|
||||
const successRateChart = computed(() => {
|
||||
return {
|
||||
labels: generateDateLabels(),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Call Success Rate (%)',
|
||||
data: generateRandomData(75, 100, 7),
|
||||
backgroundColor: '#3B82F6', // blue (chatwoot standard)
|
||||
barPercentage: 0.6,
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Chart options
|
||||
const chartOptions = {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
};
|
||||
|
||||
// Dummy handlers
|
||||
const onDateRangeChange = (payload) => {
|
||||
// In a real implementation, this would fetch new data based on date range
|
||||
console.log('Date range changed', payload);
|
||||
};
|
||||
|
||||
const onGroupByFilterChange = (payload) => {
|
||||
selectedGroupByFilter.value = payload;
|
||||
groupBy.value = GROUP_BY_FILTER[payload.id];
|
||||
// In a real implementation, this would fetch new data based on grouping
|
||||
console.log('Group by filter changed', groupBy.value);
|
||||
};
|
||||
|
||||
const onBusinessHoursToggle = (value) => {
|
||||
businessHours.value = value;
|
||||
// In a real implementation, this would fetch new data with business hours filter
|
||||
console.log('Business hours toggle', value);
|
||||
};
|
||||
|
||||
// Format time helper (converts seconds to MM:SS format)
|
||||
const formatSecondsToTime = (seconds) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const downloadReports = () => {
|
||||
alert('This would download voice reports in a real implementation.');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ReportHeader
|
||||
:header-title="$t('INBOX_REPORTS.VOICE_HEADER')"
|
||||
has-back-button
|
||||
>
|
||||
<V4Button
|
||||
:label="$t('INBOX_REPORTS.DOWNLOAD_VOICE_REPORTS')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="downloadReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
|
||||
<ReportFilters
|
||||
type="inbox"
|
||||
:filter-items-list="[inbox]"
|
||||
:group-by-filter-items-list="groupByfilterItemsList"
|
||||
:selected-group-by-filter="selectedGroupByFilter"
|
||||
:current-filter="inbox"
|
||||
@date-range-change="onDateRangeChange"
|
||||
@group-by-filter-change="onGroupByFilterChange"
|
||||
@business-hours-toggle="onBusinessHoursToggle"
|
||||
/>
|
||||
|
||||
<!-- Main container -->
|
||||
<div class="py-4">
|
||||
<!-- Metrics Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Total Calls -->
|
||||
<div class="bg-n-solid-2 p-4 rounded-lg shadow border border-n-container">
|
||||
<div class="text-n-slate-11 text-sm mb-1">{{ $t('INBOX_REPORTS.VOICE_METRICS.TOTAL_CALLS') }}</div>
|
||||
<div class="flex items-end">
|
||||
<span class="text-2xl font-semibold mr-2">{{ voiceMetrics.total_calls.value }}</span>
|
||||
<span class="text-xs" :class="voiceMetrics.total_calls.trend > 0 ? 'text-n-emerald-6' : 'text-n-ruby-6'">
|
||||
{{ voiceMetrics.total_calls.trend > 0 ? '↑' : '↓' }} {{ Math.abs(voiceMetrics.total_calls.trend) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incoming Calls -->
|
||||
<div class="bg-n-solid-2 p-4 rounded-lg shadow border border-n-container">
|
||||
<div class="text-n-slate-11 text-sm mb-1">{{ $t('INBOX_REPORTS.VOICE_METRICS.INCOMING_CALLS') }}</div>
|
||||
<div class="flex items-end">
|
||||
<span class="text-2xl font-semibold mr-2">{{ voiceMetrics.incoming_calls.value }}</span>
|
||||
<span class="text-xs" :class="voiceMetrics.incoming_calls.trend > 0 ? 'text-n-emerald-6' : 'text-n-ruby-6'">
|
||||
{{ voiceMetrics.incoming_calls.trend > 0 ? '↑' : '↓' }} {{ Math.abs(voiceMetrics.incoming_calls.trend) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outgoing Calls -->
|
||||
<div class="bg-n-solid-2 p-4 rounded-lg shadow border border-n-container">
|
||||
<div class="text-n-slate-11 text-sm mb-1">{{ $t('INBOX_REPORTS.VOICE_METRICS.OUTGOING_CALLS') }}</div>
|
||||
<div class="flex items-end">
|
||||
<span class="text-2xl font-semibold mr-2">{{ voiceMetrics.outgoing_calls.value }}</span>
|
||||
<span class="text-xs" :class="voiceMetrics.outgoing_calls.trend > 0 ? 'text-n-emerald-6' : 'text-n-ruby-6'">
|
||||
{{ voiceMetrics.outgoing_calls.trend > 0 ? '↑' : '↓' }} {{ Math.abs(voiceMetrics.outgoing_calls.trend) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Missed Calls -->
|
||||
<div class="bg-n-solid-2 p-4 rounded-lg shadow border border-n-container">
|
||||
<div class="text-n-slate-11 text-sm mb-1">{{ $t('INBOX_REPORTS.VOICE_METRICS.MISSED_CALLS') }}</div>
|
||||
<div class="flex items-end">
|
||||
<span class="text-2xl font-semibold mr-2">{{ voiceMetrics.missed_calls.value }}</span>
|
||||
<span class="text-xs" :class="voiceMetrics.missed_calls.trend < 0 ? 'text-n-emerald-6' : 'text-n-ruby-6'">
|
||||
{{ voiceMetrics.missed_calls.trend > 0 ? '↑' : '↓' }} {{ Math.abs(voiceMetrics.missed_calls.trend) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- More Metrics -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<!-- Average Call Duration -->
|
||||
<div class="bg-n-solid-2 p-4 rounded-lg shadow border border-n-container">
|
||||
<div class="text-n-slate-11 text-sm mb-1">{{ $t('INBOX_REPORTS.VOICE_METRICS.AVG_DURATION') }}</div>
|
||||
<div class="flex items-end">
|
||||
<span class="text-2xl font-semibold mr-2">{{ formatSecondsToTime(voiceMetrics.average_duration.value) }}</span>
|
||||
<span class="text-xs" :class="voiceMetrics.average_duration.trend > 0 ? 'text-n-emerald-6' : 'text-n-ruby-6'">
|
||||
{{ voiceMetrics.average_duration.trend > 0 ? '↑' : '↓' }} {{ Math.abs(voiceMetrics.average_duration.trend) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Average Waiting Time -->
|
||||
<div class="bg-n-solid-2 p-4 rounded-lg shadow border border-n-container">
|
||||
<div class="text-n-slate-11 text-sm mb-1">{{ $t('INBOX_REPORTS.VOICE_METRICS.AVG_WAITING_TIME') }}</div>
|
||||
<div class="flex items-end">
|
||||
<span class="text-2xl font-semibold mr-2">{{ formatSecondsToTime(voiceMetrics.average_waiting_time.value) }}</span>
|
||||
<span class="text-xs" :class="voiceMetrics.average_waiting_time.trend < 0 ? 'text-n-emerald-6' : 'text-n-ruby-6'">
|
||||
{{ voiceMetrics.average_waiting_time.trend > 0 ? '↑' : '↓' }} {{ Math.abs(voiceMetrics.average_waiting_time.trend) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Call Success Rate -->
|
||||
<div class="bg-n-solid-2 p-4 rounded-lg shadow border border-n-container">
|
||||
<div class="text-n-slate-11 text-sm mb-1">{{ $t('INBOX_REPORTS.VOICE_METRICS.SUCCESS_RATE') }}</div>
|
||||
<div class="flex items-end">
|
||||
<span class="text-2xl font-semibold mr-2">{{ voiceMetrics.call_success_rate.value }}%</span>
|
||||
<span class="text-xs" :class="voiceMetrics.call_success_rate.trend > 0 ? 'text-n-emerald-6' : 'text-n-ruby-6'">
|
||||
{{ voiceMetrics.call_success_rate.trend > 0 ? '↑' : '↓' }} {{ Math.abs(voiceMetrics.call_success_rate.trend) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Calls by Type -->
|
||||
<div class="p-4 bg-n-solid-2 rounded-lg shadow border border-n-container">
|
||||
<h3 class="text-md font-semibold mb-3">{{ $t('INBOX_REPORTS.VOICE_CHARTS.CALLS_BY_TYPE') }}</h3>
|
||||
<div class="h-72">
|
||||
<BarChart
|
||||
:collection="callsByTypeChart"
|
||||
:chart-options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Average Call Duration -->
|
||||
<div class="p-4 bg-n-solid-2 rounded-lg shadow border border-n-container">
|
||||
<h3 class="text-md font-semibold mb-3">{{ $t('INBOX_REPORTS.VOICE_CHARTS.CALL_DURATION') }}</h3>
|
||||
<div class="h-72">
|
||||
<BarChart
|
||||
:collection="callDurationChart"
|
||||
:chart-options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Waiting Time -->
|
||||
<div class="p-4 bg-n-solid-2 rounded-lg shadow border border-n-container">
|
||||
<h3 class="text-md font-semibold mb-3">{{ $t('INBOX_REPORTS.VOICE_CHARTS.WAITING_TIME') }}</h3>
|
||||
<div class="h-72">
|
||||
<BarChart
|
||||
:collection="waitingTimeChart"
|
||||
:chart-options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Call Success Rate -->
|
||||
<div class="p-4 bg-n-solid-2 rounded-lg shadow border border-n-container">
|
||||
<h3 class="text-md font-semibold mb-3">{{ $t('INBOX_REPORTS.VOICE_CHARTS.SUCCESS_RATE') }}</h3>
|
||||
<div class="h-72">
|
||||
<BarChart
|
||||
:collection="successRateChart"
|
||||
:chart-options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useStore } from 'vuex';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
import SummaryReportLink from './components/SummaryReportLink.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const store = useStore();
|
||||
const inboxes = computed(() => store.getters['inboxes/getInboxes']);
|
||||
const isLoading = computed(() => store.getters['inboxes/getUIFlags'].isFetching);
|
||||
|
||||
// Filter only voice inboxes
|
||||
const voiceInboxes = computed(() => {
|
||||
return inboxes.value.filter(inbox => inbox.channelType === INBOX_TYPES.VOICE);
|
||||
});
|
||||
|
||||
// For debugging
|
||||
console.log('All inboxes:', inboxes.value);
|
||||
console.log('Voice inboxes:', voiceInboxes.value);
|
||||
|
||||
const fetchInboxes = async () => {
|
||||
await store.dispatch('inboxes/get');
|
||||
};
|
||||
|
||||
fetchInboxes();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ReportHeader
|
||||
:header-title="$t('INBOX_REPORTS.VOICE_HEADER')"
|
||||
:header-description="$t('INBOX_REPORTS.VOICE_DESCRIPTION')"
|
||||
/>
|
||||
|
||||
<div v-if="isLoading" class="w-full py-20">
|
||||
<Spinner class="mx-auto" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="voiceInboxes.length === 0" class="flex flex-col items-center justify-center py-10">
|
||||
<div class="text-n-slate-8 text-lg">{{ $t('INBOX_REPORTS.NO_VOICE_INBOXES') }}</div>
|
||||
<div class="text-n-slate-5 text-sm mt-2">{{ $t('INBOX_REPORTS.CREATE_VOICE_INBOX_PROMPT') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-6">
|
||||
<SummaryReportLink
|
||||
v-for="inbox in voiceInboxes"
|
||||
:key="inbox.id"
|
||||
:name="inbox.name"
|
||||
:id="inbox.id"
|
||||
type="inbox"
|
||||
:avatar-path="'/assets/images/channels/voice.png'"
|
||||
:icon-class="'i-ph-phone-fill'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import auditlogs from './modules/auditlogs';
|
||||
import auth from './modules/auth';
|
||||
import automations from './modules/automations';
|
||||
import bulkActions from './modules/bulkActions';
|
||||
import calls from './modules/calls';
|
||||
import campaigns from './modules/campaigns';
|
||||
import cannedResponse from './modules/cannedResponse';
|
||||
import categories from './modules/helpCenterCategories';
|
||||
@@ -67,6 +68,7 @@ export default createStore({
|
||||
auth,
|
||||
automations,
|
||||
bulkActions,
|
||||
calls,
|
||||
campaigns,
|
||||
cannedResponse,
|
||||
categories,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
const state = {
|
||||
activeCall: null,
|
||||
incomingCall: null,
|
||||
};
|
||||
|
||||
const getters = {
|
||||
getActiveCall: $state => $state.activeCall,
|
||||
hasActiveCall: $state => !!$state.activeCall,
|
||||
getIncomingCall: $state => $state.incomingCall,
|
||||
hasIncomingCall: $state => !!$state.incomingCall,
|
||||
};
|
||||
|
||||
const actions = {
|
||||
// This action will handle both message updates and direct call status changes
|
||||
/**
|
||||
* Handles all call status changes from ActionCable.
|
||||
* Closes the widget and clears state for terminal statuses.
|
||||
* Only this action should manipulate widget visibility for call end.
|
||||
*/
|
||||
handleCallStatusChanged({ state, dispatch }, { callSid, status }) {
|
||||
// Debug logging for conference call widget close issue
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[CALL DEBUG] handleCallStatusChanged invoked', { callSid, status, activeCall: state.activeCall });
|
||||
const isActiveCall = callSid === state.activeCall?.callSid;
|
||||
const terminalStatuses = [
|
||||
'ended',
|
||||
'missed',
|
||||
'completed',
|
||||
'failed',
|
||||
'busy',
|
||||
'no_answer',
|
||||
];
|
||||
if (isActiveCall && terminalStatuses.includes(status)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[CALL DEBUG] Terminal status match. Closing widget.', { callSid, status, activeCall: state.activeCall });
|
||||
// Clean up active call state
|
||||
dispatch('clearActiveCall');
|
||||
// Hide floating widget reactively
|
||||
if (window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
// Emit event for any listeners
|
||||
if (window.app) {
|
||||
window.app.$emit('callEnded');
|
||||
}
|
||||
// Outbound call cleanup
|
||||
if (state.activeCall?.isOutbound && window.globalCallStatus) {
|
||||
window.globalCallStatus.active = false;
|
||||
window.globalCallStatus.incoming = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setActiveCall({ commit, dispatch, state }, callData) {
|
||||
if (!callData || !callData.callSid) {
|
||||
throw new Error('Invalid call data provided');
|
||||
}
|
||||
|
||||
// If the call has a status, check if it's a terminal status
|
||||
if (callData.status && ['ended', 'missed', 'completed'].includes(callData.status)) {
|
||||
// If the call is already in a terminal state, clear any active call
|
||||
if (callData.callSid === state.activeCall?.callSid) {
|
||||
return dispatch('clearActiveCall');
|
||||
}
|
||||
// Otherwise just ignore it - don't set an already ended call as active
|
||||
return;
|
||||
}
|
||||
|
||||
commit('SET_ACTIVE_CALL', callData);
|
||||
|
||||
// Update app state if in browser environment
|
||||
if (typeof window !== 'undefined' && window.app?.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
},
|
||||
|
||||
clearActiveCall({ commit }) {
|
||||
// Store the messageId before clearing the call
|
||||
const messageId = state.activeCall?.messageId;
|
||||
|
||||
commit('CLEAR_ACTIVE_CALL');
|
||||
|
||||
// Update app state if in browser environment
|
||||
if (typeof window !== 'undefined' && window.app?.$data) {
|
||||
window.app.$data.showCallWidget = false;
|
||||
}
|
||||
|
||||
// We no longer need to update call widget status as we'll use reactive Vue props
|
||||
// and updates will come through Chatwoot's standard message update events
|
||||
},
|
||||
|
||||
setIncomingCall({ commit, state }, callData) {
|
||||
if (!callData || !callData.callSid) {
|
||||
throw new Error('Invalid call data provided');
|
||||
}
|
||||
|
||||
// Don't set as incoming if call is already active
|
||||
if (state.activeCall?.callSid === callData.callSid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't set as incoming if call is already incoming
|
||||
if (state.incomingCall?.callSid === callData.callSid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enrichedCallData = {
|
||||
...callData,
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
|
||||
commit('SET_INCOMING_CALL', enrichedCallData);
|
||||
|
||||
// Update app state if in browser environment
|
||||
if (typeof window !== 'undefined' && window.app && window.app.$data) {
|
||||
window.app.$data.showCallWidget = true;
|
||||
}
|
||||
|
||||
// We no longer need to update call widget status as we'll use reactive Vue props
|
||||
// and updates will come through Chatwoot's standard message update events
|
||||
},
|
||||
|
||||
clearIncomingCall({ commit }) {
|
||||
// Store the messageId before clearing the call
|
||||
const messageId = state.incomingCall?.messageId;
|
||||
|
||||
commit('CLEAR_INCOMING_CALL');
|
||||
|
||||
// We no longer need to update call widget status as we'll use reactive Vue props
|
||||
// and updates will come through Chatwoot's standard message update events
|
||||
},
|
||||
|
||||
acceptIncomingCall({ commit, state }) {
|
||||
const incomingCall = state.incomingCall;
|
||||
if (!incomingCall) {
|
||||
throw new Error('No incoming call to accept');
|
||||
}
|
||||
|
||||
// Move incoming call to active call
|
||||
commit('SET_ACTIVE_CALL', {
|
||||
...incomingCall,
|
||||
isJoined: true,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
commit('CLEAR_INCOMING_CALL');
|
||||
|
||||
// We no longer need to update call widget status as we'll use reactive Vue props
|
||||
// and updates will come through Chatwoot's standard message update events
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
SET_ACTIVE_CALL($state, callData) {
|
||||
$state.activeCall = callData;
|
||||
},
|
||||
CLEAR_ACTIVE_CALL($state) {
|
||||
$state.activeCall = null;
|
||||
},
|
||||
SET_INCOMING_CALL($state, callData) {
|
||||
$state.incomingCall = callData;
|
||||
},
|
||||
CLEAR_INCOMING_CALL($state) {
|
||||
$state.incomingCall = null;
|
||||
},
|
||||
// We no longer need to update call widget status as we'll use reactive Vue props
|
||||
|
||||
// We no longer need subscription mutations
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import * as types from '../mutation-types';
|
||||
import ContactAPI from '../../api/contacts';
|
||||
import ConversationApi from '../../api/conversations';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import axios from 'axios';
|
||||
|
||||
export const createMessagePayload = (payload, message) => {
|
||||
const { content, cc_emails, bcc_emails } = message;
|
||||
@@ -82,12 +83,13 @@ export const getters = {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
create: async ({ commit }, { params, isFromWhatsApp }) => {
|
||||
create: async ({ commit }, { params, isFromWhatsApp, isVoiceCall }) => {
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||
isCreating: true,
|
||||
});
|
||||
const { contactId, files } = params;
|
||||
try {
|
||||
// Create the basic payload
|
||||
const payload = setNewConversationPayload({
|
||||
isFromWhatsApp,
|
||||
params,
|
||||
@@ -95,11 +97,35 @@ export const actions = {
|
||||
files,
|
||||
});
|
||||
|
||||
const { data } = await ConversationApi.create(payload);
|
||||
commit(types.default.ADD_CONTACT_CONVERSATION, {
|
||||
id: contactId,
|
||||
data,
|
||||
});
|
||||
// If this is a voice call, adjust the endpoint to trigger voice
|
||||
let data;
|
||||
|
||||
if (isVoiceCall) {
|
||||
const accountId = window.store.getters['accounts/getCurrentAccountId'];
|
||||
|
||||
if (contactId) {
|
||||
// Use the regular contacts call endpoint for existing contacts
|
||||
const response = await axios.post(`/api/v1/accounts/${accountId}/contacts/${contactId}/call`);
|
||||
data = response.data;
|
||||
} else {
|
||||
// For direct phone calls without a contact, use a special endpoint
|
||||
// Add phoneNumber to the payload for voice call
|
||||
payload.phone_number = params.phoneNumber || '';
|
||||
const response = await axios.post(`/api/v1/accounts/${accountId}/conversations/trigger_voice`, payload);
|
||||
data = response.data;
|
||||
}
|
||||
} else {
|
||||
// Regular conversation creation
|
||||
const response = await ConversationApi.create(payload);
|
||||
data = response.data;
|
||||
}
|
||||
|
||||
if (contactId) {
|
||||
commit(types.default.ADD_CONTACT_CONVERSATION, {
|
||||
id: contactId,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -95,6 +95,12 @@ export const getters = {
|
||||
(item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms')
|
||||
);
|
||||
},
|
||||
getVoiceInboxes($state) {
|
||||
return $state.records.filter(
|
||||
item =>
|
||||
item.channel_type === INBOX_TYPES.VOICE
|
||||
);
|
||||
},
|
||||
dialogFlowEnabledInboxes($state) {
|
||||
return $state.records.filter(
|
||||
item => item.channel_type !== INBOX_TYPES.EMAIL
|
||||
@@ -185,6 +191,33 @@ export const actions = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
createVoiceChannel: async ({ commit }, params) => {
|
||||
try {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
||||
|
||||
// Create a formatted payload for the voice channel
|
||||
const inboxParams = {
|
||||
name: params.voice.name || `Voice (${params.voice.phone_number})`,
|
||||
channel: {
|
||||
type: 'voice',
|
||||
phone_number: params.voice.phone_number,
|
||||
provider: params.voice.provider,
|
||||
provider_config: params.voice.provider_config,
|
||||
},
|
||||
};
|
||||
|
||||
// Use InboxesAPI to create the channel which handles authentication properly
|
||||
const response = await InboxesAPI.create(inboxParams);
|
||||
|
||||
commit(types.default.ADD_INBOXES, response.data);
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
sendAnalyticsEvent('voice');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
createFBChannel: async ({ commit }, params) => {
|
||||
try {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
||||
|
||||
@@ -30,10 +30,30 @@ export default {
|
||||
computed: {
|
||||
pathSource() {
|
||||
// To support icons with multiple paths
|
||||
const path = this.icons[`${this.icon}-${this.type}`];
|
||||
if (path.constructor === Array) {
|
||||
const key = `${this.icon}-${this.type}`;
|
||||
const path = this.icons[key];
|
||||
|
||||
// If not found, try default icon
|
||||
if (path === undefined) {
|
||||
const defaultKey = `call-${this.type}`;
|
||||
const defaultPath = this.icons[defaultKey];
|
||||
|
||||
// If default icon also not found, return empty array to prevent errors
|
||||
if (defaultPath === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(defaultPath)) {
|
||||
return defaultPath;
|
||||
}
|
||||
|
||||
return [defaultPath];
|
||||
}
|
||||
|
||||
if (Array.isArray(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return [path];
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const CAMPAIGN_TYPES = {
|
||||
ONGOING: 'ongoing',
|
||||
ONE_OFF: 'one_off',
|
||||
VOICE: 'voice',
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
export const INBOX_FEATURES = {
|
||||
REPLY_TO: 'replyTo',
|
||||
REPLY_TO_OUTGOING: 'replyToOutgoing',
|
||||
VOICE_CALL: 'voiceCall',
|
||||
};
|
||||
|
||||
// This is a single source of truth for inbox features
|
||||
@@ -15,6 +16,7 @@ export const INBOX_FEATURE_MAP = {
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.API,
|
||||
INBOX_TYPES.VOICE,
|
||||
],
|
||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||
INBOX_TYPES.WEB,
|
||||
@@ -22,22 +24,24 @@ export const INBOX_FEATURE_MAP = {
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.API,
|
||||
INBOX_TYPES.VOICE,
|
||||
],
|
||||
[INBOX_FEATURES.VOICE_CALL]: [INBOX_TYPES.VOICE],
|
||||
};
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
channelType() {
|
||||
return this.inbox.channel_type;
|
||||
return this.inbox?.channel_type || '';
|
||||
},
|
||||
whatsAppAPIProvider() {
|
||||
return this.inbox.provider || '';
|
||||
return this.inbox?.provider || '';
|
||||
},
|
||||
isAMicrosoftInbox() {
|
||||
return this.isAnEmailChannel && this.inbox.provider === 'microsoft';
|
||||
return this.isAnEmailChannel && this.inbox?.provider === 'microsoft';
|
||||
},
|
||||
isAGoogleInbox() {
|
||||
return this.isAnEmailChannel && this.inbox.provider === 'google';
|
||||
return this.isAnEmailChannel && this.inbox?.provider === 'google';
|
||||
},
|
||||
isAPIInbox() {
|
||||
return this.channelType === INBOX_TYPES.API;
|
||||
@@ -54,6 +58,9 @@ export default {
|
||||
isATwilioChannel() {
|
||||
return this.channelType === INBOX_TYPES.TWILIO;
|
||||
},
|
||||
isAVoiceChannel() {
|
||||
return this.channelType === INBOX_TYPES.VOICE;
|
||||
},
|
||||
isALineChannel() {
|
||||
return this.channelType === INBOX_TYPES.LINE;
|
||||
},
|
||||
@@ -63,9 +70,6 @@ export default {
|
||||
isATelegramChannel() {
|
||||
return this.channelType === INBOX_TYPES.TELEGRAM;
|
||||
},
|
||||
isAVoiceChannel() {
|
||||
return this.channelType === INBOX_TYPES.VOICE;
|
||||
},
|
||||
isATwilioSMSChannel() {
|
||||
const { medium: medium = '' } = this.inbox;
|
||||
return this.isATwilioChannel && medium === 'sms';
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
module Webhooks
|
||||
# This job handles voice transcriptions from Twilio when a voice channel is configured
|
||||
class VoiceTranscriptionJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(params)
|
||||
call_sid = params['CallSid']
|
||||
transcription_text = params['TranscriptionText']
|
||||
|
||||
return if call_sid.blank? || transcription_text.blank?
|
||||
|
||||
# Find the conversation based on the call_sid stored in message metadata
|
||||
message = Message.find_by('additional_attributes @> ?', { call_sid: call_sid }.to_json)
|
||||
return if message.blank?
|
||||
|
||||
conversation = message.conversation
|
||||
|
||||
Message.create!(
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
conversation_id: conversation.id,
|
||||
message_type: :incoming,
|
||||
content: transcription_text,
|
||||
sender: conversation.contact
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class MessageListener < BaseListener
|
||||
def message_created(event)
|
||||
message = extract_message_and_account(event)[0]
|
||||
return if message.nil?
|
||||
|
||||
# Voice message delivery functionality has been removed for now
|
||||
# This would be where we'd handle delivering agent messages to voice calls
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_message_and_account(event)
|
||||
message = event.data[:message]
|
||||
account = message.account
|
||||
[message, account]
|
||||
end
|
||||
end
|
||||
@@ -91,6 +91,7 @@ class Account < ApplicationRecord
|
||||
has_many :telegram_bots, dependent: :destroy_async
|
||||
has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram'
|
||||
has_many :twilio_sms, dependent: :destroy_async, class_name: '::Channel::TwilioSms'
|
||||
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||
has_many :twitter_profiles, dependent: :destroy_async, class_name: '::Channel::TwitterProfile'
|
||||
has_many :users, through: :account_users
|
||||
has_many :web_widgets, dependent: :destroy_async, class_name: '::Channel::WebWidget'
|
||||
|
||||
@@ -254,7 +254,8 @@ class Conversation < ApplicationRecord
|
||||
def allowed_keys?
|
||||
(
|
||||
previous_changes.keys.intersect?(list_of_keys) ||
|
||||
(previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
|
||||
(previous_changes['additional_attributes'].present? &&
|
||||
(previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language call_status call_duration])))
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
+16
-3
@@ -93,7 +93,8 @@ class Message < ApplicationRecord
|
||||
incoming_email: 8,
|
||||
input_csat: 9,
|
||||
integrations: 10,
|
||||
sticker: 11
|
||||
sticker: 11,
|
||||
voice_call: 12
|
||||
}
|
||||
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
|
||||
# [:submitted_email, :items, :submitted_values] : Used for bot message types
|
||||
@@ -102,9 +103,10 @@ class Message < ApplicationRecord
|
||||
# [:deleted] : Used to denote whether the message was deleted by the agent
|
||||
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
|
||||
# [:external_error : Can specify if the message creation failed due to an error at external API
|
||||
# [:data] : Used for structured content types such as voice_call
|
||||
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
|
||||
:external_created_at, :story_sender, :story_id, :external_error,
|
||||
:translations, :in_reply_to_external_id, :is_unsupported], coder: JSON
|
||||
:translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON
|
||||
|
||||
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
|
||||
|
||||
@@ -112,6 +114,7 @@ class Message < ApplicationRecord
|
||||
scope :chat, -> { where.not(message_type: :activity).where(private: false) }
|
||||
scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('id desc') }
|
||||
scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) }
|
||||
scope :voice_calls, -> { where(content_type: :voice_call) }
|
||||
|
||||
# TODO: Get rid of default scope
|
||||
# https://stackoverflow.com/a/1834250/939299
|
||||
@@ -217,6 +220,16 @@ class Message < ApplicationRecord
|
||||
save!
|
||||
end
|
||||
|
||||
# For voice calls - convenience method to get status from content attributes
|
||||
def voice_call_status
|
||||
content_attributes.dig('data', 'status')
|
||||
end
|
||||
|
||||
# For voice calls - check if this is an active call
|
||||
def active_voice_call?
|
||||
voice_call? && !%w[completed failed busy no-answer canceled missed ended].include?(voice_call_status)
|
||||
end
|
||||
|
||||
def send_update_event
|
||||
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
|
||||
previous_changes: previous_changes)
|
||||
@@ -402,4 +415,4 @@ class Message < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
Message.prepend_mod_with('Message')
|
||||
Message.prepend_mod_with('Message')
|
||||
@@ -32,7 +32,8 @@ class Conversations::EventDataPresenter < SimpleDelegator
|
||||
sender: contact.push_event_data,
|
||||
assignee: assignee&.push_event_data,
|
||||
team: team&.push_event_data,
|
||||
hmac_verified: contact_inbox&.hmac_verified
|
||||
hmac_verified: contact_inbox&.hmac_verified,
|
||||
channel: inbox.try(:channel_type)
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ class Contacts::ContactableInboxesService
|
||||
api_contactable_inbox(inbox)
|
||||
when 'Channel::WebWidget'
|
||||
website_contactable_inbox(inbox)
|
||||
when 'Channel::Voice'
|
||||
voice_contactable_inbox(inbox)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -70,4 +72,10 @@ class Contacts::ContactableInboxesService
|
||||
{ source_id: "whatsapp:#{@contact.phone_number}", inbox: inbox }
|
||||
end
|
||||
end
|
||||
|
||||
def voice_contactable_inbox(inbox)
|
||||
return if @contact.phone_number.blank?
|
||||
|
||||
{ source_id: @contact.phone_number, inbox: inbox }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
module Voice
|
||||
module CallStatus
|
||||
# CallStatusManager is the centralized service for managing all voice call statuses
|
||||
# It handles updating conversation attributes, message attributes, and broadcasting updates.
|
||||
# All status changes for voice calls should flow through this service to ensure consistency.
|
||||
#
|
||||
# This service replaces the older TwilioCallStatusService and MessageUpdateService,
|
||||
# centralizing all voice call status management in one place with consistent behavior.
|
||||
#
|
||||
# Key responsibilities:
|
||||
# 1. Tracking call status transitions (initiated → ringing → in-progress → completed)
|
||||
# 2. Updating conversation additional_attributes with call metadata
|
||||
# 3. Updating voice call message content_attributes with matching status
|
||||
# 4. Creating appropriate activity messages for status changes
|
||||
# 5. Broadcasting status changes via ActionCable
|
||||
# 6. Determining call direction (inbound vs outbound)
|
||||
# 7. Providing provider-specific messaging templates
|
||||
#
|
||||
# Usage:
|
||||
# status_manager = Voice::CallStatus::Manager.new(
|
||||
# conversation: conversation,
|
||||
# call_sid: 'CA123456789',
|
||||
# provider: :twilio
|
||||
# )
|
||||
#
|
||||
# # Process a status update (recommended way to update status)
|
||||
# status_manager.process_status_update('completed', 120) # Status with duration in seconds
|
||||
#
|
||||
# # Create an activity message
|
||||
# status_manager.create_activity_message('Call ended by agent')
|
||||
#
|
||||
# # Check if call is outbound
|
||||
# is_outbound = status_manager.is_outbound?
|
||||
class Manager
|
||||
# Constructor parameters:
|
||||
# - conversation: The conversation associated with the call
|
||||
# - call_sid: The external ID for the call from the provider
|
||||
# - provider: Symbol representing the provider (e.g., :twilio)
|
||||
pattr_initialize [:conversation!, :call_sid, :provider]
|
||||
|
||||
# Valid call statuses with their transitions
|
||||
VALID_STATUSES = %w[initiated ringing in-progress active completed missed busy failed no-answer canceled].freeze
|
||||
|
||||
# Terminal statuses that indicate the call has ended
|
||||
TERMINAL_STATUSES = %w[completed missed busy failed no-answer canceled].freeze
|
||||
|
||||
# Map external status names to our internal statuses
|
||||
STATUS_MAPPING = {
|
||||
# Twilio statuses
|
||||
'queued' => 'initiated',
|
||||
'initiated' => 'initiated',
|
||||
'ringing' => 'ringing',
|
||||
'in-progress' => 'in-progress',
|
||||
'completed' => 'completed',
|
||||
'busy' => 'busy',
|
||||
'failed' => 'failed',
|
||||
'no-answer' => 'no-answer',
|
||||
'canceled' => 'canceled',
|
||||
|
||||
# Internal/UI statuses
|
||||
'active' => 'in-progress',
|
||||
'ended' => 'completed'
|
||||
}.freeze
|
||||
|
||||
# Provider-specific message templates for different call statuses
|
||||
# These messages must EXACTLY match what the UI expects to show
|
||||
# "In-progress" messages have been removed as they don't provide value
|
||||
PROVIDER_MESSAGES = {
|
||||
twilio: {
|
||||
# Status messages (only for terminal statuses)
|
||||
'completed' => { outbound: 'Call ended', inbound: 'Call ended' },
|
||||
'busy' => { outbound: 'Line busy', inbound: 'Missed call' },
|
||||
'failed' => { outbound: 'Call failed', inbound: 'Missed call' },
|
||||
'no-answer' => { outbound: 'No answer', inbound: 'Missed call' },
|
||||
'canceled' => { outbound: 'Call canceled', inbound: 'Missed call' },
|
||||
'missed' => { outbound: 'Call missed', inbound: 'Missed call' }
|
||||
}
|
||||
}.freeze
|
||||
|
||||
# Create a custom activity message
|
||||
# This provides a clean migration path from MessageUpdateService
|
||||
def create_activity_message(content, additional_attributes = {})
|
||||
return nil if content.blank?
|
||||
|
||||
Rails.logger.info("📝 [CallStatusManager] Creating activity message: '#{content}'")
|
||||
|
||||
# Activity messages should not have a sender
|
||||
# Pass nil for user and set the sender explicitly to nil
|
||||
message = conversation.messages.create!(
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: content,
|
||||
sender: nil,
|
||||
additional_attributes: additional_attributes
|
||||
)
|
||||
|
||||
Rails.logger.info("📝 [CallStatusManager] Created activity message ID #{message.id}")
|
||||
message
|
||||
end
|
||||
|
||||
# Process a call status update from any provider (e.g., Twilio, Vonage)
|
||||
# This is the primary method that should be used to update call statuses.
|
||||
# It handles different provider statuses, calculates duration, updates conversation
|
||||
# and message attributes, and creates appropriate activity messages.
|
||||
#
|
||||
# @param status [String] The status from the provider (e.g., 'completed', 'ringing')
|
||||
# @param duration [Integer, nil] The call duration in seconds (if available)
|
||||
# @param is_first_response [Boolean] Whether this is the first status update for this status
|
||||
# @param custom_message [String, nil] Optional custom activity message to create
|
||||
# @return [Boolean] Whether the update was processed successfully
|
||||
def process_status_update(status, duration = nil, is_first_response = false, custom_message = nil)
|
||||
# Normalize status using the STATUS_MAPPING if present
|
||||
normalized_status = STATUS_MAPPING[status] || status
|
||||
|
||||
# Get current status
|
||||
prev_status = conversation.additional_attributes&.dig('call_status')
|
||||
|
||||
# Skip if no changes needed to avoid duplicate processing
|
||||
# Unless this is marked as the first response, which we should always process
|
||||
if !is_first_response && prev_status == normalized_status
|
||||
Rails.logger.info("🔄 [CallStatusManager] Skipping duplicate status update: '#{normalized_status}'")
|
||||
return true
|
||||
end
|
||||
|
||||
# Handle status transitions - only allow certain transitions
|
||||
if prev_status.present? && !is_first_response
|
||||
# Don't move backwards in the status flow unless forced to
|
||||
if prev_status == 'in-progress' && normalized_status == 'ringing'
|
||||
Rails.logger.info("🔄 [CallStatusManager] Skipping backward transition: '#{prev_status}' -> '#{normalized_status}'")
|
||||
return true
|
||||
end
|
||||
|
||||
# Don't override a completed status with in-progress
|
||||
if TERMINAL_STATUSES.include?(prev_status) && normalized_status == 'in-progress'
|
||||
Rails.logger.info("🔄 [CallStatusManager] Call already ended, skipping update to: '#{normalized_status}'")
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate call duration automatically if not provided and call is ending
|
||||
if duration.nil? && call_ended?(normalized_status) && conversation.additional_attributes['call_started_at']
|
||||
calculated_duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i
|
||||
Rails.logger.info("⏱️ [CallStatusManager] Calculated call duration: #{calculated_duration} seconds")
|
||||
duration = calculated_duration
|
||||
end
|
||||
|
||||
# Update conversation and message status in a database transaction
|
||||
result = update_status(normalized_status, duration)
|
||||
|
||||
# Create activity message based on the provided custom message or use default provider message
|
||||
if result
|
||||
if custom_message.present?
|
||||
# Use the custom message provided
|
||||
Rails.logger.info("📝 [CallStatusManager] Creating custom activity message: '#{custom_message}'")
|
||||
create_activity_message(custom_message, { call_sid: call_sid, call_status: normalized_status })
|
||||
elsif should_create_activity_message?(normalized_status)
|
||||
# For outbound calls in initiated/ringing stage, only add message if explicitly requested
|
||||
if is_outbound? && ['initiated', 'ringing'].include?(normalized_status) && !is_first_response
|
||||
Rails.logger.info("📝 [CallStatusManager] Skipping default activity message for outbound call status: '#{normalized_status}'")
|
||||
else
|
||||
# Use default provider message
|
||||
Rails.logger.info("📝 [CallStatusManager] Creating default activity message for status: '#{normalized_status}'")
|
||||
create_provider_activity_message(normalized_status, is_first_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
# Determine if a call is outbound based on conversation attributes
|
||||
# This is the centralized method that should be used throughout the application
|
||||
# to determine call direction instead of passing around the is_outbound flag.
|
||||
#
|
||||
# @return [Boolean] true if the call is outbound, false otherwise
|
||||
def is_outbound?
|
||||
# Strategy 1: Check the call_direction attribute (most reliable)
|
||||
direction = conversation.additional_attributes['call_direction']
|
||||
return direction == 'outbound' if direction.present?
|
||||
|
||||
# Strategy 2: Check for requires_agent_join flag (set for outbound calls)
|
||||
# When an agent initiates an outbound call, this flag is set to true
|
||||
# This helps us identify outbound calls even if call_direction is not set
|
||||
return true if conversation.additional_attributes['requires_agent_join'] == true
|
||||
|
||||
# Strategy 3: Check for other outbound indicators
|
||||
# e.g., check for call_type or other attributes that might indicate direction
|
||||
call_type = conversation.additional_attributes['call_type']
|
||||
return true if call_type == 'outbound'
|
||||
|
||||
# Default to inbound if we can't determine
|
||||
# Most calls are inbound, so this is a reasonable default
|
||||
false
|
||||
end
|
||||
|
||||
# Convert internal status to UI-friendly status
|
||||
# For consistent display across all parts of the UI
|
||||
# This is used by other services to ensure consistent status display
|
||||
# @param status [String] The raw status to normalize
|
||||
# @return [String] The UI-friendly status value
|
||||
def normalized_ui_status(status)
|
||||
incoming = !is_outbound?
|
||||
|
||||
case status
|
||||
when 'initiated', 'ringing'
|
||||
is_outbound? ? 'started' : 'ringing'
|
||||
when 'in-progress', 'active'
|
||||
'in_progress'
|
||||
when 'completed', 'ended'
|
||||
'ended'
|
||||
when 'missed'
|
||||
is_outbound? ? 'no_answer' : 'missed'
|
||||
when 'busy'
|
||||
is_outbound? ? 'busy' : 'missed' # Treat busy as missed for incoming
|
||||
when 'failed'
|
||||
is_outbound? ? 'failed' : 'missed' # Treat failed as missed for incoming
|
||||
when 'no-answer'
|
||||
is_outbound? ? 'no_answer' : 'missed' # For incoming, this is missed
|
||||
when 'canceled'
|
||||
is_outbound? ? 'canceled' : 'missed' # Treat canceled as missed for incoming
|
||||
else
|
||||
is_outbound? ? 'ended' : 'missed' # Default to missed for incoming if we don't know
|
||||
end
|
||||
end
|
||||
|
||||
# Generate provider-specific activity messages (e.g., for Twilio)
|
||||
def create_provider_activity_message(status, is_first_response = false)
|
||||
# Skip activity messages for non-terminal states
|
||||
return nil unless call_ended?(status)
|
||||
|
||||
provider_key = provider&.to_sym
|
||||
call_direction = is_outbound? ? :outbound : :inbound
|
||||
|
||||
# Default message in case we can't find a provider-specific one
|
||||
message = "Call status: #{status}"
|
||||
|
||||
if provider_key && PROVIDER_MESSAGES.key?(provider_key)
|
||||
messages = PROVIDER_MESSAGES[provider_key]
|
||||
|
||||
if messages.dig(status, call_direction)
|
||||
message = messages.dig(status, call_direction)
|
||||
end
|
||||
end
|
||||
|
||||
# Create the activity message
|
||||
create_activity_message(message, {
|
||||
call_sid: call_sid,
|
||||
call_status: status
|
||||
})
|
||||
end
|
||||
|
||||
# Update call status in a single operation
|
||||
# This ensures conversation and message statuses are in sync
|
||||
def update_status(status, duration = nil)
|
||||
# Map external status to internal status
|
||||
internal_status = STATUS_MAPPING[status] || status
|
||||
|
||||
Rails.logger.info("🔄 [CallStatusManager] Updating call status for conversation #{conversation.display_id}: '#{internal_status}'")
|
||||
|
||||
# Validate status
|
||||
unless VALID_STATUSES.include?(internal_status)
|
||||
Rails.logger.error("❌ [CallStatusManager] Invalid status: #{internal_status}")
|
||||
return false
|
||||
end
|
||||
|
||||
# Get current status
|
||||
current_status = conversation.additional_attributes['call_status']
|
||||
|
||||
# Only update if status is changing or we're forcing an update
|
||||
# Exception: Don't create duplicate "completed" status updates for the same conversation
|
||||
if current_status == internal_status
|
||||
Rails.logger.info("🔄 [CallStatusManager] Status unchanged: '#{internal_status}'")
|
||||
return true
|
||||
end
|
||||
|
||||
# Don't process multiple call ending events - once a call is in a terminal state, keep it there
|
||||
# This prevents duplicate "Call ended" messages
|
||||
if current_status.present? && call_ended?(current_status) && call_ended?(internal_status)
|
||||
Rails.logger.info("🔄 [CallStatusManager] Call already in terminal state '#{current_status}', not changing to '#{internal_status}'")
|
||||
return true
|
||||
end
|
||||
|
||||
# Log the status transition
|
||||
Rails.logger.info("🔄 [CallStatusManager] Status transition: '#{current_status}' -> '#{internal_status}'")
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# Update conversation additional_attributes
|
||||
update_conversation_status(internal_status, duration)
|
||||
|
||||
# Update message content_attributes
|
||||
update_message_status(internal_status, duration)
|
||||
|
||||
# Only create activity messages for status changes that warrant them
|
||||
# For terminal states, we'll create exactly one appropriate activity message
|
||||
# For outbound calls in initiated/ringing states, we'll suppress standard messages
|
||||
|
||||
# Track status transitions in conversation metadata to prevent duplicate activity messages
|
||||
status_from = conversation.additional_attributes['call_status'] || 'none'
|
||||
status_to = internal_status
|
||||
transition = "#{status_from}→#{status_to}"
|
||||
|
||||
# Store which transitions we've already handled with activity messages
|
||||
conversation.additional_attributes['status_transitions'] ||= {}
|
||||
transition_handled = conversation.additional_attributes['status_transitions'][transition]
|
||||
|
||||
# Mark this transition as handled
|
||||
conversation.additional_attributes['status_transitions'][transition] = true
|
||||
|
||||
# Only create an activity message for the first occurrence of any status transition
|
||||
# and only for terminal states or custom messages
|
||||
create_should_activity = should_create_activity_message?(internal_status) &&
|
||||
!(is_outbound? && ['initiated', 'ringing'].include?(internal_status)) &&
|
||||
!transition_handled
|
||||
|
||||
# Create activity message for status change if needed
|
||||
create_status_activity_message(internal_status) if create_should_activity
|
||||
|
||||
# Broadcast status change notification
|
||||
broadcast_status_change(internal_status)
|
||||
|
||||
# No need for additional broadcast - status change already broadcasts
|
||||
# conversation.updated events for terminal call states
|
||||
end
|
||||
|
||||
true
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("❌ [CallStatusManager] Error updating status: #{e.message}")
|
||||
Rails.logger.error(e.backtrace.first(5).join("\n"))
|
||||
false
|
||||
end
|
||||
|
||||
def call_ended?(status)
|
||||
TERMINAL_STATUSES.include?(status)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_conversation_status(status, duration)
|
||||
# Update the status
|
||||
conversation.additional_attributes ||= {}
|
||||
conversation.additional_attributes['call_status'] = status
|
||||
|
||||
# Also store the UI-friendly status for consistent display
|
||||
conversation.additional_attributes['ui_call_status'] = normalized_ui_status(status)
|
||||
|
||||
# Update timestamps and metadata based on status
|
||||
if %w[in-progress active].include?(status)
|
||||
# Record the start time if not already set
|
||||
conversation.additional_attributes['call_started_at'] = Time.now.to_i unless conversation.additional_attributes['call_started_at']
|
||||
|
||||
# Ensure we have call meta data
|
||||
conversation.additional_attributes['meta'] ||= {}
|
||||
conversation.additional_attributes['meta']['active_at'] = Time.now.to_i
|
||||
elsif call_ended?(status)
|
||||
# Record end time
|
||||
conversation.additional_attributes['call_ended_at'] = Time.now.to_i
|
||||
|
||||
# Calculate and record duration
|
||||
if duration
|
||||
conversation.additional_attributes['call_duration'] = duration
|
||||
elsif conversation.additional_attributes['call_started_at']
|
||||
conversation.additional_attributes['call_duration'] =
|
||||
Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i
|
||||
end
|
||||
|
||||
# Add call end metadata
|
||||
conversation.additional_attributes['meta'] ||= {}
|
||||
conversation.additional_attributes['meta']["#{status}_at"] = Time.now.to_i
|
||||
end
|
||||
|
||||
# Save the conversation - force timestamp update to trigger UI refresh
|
||||
conversation.update!(last_activity_at: Time.current)
|
||||
end
|
||||
|
||||
def update_message_status(status, duration)
|
||||
message = find_voice_call_message
|
||||
return unless message
|
||||
|
||||
# Use the normalized UI status for consistent display
|
||||
ui_status = normalized_ui_status(status)
|
||||
|
||||
# Get current content attributes, initialize if needed
|
||||
content_attributes = message.content_attributes || {}
|
||||
content_attributes['data'] ||= {}
|
||||
|
||||
# Update fields
|
||||
content_attributes['data']['status'] = ui_status
|
||||
content_attributes['data']['duration'] = duration if duration
|
||||
content_attributes['data']['meta'] ||= {}
|
||||
content_attributes['data']['meta']["#{ui_status}_at"] = Time.now.to_i
|
||||
content_attributes['data']['updated_at'] = Time.now.to_i
|
||||
|
||||
# Add a flag to force the UI to refresh
|
||||
content_attributes['data']['status_updated'] = Time.now.to_i
|
||||
|
||||
# Save the message
|
||||
message.update!(content_attributes: content_attributes)
|
||||
end
|
||||
|
||||
def find_voice_call_message
|
||||
# First try to find by call_sid
|
||||
message = nil
|
||||
|
||||
if call_sid.present?
|
||||
# Try to find by exact call_sid match
|
||||
message = conversation.messages
|
||||
.where(content_type: 'voice_call')
|
||||
.where("content_attributes->'data'->>'call_sid' = ?", call_sid)
|
||||
.first
|
||||
end
|
||||
|
||||
# If not found, try by looking for a call_sid that contains our call_sid (Twilio sometimes sends partial SIDs)
|
||||
if message.nil? && call_sid.present?
|
||||
# Look for messages where call_sid is a substring
|
||||
messages = conversation.messages
|
||||
.where(content_type: 'voice_call')
|
||||
.order(created_at: :desc)
|
||||
|
||||
# Manually check for partial matches in content_attributes
|
||||
message = messages.find do |msg|
|
||||
stored_call_sid = msg.content_attributes.dig('data', 'call_sid')
|
||||
stored_call_sid.present? && (stored_call_sid.include?(call_sid) || call_sid.include?(stored_call_sid))
|
||||
end
|
||||
end
|
||||
|
||||
# If still not found, get the most recent voice call message
|
||||
if message.nil?
|
||||
message = conversation.messages
|
||||
.where(content_type: 'voice_call')
|
||||
.order(created_at: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
message
|
||||
end
|
||||
|
||||
def should_create_activity_message?(status)
|
||||
# Only create activity messages for terminal call states
|
||||
# "in-progress" messages are removed as they don't provide much value
|
||||
call_ended?(status)
|
||||
end
|
||||
|
||||
def create_status_activity_message(status)
|
||||
# Use messages that match the UI expectations exactly
|
||||
# These messages will appear in the activity feed
|
||||
content = if call_ended?(status)
|
||||
if !is_outbound?
|
||||
# All ended incoming calls should show as "Missed call" if they weren't answered
|
||||
# Only show "Call ended" if the call was actually answered (in progress)
|
||||
conversation_was_active = conversation.additional_attributes['call_started_at'].present?
|
||||
if conversation_was_active
|
||||
'Call ended'
|
||||
else
|
||||
'Missed call' # Default for all incoming ended calls that weren't answered
|
||||
end
|
||||
else
|
||||
# For outbound calls, show appropriate endings
|
||||
case status
|
||||
when 'missed'
|
||||
'No answer'
|
||||
when 'busy'
|
||||
'Line busy'
|
||||
when 'failed'
|
||||
'Call failed'
|
||||
when 'no-answer'
|
||||
'No answer'
|
||||
when 'canceled'
|
||||
'Call canceled'
|
||||
else
|
||||
'Call ended'
|
||||
end
|
||||
end
|
||||
else
|
||||
# No intermediate status messages for in-progress or ringing
|
||||
nil
|
||||
end
|
||||
|
||||
# Use the public create_activity_message method
|
||||
create_activity_message(content, {
|
||||
call_sid: call_sid,
|
||||
call_status: status
|
||||
})
|
||||
end
|
||||
|
||||
def broadcast_status_change(status)
|
||||
# Broadcast to the account-wide channel
|
||||
Rails.logger.info("📢 [CallStatusManager] Broadcasting status change: '#{status}' for conversation_id=#{conversation.id}")
|
||||
|
||||
# Use account-level channel for maximum compatibility
|
||||
# Convert the internal status to the UI-friendly format
|
||||
# This ensures the conversation list shows consistent status texts
|
||||
ui_status = normalized_ui_status(status)
|
||||
|
||||
Rails.logger.info("📢 [CallStatusManager] Broadcasting UI status: '#{ui_status}' for conversation_id=#{conversation.display_id}")
|
||||
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{conversation.account_id}",
|
||||
{
|
||||
event_name: 'call_status_changed',
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: ui_status, # Send UI-friendly status
|
||||
conversation_id: conversation.display_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
timestamp: Time.now.to_i
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Also broadcast a conversation.updated event for terminal statuses
|
||||
# This ensures the conversation list gets refreshed when a call ends
|
||||
if call_ended?(status)
|
||||
Rails.logger.info("📢 [CallStatusManager] Broadcasting conversation.updated for completed call")
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{conversation.account_id}",
|
||||
{
|
||||
event_name: 'conversation.updated',
|
||||
data: conversation.push_event_data
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,369 @@
|
||||
module Voice
|
||||
# Handles conference events (start, end, participant joins and leaves)
|
||||
# Uses CallStatusManager to update call statuses, ensuring consistency
|
||||
# This service is called directly by ConferenceStatusService
|
||||
class ConferenceManagerService
|
||||
pattr_initialize [:conversation!, :event!, :call_sid!, :conference_sid, :participant_sid, :participant_label]
|
||||
|
||||
# Event constants to make code more readable
|
||||
CONFERENCE_START = 'conference-start'.freeze
|
||||
CONFERENCE_END = 'conference-end'.freeze
|
||||
PARTICIPANT_JOIN = 'participant-join'.freeze
|
||||
PARTICIPANT_LEAVE = 'participant-leave'.freeze
|
||||
|
||||
# Participant types
|
||||
AGENT = 'agent'.freeze
|
||||
CALLER = 'caller'.freeze
|
||||
|
||||
def process
|
||||
Rails.logger.info("🎧 CONFERENCE EVENT: #{event} for conference_sid=#{conference_sid}")
|
||||
|
||||
# Process the conference event
|
||||
case event
|
||||
when CONFERENCE_START
|
||||
handle_conference_start
|
||||
when CONFERENCE_END
|
||||
handle_conference_end
|
||||
when PARTICIPANT_JOIN
|
||||
handle_participant_join
|
||||
when PARTICIPANT_LEAVE
|
||||
handle_participant_leave
|
||||
else
|
||||
Rails.logger.warn("🎧 UNKNOWN CONFERENCE EVENT: #{event}")
|
||||
end
|
||||
|
||||
# Save conversation changes
|
||||
conversation.save!
|
||||
|
||||
# Ensure ActionCable broadcast for terminal call states (for debugging and reliability)
|
||||
if %w[conference-end participant-leave].include?(event)
|
||||
current_status = conversation.additional_attributes['call_status']
|
||||
if %w[completed ended missed busy failed no-answer canceled].include?(current_status)
|
||||
# Defensive: broadcast call_status_changed event to ensure frontend is notified
|
||||
ui_status = Voice::CallStatus::Manager.new(conversation: conversation, call_sid: call_sid, provider: :twilio).normalized_ui_status(current_status)
|
||||
Rails.logger.info("📢 [ConferenceManagerService] Forcing ActionCable broadcast: call_status_changed (call_sid=#{call_sid}, status=#{ui_status}) for conversation_id=#{conversation.display_id}")
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{conversation.account_id}",
|
||||
{
|
||||
event_name: 'call_status_changed',
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: ui_status,
|
||||
conversation_id: conversation.display_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
timestamp: Time.now.to_i
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def call_status_manager
|
||||
# Use the CallStatusManager, which will determine internally if the call is outbound
|
||||
@call_status_manager ||= Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid,
|
||||
provider: :twilio
|
||||
)
|
||||
end
|
||||
|
||||
def handle_conference_start
|
||||
# Update conference status
|
||||
update_conference_status('started')
|
||||
|
||||
# Check if we need to update call status
|
||||
current_status = conversation.additional_attributes['call_status']
|
||||
|
||||
# Only update to ringing if not already in a more advanced state
|
||||
return if %w[active in-progress completed].include?(current_status)
|
||||
|
||||
Rails.logger.info('📞 UPDATING CALL TO RINGING ON CONFERENCE START')
|
||||
call_status_manager.process_status_update('ringing')
|
||||
end
|
||||
|
||||
def handle_conference_end
|
||||
# Update conference status
|
||||
update_conference_status('ended')
|
||||
|
||||
# Get current call status
|
||||
current_status = conversation.additional_attributes['call_status']
|
||||
|
||||
# Determine final call status based on current state
|
||||
finalize_call_status(current_status)
|
||||
end
|
||||
|
||||
def handle_participant_join
|
||||
# Track participant join
|
||||
track_participant_join
|
||||
|
||||
# Handle call status updates based on who joined
|
||||
if agent_participant?
|
||||
handle_agent_join
|
||||
elsif caller_participant?
|
||||
handle_caller_join
|
||||
else
|
||||
handle_generic_participant_join
|
||||
end
|
||||
|
||||
# Check if both parties are present to mark call as active
|
||||
check_both_parties_present
|
||||
end
|
||||
|
||||
def handle_participant_leave
|
||||
# Track participant leave
|
||||
track_participant_leave
|
||||
|
||||
# Handle missed calls when caller leaves during ringing
|
||||
check_for_missed_call
|
||||
|
||||
# Check if everyone left to end conference
|
||||
check_if_everyone_left
|
||||
end
|
||||
|
||||
# Helper methods for updating conference status
|
||||
def update_conference_status(status)
|
||||
conversation.additional_attributes ||= {}
|
||||
conversation.additional_attributes['conference_status'] = status
|
||||
conversation.additional_attributes["conference_#{status}_at"] = Time.now.to_i
|
||||
|
||||
# Update metadata
|
||||
conversation.additional_attributes['meta'] ||= {}
|
||||
conversation.additional_attributes['meta']["conference_#{status}_at"] = Time.now.to_i
|
||||
|
||||
Rails.logger.info("🎧 CONFERENCE #{status.upcase}: conference_sid=#{conference_sid}")
|
||||
end
|
||||
|
||||
# Helper methods for finalizing call status
|
||||
def finalize_call_status(current_status)
|
||||
if %w[active in-progress].include?(current_status)
|
||||
# Call was active, mark as completed with duration
|
||||
complete_active_call
|
||||
elsif current_status == 'ringing'
|
||||
# Call never connected
|
||||
Rails.logger.info('📞 MARKING RINGING CALL AS MISSED')
|
||||
call_status_manager.process_status_update('missed', nil, false, 'Missed call')
|
||||
else
|
||||
# Default to completed status
|
||||
Rails.logger.info('📞 MARKING CALL AS COMPLETED (DEFAULT)')
|
||||
call_status_manager.process_status_update('completed', nil, false, 'Call ended')
|
||||
end
|
||||
end
|
||||
|
||||
def complete_active_call
|
||||
# Calculate duration if possible
|
||||
duration = nil
|
||||
if conversation.additional_attributes['call_started_at']
|
||||
duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i
|
||||
Rails.logger.info("⏱️ CALCULATED CALL DURATION: #{duration} seconds")
|
||||
end
|
||||
|
||||
Rails.logger.info('📞 MARKING ACTIVE CALL AS COMPLETED')
|
||||
call_status_manager.process_status_update('completed', duration, false, 'Call ended')
|
||||
end
|
||||
|
||||
# Helper methods for participant handling
|
||||
def track_participant_join
|
||||
# Update participant tracking
|
||||
update_participant_info('joined')
|
||||
Rails.logger.info("👥 PARTICIPANT JOINED: #{participant_label || 'unknown'} (#{participant_sid})")
|
||||
end
|
||||
|
||||
def track_participant_leave
|
||||
# Update participant tracking
|
||||
update_participant_info('left')
|
||||
Rails.logger.info("👥 PARTICIPANT LEFT: #{participant_label || 'unknown'} (#{participant_sid})")
|
||||
|
||||
# Record leave time based on participant type
|
||||
if agent_participant?
|
||||
conversation.additional_attributes['agent_left_at'] = Time.now.to_i
|
||||
elsif caller_participant?
|
||||
conversation.additional_attributes['caller_left_at'] = Time.now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
# Participant type checks
|
||||
def agent_participant?
|
||||
participant_label&.start_with?(AGENT)
|
||||
end
|
||||
|
||||
def caller_participant?
|
||||
participant_label&.start_with?(CALLER)
|
||||
end
|
||||
|
||||
# Participant join handlers
|
||||
def handle_agent_join
|
||||
conversation.additional_attributes['agent_joined_at'] = Time.now.to_i
|
||||
Rails.logger.info("👤 AGENT JOINED AT: #{Time.now.to_i}")
|
||||
|
||||
# If call is ringing when agent joins, mark as connected
|
||||
return unless conversation.additional_attributes['call_status'] == 'ringing'
|
||||
|
||||
Rails.logger.info('📞 UPDATING RINGING CALL TO CONNECTED (agent joined)')
|
||||
# Always use in-progress to be consistent with status mapping
|
||||
# Pass event context to create appropriate activity message
|
||||
call_status_manager.process_status_update('in-progress', nil, true, 'Agent joined the call')
|
||||
end
|
||||
|
||||
def handle_caller_join
|
||||
conversation.additional_attributes['caller_joined_at'] = Time.now.to_i
|
||||
Rails.logger.info("👤 CALLER JOINED AT: #{Time.now.to_i}")
|
||||
|
||||
# For outbound calls - mark as connected when caller joins if still ringing
|
||||
return unless outbound_call? && ringing_call?
|
||||
|
||||
Rails.logger.info('📞 UPDATING RINGING OUTBOUND CALL TO CONNECTED (caller joined)')
|
||||
# Always use in-progress to be consistent with status mapping
|
||||
# Only create activity message for inbound calls where caller joining is significant
|
||||
custom_message = call_status_manager.is_outbound? ? nil : 'Caller joined the call'
|
||||
call_status_manager.process_status_update('in-progress', nil, true, custom_message)
|
||||
end
|
||||
|
||||
def handle_generic_participant_join
|
||||
Rails.logger.info('👤 GENERIC PARTICIPANT JOINED')
|
||||
|
||||
# If we're stuck in ringing for a while, try to move forward
|
||||
return unless ringing_call? && long_ringing?
|
||||
|
||||
Rails.logger.info('📞 UPDATING LONG-RINGING CALL TO CONNECTED (participant joined)')
|
||||
# Always use in-progress to be consistent with status mapping
|
||||
call_status_manager.process_status_update('in-progress', nil, true)
|
||||
end
|
||||
|
||||
# Call state checks
|
||||
def outbound_call?
|
||||
conversation.additional_attributes['call_direction'] == 'outbound'
|
||||
end
|
||||
|
||||
def ringing_call?
|
||||
conversation.additional_attributes['call_status'] == 'ringing'
|
||||
end
|
||||
|
||||
def long_ringing?
|
||||
ringing_at = conversation.additional_attributes.dig('meta', 'ringing_at').to_i
|
||||
ringing_at > 0 && (Time.now.to_i - ringing_at > 10)
|
||||
end
|
||||
|
||||
# Both parties present check
|
||||
def check_both_parties_present
|
||||
both_present = conversation.additional_attributes['agent_joined_at'] &&
|
||||
conversation.additional_attributes['caller_joined_at']
|
||||
|
||||
# Only update if not already in an active state
|
||||
return unless both_present && !%w[active in-progress].include?(conversation.additional_attributes['call_status'])
|
||||
|
||||
Rails.logger.info('📞 UPDATING CALL STATUS TO CONNECTED (both parties present)')
|
||||
# Always use in-progress to be consistent with status mapping
|
||||
call_status_manager.process_status_update('in-progress', nil, true)
|
||||
end
|
||||
|
||||
# Missed call check when caller leaves
|
||||
def check_for_missed_call
|
||||
return unless caller_participant? && ringing_call? && !participant_has_joined?(AGENT)
|
||||
|
||||
Rails.logger.info('📞 MARKING AS MISSED (caller left during ringing, no agent joined)')
|
||||
call_status_manager.process_status_update('missed', nil, false, 'Missed call')
|
||||
end
|
||||
|
||||
# Everyone left check
|
||||
def check_if_everyone_left
|
||||
call_in_progress = %w[active in-progress].include?(conversation.additional_attributes['call_status'])
|
||||
conference_active = conversation.additional_attributes['conference_status'] != 'ended'
|
||||
|
||||
# If the caller has left but the call is still active, mark it as completed
|
||||
if caller_participant? && call_in_progress
|
||||
# Calculate duration if possible
|
||||
duration = nil
|
||||
duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i if conversation.additional_attributes['call_started_at']
|
||||
|
||||
Rails.logger.info('📞 MARKING CALL AS COMPLETED (caller left during active call)')
|
||||
call_status_manager.process_status_update('completed', duration, false, 'Caller left the call')
|
||||
return
|
||||
end
|
||||
|
||||
# Old code path for when everyone has left
|
||||
return unless all_participants_left? && conference_active && call_in_progress
|
||||
|
||||
# Calculate duration if possible
|
||||
duration = nil
|
||||
duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i if conversation.additional_attributes['call_started_at']
|
||||
|
||||
Rails.logger.info('📞 MARKING CALL AS COMPLETED (all participants left)')
|
||||
call_status_manager.process_status_update('completed', duration, false, 'Call ended')
|
||||
# Defensive: Immediately broadcast ActionCable event for reliability
|
||||
ui_status = call_status_manager.normalized_ui_status('completed')
|
||||
Rails.logger.info("📢 [ConferenceManagerService] Broadcasting call_status_changed (call_sid=#{call_sid}, status=#{ui_status}) after all participants left for conversation_id=#{conversation.display_id}")
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{conversation.account_id}",
|
||||
{
|
||||
event_name: 'call_status_changed',
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: ui_status,
|
||||
conversation_id: conversation.display_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
timestamp: Time.now.to_i
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
# Participant tracking methods
|
||||
def update_participant_info(status)
|
||||
# Initialize participants tracking
|
||||
conversation.additional_attributes ||= {}
|
||||
conversation.additional_attributes['participants'] ||= {}
|
||||
|
||||
# Determine participant type from label
|
||||
participant_type = agent_participant? ? AGENT : CALLER
|
||||
|
||||
if status == 'joined'
|
||||
# Add or update participant
|
||||
conversation.additional_attributes['participants'][participant_sid] = {
|
||||
'joined_at' => Time.now.to_i,
|
||||
'type' => participant_type,
|
||||
'call_sid' => call_sid,
|
||||
'status' => 'joined'
|
||||
}
|
||||
elsif status == 'left' && conversation.additional_attributes['participants'].key?(participant_sid)
|
||||
# Update existing participant
|
||||
conversation.additional_attributes['participants'][participant_sid]['status'] = 'left'
|
||||
conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
def participant_has_joined?(type)
|
||||
participants = conversation.additional_attributes['participants'] || {}
|
||||
return false unless participants.is_a?(Hash)
|
||||
|
||||
participants.values.any? { |p| p['type'] == type && p['status'] == 'joined' }
|
||||
end
|
||||
|
||||
def all_participants_left?
|
||||
participants = conversation.additional_attributes['participants'] || {}
|
||||
return true unless participants.is_a?(Hash)
|
||||
|
||||
!participants.values.any? { |p| p['status'] == 'joined' }
|
||||
end
|
||||
|
||||
def broadcast_call_status_changed
|
||||
ui_status = call_status_manager.normalized_ui_status('completed')
|
||||
Rails.logger.info("📢 [ConferenceManagerService] Broadcasting call_status_changed (call_sid=#{call_sid}, status=#{ui_status}) after all participants left for conversation_id=#{conversation.display_id}")
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{conversation.account_id}",
|
||||
{
|
||||
event_name: 'call_status_changed',
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: ui_status,
|
||||
conversation_id: conversation.display_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
timestamp: Time.now.to_i
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,196 @@
|
||||
module Voice
|
||||
# Handles Twilio conference status webhook callbacks
|
||||
# Normalizes events, finds the relevant conversation,
|
||||
# and delegates processing to ConferenceManagerService
|
||||
class ConferenceStatusService
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
# Map of Twilio event names to our normalized format
|
||||
EVENT_MAPPING = {
|
||||
# Join events
|
||||
'participant-join' => 'participant-join',
|
||||
'participantjoin' => 'participant-join',
|
||||
'participantjoined' => 'participant-join',
|
||||
'participant_join' => 'participant-join',
|
||||
|
||||
# Leave events
|
||||
'participant-leave' => 'participant-leave',
|
||||
'participantleave' => 'participant-leave',
|
||||
'participantleft' => 'participant-leave',
|
||||
'participant_leave' => 'participant-leave',
|
||||
|
||||
# Conference start events
|
||||
'conference-start' => 'conference-start',
|
||||
'conferencestart' => 'conference-start',
|
||||
'conferencestarted' => 'conference-start',
|
||||
'conference_start' => 'conference-start',
|
||||
|
||||
# Conference end events
|
||||
'conference-end' => 'conference-end',
|
||||
'conferenceend' => 'conference-end',
|
||||
'conferenceended' => 'conference-end',
|
||||
'conference_end' => 'conference-end'
|
||||
}.freeze
|
||||
|
||||
def process
|
||||
|
||||
# Extract status info and find conversation
|
||||
info = status_info
|
||||
conversation = find_conversation(info)
|
||||
|
||||
return unless conversation
|
||||
|
||||
# Update participant tracking info
|
||||
update_participant_info(conversation, info)
|
||||
|
||||
# Process the event with the conference manager
|
||||
|
||||
Voice::ConferenceManagerService.new(
|
||||
conversation: conversation,
|
||||
event: info[:event],
|
||||
call_sid: info[:call_sid],
|
||||
conference_sid: info[:conference_sid],
|
||||
participant_sid: info[:participant_sid],
|
||||
participant_label: info[:participant_label]
|
||||
).process
|
||||
end
|
||||
|
||||
def status_info
|
||||
# Get and normalize the event name
|
||||
raw_event = params['StatusCallbackEvent']
|
||||
normalized_event = normalize_event_name(raw_event)
|
||||
|
||||
{
|
||||
call_sid: params['CallSid'],
|
||||
conference_sid: params['ConferenceSid'],
|
||||
event: normalized_event,
|
||||
participant_sid: params['ParticipantSid'],
|
||||
participant_label: params['ParticipantLabel']
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_event_name(raw_event)
|
||||
return 'unknown' unless raw_event.present?
|
||||
|
||||
event_text = raw_event.downcase.gsub(/\s+/, '')
|
||||
|
||||
# Look up in mapping
|
||||
EVENT_MAPPING[event_text] || event_text.gsub(/([a-z\d])([A-Z])/, '\1-\2').gsub('_', '-').gsub(/--+/, '-')
|
||||
end
|
||||
|
||||
def find_conversation(info)
|
||||
# Try by conference_sid first
|
||||
if info[:conference_sid].present?
|
||||
conversation = find_by_conference_sid(info[:conference_sid])
|
||||
return conversation if conversation
|
||||
end
|
||||
|
||||
# Try by call_sid if available
|
||||
if info[:call_sid].present?
|
||||
conversation = account.conversations.where("additional_attributes->>'call_sid' = ?", info[:call_sid]).first
|
||||
return conversation if conversation
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def find_by_conference_sid(conference_sid)
|
||||
# Direct match by conference_sid
|
||||
conversation = account.conversations.where("additional_attributes->>'conference_sid' = ?", conference_sid).first
|
||||
return conversation if conversation
|
||||
|
||||
# Try pattern matching if it looks like our format
|
||||
if conference_sid.start_with?('conf_account_')
|
||||
conference_parts = conference_sid.match(/conf_account_\d+_conv_(\d+)/)
|
||||
if conference_parts && conference_parts[1].present?
|
||||
conversation_display_id = conference_parts[1]
|
||||
return account.conversations.find_by(display_id: conversation_display_id)
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def update_participant_info(conversation, info)
|
||||
# Skip if no participant info
|
||||
return unless info[:participant_sid].present?
|
||||
|
||||
# Initialize tracking
|
||||
conversation.additional_attributes ||= {}
|
||||
conversation.additional_attributes['participants'] ||= {}
|
||||
|
||||
participant_sid = info[:participant_sid]
|
||||
|
||||
# Update based on event type
|
||||
case info[:event]
|
||||
when 'participant-join'
|
||||
track_participant_join(conversation, participant_sid, info)
|
||||
when 'participant-leave'
|
||||
track_participant_leave(conversation, participant_sid)
|
||||
end
|
||||
|
||||
conversation.save!
|
||||
end
|
||||
|
||||
def track_participant_join(conversation, participant_sid, info)
|
||||
# Add participant
|
||||
conversation.additional_attributes['participants'][participant_sid] = {
|
||||
'joined_at' => Time.now.to_i,
|
||||
'type' => info[:participant_label]&.start_with?('agent') ? 'agent' : 'caller',
|
||||
'call_sid' => info[:call_sid],
|
||||
'status' => 'joined'
|
||||
}
|
||||
|
||||
# Handle outbound calls where caller has joined
|
||||
if conversation.additional_attributes['call_direction'] == 'outbound' &&
|
||||
info[:participant_label]&.start_with?('caller-')
|
||||
conversation.additional_attributes['requires_agent_join'] = true
|
||||
broadcast_agent_notification(conversation, info)
|
||||
end
|
||||
end
|
||||
|
||||
def track_participant_leave(conversation, participant_sid)
|
||||
if conversation.additional_attributes['participants'].key?(participant_sid)
|
||||
conversation.additional_attributes['participants'][participant_sid]['status'] = 'left'
|
||||
conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
def broadcast_agent_notification(conversation, info)
|
||||
contact = conversation.contact
|
||||
inbox = conversation.inbox
|
||||
|
||||
# Get contact name, ensuring we have a valid value
|
||||
contact_name_value = contact&.name.presence || contact&.phone_number || 'Outbound Call'
|
||||
|
||||
# Create the data payload
|
||||
broadcast_data = {
|
||||
call_sid: info[:call_sid],
|
||||
conversation_id: conversation.display_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
inbox_name: conversation.inbox.name,
|
||||
inbox_avatar_url: inbox.avatar_url, # Include inbox avatar
|
||||
inbox_phone_number: inbox.channel.phone_number, # Include inbox phone number
|
||||
contact_name: contact_name_value,
|
||||
contact_id: contact&.id,
|
||||
is_outbound: true,
|
||||
account_id: account.id,
|
||||
conference_sid: info[:conference_sid],
|
||||
phone_number: contact&.phone_number, # Include phone number for display in UI
|
||||
avatar_url: contact&.avatar_url, # Include avatar URL for display in UI
|
||||
call_direction: 'outbound' # Add call direction for context
|
||||
}
|
||||
|
||||
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{account.id}",
|
||||
{
|
||||
event: 'incoming_call',
|
||||
data: broadcast_data
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
module Voice
|
||||
class ConversationFinderService
|
||||
pattr_initialize [:account!, :phone_number!, :inbox, :call_sid, :is_outbound]
|
||||
|
||||
def perform
|
||||
# Ensure we have a phone number
|
||||
validate_and_normalize_phone_number
|
||||
|
||||
# First try to find existing conversation by call_sid if available
|
||||
conversation = find_by_call_sid if call_sid.present?
|
||||
return conversation if conversation
|
||||
|
||||
# If not found, create a new conversation
|
||||
create_new_conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_and_normalize_phone_number
|
||||
# Simple validation to ensure we have something to work with
|
||||
raise 'Phone number cannot be blank' if phone_number.blank?
|
||||
|
||||
# Normalize the phone number (strip any whitespace)
|
||||
@phone_number = phone_number.strip
|
||||
end
|
||||
|
||||
def find_by_call_sid
|
||||
account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first
|
||||
end
|
||||
|
||||
def find_or_create_contact
|
||||
# Always find or create a contact based on the phone number
|
||||
account.contacts.find_or_create_by!(phone_number: phone_number) do |c|
|
||||
c.name = "Contact from #{phone_number}"
|
||||
end
|
||||
end
|
||||
|
||||
def create_new_conversation
|
||||
# First ensure we have a contact
|
||||
contact = find_or_create_contact
|
||||
|
||||
# Find or initialize the contact inbox
|
||||
contact_inbox = ContactInbox.find_or_initialize_by(
|
||||
contact_id: contact.id,
|
||||
inbox_id: inbox.id
|
||||
)
|
||||
|
||||
# Set source_id if not set - needed for properly mapping the conversation
|
||||
contact_inbox.source_id ||= phone_number
|
||||
contact_inbox.save!
|
||||
|
||||
# Create the conversation
|
||||
conversation = account.conversations.create!(
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: contact.id, # Explicitly set the contact_id to avoid any validation issues
|
||||
status: :open,
|
||||
additional_attributes: initial_attributes
|
||||
)
|
||||
|
||||
# Need to reload conversation to get the display_id populated by the database
|
||||
conversation.reload
|
||||
|
||||
# Add conference_sid to attributes
|
||||
conference_name = generate_conference_name(conversation)
|
||||
conversation.additional_attributes['conference_sid'] = conference_name
|
||||
conversation.save!
|
||||
|
||||
conversation
|
||||
end
|
||||
|
||||
def initial_attributes
|
||||
attributes = {
|
||||
'call_initiated_at' => Time.now.to_i
|
||||
}
|
||||
|
||||
# Add call_sid if available
|
||||
attributes['call_sid'] = call_sid if call_sid.present?
|
||||
|
||||
# Set call direction based on is_outbound flag
|
||||
if is_outbound
|
||||
attributes['call_direction'] = 'outbound'
|
||||
attributes['call_type'] = 'outbound'
|
||||
|
||||
# For outbound calls, set the requires_agent_join flag
|
||||
# This is used by the CallStatusManager to identify outbound calls
|
||||
attributes['requires_agent_join'] = true
|
||||
else
|
||||
attributes['call_direction'] = 'inbound'
|
||||
attributes['call_type'] = 'inbound'
|
||||
end
|
||||
|
||||
# Add metadata for tracking important timestamps
|
||||
attributes['meta'] = {
|
||||
'initiated_at' => Time.now.to_i
|
||||
}
|
||||
|
||||
attributes
|
||||
end
|
||||
|
||||
def generate_conference_name(conversation)
|
||||
"conf_account_#{account.id}_conv_#{conversation.display_id}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,271 @@
|
||||
module Voice
|
||||
class IncomingCallService
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def process
|
||||
|
||||
begin
|
||||
find_inbox
|
||||
|
||||
return captain_twiml if @inbox.captain_active?
|
||||
|
||||
create_contact
|
||||
|
||||
# Use a transaction to ensure the conversation and voice call message are created together
|
||||
# This ensures the voice call message is created before any auto-assignment activity messages
|
||||
ActiveRecord::Base.transaction do
|
||||
create_conversation
|
||||
create_voice_call_message
|
||||
end
|
||||
|
||||
# Create activity message separately, after the voice call message
|
||||
create_activity_message
|
||||
|
||||
twiml = generate_twiml_response
|
||||
|
||||
twiml
|
||||
rescue StandardError => e
|
||||
# Log the error
|
||||
Rails.logger.error("Error processing incoming call: #{e.message}")
|
||||
|
||||
# Return a simple error TwiML
|
||||
error_twiml(e.message)
|
||||
end
|
||||
end
|
||||
|
||||
def caller_info
|
||||
{
|
||||
call_sid: params['CallSid'],
|
||||
from_number: params['From'],
|
||||
to_number: params['To']
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_twiml
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
|
||||
media_service_url = "#{ENV.fetch('AI_MEDIA_SERVICE_URL', nil)}/incoming-call"
|
||||
|
||||
response.redirect(media_service_url)
|
||||
response.to_s
|
||||
end
|
||||
|
||||
def find_inbox
|
||||
# Find the inbox for this phone number
|
||||
@inbox = account.inboxes
|
||||
.where(channel_type: 'Channel::Voice')
|
||||
.joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
|
||||
.where('channel_voice.phone_number = ?', caller_info[:to_number])
|
||||
.first
|
||||
|
||||
raise "Inbox not found for phone number #{caller_info[:to_number]}" unless @inbox.present?
|
||||
|
||||
end
|
||||
|
||||
def create_contact
|
||||
# Normalize the phone number
|
||||
phone_number = caller_info[:from_number].strip
|
||||
|
||||
# Find or create the contact
|
||||
@contact = account.contacts.find_or_create_by!(phone_number: phone_number) do |c|
|
||||
c.name = "Contact from #{phone_number}"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def create_conversation
|
||||
# Create or update contact inbox
|
||||
@contact_inbox = ContactInbox.find_or_initialize_by(
|
||||
contact_id: @contact.id,
|
||||
inbox_id: @inbox.id
|
||||
)
|
||||
|
||||
# Set source_id if not already set
|
||||
@contact_inbox.source_id ||= caller_info[:from_number]
|
||||
@contact_inbox.save!
|
||||
|
||||
|
||||
# Create a new conversation with basic call details
|
||||
# Status will be properly set by CallStatusManager later
|
||||
@conversation = account.conversations.create!(
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: @contact.id,
|
||||
status: :open,
|
||||
additional_attributes: {
|
||||
'call_sid' => caller_info[:call_sid],
|
||||
'call_direction' => 'inbound',
|
||||
'call_initiated_at' => Time.now.to_i,
|
||||
'call_type' => 'inbound'
|
||||
}
|
||||
)
|
||||
|
||||
# Need to reload conversation to get the display_id populated by the database
|
||||
@conversation.reload
|
||||
|
||||
# Set up conference name
|
||||
conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
|
||||
@conversation.additional_attributes['conference_sid'] = conference_name
|
||||
@conversation.save!
|
||||
|
||||
end
|
||||
|
||||
def create_voice_call_message
|
||||
# Create a single voice call message from contact for this call
|
||||
# Initialize CallStatusManager to get normalized status
|
||||
status_manager = Voice::CallStatus::Manager.new(
|
||||
conversation: @conversation,
|
||||
call_sid: caller_info[:call_sid],
|
||||
provider: :twilio
|
||||
)
|
||||
|
||||
# Get UI-friendly status for consistent display
|
||||
ui_status = status_manager.normalized_ui_status('ringing')
|
||||
|
||||
message_params = {
|
||||
content: 'Voice Call',
|
||||
message_type: 'incoming',
|
||||
content_type: 'voice_call',
|
||||
content_attributes: {
|
||||
data: {
|
||||
call_sid: caller_info[:call_sid],
|
||||
status: ui_status, # Use normalized UI status
|
||||
conversation_id: @conversation.display_id,
|
||||
call_direction: 'inbound',
|
||||
conference_sid: @conversation.additional_attributes['conference_sid'],
|
||||
from_number: caller_info[:from_number],
|
||||
to_number: caller_info[:to_number],
|
||||
meta: {
|
||||
created_at: Time.now.to_i,
|
||||
ringing_at: Time.now.to_i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Create the voice call message only - this ensures it appears first
|
||||
@voice_call_message = Messages::MessageBuilder.new(
|
||||
@contact,
|
||||
@conversation,
|
||||
message_params
|
||||
).perform
|
||||
|
||||
|
||||
# Broadcast call notification
|
||||
broadcast_call_status
|
||||
end
|
||||
|
||||
# Create activity message separately after the voice call message
|
||||
def create_activity_message
|
||||
# Use CallStatusManager for consistency
|
||||
status_manager = Voice::CallStatus::Manager.new(
|
||||
conversation: @conversation,
|
||||
call_sid: caller_info[:call_sid],
|
||||
provider: :twilio
|
||||
)
|
||||
|
||||
# First process ringing status
|
||||
status_manager.process_status_update('ringing', nil, true)
|
||||
|
||||
# Then add a custom message about the incoming call - it will be created without a sender
|
||||
activity_message = status_manager.create_activity_message(
|
||||
"Incoming call from #{@contact.name.presence || caller_info[:from_number]}"
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast_call_status
|
||||
# Get contact name, ensuring we have a valid value
|
||||
contact_name_value = @contact.name.presence || caller_info[:from_number]
|
||||
|
||||
# Create the data payload
|
||||
broadcast_data = {
|
||||
call_sid: caller_info[:call_sid],
|
||||
conversation_id: @conversation.display_id,
|
||||
inbox_id: @inbox.id,
|
||||
inbox_name: @inbox.name,
|
||||
inbox_avatar_url: @inbox.avatar_url,
|
||||
inbox_phone_number: @inbox.channel.phone_number,
|
||||
contact_name: contact_name_value,
|
||||
contact_id: @contact.id,
|
||||
account_id: account.id,
|
||||
phone_number: @contact.phone_number,
|
||||
avatar_url: @contact.avatar_url,
|
||||
call_direction: 'inbound',
|
||||
# CRITICAL: Include the conference_sid
|
||||
conference_sid: @conversation.additional_attributes['conference_sid']
|
||||
}
|
||||
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{account.id}",
|
||||
{
|
||||
event: 'incoming_call',
|
||||
data: broadcast_data
|
||||
}
|
||||
)
|
||||
|
||||
end
|
||||
|
||||
def generate_twiml_response
|
||||
conference_name = @conversation.additional_attributes['conference_sid']
|
||||
Rails.logger.info("📞 IncomingCallService: Generating TwiML with conference name: #{conference_name}")
|
||||
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.')
|
||||
|
||||
# Setup callback URLs - include conference name and speaker_type in transcription URL
|
||||
conference_callback_url = "#{base_url}/api/v1/accounts/#{account.id}/channels/voice/webhooks/conference_status"
|
||||
transcription_url = "#{base_url}/twilio/transcription_callback?account_id=#{account.id}&conference_sid=#{conference_name}&speaker_type=contact&contact_id=#{@contact.id}"
|
||||
|
||||
Rails.logger.info("📞 IncomingCallService: Setting transcription callback to: #{transcription_url}")
|
||||
Rails.logger.info("📞 IncomingCallService: Setting conference callback to: #{conference_callback_url}")
|
||||
|
||||
# Start real-time transcription for this caller's leg
|
||||
response.start do |s|
|
||||
s.transcription(
|
||||
status_callback_url: transcription_url,
|
||||
status_callback_method: 'POST',
|
||||
track: 'inbound_track', # Must be inbound_track or outbound_track per Twilio API
|
||||
language_code: 'en-US'
|
||||
)
|
||||
end
|
||||
|
||||
# Now add the caller to the conference
|
||||
response.dial do |dial|
|
||||
dial.conference(
|
||||
conference_name,
|
||||
startConferenceOnEnter: false,
|
||||
endConferenceOnExit: true,
|
||||
beep: false,
|
||||
muted: false,
|
||||
waitUrl: '',
|
||||
statusCallback: conference_callback_url,
|
||||
statusCallbackMethod: 'POST',
|
||||
statusCallbackEvent: 'start end join leave',
|
||||
participantLabel: "caller-#{caller_info[:call_sid].last(8)}",
|
||||
record: 'record-from-start',
|
||||
recording_status_callback: "#{base_url}/twilio/recording_callback?account_id=#{account.id}&conference_sid=#{conference_name}",
|
||||
recording_status_callback_method: 'POST'
|
||||
)
|
||||
end
|
||||
|
||||
result = response.to_s
|
||||
Rails.logger.info("📞 IncomingCallService: Generated TwiML: #{result}")
|
||||
result
|
||||
end
|
||||
|
||||
def error_twiml(message)
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
response.say(message: 'We are experiencing technical difficulties with our phone system. Please try again later.')
|
||||
response.hangup
|
||||
|
||||
response.to_s
|
||||
end
|
||||
|
||||
def base_url
|
||||
url = ENV.fetch('FRONTEND_URL', "https://#{params['host_with_port']}")
|
||||
url.gsub(%r{/$}, '') # Remove trailing slash if present
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,208 @@
|
||||
module Voice
|
||||
class OutgoingCallService
|
||||
pattr_initialize [:account!, :contact!, :user!]
|
||||
|
||||
def process
|
||||
find_voice_inbox
|
||||
|
||||
# Create conversation and voice message in a single transaction
|
||||
# This ensures the voice call message is created before any auto-assignment activity messages
|
||||
ActiveRecord::Base.transaction do
|
||||
create_conversation
|
||||
initiate_call
|
||||
create_voice_call_message
|
||||
end
|
||||
|
||||
# Add the activity message separately, after the voice call message
|
||||
create_activity_message
|
||||
|
||||
broadcast_to_agent
|
||||
@conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_voice_inbox
|
||||
@voice_inbox = account.inboxes.find_by(channel_type: 'Channel::Voice')
|
||||
raise 'No Voice channel found' if @voice_inbox.blank?
|
||||
raise 'Contact has no phone number' if contact.phone_number.blank?
|
||||
|
||||
end
|
||||
|
||||
def create_conversation
|
||||
# Use the ConversationFinderService to create the conversation
|
||||
@conversation = Voice::ConversationFinderService.new(
|
||||
account: account,
|
||||
phone_number: contact.phone_number,
|
||||
is_outbound: true,
|
||||
inbox: @voice_inbox,
|
||||
call_sid: nil # This will be set after call is initiated
|
||||
).perform
|
||||
|
||||
# Need to reload conversation to get the display_id populated by the database
|
||||
@conversation.reload
|
||||
|
||||
# Log the conversation ID and display_id for debugging
|
||||
Rails.logger.info("🔍 OUTGOING CALL: Created conversation with ID=#{@conversation.id}, display_id=#{@conversation.display_id}")
|
||||
|
||||
# The conference_sid should be set by the ConversationFinderService, but we double-check
|
||||
@conference_name = @conversation.additional_attributes['conference_sid']
|
||||
|
||||
# Verify conference name is valid, if not, fix it
|
||||
if @conference_name.blank? || !@conference_name.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
# Generate proper conference name
|
||||
@conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
|
||||
|
||||
# Store it in the conversation
|
||||
@conversation.additional_attributes['conference_sid'] = @conference_name
|
||||
@conversation.save!
|
||||
|
||||
Rails.logger.info("🔧 OUTGOING CALL: Fixed conference name to #{@conference_name}")
|
||||
else
|
||||
Rails.logger.info("✅ OUTGOING CALL: Using existing conference name #{@conference_name}")
|
||||
end
|
||||
end
|
||||
|
||||
def initiate_call
|
||||
# Double-check that we have a valid conference name before calling
|
||||
if @conference_name.blank? || !@conference_name.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
Rails.logger.error("❌ OUTGOING CALL: Invalid conference name before initiating call: #{@conference_name}")
|
||||
|
||||
# Re-generate the conference name as a last resort
|
||||
@conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
|
||||
Rails.logger.info("🔧 OUTGOING CALL: Re-generated conference name: #{@conference_name}")
|
||||
|
||||
# Update the conversation with the new conference name
|
||||
@conversation.additional_attributes['conference_sid'] = @conference_name
|
||||
@conversation.save!
|
||||
else
|
||||
Rails.logger.info("✅ OUTGOING CALL: Valid conference name: #{@conference_name}")
|
||||
end
|
||||
|
||||
# Log that we're about to initiate the call
|
||||
Rails.logger.info("📞 OUTGOING CALL: Initiating call to #{contact.phone_number} with conference #{@conference_name}")
|
||||
|
||||
# Initiate the call using the channel's implementation
|
||||
@call_details = @voice_inbox.channel.initiate_call(
|
||||
to: contact.phone_number,
|
||||
conference_name: @conference_name,
|
||||
agent_id: user.id # Pass the agent ID to track who initiated the call
|
||||
)
|
||||
|
||||
# Log the returned call details for debugging
|
||||
Rails.logger.info("📞 OUTGOING CALL: Call initiated with details: #{@call_details.inspect}")
|
||||
|
||||
# Update conversation with call details, but don't set status
|
||||
# Status will be properly set by CallStatusManager
|
||||
updated_attributes = @conversation.additional_attributes.merge({
|
||||
'call_sid' => @call_details[:call_sid],
|
||||
'requires_agent_join' => true,
|
||||
'agent_id' => user.id, # Store the agent ID who initiated the call
|
||||
'conference_sid' => @conference_name, # Ensure conference_sid is set correctly
|
||||
'conference_name' => @conference_name, # Add an additional field for backwards compatibility
|
||||
})
|
||||
|
||||
# Ensure the call is marked as outbound
|
||||
updated_attributes['call_direction'] = 'outbound'
|
||||
|
||||
# Save the updated attributes
|
||||
@conversation.update!(additional_attributes: updated_attributes)
|
||||
|
||||
# Log the final conversation state
|
||||
Rails.logger.info("📞 OUTGOING CALL: Conversation updated with call_sid=#{@call_details[:call_sid]}, conference_sid=#{@conference_name}")
|
||||
end
|
||||
|
||||
def create_voice_call_message
|
||||
# Create a voice call message
|
||||
# Initialize CallStatusManager to get normalized status
|
||||
status_manager = Voice::CallStatus::Manager.new(
|
||||
conversation: @conversation,
|
||||
call_sid: @call_details[:call_sid],
|
||||
provider: :twilio
|
||||
)
|
||||
|
||||
# Get UI-friendly status
|
||||
ui_status = status_manager.normalized_ui_status('ringing')
|
||||
|
||||
message_params = {
|
||||
content: 'Voice Call',
|
||||
message_type: 'outgoing',
|
||||
content_type: 'voice_call',
|
||||
content_attributes: {
|
||||
data: {
|
||||
call_sid: @call_details[:call_sid],
|
||||
status: ui_status, # Set the normalized UI status
|
||||
conversation_id: @conversation.display_id,
|
||||
call_direction: 'outbound',
|
||||
conference_sid: @conference_name,
|
||||
from_number: @voice_inbox.channel.phone_number,
|
||||
to_number: contact.phone_number,
|
||||
agent_id: user.id,
|
||||
meta: {
|
||||
created_at: Time.now.to_i,
|
||||
ringing_at: Time.now.to_i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Create just the voice call message - this ensures it appears first in the conversation
|
||||
@widget_message = Messages::MessageBuilder.new(
|
||||
user,
|
||||
@conversation,
|
||||
message_params
|
||||
).perform
|
||||
|
||||
# Update last activity timestamp
|
||||
@conversation.update(last_activity_at: Time.current)
|
||||
end
|
||||
|
||||
# Create the activity message in a separate method
|
||||
def create_activity_message
|
||||
# Initialize the status manager with provider information
|
||||
status_manager = Voice::CallStatus::Manager.new(
|
||||
conversation: @conversation,
|
||||
call_sid: @call_details[:call_sid],
|
||||
provider: :twilio # Specify the provider for accurate messaging
|
||||
)
|
||||
|
||||
# Process first status update with a custom message instead of default
|
||||
# This creates only one activity message
|
||||
custom_message = "Outgoing call to #{contact.name || contact.phone_number}"
|
||||
status_manager.process_status_update('initiated', nil, true, custom_message)
|
||||
end
|
||||
|
||||
def broadcast_to_agent
|
||||
# Get contact name, ensuring we have a valid value
|
||||
contact_name_value = contact.name.presence || contact.phone_number
|
||||
|
||||
# Create the data payload
|
||||
broadcast_data = {
|
||||
call_sid: @call_details[:call_sid],
|
||||
conversation_id: @conversation.display_id,
|
||||
inbox_id: @voice_inbox.id,
|
||||
inbox_name: @voice_inbox.name,
|
||||
inbox_avatar_url: @voice_inbox.avatar_url, # Include inbox avatar
|
||||
inbox_phone_number: @voice_inbox.channel.phone_number, # Include inbox phone number
|
||||
contact_name: contact_name_value,
|
||||
contact_id: contact.id,
|
||||
account_id: account.id,
|
||||
is_outbound: true,
|
||||
conference_sid: @conference_name,
|
||||
requires_agent_join: true,
|
||||
call_direction: 'outbound',
|
||||
phone_number: contact.phone_number, # Include phone number for display in the UI
|
||||
avatar_url: contact.avatar_url # Include avatar URL for display in the UI
|
||||
}
|
||||
|
||||
# Direct notification that agent needs to join
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{account.id}",
|
||||
{
|
||||
event: 'incoming_call',
|
||||
data: broadcast_data
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
module Voice
|
||||
# Validates incoming Twilio webhooks to ensure they are legitimate requests
|
||||
class TwilioValidatorService
|
||||
pattr_initialize [:account!, :params!, :request!]
|
||||
|
||||
def valid?
|
||||
# Skip validation for these cases:
|
||||
return true if skip_validation?
|
||||
|
||||
begin
|
||||
# Find the inbox and get the auth token
|
||||
to_number = params['To']
|
||||
inbox = find_voice_inbox(to_number)
|
||||
|
||||
# Allow callbacks if we can't find the inbox or auth token
|
||||
return true unless inbox
|
||||
return true unless (auth_token = get_auth_token(inbox))
|
||||
|
||||
# Check if we have a signature to validate
|
||||
signature = request.headers['X-Twilio-Signature']
|
||||
return true unless signature.present?
|
||||
|
||||
# Validate the signature
|
||||
validator = Twilio::Security::RequestValidator.new(auth_token)
|
||||
url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}"
|
||||
|
||||
is_valid = validator.validate(url, params.to_unsafe_h, signature)
|
||||
|
||||
# Log validation result
|
||||
if is_valid
|
||||
Rails.logger.info("✅ TWILIO VALIDATION: Valid signature confirmed")
|
||||
else
|
||||
Rails.logger.error("⚠️ TWILIO VALIDATION: Invalid signature for URL: #{url}")
|
||||
return false
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}")
|
||||
return true # Allow on errors for robustness
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_validation?
|
||||
# Skip for OPTIONS requests and in development
|
||||
return true if request.method == "OPTIONS"
|
||||
return true if Rails.env.development?
|
||||
return true if account.blank?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def get_auth_token(inbox)
|
||||
channel = inbox.channel
|
||||
return nil unless channel.is_a?(Channel::Voice)
|
||||
|
||||
provider_config = channel.provider_config_hash
|
||||
provider_config['auth_token'] if provider_config.present?
|
||||
end
|
||||
|
||||
def find_voice_inbox(to_number)
|
||||
return nil if to_number.blank?
|
||||
|
||||
account.inboxes
|
||||
.where(channel_type: 'Channel::Voice')
|
||||
.joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
|
||||
.where('channel_voice.phone_number = ?', to_number)
|
||||
.first
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -31,6 +31,51 @@
|
||||
|
||||
en:
|
||||
hello: 'Hello world'
|
||||
INBOX_MGMT:
|
||||
ADD:
|
||||
TWILIO_VOICE:
|
||||
TITLE: 'Add Twilio Voice Channel'
|
||||
DESC: 'Integrate Twilio Voice to accept and make voice calls through Chatwoot.'
|
||||
PHONE_NUMBER:
|
||||
LABEL: 'Phone Number'
|
||||
PLACEHOLDER: '+1234567890'
|
||||
ERROR: 'Please enter a valid phone number'
|
||||
ACCOUNT_SID:
|
||||
LABEL: 'Account SID'
|
||||
PLACEHOLDER: 'Enter your Twilio Account SID'
|
||||
REQUIRED: 'Account SID is required'
|
||||
AUTH_TOKEN:
|
||||
LABEL: 'Auth Token'
|
||||
PLACEHOLDER: 'Enter your Twilio Auth Token'
|
||||
REQUIRED: 'Auth Token is required'
|
||||
SUBMIT_BUTTON: 'Create Channel'
|
||||
API:
|
||||
ERROR_MESSAGE: 'Failed to create Twilio Voice channel'
|
||||
CONVERSATION:
|
||||
VOICE_CALL: 'Voice Call'
|
||||
CALL_ERROR: 'Failed to initiate voice call'
|
||||
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'
|
||||
VOICE_CALL:
|
||||
RINGING: 'Incoming Call - Join'
|
||||
ACTIVE: 'Call in progress'
|
||||
MISSED: 'Missed Call'
|
||||
ENDED: 'Call Ended'
|
||||
INCOMING_CALL: 'Incoming Call'
|
||||
JOIN_CALL: 'Join'
|
||||
CALL_JOINED: 'Joining call...'
|
||||
JOIN_ERROR: 'Failed to join call. Please try again.'
|
||||
MISSED_CALL: 'Call was not answered'
|
||||
DURATION: 'Duration: %{duration}'
|
||||
INCOMING_FROM: 'Incoming call from %{name}'
|
||||
OUTGOING_FROM: 'Outgoing call from %{name}'
|
||||
CONTACT_PANEL:
|
||||
NEW_MESSAGE: 'New Message'
|
||||
MERGE_CONTACT: 'Merge Contact'
|
||||
messages:
|
||||
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
|
||||
reset_password_failure: Uh ho! We could not find any user with the specified email.
|
||||
|
||||
@@ -97,6 +97,16 @@ Rails.application.routes.draw do
|
||||
resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]
|
||||
namespace :channels do
|
||||
resource :twilio_channel, only: [:create]
|
||||
namespace :voice do
|
||||
# Voice webhooks using resource scope to avoid plural/singular confusion
|
||||
resource :webhooks, only: [], controller: 'webhooks' do
|
||||
collection do
|
||||
post :incoming
|
||||
match :conference_status, via: [:post, :options] # Allow both POST and OPTIONS
|
||||
match :incoming, via: [:post, :options] # Allow both POST and OPTIONS
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
resources :conversations, only: [:index, :create, :show, :update, :destroy] do
|
||||
collection do
|
||||
@@ -111,6 +121,7 @@ Rails.application.routes.draw do
|
||||
post :retry
|
||||
end
|
||||
end
|
||||
post :call, on: :member
|
||||
resources :assignments, only: [:create]
|
||||
resources :labels, only: [:create, :index]
|
||||
resource :participants, only: [:show, :create, :update, :destroy]
|
||||
@@ -159,6 +170,7 @@ Rails.application.routes.draw do
|
||||
resources :contact_inboxes, only: [:create]
|
||||
resources :labels, only: [:create, :index]
|
||||
resources :notes
|
||||
post :call, to: 'calls#create'
|
||||
end
|
||||
end
|
||||
resources :csat_survey_responses, only: [:index] do
|
||||
@@ -182,6 +194,23 @@ Rails.application.routes.draw do
|
||||
post :set_agent_bot, on: :member
|
||||
delete :avatar, on: :member
|
||||
end
|
||||
|
||||
# Voice call management - using resource to avoid plural/singular confusion
|
||||
resource :voice, only: [], controller: 'voice' do
|
||||
member do
|
||||
post :end_call
|
||||
post :join_call
|
||||
post :reject_call
|
||||
get :call_status
|
||||
# Explicitly set the format for TwiML to ensure proper Content-Type headers
|
||||
match :twiml_for_client, via: [:get, :post, :options], defaults: { format: :xml } # Allow GET, POST, and OPTIONS
|
||||
end
|
||||
end
|
||||
|
||||
# Voice call client SDK support
|
||||
namespace :voice do
|
||||
resources :tokens, only: [:create]
|
||||
end
|
||||
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
||||
collection do
|
||||
delete :destroy
|
||||
@@ -497,6 +526,21 @@ Rails.application.routes.draw do
|
||||
namespace :twilio do
|
||||
resources :callback, only: [:create]
|
||||
resources :delivery_status, only: [:create]
|
||||
|
||||
# Transcription webhook
|
||||
post :transcription_callback, to: 'transcription#transcription_callback'
|
||||
|
||||
# Recording webhook
|
||||
post :recording_callback, to: 'recording#recording_callback'
|
||||
|
||||
# Use resource scope to avoid plural/singular confusion
|
||||
resource :voice, only: [], controller: 'voice' do
|
||||
collection do
|
||||
get :simple, action: :simple_twiml
|
||||
post :simple, action: :simple_twiml
|
||||
post :status_callback
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
get 'microsoft/callback', to: 'microsoft/callbacks#show'
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#
|
||||
class Channel::Voice < ApplicationRecord
|
||||
include Channelable
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
self.table_name = 'channel_voice'
|
||||
|
||||
@@ -41,6 +42,18 @@ class Channel::Voice < ApplicationRecord
|
||||
false
|
||||
end
|
||||
|
||||
def initiate_call(to:, conference_name: nil, agent_id: nil)
|
||||
case provider
|
||||
when 'twilio'
|
||||
initiate_twilio_call(to, conference_name, agent_id)
|
||||
# Add more providers as needed
|
||||
# when 'other_provider'
|
||||
# initiate_other_provider_call(to)
|
||||
else
|
||||
raise "Unsupported voice provider: #{provider}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_provider_config
|
||||
@@ -60,5 +73,97 @@ class Channel::Voice < ApplicationRecord
|
||||
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||
end
|
||||
end
|
||||
|
||||
def initiate_twilio_call(to, conference_name = nil, agent_id = nil)
|
||||
config = provider_config_hash
|
||||
|
||||
# Generate a public URL for Twilio to request TwiML (must set FRONTEND_URL)
|
||||
host = ENV.fetch('FRONTEND_URL')
|
||||
|
||||
# Use the simplest possible TwiML endpoint
|
||||
callback_url = "#{host}/twilio/voice/simple"
|
||||
|
||||
# Start building query parameters
|
||||
query_params = []
|
||||
|
||||
# Make sure conference_name is URL-safe and correctly formatted
|
||||
if conference_name.present?
|
||||
# Check format - it should be like 'conf_account_123_conv_456'
|
||||
if !conference_name.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
# If format is wrong, log an error and try to fix it
|
||||
Rails.logger.error("🚨 MALFORMED CONFERENCE NAME: '#{conference_name}'")
|
||||
|
||||
# Try to extract account_id and conversation_id from the string if possible
|
||||
if conference_name.include?('_account_') && conference_name.include?('_conv_')
|
||||
# It has the parts but wrong format, let's try to keep it
|
||||
Rails.logger.info("🔄 Using conference name as-is: '#{conference_name}'")
|
||||
else
|
||||
# Can't salvage it, generate a placeholder with timestamp to avoid collisions
|
||||
timestamp = Time.now.to_i
|
||||
Rails.logger.warn("🚨 GENERATING PLACEHOLDER CONFERENCE NAME with timestamp #{timestamp}")
|
||||
conference_name = "conf_placeholder_#{timestamp}"
|
||||
end
|
||||
else
|
||||
# Format looks good, continue
|
||||
Rails.logger.info("✅ VALIDATED CONFERENCE NAME: '#{conference_name}'")
|
||||
end
|
||||
|
||||
# Add URL-encoded conference name as a parameter
|
||||
query_params << "conference_name=#{CGI.escape(conference_name)}"
|
||||
else
|
||||
# No conference name provided, log this as a warning
|
||||
Rails.logger.warn("⚠️ NO CONFERENCE NAME PROVIDED for outgoing call to #{to}")
|
||||
end
|
||||
|
||||
# Add agent ID as a parameter if provided
|
||||
query_params << "agent_id=#{agent_id}" if agent_id.present?
|
||||
|
||||
# Append query parameters to URL if any exist
|
||||
if query_params.any?
|
||||
callback_url += "?#{query_params.join('&')}"
|
||||
Rails.logger.info("📞 OUTBOUND CALL: Using callback URL with params: #{callback_url}")
|
||||
end
|
||||
|
||||
# Parameters including status callbacks for call progress tracking
|
||||
params = {
|
||||
from: phone_number,
|
||||
to: to,
|
||||
url: callback_url,
|
||||
status_callback: "#{host}/twilio/voice/status_callback",
|
||||
status_callback_event: %w[initiated ringing answered completed failed busy no-answer canceled],
|
||||
status_callback_method: 'POST'
|
||||
}
|
||||
|
||||
# Log the full parameters for debugging
|
||||
Rails.logger.info("📞 OUTBOUND CALL PARAMS: to=#{to}, from=#{phone_number}, conference=#{conference_name}")
|
||||
|
||||
# Create the call
|
||||
call = twilio_client(config).calls.create(**params)
|
||||
|
||||
# Return info needed to properly route and track the call
|
||||
{
|
||||
provider: 'twilio',
|
||||
call_sid: call.sid,
|
||||
status: call.status,
|
||||
call_direction: 'outbound', # CRITICAL: Tag as outbound so webhooks know to prompt agent
|
||||
requires_agent_join: true, # Flag that agent should join immediately
|
||||
agent_id: agent_id, # Include agent_id for tracking who initiated the call
|
||||
conference_name: conference_name # Include the conference name in the return value for debugging
|
||||
}
|
||||
end
|
||||
|
||||
def twilio_client(config)
|
||||
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
||||
end
|
||||
|
||||
def provider_config_hash
|
||||
if provider_config.is_a?(Hash)
|
||||
provider_config
|
||||
else
|
||||
JSON.parse(provider_config.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
public :provider_config_hash
|
||||
end
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@sindresorhus/slugify": "2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
"@twilio/voice-sdk": "^2.12.4",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vue/compiler-sfc": "^3.5.8",
|
||||
"@vuelidate/core": "^2.0.3",
|
||||
|
||||
Generated
+51
@@ -70,6 +70,9 @@ importers:
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.20.5
|
||||
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
|
||||
'@twilio/voice-sdk':
|
||||
specifier: ^2.12.4
|
||||
version: 2.12.4
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4(vite@5.4.19(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||
@@ -1746,6 +1749,13 @@ packages:
|
||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@twilio/voice-errors@1.7.0':
|
||||
resolution: {integrity: sha512-9TvniWpzU0iy6SYFAcDP+HG+/mNz2yAHSs7+m0DZk86lE+LoTB6J/ZONTPuxXrXWi4tso/DulSHuA0w7nIQtGg==}
|
||||
|
||||
'@twilio/voice-sdk@2.12.4':
|
||||
resolution: {integrity: sha512-zP2lXl8ciWogTfBEc6pGVAeSvJ/zectX6guu8U1MRa3ZKauLr899JMoVkgGMgJUNFI4vxEi6vacWV4uL7KdnnQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
'@types/estree@1.0.7':
|
||||
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
|
||||
|
||||
@@ -1764,6 +1774,9 @@ packages:
|
||||
'@types/markdown-it@12.2.3':
|
||||
resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==}
|
||||
|
||||
'@types/md5@2.3.2':
|
||||
resolution: {integrity: sha512-v+JFDu96+UYJ3/UWzB0mEglIS//MZXgRaJ4ubUPwOM0gvLc/kcQ3TWNYwENEK7/EcXGQVrW8h/XqednSjBd/Og==}
|
||||
|
||||
'@types/mdurl@2.0.0':
|
||||
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||
|
||||
@@ -2815,6 +2828,10 @@ packages:
|
||||
eventemitter3@5.0.1:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
execa@7.2.0:
|
||||
resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==}
|
||||
engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0}
|
||||
@@ -3538,6 +3555,10 @@ packages:
|
||||
resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
loglevel@1.6.7:
|
||||
resolution: {integrity: sha512-cY2eLFrQSAfVPhCgH1s7JI73tMbg9YC3v3+ZHVW67sBS7UxWzNEk/ZBbSfLykBWHp33dqqtOv82gjhKEi81T/A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
loupe@3.1.3:
|
||||
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
|
||||
|
||||
@@ -4358,6 +4379,10 @@ packages:
|
||||
rrweb-cssom@0.7.1:
|
||||
resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==}
|
||||
|
||||
rtcpeerconnection-shim@1.2.8:
|
||||
resolution: {integrity: sha512-5Sx90FGru1sQw9aGOM+kHU4i6mbP8eJPgxliu2X3Syhg8qgDybx8dpDTxUwfJvPnubXFnZeRNl59DWr4AttJKQ==}
|
||||
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
@@ -4398,6 +4423,9 @@ packages:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
sdp@2.12.0:
|
||||
resolution: {integrity: sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw==}
|
||||
|
||||
sdp@3.2.0:
|
||||
resolution: {integrity: sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==}
|
||||
|
||||
@@ -6744,6 +6772,17 @@ snapshots:
|
||||
|
||||
'@tootallnate/once@2.0.0': {}
|
||||
|
||||
'@twilio/voice-errors@1.7.0': {}
|
||||
|
||||
'@twilio/voice-sdk@2.12.4':
|
||||
dependencies:
|
||||
'@twilio/voice-errors': 1.7.0
|
||||
'@types/md5': 2.3.2
|
||||
events: 3.3.0
|
||||
loglevel: 1.6.7
|
||||
md5: 2.3.0
|
||||
rtcpeerconnection-shim: 1.2.8
|
||||
|
||||
'@types/estree@1.0.7': {}
|
||||
|
||||
'@types/flexsearch@0.7.6': {}
|
||||
@@ -6761,6 +6800,8 @@ snapshots:
|
||||
'@types/linkify-it': 5.0.0
|
||||
'@types/mdurl': 2.0.0
|
||||
|
||||
'@types/md5@2.3.2': {}
|
||||
|
||||
'@types/mdurl@2.0.0': {}
|
||||
|
||||
'@types/node@22.7.0':
|
||||
@@ -8082,6 +8123,8 @@ snapshots:
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
execa@7.2.0:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
@@ -8923,6 +8966,8 @@ snapshots:
|
||||
strip-ansi: 7.1.0
|
||||
wrap-ansi: 8.1.0
|
||||
|
||||
loglevel@1.6.7: {}
|
||||
|
||||
loupe@3.1.3: {}
|
||||
|
||||
lower-case@2.0.2:
|
||||
@@ -9798,6 +9843,10 @@ snapshots:
|
||||
|
||||
rrweb-cssom@0.7.1: {}
|
||||
|
||||
rtcpeerconnection-shim@1.2.8:
|
||||
dependencies:
|
||||
sdp: 2.12.0
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -9853,6 +9902,8 @@ snapshots:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
sdp@2.12.0: {}
|
||||
|
||||
sdp@3.2.0: {}
|
||||
|
||||
section-matter@1.0.0:
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user