Compare commits

...
Author SHA1 Message Date
Sojan JoseandGitHub 64df27d8c9 Update .gitignore 2025-07-09 03:21:16 -07:00
Sojan Jose bf4a596726 Merge develop into feat/voice-channel
- Resolved merge conflicts in Vue components
- Updated color scheme to use new n-slate tokens
- Preserved voice channel functionality including isPrivateNoteOnly
- Removed files that were deleted in develop
- Fixed ESLint errors in App.vue
- Fixed SCSS imports in CollaboratorsPage.vue
- Fixed duplicate voice channel entries in ChannelList and ChannelItem
- Moved voice channel to end of channel list
- Integrated with new component structure
2025-07-09 02:42:18 -07:00
Sojan JoseandGitHub bb1a4fc466 Merge branch 'develop' into feat/voice-channel 2025-06-26 22:13:17 -07:00
SojanandClaude 4343ebde59 fix: restore missing functionality to enterprise voice model
Add back critical methods that were missing from enterprise voice model:
- initiate_call: Core functionality for making calls
- initiate_twilio_call: Twilio-specific call logic with conference support
- twilio_client: Twilio client initialization
- provider_config_hash: Config parsing utilities
- Rails routes inclusion for URL helpers

This ensures the enterprise model has complete functionality from the original.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 16:21:09 -07:00
SojanandClaude 9d95576f21 chore: remove redundant voice model
Remove app/models/channel/voice.rb as we now use the enterprise version from develop branch after merge.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 16:16:06 -07:00
SojanandClaude 3c7c460fb9 chore: remove duplicate voice channel migration
- Remove db/migrate/20250426130000_create_channel_voice.rb (older duplicate)
- Keep db/migrate/20250620120000_create_channel_voice.rb from develop

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 16:11:25 -07:00
SojanandClaude bd84d1b17e Merge develop into feat/voice-channel
Resolved conflicts:
- Voice.vue: Keep modern composition API version from develop
- ChannelItem.vue: Add voice channel feature flag check
- inboxes_controller.rb: Use allowed_channel_types method
- schema.rb: Add account_id index for voice channels
- inboxMgmt.json: Fix voice channel translations
- Note: Voice channel logic in base files removed in develop (moved to enterprise)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 16:04:27 -07:00
Sojan bca048fc69 chore: add phone number 2025-06-20 01:14:32 -07:00
Sojan 43b952d486 chore: clean up 2025-06-20 00:13:20 -07:00
Sojan e58f60c27b chore: changes 2025-06-20 00:09:05 -07:00
Sojan 759fe0d3f6 Merge develop into feat/voice-channel
Resolved merge conflicts in:
- MessagesView.vue: Combined VoiceTimelineView component from voice channel feature with component list updates from develop
- actionCable.js: Merged call event handlers (incoming_call, call_status_changed) with copilot message event handler from develop
- message.rb: Combined voice call helper methods with send_update_event method from develop

All conflicts resolved while preserving functionality from both branches.
2025-06-20 00:02:50 -07:00
Sojan JoseandGitHub ce6489b485 Merge branch 'develop' into feat/voice-channel 2025-05-27 16:49:02 -07:00
Sojan b8187ed8a7 chore: transcription recording now working 2025-05-13 20:19:41 -07:00
Sojan b3af194894 chore: more clean up 2025-05-13 16:52:38 -07:00
Sojan 1e9180d3cd chore: disable read status for voice channel 2025-05-13 04:05:01 -07:00
Sojan e06525181b chore: Clean up and add transcriptions 2025-05-13 03:50:41 -07:00
Sojan 3d29962969 chore: clean up 2025-05-11 15:57:17 -07:00
Sojan 7328e636ac Merge branch 'develop' into feat/voice-channel
# Conflicts:
#	db/schema.rb
2025-05-11 01:52:56 -07:00
Sojan e2e68868d5 chore: clean code 2025-05-11 01:49:24 -07:00
Sojan 670cf689f5 chore: clean up twilio tokens controller 2025-05-11 01:25:40 -07:00
Sojan ccdbc2c7f9 chore: new call button 2025-05-11 00:35:32 -07:00
Sojan aa4ef28e0e chore: fixes 2025-05-09 20:47:03 -07:00
Sojan 2879a0cd42 chore: new floating widget design 2025-05-08 03:42:47 -07:00
Sojan ce468bac01 chore: more fixes 2025-05-07 22:00:39 -07:00
Sojan 2b0c154bc5 chore: dummy reports page 2025-05-07 03:47:34 -07:00
Sojan ebabb69048 chore: call routing dummy setting 2025-05-07 03:10:46 -07:00
Sojan 5b77618a43 chore: voice debug component 2025-05-07 03:04:51 -07:00
Sojan d6dd8efe46 chore: add sender option for bot campaigns 2025-05-06 00:46:55 -07:00
Sojan 74cd639574 chore: voice call campaigns 2025-05-05 23:50:34 -07:00
Sojan 414daff4f1 chore: minor fixes 2025-05-04 21:05:19 -07:00
Sojan 4e8a39f358 chore: fix conversation list status 2025-05-04 18:07:16 -07:00
Sojan 5dc1735f69 chore: random clean up 2025-05-04 09:17:03 -07:00
Sojan 2f7c8f6cfc chore: Clean up some code andn simplify 2025-05-04 08:47:14 -07:00
Sojan e8d3679aba chore: stop auto resoluving voice conversations 2025-05-04 07:36:25 -07:00
Sojan c9daf70655 chore: message previews in conversation list 2025-05-04 06:02:51 -07:00
Sojan 4ea22f7f36 chore: voice call components working correctly 2025-05-04 05:08:55 -07:00
Sojan 4348c4ab87 chore: clean up voice message components 2025-05-04 05:00:20 -07:00
Sojan 196d7afccf Merge branch 'develop' into feat/voice-channel 2025-05-03 02:10:18 -07:00
Sojan 4c48a565f6 chore: code cleanup 2025-05-03 02:09:34 -07:00
Sojan 3692cde1a9 chore: inbound and outbound calls that work 2025-05-02 02:41:38 -07:00
Sojan 3f0c01e166 chore: floating button for incoming call 2025-04-29 03:45:08 -07:00
Sojan 4c579bc71e fix: incoming call functionality in voice webhooks controller - Fix polymorphic association query for finding voice inbox - Fix contact and conversation creation with proper associations - Remove unnecessary verify_authenticity_token skip - Ensure proper validation and error handling for contact creation 2025-04-28 02:16:57 -07:00
Sojan a7ff808d01 chore: floating call button 2025-04-28 01:08:03 -07:00
Sojan 8d660df4c4 feat: Add voice channel support for Chatwoot 2025-04-27 00:48:05 -07:00
99 changed files with 11665 additions and 172 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+9 -2
View File
@@ -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
+13 -10
View File
@@ -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 dialin
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
+189
View File
@@ -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
+1
View File
@@ -15,6 +15,7 @@ class AsyncDispatcher < BaseDispatcher
CsatSurveyListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
MessageListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
ReportingEventListener.instance,
+2 -1
View File
@@ -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
+122
View File
@@ -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"
@@ -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>
@@ -37,7 +37,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
id: 1,
name: 'Jamie Lee',
},
message: 'Hello! 👋 Any questions on pricing? Im 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! 👋 Im here for any questions you may have. Lets 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! Were 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',
}
];
@@ -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>
@@ -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)"
/>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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"
@@ -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"
@@ -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);
+16 -5
View File
@@ -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 {
+7 -8
View File
@@ -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 = () => {
@@ -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>
+2
View File
@@ -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',
};
+11 -7
View File
@@ -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
+17
View File
@@ -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
+1
View File
@@ -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'
+2 -1
View File
@@ -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
View File
@@ -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
+526
View File
@@ -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
+271
View File
@@ -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
+208
View File
@@ -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
+45
View File
@@ -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.
+44
View File
@@ -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'
+105
View File
@@ -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
+1
View File
@@ -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",
+51
View File
@@ -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.