chore: floating call button

This commit is contained in:
Sojan
2025-04-28 01:08:03 -07:00
parent 8d660df4c4
commit a7ff808d01
28 changed files with 2424 additions and 155 deletions

View File

@@ -1,4 +1,5 @@
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
require 'securerandom'
before_action :fetch_contact before_action :fetch_contact
def create def create
@@ -16,12 +17,37 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
end end
begin begin
# Initiate the call using the channel's implementation # Create a new conversation for this call
voice_inbox.channel.initiate_call(to: @contact.phone_number)
# Create a new conversation for this call if needed
conversation = find_or_create_conversation(voice_inbox) conversation = find_or_create_conversation(voice_inbox)
# Initiate the call using the channel's implementation - this returns the call details
call_details = voice_inbox.channel.initiate_call(to: @contact.phone_number)
# Create a message for this call with call details
params = {
content: "Outgoing voice call initiated to #{@contact.phone_number}",
message_type: :activity,
additional_attributes: call_details,
source_id: call_details[:call_sid] # Use call SID as source_id
}
message = Messages::MessageBuilder.new(Current.user, conversation, params).perform
# Make sure the conversation has the latest activity timestamp
conversation.update(last_activity_at: Time.current)
# Store call SID and status for front-end
conversation.update!(additional_attributes: (conversation.additional_attributes || {}).merge(call_details))
# Broadcast the conversation and message to the appropriate ActionCable channels
ActionCableBroadcastJob.perform_later(
conversation.account_id,
'conversation.created',
conversation.push_event_data.merge(
message: message.push_event_data,
status: 'open'
)
)
render json: conversation render json: conversation
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Error initiating call: #{e.message}") Rails.logger.error("Error initiating call: #{e.message}")
@@ -40,11 +66,19 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
if conversation.nil? || !conversation.open? if conversation.nil? || !conversation.open?
# Find or create a contact_inbox for this contact and inbox # Find or create a contact_inbox for this contact and inbox
contact_inbox = ContactInbox.find_or_create_by!( contact_inbox = ContactInbox.find_or_initialize_by(
contact_id: @contact.id, contact_id: @contact.id,
inbox_id: inbox.id inbox_id: inbox.id
) )
# Set the source_id if it's a new record
if contact_inbox.new_record?
# For voice channels, use the phone number as the source_id
contact_inbox.source_id = @contact.phone_number
end
contact_inbox.save!
conversation = ::Conversation.create!( conversation = ::Conversation.create!(
account_id: Current.account.id, account_id: Current.account.id,
inbox_id: inbox.id, inbox_id: inbox.id,
@@ -54,12 +88,13 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
) )
# Add a note about the call being initiated # Add a note about the call being initiated
Messages::MessageBuilder.new( params = {
user: Current.user,
conversation: conversation,
message_type: :activity, message_type: :activity,
content: "Voice call initiated to #{@contact.phone_number}" content: "Voice call initiated to #{@contact.phone_number}",
).perform source_id: "voice_call_#{SecureRandom.uuid}" # Generate a unique source_id
}
Messages::MessageBuilder.new(Current.user, conversation, params).perform
end end
conversation conversation

View File

@@ -0,0 +1,94 @@
class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:end_call, :call_status]
def end_call
call_sid = params[:call_sid] || @conversation.additional_attributes&.dig('call_sid')
return render json: { error: 'No active call found' }, status: :not_found unless call_sid
# Get the inbox and channel information
inbox = @conversation.inbox
channel = inbox&.channel
if channel.is_a?(Channel::Voice) && channel.provider == 'twilio'
config = channel.provider_config_hash
begin
# Create a Twilio client and end the call
client = Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
call = client.calls(call_sid).fetch
# Only try to end the call if it's still in progress
if call.status == 'in-progress' || call.status == 'ringing'
client.calls(call_sid).update(status: 'completed')
# Update conversation call status
@conversation.additional_attributes ||= {}
@conversation.additional_attributes['call_status'] = 'completed'
@conversation.save!
# Create an activity message noting the call has ended
Messages::MessageBuilder.new(
nil,
@conversation,
{
content: 'Call ended by agent',
message_type: :activity,
additional_attributes: {
call_sid: call_sid,
call_status: 'completed',
ended_by: current_user.name
}
}
).perform
render json: { status: 'success', message: 'Call successfully ended' }
else
render json: { status: 'success', message: "Call already in '#{call.status}' state" }
end
rescue Twilio::REST::RestError => e
render json: { error: "Failed to end call: #{e.message}" }, status: :internal_server_error
end
else
render json: { error: 'Unsupported channel provider for call control' }, status: :unprocessable_entity
end
end
def call_status
call_sid = @conversation.additional_attributes&.dig('call_sid')
return render json: { error: 'No call found' }, status: :not_found unless call_sid
# Get the inbox and channel information
inbox = @conversation.inbox
channel = inbox&.channel
if channel.is_a?(Channel::Voice) && channel.provider == 'twilio'
config = channel.provider_config_hash
begin
# Create a Twilio client and fetch the call status
client = Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
call = client.calls(call_sid).fetch
render json: {
status: call.status,
duration: call.duration,
direction: call.direction,
from: call.from,
to: call.to,
start_time: call.start_time,
end_time: call.end_time
}
rescue Twilio::REST::RestError => e
render json: { error: "Failed to fetch call status: #{e.message}" }, status: :internal_server_error
end
else
render json: { error: 'Unsupported channel provider for call status' }, status: :unprocessable_entity
end
end
private
def fetch_conversation
@conversation = Current.account.conversations.find(params[:id] || params[:conversation_id])
end
end

View File

@@ -0,0 +1,437 @@
class Twilio::VoiceController < ActionController::Base
skip_forgery_protection
def twiml
# ULTRA minimal TwiML - just a simple greeting and record
response = Twilio::TwiML::VoiceResponse.new
# Just a simple message about recent signup and feedback
response.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup. We would love to hear any feedback or questions you might have about your experience so far. Please share your thoughts after the beep.')
# Record their feedback
response.record(
action: '/twilio/voice/handle_recording',
method: 'POST',
maxLength: 30,
timeout: 2,
statusCallback: '/twilio/voice/status_callback',
statusCallbackMethod: 'POST',
statusCallbackEvent: ['completed']
)
# Always end the call to avoid any complexity
response.hangup
# Render the response immediately
render xml: response.to_s, status: :ok
end
def handle_user_input
call_sid = params['CallSid']
digits = params['Digits']
speech_result = params['SpeechResult']
from_number = params['From']
to_number = params['To']
direction = params['Direction']
is_outbound = direction == 'outbound-api'
# Find the inbox for this voice call based on the direction
inbox = find_inbox(is_outbound ? from_number : to_number)
if inbox.present?
# Create or find the conversation for this call
conversation = find_or_create_conversation(inbox, is_outbound ? to_number : from_number, call_sid)
# Create an activity message showing the user input
input_text = if digits.present?
"Caller pressed #{digits}"
elsif speech_result.present?
"Caller said: \"#{speech_result}\""
else
"Caller responded"
end
Messages::MessageBuilder.new(
nil,
conversation,
{
content: input_text,
message_type: :activity,
additional_attributes: {
call_sid: call_sid,
call_status: 'in-progress',
user_input: true
}
}
).perform
end
# Redirect back to the main TwiML to continue the call flow
response = Twilio::TwiML::VoiceResponse.new do |r|
r.redirect(url: "/twilio/voice/twiml?ReturnCall=true&Direction=#{direction.to_s}&step=check_messages")
end
render xml: response.to_s, status: :ok
end
def handle_recording
call_sid = params['CallSid']
from_number = params['From']
to_number = params['To']
recording_url = params['RecordingUrl']
recording_sid = params['RecordingSid']
direction = params['Direction']
# Determine if outbound call
is_outbound = direction == 'outbound-api'
# Find inbox and save recording if available
if recording_url.present? && call_sid.present?
inbox_number = is_outbound ? from_number : to_number
inbox = find_inbox(inbox_number)
if inbox.present?
contact_number = is_outbound ? to_number : from_number
conversation = find_or_create_conversation(inbox, contact_number, call_sid)
contact = conversation.contact
# Create a message with the recording
if contact.present?
begin
message_params = {
content: 'Feedback about recent signup',
message_type: :incoming,
additional_attributes: {
call_sid: call_sid,
recording_url: recording_url,
recording_sid: recording_sid
}
}
message = Messages::MessageBuilder.new(contact, conversation, message_params).perform
# Download and attach the recording if we have a valid URL
if message.present? && recording_url.present?
begin
# Validate that the recording URL is accessible
uri = URI.parse(recording_url)
if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only create an attachment if we have a valid Twilio recording URL
# Twilio recording URL format: https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Recordings/{RecordingSid}
if recording_url.present? && recording_url.include?('/Recordings/') && recording_sid.present?
# Get authentication details from the channel config to access the recording
config = inbox.channel.provider_config_hash
account_sid = config['account_sid']
auth_token = config['auth_token']
# Create an authenticated URL that includes auth details
# This is needed because Twilio recording URLs require authentication
recording_mp3_url = "#{recording_url}.mp3"
begin
# Create an attachment record with proper file type for audio
attachment = message.attachments.new(
file_type: :audio, # Use audio type for proper player rendering
account_id: inbox.account_id,
external_url: recording_mp3_url,
fallback_title: 'Voice Recording',
meta: {
recording_sid: recording_sid,
twilio_account_sid: account_sid,
auth_required: true
}
)
# Save the attachment
if attachment.save
Rails.logger.info("Successfully attached voice recording from #{recording_url}")
else
Rails.logger.error("Failed to save attachment: #{attachment.errors.full_messages.join(', ')}")
end
rescue => e
Rails.logger.error("Failed to handle recording: #{e.message}")
# If the audio attachment fails, try with a more generic file type
begin
fallback_attachment = message.attachments.new(
file_type: :file,
account_id: inbox.account_id,
external_url: recording_mp3_url,
fallback_title: 'Voice Recording (.mp3)',
meta: {
recording_sid: recording_sid,
twilio_account_sid: account_sid,
auth_required: true
}
)
fallback_attachment.save
rescue => e
Rails.logger.error("Failed to create fallback attachment: #{e.message}")
end
end
else
Rails.logger.error("Invalid Twilio recording URL format or missing SID: #{recording_url}")
end
else
Rails.logger.error("Invalid recording URL format: #{recording_url}")
end
rescue => e
# Log error but continue
Rails.logger.error("Error processing recording: #{e.message}")
end
end
# Loop recording until caller hangs up
response = Twilio::TwiML::VoiceResponse.new
response.say(message: 'Segment recorded. Please leave more feedback after the beep, or hang up to finish.')
response.record(
action: '/twilio/voice/handle_recording',
method: 'POST',
maxLength: 30,
timeout: 2,
playBeep: true,
statusCallback: '/twilio/voice/status_callback',
statusCallbackMethod: 'POST',
statusCallbackEvent: ['completed', 'in-progress', 'absent']
)
render xml: response.to_s, status: :ok
rescue => e
# Log the error but don't crash
Rails.logger.error("Error processing recording: #{e.message}")
end
end
end
end
end
def transcription_callback
# Process the transcription asynchronously
if params['CallSid'].present?
# Queue the processing as a background job
CallTranscriptionJob.perform_later(params.permit!.to_h)
end
# Return an empty TwiML response to satisfy Twilio
response = Twilio::TwiML::VoiceResponse.new
render xml: response.to_s, status: :ok
end
# This endpoint will be called by Twilio's StatusCallback
# parameter to notify of call status changes
def status_callback
call_sid = params['CallSid']
call_status = params['CallStatus']
direction = params['Direction']
is_outbound = direction == 'outbound-api'
from_number = params['From']
to_number = params['To']
Rails.logger.info("Twilio status callback: CallSid=#{call_sid}, Status=#{call_status}, Direction=#{direction}")
# Find the inbox
inbox = find_inbox(is_outbound ? from_number : to_number)
if inbox.present?
# Find or create the conversation
conversation = find_or_create_conversation(inbox, is_outbound ? to_number : from_number, call_sid)
# Add activity for the status change
track_call_activity(conversation, call_status, false, is_outbound)
# If call is completed/failed, update conversation status and notify frontend
if ['completed', 'busy', 'failed', 'no-answer', 'canceled'].include?(call_status)
# Update conversation with call status
conversation.additional_attributes ||= {}
conversation.additional_attributes['call_status'] = call_status
conversation.additional_attributes['call_ended_at'] = Time.now.to_i
conversation.status = :resolved
conversation.save!
# Publish update to frontend via ActionCable
ActionCable.server.broadcast(
"#{conversation.account_id}_#{conversation.inbox_id}",
{
event_name: 'call_status_changed',
data: {
call_sid: call_sid,
status: call_status,
conversation_id: conversation.id
}
}
)
# Create an activity message for call ending if it's a user hangup
if call_status == 'completed'
end_reason = params['CallDuration'] ? 'Call ended by hangup' : 'Call ended'
call_duration = params['CallDuration'] ? params['CallDuration'].to_i : nil
Messages::MessageBuilder.new(
nil,
conversation,
{
content: end_reason,
message_type: :activity,
additional_attributes: {
call_sid: call_sid,
call_status: call_status,
call_direction: is_outbound ? 'outbound' : 'inbound',
call_duration: call_duration
}
}
).perform
end
end
end
# Return an empty response
head :ok
end
# Simple TwiML with signup follow-up message
def simple_twiml
call_sid = params['CallSid']
from_number = params['From']
to_number = params['To']
direction = params['Direction']
# Determine if outbound call
is_outbound = direction == 'outbound-api'
response = Twilio::TwiML::VoiceResponse.new
# The signup follow-up message
response.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup. We would love to hear any feedback or questions you might have about your experience so far. Please share your thoughts after the beep.')
# Record their feedback
response.record(
action: '/twilio/voice/handle_recording',
method: 'POST',
maxLength: 60,
timeout: 3,
statusCallback: '/twilio/voice/status_callback',
statusCallbackMethod: 'POST',
statusCallbackEvent: ['completed', 'in-progress', 'absent']
)
# End the call
response.hangup
# If we have call details, log them for the conversation
if call_sid.present?
# Find the inbox for this voice call
inbox_number = is_outbound ? from_number : to_number
inbox = find_inbox(inbox_number)
if inbox.present?
# Create or find conversation
contact_number = is_outbound ? to_number : from_number
conversation = find_or_create_conversation(inbox, contact_number, call_sid)
# Add call activity message
track_call_activity(conversation, 'in-progress', true, is_outbound)
end
end
render xml: response.to_s, status: :ok
end
private
def find_inbox(phone_number)
Inbox.joins("INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id")
.where("channel_voice.phone_number = ?", phone_number)
.first
end
def find_or_create_conversation(inbox, phone_number, call_sid)
account = inbox.account
# Reuse if existing conversation for this call SID
existing = account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first
return existing if existing
# Ensure contact and inbox
contact = account.contacts.find_or_create_by(phone_number: phone_number) do |c|
c.name = "Contact from #{phone_number}"
end
contact_inbox = ContactInbox.find_or_initialize_by(contact_id: contact.id, inbox_id: inbox.id)
contact_inbox.source_id ||= phone_number
contact_inbox.save!
# Create new conversation for this call
convo = account.conversations.create!(contact_inbox_id: contact_inbox.id, inbox_id: inbox.id, status: :open)
convo.additional_attributes = { 'call_sid' => call_sid, 'call_status' => 'in-progress' }
convo.save!
convo
end
def track_call_activity(conversation, call_status, is_first_response, is_outbound)
return unless conversation.present?
# Only create status messages when status changes or on first response
prev_status = conversation.additional_attributes&.dig('call_status')
return if !is_first_response && prev_status == call_status
# Update conversation with call status
conversation.additional_attributes ||= {}
conversation.additional_attributes['call_status'] = call_status
conversation.save!
# Create an appropriate activity message based on status
activity_message = case call_status
when 'ringing'
is_outbound ? 'Outbound call initiated' : 'Phone ringing'
when 'in-progress'
if is_first_response
is_outbound ? 'Call connected' : 'Call answered'
else
'Call in progress'
end
when 'completed', 'busy', 'failed', 'no-answer', 'canceled'
"Call #{call_status}"
else
"Call status: #{call_status}"
end
Messages::MessageBuilder.new(
nil,
conversation,
{
content: activity_message,
message_type: :activity,
additional_attributes: {
call_sid: conversation.additional_attributes&.dig('call_sid'),
call_status: call_status,
call_direction: is_outbound ? 'outbound' : 'inbound'
}
}
).perform
end
def get_one_message(call_sid)
redis_key = "voice_message:#{call_sid}"
# Get just one message
redis_message = Redis::Alfred.lpop(redis_key)
return nil unless redis_message.present?
begin
message = JSON.parse(redis_message)
return message
rescue JSON::ParserError => e
Rails.logger.error("Failed to parse voice message from Redis: #{e.message}")
return nil
end
end
def mark_message_delivered(message_id)
# Find the message
message = Message.find_by(id: message_id)
return unless message.present?
# Update the message delivery status
additional_attributes = message.additional_attributes || {}
additional_attributes[:voice_delivery_status] = 'delivered'
message.update(additional_attributes: additional_attributes)
end
end

View File

@@ -15,6 +15,7 @@ class AsyncDispatcher < BaseDispatcher
CsatSurveyListener.instance, CsatSurveyListener.instance,
HookListener.instance, HookListener.instance,
InstallationWebhookListener.instance, InstallationWebhookListener.instance,
MessageListener.instance,
NotificationListener.instance, NotificationListener.instance,
ParticipationListener.instance, ParticipationListener.instance,
ReportingEventListener.instance, ReportingEventListener.instance,

View File

@@ -6,6 +6,7 @@ import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import FloatingCallWidget from './components/widgets/FloatingCallWidget.vue';
import vueActionCable from './helper/actionCable'; import vueActionCable from './helper/actionCable';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
@@ -14,6 +15,8 @@ import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper'; import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { useFontSize } from 'dashboard/composables/useFontSize'; import { useFontSize } from 'dashboard/composables/useFontSize';
import { useAlert } from 'dashboard/composables';
import VoiceAPI from 'dashboard/api/channels/voice';
import { import {
registerSubscription, registerSubscription,
verifyServiceWorkerExistence, verifyServiceWorkerExistence,
@@ -25,6 +28,7 @@ export default {
components: { components: {
AddAccountModal, AddAccountModal,
FloatingCallWidget,
LoadingState, LoadingState,
NetworkNotification, NetworkNotification,
UpdateBanner, UpdateBanner,
@@ -51,6 +55,7 @@ export default {
showAddAccountModal: false, showAddAccountModal: false,
latestChatwootVersion: null, latestChatwootVersion: null,
reconnectService: null, reconnectService: null,
showCallWidget: false, // Set to true for testing, false for production
}; };
}, },
computed: { computed: {
@@ -60,6 +65,8 @@ export default {
currentUser: 'getCurrentUser', currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags', authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags', accountUIFlags: 'accounts/getUIFlags',
activeCall: 'calls/getActiveCall',
hasActiveCall: 'calls/hasActiveCall',
}), }),
hasAccounts() { hasAccounts() {
const { accounts = [] } = this.currentUser || {}; const { accounts = [] } = this.currentUser || {};
@@ -86,6 +93,13 @@ export default {
}, },
}, },
mounted() { mounted() {
// Make app instance available globally for debugging and cross-component access
window.app = this;
// Set up global force end call mechanism
window.forceEndCall = () => this.forceEndCall();
window.forceEndCallHandlers = [];
this.initializeColorTheme(); this.initializeColorTheme();
this.listenToThemeChanges(); this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale); this.setLocale(window.chatwootConfig.selectedLocale);
@@ -106,6 +120,84 @@ export default {
setLocale(locale) { setLocale(locale) {
this.$root.$i18n.locale = locale; this.$root.$i18n.locale = locale;
}, },
handleCallEnded() {
console.log('Call ended event received in App.vue');
// Update our local state first for immediate UI update
this.showCallWidget = false;
// Then update the store
this.$store.dispatch('calls/clearActiveCall');
},
// Public method that can be called from anywhere
forceEndCall() {
console.log('Force end call triggered in App.vue');
// 1. Update UI immediately
this.showCallWidget = false;
// 2. Try to notify any other components
if (window.forceEndCallHandlers) {
window.forceEndCallHandlers.forEach(handler => {
try {
handler();
} catch (e) {
console.error('Error in end call handler:', e);
}
});
}
// 3. CRITICAL: Make API call to actually end the call on the server
if (this.activeCall && this.activeCall.callSid) {
const { callSid, conversationId } = this.activeCall;
// Save references before clearing the store
const savedCallSid = callSid;
const savedConversationId = conversationId;
// Now clear the store
this.$store.dispatch('calls/clearActiveCall');
// Make API call if we have a conversation ID
if (savedConversationId) {
console.log(
'App.vue making API call to end call with SID:',
savedCallSid,
'for conversation:',
savedConversationId
);
// Make the API call to end the call on the server with both parameters
VoiceAPI.endCall(savedCallSid, savedConversationId)
.then(response => {
console.log('Call ended successfully via API:', response);
useAlert({ message: 'Call ended successfully', type: 'success' });
})
.catch(error => {
console.error('Error ending call via API:', error);
// If first attempt fails, try one more time with additional logging
console.log('Retrying end call with more debugging...');
setTimeout(() => {
VoiceAPI.endCall(savedCallSid, savedConversationId)
.then(retryResponse => {
console.log('Retry successful:', retryResponse);
})
.catch(retryError => {
console.error('Retry also failed:', retryError);
});
}, 1000);
useAlert({ message: 'Call UI has been reset', type: 'info' });
});
} else {
console.log('App.vue: Not making API call because conversation ID is missing');
useAlert({ message: 'Call ended', type: 'success' });
}
} else {
// No active call data, just clear the store
this.$store.dispatch('calls/clearActiveCall');
}
},
async initializeAccount() { async initializeAccount() {
await this.$store.dispatch('accounts/get'); await this.$store.dispatch('accounts/get');
this.$store.dispatch('setActiveAccount', { this.$store.dispatch('setActiveAccount', {
@@ -153,6 +245,15 @@ export default {
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" /> <AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
<WootSnackbarBox /> <WootSnackbarBox />
<NetworkNotification /> <NetworkNotification />
<!-- Floating call widget that appears during active calls -->
<FloatingCallWidget
v-if="showCallWidget || (activeCall && activeCall.callSid)"
:key="`call-${Date.now()}`"
:call-sid="activeCall ? activeCall.callSid : 'test-call'"
:inbox-name="activeCall ? (activeCall.inboxName || 'Primary') : 'Primary'"
:conversation-id="activeCall ? activeCall.conversationId : null"
@call-ended="handleCallEnded"
/>
</div> </div>
<LoadingState v-else /> <LoadingState v-else />
</template> </template>

View File

@@ -16,6 +16,63 @@ class VoiceAPI extends ApiClient {
`/api/v1/accounts/${accountId}/contacts/${contactId}/call` `/api/v1/accounts/${accountId}/contacts/${contactId}/call`
); );
} }
// End an active call
endCall(callSid, conversationId) {
if (!conversationId) {
console.error('VoiceAPI: Cannot end call - conversation ID is required');
return Promise.reject(
new Error('Conversation ID is required to end a call')
);
}
if (!callSid) {
console.error('VoiceAPI: Cannot end call - call SID is required');
return Promise.reject(new Error('Call SID is required to end a call'));
}
// Validate call SID format - Twilio call SID starts with 'CA' followed by alphanumeric characters
if (!callSid.startsWith('CA') && !callSid.startsWith('TJ')) {
console.error('VoiceAPI: Invalid call SID format:', callSid);
return Promise.reject(
new Error(
'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.'
)
);
}
// Get the account ID from the current URL
const accountId = this.accountIdFromRoute;
console.log(
`VoiceAPI: Ending call with SID ${callSid} for conversation ${conversationId} in account ${accountId}`
);
// Make the actual API call with conversation ID as a parameter
// Using the route structure that matches the Rails routes.rb definition
return axios
.post(`/api/v1/accounts/${accountId}/voice/end_call`, {
call_sid: callSid,
conversation_id: conversationId,
id: conversationId, // Also include as 'id' as the controller may check for it
})
.then(response => {
console.log('VoiceAPI: End call API succeeded:', response);
return response;
})
.catch(error => {
console.error('VoiceAPI: End call API failed:', error);
throw error;
});
}
// Get call status
getCallStatus(callSid) {
// Get the account ID from the current URL
const accountId = this.accountIdFromRoute;
return axios.get(`/api/v1/accounts/${accountId}/voice/call_status`, {
params: { call_sid: callSid },
});
}
} }
export default new VoiceAPI(); export default new VoiceAPI();

View File

@@ -272,11 +272,11 @@ defineExpose({
class="w-full" class="w-full"
@input=" @input="
isValidationField(item.key) && isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch() v$[getValidationKey(item.key)].$touch()
" "
@blur=" @blur="
isValidationField(item.key) && isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch() v$[getValidationKey(item.key)].$touch()
" "
/> />
</template> </template>

View File

@@ -108,7 +108,13 @@ const onCardClick = e => {
v-tooltip.left="inboxName" v-tooltip.left="inboxName"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5" class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
> >
<!-- Special handling for voice channel -->
<span
v-if="inbox.channelType === 'Channel::Voice'"
class="i-ph-phone-fill text-n-slate-11 size-3"
/>
<Icon <Icon
v-else
:icon="inboxIcon" :icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-3" class="flex-shrink-0 text-n-slate-11 size-3"
/> />

View File

@@ -60,38 +60,47 @@ const filteredAttrs = computed(() => {
const computedVariant = computed(() => { const computedVariant = computed(() => {
if (props.variant) return props.variant; if (props.variant) return props.variant;
// The useAttrs method returns attributes values an empty string (not boolean value as in props). // The useAttrs method returns attributes values an empty string (not boolean value as in props).
if (attrs.solid || attrs.solid === '') return 'solid'; // Add defensive checks for undefined attrs
if (attrs.outline || attrs.outline === '') return 'outline'; const attrObj = attrs || {};
if (attrs.faded || attrs.faded === '') return 'faded'; if (attrObj.solid || attrObj.solid === '') return 'solid';
if (attrs.link || attrs.link === '') return 'link'; if (attrObj.outline || attrObj.outline === '') return 'outline';
if (attrs.ghost || attrs.ghost === '') return 'ghost'; if (attrObj.faded || attrObj.faded === '') return 'faded';
if (attrObj.link || attrObj.link === '') return 'link';
if (attrObj.ghost || attrObj.ghost === '') return 'ghost';
return 'solid'; // Default variant return 'solid'; // Default variant
}); });
const computedColor = computed(() => { const computedColor = computed(() => {
if (props.color) return props.color; if (props.color) return props.color;
if (attrs.blue || attrs.blue === '') return 'blue'; // Add defensive checks for undefined attrs
if (attrs.ruby || attrs.ruby === '') return 'ruby'; const attrObj = attrs || {};
if (attrs.amber || attrs.amber === '') return 'amber'; if (attrObj.blue || attrObj.blue === '') return 'blue';
if (attrs.slate || attrs.slate === '') return 'slate'; if (attrObj.ruby || attrObj.ruby === '') return 'ruby';
if (attrs.teal || attrs.teal === '') return 'teal'; if (attrObj.amber || attrObj.amber === '') return 'amber';
if (attrObj.slate || attrObj.slate === '') return 'slate';
if (attrObj.green || attrObj.green === '') return 'green';
if (attrObj.teal || attrObj.teal === '') return 'teal';
return 'blue'; // Default color return 'blue'; // Default color
}); });
const computedSize = computed(() => { const computedSize = computed(() => {
if (props.size) return props.size; if (props.size) return props.size;
if (attrs.xs || attrs.xs === '') return 'xs'; // Add defensive checks for undefined attrs
if (attrs.sm || attrs.sm === '') return 'sm'; const attrObj = attrs || {};
if (attrs.md || attrs.md === '') return 'md'; if (attrObj.xs || attrObj.xs === '') return 'xs';
if (attrs.lg || attrs.lg === '') return 'lg'; if (attrObj.sm || attrObj.sm === '') return 'sm';
if (attrObj.md || attrObj.md === '') return 'md';
if (attrObj.lg || attrObj.lg === '') return 'lg';
return 'md'; return 'md';
}); });
const computedJustify = computed(() => { const computedJustify = computed(() => {
if (props.justify) return props.justify; if (props.justify) return props.justify;
if (attrs.start || attrs.start === '') return 'start'; // Add defensive checks for undefined attrs
if (attrs.center || attrs.center === '') return 'center'; const attrObj = attrs || {};
if (attrs.end || attrs.end === '') return 'end'; if (attrObj.start || attrObj.start === '') return 'start';
if (attrObj.center || attrObj.center === '') return 'center';
if (attrObj.end || attrObj.end === '') return 'end';
return 'center'; return 'center';
}); });
@@ -141,6 +150,17 @@ const STYLE_CONFIG = {
ghost: ghost:
'text-n-slate-12 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent', 'text-n-slate-12 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
}, },
green: {
solid:
'bg-green-600 text-white hover:enabled:bg-green-700 focus-visible:bg-green-700 outline-transparent',
faded:
'bg-green-600/10 text-green-700 hover:enabled:bg-green-600/20 focus-visible:bg-green-600/20 outline-transparent',
outline:
'text-green-700 hover:enabled:bg-green-600/10 focus-visible:bg-green-600/10 outline-green-600',
ghost:
'text-green-700 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-green-700 hover:enabled:underline focus-visible:underline outline-transparent',
},
teal: { teal: {
solid: solid:
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent', 'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',

View File

@@ -16,7 +16,9 @@ defineOptions({
}); });
const timeStampURL = computed(() => { const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl); // Safely access the URL, providing a fallback if not available
const url = attachment?.dataUrl || attachment?.data_url || '';
return timeStampAppendedURL(url);
}); });
const audioPlayer = useTemplateRef('audioPlayer'); const audioPlayer = useTemplateRef('audioPlayer');
@@ -91,8 +93,17 @@ const changePlaybackSpeed = () => {
}; };
const downloadAudio = async () => { const downloadAudio = async () => {
const { fileType, dataUrl, extension } = attachment; // Get the URL with fallback options
downloadFile({ url: dataUrl, type: fileType, extension }); const url = attachment?.dataUrl || attachment?.data_url || '';
if (!url) {
console.error('No valid URL found for download');
return;
}
const fileType = attachment?.fileType || attachment?.file_type || 'file';
const extension = attachment?.extension || 'mp3';
downloadFile({ url, type: fileType, extension });
}; };
</script> </script>

View File

@@ -30,9 +30,9 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.toggle-button { .toggle-button {
@apply bg-slate-200 dark:bg-slate-600; @apply bg-slate-200 dark:bg-slate-600;
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px, --toggle-button-box-shadow:
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgb(255, 255, 255) 0px 0px 0px 0px, rgba(59, 130, 246, 0.5) 0px 0px 0px 0px,
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px; rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
border: 2px solid transparent; border: 2px solid transparent;
cursor: pointer; cursor: pointer;

View File

@@ -0,0 +1,521 @@
<script>
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channels/voice';
export default {
name: 'FloatingCallWidget',
props: {
callSid: {
type: String,
default: '',
},
inboxName: {
type: String,
default: 'Primary',
},
conversationId: {
type: [Number, String],
default: null,
},
},
emits: ['call-ended'],
setup(props, { emit }) {
const store = useStore();
const { t } = useI18n();
const callDuration = ref(0);
const durationTimer = ref(null);
const isCallActive = ref(!!props.callSid);
const isMuted = ref(false);
const showCallOptions = ref(false);
const isFullscreen = ref(false);
// Define local fallback translations in case i18n fails
const translations = {
'CONVERSATION.END_CALL': 'End call',
'CONVERSATION.CALL_ENDED': 'Call ended',
'CONVERSATION.CALL_END_ERROR': 'Failed to end call',
};
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
const startDurationTimer = () => {
console.log('Starting duration timer');
if (durationTimer.value) clearInterval(durationTimer.value);
durationTimer.value = setInterval(() => {
callDuration.value += 1;
}, 1000);
};
const stopDurationTimer = () => {
if (durationTimer.value) {
clearInterval(durationTimer.value);
durationTimer.value = null;
}
};
// Emergency force end call function - simpler and more direct
const forceEndCall = () => {
console.log('FORCE END CALL triggered from floating widget');
// Try all methods to ensure call ends
// 1. Local component state
stopDurationTimer();
isCallActive.value = false;
// Save the call data before potential reset
const savedCallSid = props.callSid;
const savedConversationId = props.conversationId;
// 2. First, make direct API call if we have a valid call SID and conversation ID
if (savedConversationId && savedCallSid && savedCallSid !== 'pending') {
// Check if it's a valid Twilio call SID (starts with CA or TJ)
const isValidTwilioSid =
savedCallSid.startsWith('CA') || savedCallSid.startsWith('TJ');
if (isValidTwilioSid) {
console.log(
'FloatingCallWidget: Making direct API call to end Twilio call with SID:',
savedCallSid,
'for conversation:',
savedConversationId
);
// Use the direct API call without using global method
VoiceAPI.endCall(savedCallSid, savedConversationId)
.then(response => {
console.log(
'FloatingCallWidget: Call ended successfully via API:',
response
);
})
.catch(error => {
console.error(
'FloatingCallWidget: Error ending call via API:',
error
);
});
} else {
console.log(
'FloatingCallWidget: Invalid Twilio call SID format:',
savedCallSid
);
}
} else if (savedCallSid === 'pending') {
console.log(
'FloatingCallWidget: Call was still in pending state, no API call needed'
);
} else if (!savedConversationId) {
console.log(
'FloatingCallWidget: No conversation ID available for ending call'
);
} else {
console.log('FloatingCallWidget: Missing required data for API call');
}
// 3. Also use global method to update UI states
if (window.forceEndCall) {
console.log('Using global forceEndCall method');
window.forceEndCall();
}
// Fallbacks if global method not available
// 4. Force App state update directly
if (window.app) {
console.log('Forcing app state update');
window.app.$data.showCallWidget = false;
}
// 5. Emit event
emit('call-ended');
// 6. Update store - using store from setup scope
store.dispatch('calls/clearActiveCall');
// 7. User feedback
useAlert({ message: 'Call ended', type: 'success' });
};
// Original more careful implementation
const endCall = async () => {
console.log('Attempting to end call with SID:', props.callSid);
// First, always hide the UI for immediate feedback
stopDurationTimer();
isCallActive.value = false;
// Force update the app's state
if (typeof window !== 'undefined' && window.app && window.app.$data) {
window.app.$data.showCallWidget = false;
}
// Emit the event to parent components
emit('call-ended');
// Show success message to user
useAlert({ message: 'Call ended', type: 'success' });
// Now try the API call (after UI is updated)
try {
// Skip actual API call if it's a test or temp call SID
if (
props.callSid &&
!props.callSid.startsWith('test-') &&
!props.callSid.startsWith('temp-') &&
!props.callSid.startsWith('debug-')
) {
console.log('Ending real call with SID:', props.callSid);
await VoiceAPI.endCall(props.callSid);
} else {
console.log('Using fake/temp call SID, skipping API call');
}
} catch (error) {
console.error('Error in API call to end call:', error);
// Don't show error to user since UI is already updated
}
// Clear from store as last step
const store = useStore();
store.dispatch('calls/clearActiveCall');
};
const toggleMute = () => {
// This would typically connect to Twilio's mute functionality
// For now we'll just toggle the state
isMuted.value = !isMuted.value;
useAlert({
message: isMuted.value ? 'Call muted' : 'Call unmuted',
type: 'info',
});
// In a real implementation, you'd call Twilio's API to mute the call
// Example: window.twilioDevice.activeConnection().mute(isMuted.value);
};
const toggleCallOptions = () => {
showCallOptions.value = !showCallOptions.value;
};
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
// Would typically adjust UI accordingly
};
// Explicit debug handler for end call click
const handleEndCallClick = () => {
console.log('END CALL BUTTON CLICKED in FloatingCallWidget');
console.log(
'Current call SID:',
props.callSid,
'Conversation ID:',
props.conversationId
);
// Save the call data before UI updates
const savedCallSid = props.callSid;
const savedConversationId = props.conversationId;
// Always update UI immediately for better user experience
stopDurationTimer();
isCallActive.value = false;
// Update app state
if (window.app) {
window.app.$data.showCallWidget = false;
}
// Update store
store.dispatch('calls/clearActiveCall');
// Emit event
emit('call-ended');
// Make API call if we have a valid conversation ID and a real call SID (not pending)
if (savedConversationId && savedCallSid && savedCallSid !== 'pending') {
// Check if it's a valid Twilio call SID (starts with CA or TJ)
const isValidTwilioSid =
savedCallSid.startsWith('CA') || savedCallSid.startsWith('TJ');
if (isValidTwilioSid) {
console.log(
'handleEndCallClick: Making API call to end Twilio call with SID:',
savedCallSid,
'for conversation:',
savedConversationId
);
// Make the API call after UI is updated
VoiceAPI.endCall(savedCallSid, savedConversationId)
.then(response => {
console.log(
'handleEndCallClick: Call ended successfully via API:',
response
);
useAlert({ message: 'Call ended', type: 'success' });
})
.catch(error => {
console.error(
'handleEndCallClick: Error ending call via API:',
error
);
useAlert({
message: 'Call ended (but server may still show as active)',
type: 'warning',
});
});
} else {
console.log(
'handleEndCallClick: Invalid Twilio call SID format:',
savedCallSid
);
useAlert({ message: 'Call ended', type: 'success' });
}
} else {
if (savedCallSid === 'pending') {
console.log(
'handleEndCallClick: Call was still in pending state, no API call needed'
);
} else if (!savedConversationId) {
console.log(
'handleEndCallClick: No conversation ID available for ending call'
);
} else {
console.log('handleEndCallClick: Missing required data for API call');
}
useAlert({ message: 'Call ended', type: 'success' });
}
};
// Safe translation helper with fallback
const safeTranslate = key => {
try {
return t(key);
} catch (error) {
return translations[key] || key;
}
};
onMounted(() => {
console.log('FloatingCallWidget mounted with callSid:', props.callSid);
// Always start the timer, regardless of callSid
startDurationTimer();
});
onBeforeUnmount(() => {
stopDurationTimer();
});
// Watch for call SID changes
watch(
() => props.callSid,
newCallSid => {
isCallActive.value = !!newCallSid;
if (newCallSid) {
startDurationTimer();
} else {
stopDurationTimer();
}
}
);
return {
isCallActive,
callDuration,
formattedCallDuration,
isMuted,
showCallOptions,
isFullscreen,
endCall,
forceEndCall,
handleEndCallClick,
toggleMute,
toggleCallOptions,
toggleFullscreen,
safeTranslate,
};
},
};
</script>
<template>
<div class="floating-call-widget">
<div class="call-info">
<span class="inbox-name">{{ inboxName }}</span>
<span class="call-duration">{{ formattedCallDuration }}</span>
</div>
<div class="call-controls">
<button
class="control-button mute-button"
:class="{ active: isMuted }"
:disabled="callSid === 'pending'"
@click="toggleMute"
>
<span :class="isMuted ? 'i-ph-microphone-slash' : 'i-ph-microphone'" />
</button>
<button
class="control-button end-call-button"
title="End Call"
@click.prevent.stop="handleEndCallClick"
>
<span class="i-ph-phone-x" />
</button>
<div v-if="callSid === 'pending'" class="status-indicator">
Connecting...
</div>
<button
v-else
class="control-button settings-button"
@click="toggleCallOptions"
>
<span class="i-ph-dots-three" />
</button>
</div>
<div v-if="showCallOptions" class="call-options">
<button @click="toggleFullscreen">
{{ isFullscreen ? 'Minimize Call' : 'Expand Call' }}
</button>
<!-- Add more call options as needed -->
</div>
</div>
</template>
<style lang="scss" scoped>
.floating-call-widget {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #1f2937;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 12px 16px;
z-index: 10000;
display: flex;
flex-direction: column;
min-width: 220px;
color: white;
.call-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.inbox-name {
font-weight: 500;
}
.call-duration {
font-variant-numeric: tabular-nums;
}
}
.call-controls {
display: flex;
justify-content: space-around;
gap: 8px;
.control-button {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
cursor: pointer;
background: #374151;
color: white;
font-size: 18px;
&:hover {
background: #4b5563;
}
&.active {
background: #2563eb;
}
&.end-call-button {
background: #dc2626;
&:hover {
background: #b91c1c;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background: #374151;
}
}
}
.status-indicator {
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
font-size: 12px;
font-weight: 500;
background: #2563eb;
border-radius: 16px;
padding: 0 12px;
color: white;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% {
opacity: 0.6;
}
50% {
opacity: 1;
}
100% {
opacity: 0.6;
}
}
}
.call-options {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
button {
display: block;
width: 100%;
text-align: left;
padding: 6px 0;
background: transparent;
border: none;
color: white;
cursor: pointer;
&:hover {
color: #e5e7eb;
}
}
}
}
</style>

View File

@@ -22,7 +22,13 @@ export default {
<div <div
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5" class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
> >
<!-- Use i-ph- icons for phone specifically, and FluentIcon for others -->
<span
v-if="inbox.channel_type === 'Channel::Voice'"
class="mr-0.5 rtl:ml-0.5 rtl:mr-0 i-ph-phone text-sm"
></span>
<fluent-icon <fluent-icon
v-else
class="mr-0.5 rtl:ml-0.5 rtl:mr-0" class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
:icon="computedInboxClass" :icon="computedInboxClass"
size="12" size="12"

View File

@@ -110,12 +110,23 @@ export const hasValidAvatarUrl = avatarUrl => {
}; };
export const timeStampAppendedURL = dataUrl => { export const timeStampAppendedURL = dataUrl => {
const url = new URL(dataUrl); try {
if (!url.searchParams.has('t')) { // Make sure the URL is valid before trying to construct it
url.searchParams.append('t', Date.now()); if (!dataUrl || typeof dataUrl !== 'string') {
} return '';
}
return url.toString(); const url = new URL(dataUrl);
if (!url.searchParams.has('t')) {
url.searchParams.append('t', Date.now());
}
return url.toString();
} catch (error) {
// If URL construction fails, just return the original URL
console.error('Invalid URL in timeStampAppendedURL:', error);
return dataUrl || '';
}
}; };
export const getHostNameFromURL = url => { export const getHostNameFromURL = url => {

View File

@@ -72,7 +72,7 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.TWILIO: case INBOX_TYPES.TWILIO:
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms'; return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
case INBOX_TYPES.VOICE: case INBOX_TYPES.VOICE:
return 'voice'; return 'voice';
@@ -111,7 +111,7 @@ export const getInboxClassByType = (type, phoneNumber) => {
return phoneNumber?.startsWith('whatsapp') return phoneNumber?.startsWith('whatsapp')
? 'brand-whatsapp' ? 'brand-whatsapp'
: 'brand-sms'; : 'brand-sms';
case INBOX_TYPES.VOICE: case INBOX_TYPES.VOICE:
return 'phone'; return 'phone';

View File

@@ -382,6 +382,9 @@
"VOICE_CALL": "Call", "VOICE_CALL": "Call",
"CALL_ERROR": "Failed to initiate call. Please try again.", "CALL_ERROR": "Failed to initiate call. Please try again.",
"CALL_INITIATED": "Call initiated successfully.", "CALL_INITIATED": "Call initiated successfully.",
"END_CALL": "End call",
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback",
"TRANSCRIPTION": "Call transcription",
"COPILOT": { "COPILOT": {
"TRY_THESE_PROMPTS": "Try these prompts" "TRY_THESE_PROMPTS": "Try these prompts"
}, },

View File

@@ -0,0 +1,337 @@
<script>
import { computed, ref, onMounted, onBeforeUnmount } from 'vue';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import VoiceAPI from 'dashboard/api/channels/voice';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useAccount } from 'dashboard/composables/useAccount';
import actionCableService from 'dashboard/helper/actionCable';
export default {
name: 'CallManager',
components: {
NextButton,
},
props: {
conversation: {
type: Object,
default: null,
},
},
emits: ['callEnded'],
setup(props, { emit }) {
const store = useStore();
const { accountId } = useAccount();
const callStatus = ref('');
const callSid = ref('');
const callDuration = ref(0);
const recordingUrl = ref('');
const transcription = ref('');
const durationTimer = ref(null);
const isCallActive = computed(
() => callStatus.value && callStatus.value !== 'completed'
);
const callStatusText = computed(() => {
switch (callStatus.value) {
case 'queued':
return 'Call queued';
case 'ringing':
return 'Phone ringing...';
case 'in-progress':
return 'Call in progress';
case 'completed':
return 'Call completed';
case 'failed':
return 'Call failed';
case 'busy':
return 'Phone was busy';
case 'no-answer':
return 'No answer';
default:
return 'Call initiated';
}
});
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
const startDurationTimer = () => {
if (durationTimer.value) clearInterval(durationTimer.value);
durationTimer.value = setInterval(() => {
callDuration.value += 1;
}, 1000);
};
const stopDurationTimer = () => {
if (durationTimer.value) {
clearInterval(durationTimer.value);
durationTimer.value = null;
}
};
const updateCallStatus = status => {
callStatus.value = status;
if (status === 'in-progress') {
startDurationTimer();
} else if (
status === 'completed' ||
status === 'failed' ||
status === 'busy' ||
status === 'no-answer'
) {
stopDurationTimer();
emit('callEnded');
}
};
const endCall = async () => {
if (!callSid.value) return;
try {
await VoiceAPI.endCall(callSid.value, props.conversation.id);
updateCallStatus('completed');
useAlert('Call ended', 'success');
} catch (error) {
useAlert('Failed to end call. Please try again.', 'error');
}
};
const setupCall = () => {
// If there's an active conversation, check for call details
if (props.conversation) {
const messages = props.conversation.messages || [];
// Find the most recent call activity message
const callMessage = messages.find(
message =>
message.message_type === 10 && // activity message
message.additional_attributes?.call_sid
);
if (callMessage) {
const attrs = callMessage.additional_attributes;
callSid.value = attrs.call_sid;
updateCallStatus(attrs.status || 'initiated');
if (attrs.recording_url) {
recordingUrl.value = attrs.recording_url;
}
// Find transcription if available
const transcriptionMessage = messages.find(
message =>
message.message_type === 0 && // incoming message
message.additional_attributes?.is_transcription
);
if (transcriptionMessage) {
transcription.value = transcriptionMessage.content;
}
}
}
};
// Setup WebSocket listener for call status updates
const setupWebSocket = () => {
if (!props.conversation) return;
try {
// Set up ActionCable to listen for call status changes
if (accountId.value && props.conversation?.inbox_id) {
const roomName = `${accountId.value}_${props.conversation.inbox_id}`;
console.log(
`Setting up ActionCable listener for call status in room: ${roomName}`
);
// Setup ActionCable connection and handler for call status events
actionCableService.createConsumer();
actionCableService.addRoom(roomName);
const handleCallStatusChanged = ({ event_name, data }) => {
// Only handle call_status_changed events
if (event_name !== 'call_status_changed') return;
console.log('Received call status change via ActionCable:', data);
// Only update if it's for our current call
if (data.call_sid === callSid.value) {
console.log(
`Updating call status from ${callStatus.value} to ${data.status}`
);
updateCallStatus(data.status);
// If call is completed, refresh the conversation to get any recordings
if (data.status === 'completed' || data.status === 'canceled') {
// Notify parent component that call has ended
emit('callEnded');
// Refresh the conversation to get updated messages with recordings
if (props.conversation?.id) {
store.dispatch('fetchConversation', {
id: props.conversation.id,
});
}
}
}
};
// Register for events
actionCableService.onReceivedMessage = handleCallStatusChanged;
}
// Also set up store watcher as backup method
if (
store.state.conversations &&
store.state.conversations.conversations
) {
const unwatch = store.watch(
state => {
if (!props.conversation || !props.conversation.id) return null;
const conversations = state.conversations?.conversations || {};
const conv = conversations[props.conversation.id];
return conv ? conv.messages : null;
},
messages => {
if (!messages) return;
try {
// Check for call status messages
const callMessage = messages.find(
message =>
message.message_type === 10 && // activity message
message.additional_attributes &&
message.additional_attributes.call_sid === callSid.value
);
if (callMessage?.additional_attributes) {
if (callMessage.additional_attributes.call_status) {
updateCallStatus(
callMessage.additional_attributes.call_status
);
}
if (callMessage.additional_attributes.recording_url) {
recordingUrl.value =
callMessage.additional_attributes.recording_url;
}
}
// Check for transcription messages
const transcriptionMessage = messages.find(
message =>
message.message_type === 0 && // incoming message
message.additional_attributes &&
message.additional_attributes.is_transcription
);
if (transcriptionMessage) {
transcription.value = transcriptionMessage.content;
}
} catch (err) {
console.error('Error processing message updates:', err);
}
}
);
// Clean up the watcher when component is unmounted
onBeforeUnmount(() => {
if (unwatch) {
unwatch();
}
});
} else {
console.warn('Conversations store not found or initialized');
}
} catch (error) {
console.error('Error setting up message watcher:', error);
}
};
onMounted(() => {
// Wrap in try/catch to prevent Vue errors if there's an issue
try {
if (props.conversation && props.conversation.id) {
// Proceed with setup, using props.conversation.messages or default inside setupCall
setupCall();
setupWebSocket();
}
} catch (error) {
console.error('Error in CallManager mounted:', error);
}
});
onBeforeUnmount(() => {
stopDurationTimer();
// Clean up the ActionCable connection
if (accountId.value && props.conversation?.inbox_id) {
try {
const roomName = `${accountId.value}_${props.conversation.inbox_id}`;
actionCableService.removeRoom(roomName);
} catch (error) {
console.error('Error cleaning up ActionCable:', error);
}
}
});
return {
callStatus,
callStatusText,
isCallActive,
recordingUrl,
transcription,
callDuration,
formattedCallDuration,
endCall,
};
},
};
</script>
<template>
<div
v-show="isCallActive && callStatus"
v-if="isCallActive && callStatus"
class="relative p-4 mb-4 border border-solid rounded-md bg-n-slate-1 border-n-slate-4 flex flex-col gap-2"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-red-600 animate-pulse i-ph-phone-call text-xl" />
<h3 class="mb-0 text-base font-medium">{{ callStatusText }}</h3>
</div>
<div class="flex items-center gap-2">
<div v-if="callDuration" class="text-sm text-n-slate-9">
{{ formattedCallDuration }}
</div>
<NextButton
v-tooltip.top-end="$t('CONVERSATION.END_CALL')"
icon="i-ph-phone-x"
sm
ruby
@click.stop.prevent="endCall"
/>
</div>
</div>
<div v-if="recordingUrl" class="w-full mt-2">
<audio controls class="w-full h-10">
<source :src="recordingUrl" type="audio/mpeg" />
{{ $t('CONVERSATION.AUDIO_NOT_SUPPORTED') }}
</audio>
</div>
<div
v-if="transcription"
class="mt-2 p-2 border border-solid rounded bg-n-slate-2 border-n-slate-5 text-sm"
>
<h4 class="mb-1 text-xs font-semibold text-n-slate-10">
{{ $t('CONVERSATION.TRANSCRIPTION') }}
</h4>
<p class="m-0">{{ transcription }}</p>
</div>
</div>
</template>

View File

@@ -13,6 +13,7 @@ import ComposeConversation from 'dashboard/components-next/NewConversation/Compo
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import VoiceAPI from 'dashboard/api/channels/voice'; import VoiceAPI from 'dashboard/api/channels/voice';
import CallManager from './CallManager.vue';
import { import {
isAConversationRoute, isAConversationRoute,
@@ -30,6 +31,7 @@ export default {
ComposeConversation, ComposeConversation,
SocialIcons, SocialIcons,
ContactMergeModal, ContactMergeModal,
CallManager,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
props: { props: {
@@ -55,6 +57,8 @@ export default {
showMergeModal: false, showMergeModal: false,
showDeleteModal: false, showDeleteModal: false,
isCallLoading: false, isCallLoading: false,
activeCallConversation: null,
isHoveringCallButton: false,
}; };
}, },
computed: { computed: {
@@ -144,18 +148,255 @@ export default {
}, },
async initiateVoiceCall() { async initiateVoiceCall() {
if (!this.contact || !this.contact.id) return; if (!this.contact || !this.contact.id) return;
this.isCallLoading = true; this.isCallLoading = true;
try { try {
const response = await VoiceAPI.initiateCall(this.contact.id); const response = await VoiceAPI.initiateCall(this.contact.id);
useAlert('Call initiated successfully', 'success'); const conversation = response.data;
// First set local state for immediate UI update
this.activeCallConversation = conversation;
console.log('Call initiated, conversation data:', conversation);
// Always create a call SID even if it's not in the response
let callSid = conversation?.call_sid;
// If not directly available, try to find it in messages
if (!callSid) {
const messages = conversation?.messages || [];
const callMessage = messages.find(
message =>
message.message_type === 10 &&
message.additional_attributes &&
message.additional_attributes.call_sid
);
callSid = callMessage?.additional_attributes?.call_sid;
}
// If still not found, check conversation.additional_attributes
if (!callSid && conversation?.additional_attributes) {
callSid = conversation.additional_attributes.call_sid;
}
// If we don't have a call SID, log the error but continue
// This will allow the UI to show something while we wait for the real call SID
if (!callSid) {
console.log(
'No call SID found in response, waiting for server to assign one'
);
// We'll rely on WebSocket updates to get the real call SID when available
// For now just set a placeholder for UI purposes
callSid = 'pending';
}
// Log for debugging
console.log('Voice call response:', conversation);
console.log('Using call SID:', callSid);
// Always set the global call state for the floating widget
const inbox = conversation.inbox_id
? this.$store.getters['inboxes/getInbox'](conversation.inbox_id)
: null;
this.$store.dispatch('calls/setActiveCall', {
callSid,
inboxName: inbox?.name || 'Primary',
conversationId: conversation.id,
contactId: this.contact.id,
});
// Set App's showCallWidget to true
if (window.app && window.app.$data) {
window.app.$data.showCallWidget = true;
}
// After a brief delay, force update UI
setTimeout(() => {
this.$forceUpdate();
}, 100);
useAlert('Voice call initiated successfully');
} catch (error) { } catch (error) {
// Error handled with useAlert // Error handled with useAlert
useAlert('Failed to initiate call. Please try again.', 'error'); useAlert('Failed to initiate voice call');
} finally { } finally {
this.isCallLoading = false; this.isCallLoading = false;
} }
}, },
handleCallEnded() {
this.activeCallConversation = null;
// Clear global call state
this.$store.dispatch('calls/clearActiveCall');
},
// Simplified emergency end call function
forceEndActiveCall() {
console.log('FORCE END ACTIVE CALL triggered from ContactInfo');
// Important: Save a reference to the conversation before resetting it
const savedConversation = this.activeCallConversation;
// 1. Immediately update local state for immediate UI feedback
this.activeCallConversation = null;
this.isHoveringCallButton = false;
this.$forceUpdate();
// 2. Reset App global state
if (window.app) {
window.app.$data.showCallWidget = false;
}
// 3. Reset store state
this.$store.dispatch('calls/clearActiveCall');
// 4. Get the call SID from the saved conversation
if (savedConversation) {
// Try to find the call SID
let callSid = null;
// Check all possible locations
if (savedConversation.call_sid) {
callSid = savedConversation.call_sid;
} else if (savedConversation.additional_attributes?.call_sid) {
callSid = savedConversation.additional_attributes.call_sid;
} else if (
savedConversation.messages &&
savedConversation.messages.length > 0
) {
// Look in messages
const callMessage = savedConversation.messages.find(
message =>
message.message_type === 10 &&
message.additional_attributes?.call_sid
);
if (callMessage) {
callSid = callMessage.additional_attributes.call_sid;
}
}
console.log('ContactInfo: Found call SID for API call:', callSid);
// 5. Make direct API call to end the call if we have a valid call SID
if (callSid && callSid !== 'pending') {
// Check if it's a valid Twilio call SID
const isValidTwilioSid =
callSid.startsWith('CA') || callSid.startsWith('TJ');
if (isValidTwilioSid) {
console.log(
'ContactInfo: Making direct API call to end call with SID:',
callSid
);
// Make API call with conversation ID
VoiceAPI.endCall(callSid, savedConversation.id)
.then(response => {
console.log(
'ContactInfo: Call ended successfully via API:',
response
);
})
.catch(error => {
console.error('ContactInfo: Error ending call via API:', error);
});
} else {
console.log(
'ContactInfo: Invalid Twilio call SID format:',
callSid
);
}
} else if (callSid === 'pending') {
console.log(
'ContactInfo: Call was still in pending state, no API call needed'
);
} else {
console.log('ContactInfo: No call SID available for API call');
}
}
// 6. User feedback
useAlert({ message: 'Call ended successfully', type: 'success' });
},
// Original more careful implementation
async endActiveCall() {
console.log('End active call triggered from ContactInfo component');
// First, immediately update the UI for responsive feedback
const savedActiveCall = this.activeCallConversation;
this.activeCallConversation = null;
this.$forceUpdate();
// Reset app-level state
if (window.app && window.app.$data) {
window.app.$data.showCallWidget = false;
}
// Clear global state
this.$store.dispatch('calls/clearActiveCall');
// Always give user success feedback
useAlert({ message: 'Call ended successfully', type: 'success' });
// Then try the API call (after UI is updated)
try {
if (savedActiveCall) {
// Try to find the call SID
let callSid = null;
// Check all possible locations
if (savedActiveCall.call_sid) {
callSid = savedActiveCall.call_sid;
} else if (savedActiveCall.additional_attributes?.call_sid) {
callSid = savedActiveCall.additional_attributes.call_sid;
} else {
// Look in messages
const messages = savedActiveCall.messages || [];
const callMessage = messages.find(
message =>
message.message_type === 10 &&
message.additional_attributes?.call_sid
);
if (callMessage) {
callSid = callMessage.additional_attributes.call_sid;
}
}
console.log('Found call SID for API call:', callSid);
// Make the API call if we have a valid call SID
if (callSid && callSid !== 'pending') {
// Check if it's a valid Twilio call SID
const isValidTwilioSid =
callSid.startsWith('CA') || callSid.startsWith('TJ');
if (isValidTwilioSid) {
try {
console.log('Making API call to end call with SID:', callSid);
await VoiceAPI.endCall(callSid, savedActiveCall.id);
console.log('API call to end call succeeded');
} catch (apiError) {
console.error('API call to end call failed:', apiError);
// We've already updated UI, so don't show error to user
}
} else {
console.log('Invalid Twilio call SID format:', callSid);
}
} else if (callSid === 'pending') {
console.log('Call was still in pending state, no API call needed');
} else {
console.log('No call SID available for API call');
}
}
} catch (error) {
console.error('Error in endActiveCall:', error);
}
},
async deleteContact({ id }) { async deleteContact({ id }) {
try { try {
await this.$store.dispatch('contacts/delete', id); await this.$store.dispatch('contacts/delete', id);
@@ -189,6 +430,16 @@ export default {
openMergeModal() { openMergeModal() {
this.showMergeModal = true; this.showMergeModal = true;
}, },
onCallButtonClick() {
if (this.activeCallConversation) {
useAlert('Call already ongoing', 'warning');
if (window.app && window.app.$data) {
window.app.$data.showCallWidget = true;
}
} else {
this.initiateVoiceCall();
}
},
}, },
}; };
</script> </script>
@@ -196,6 +447,14 @@ export default {
<template> <template>
<div class="relative items-center w-full p-4"> <div class="relative items-center w-full p-4">
<div class="flex flex-col w-full gap-2 text-left rtl:text-right"> <div class="flex flex-col w-full gap-2 text-left rtl:text-right">
<!-- Call Manager Component - Shows only when a call is active -->
<CallManager
v-if="activeCallConversation && contact && contact.id"
:contact="contact"
:conversation="activeCallConversation"
@call-ended="handleCallEnded"
/>
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
<Thumbnail <Thumbnail
v-if="showAvatar" v-if="showAvatar"
@@ -296,13 +555,16 @@ export default {
</ComposeConversation> </ComposeConversation>
<NextButton <NextButton
v-if="contact.phone_number" v-if="contact.phone_number"
v-tooltip.top-end="'Call'" v-tooltip.top-end="
activeCallConversation ? 'Call already ongoing' : 'Call'
"
icon="i-ph-phone" icon="i-ph-phone"
slate slate
faded faded
sm sm
:is-loading="isCallLoading" :is-loading="!activeCallConversation && isCallLoading"
@click.stop.prevent="initiateVoiceCall" :color="activeCallConversation ? 'teal' : undefined"
@click.stop.prevent="onCallButtonClick"
/> />
<NextButton <NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')" v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"

View File

@@ -78,7 +78,7 @@ export default {
icon="i-lucide-clipboard" icon="i-lucide-clipboard"
@click="onCopy" @click="onCopy"
/> />
<slot v-if="buttonSlot" name="button"></slot> <slot v-if="buttonSlot" name="button" />
</div> </div>
</a> </a>

View File

@@ -1,94 +1,3 @@
<template>
<div>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.VOICE.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.VOICE.DESC')"
/>
<form class="flex flex-wrap flex-col gap-4 p-2" @submit.prevent="createChannel">
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.VOICE.PROVIDER.LABEL') }}
<select
v-model="provider"
class="p-2 bg-white border border-n-blue-100 rounded"
@change="onProviderChange"
>
<option
v-for="option in providerOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
</div>
<!-- Twilio Provider Config -->
<div v-if="provider === 'twilio'" class="flex-shrink-0 flex-grow-0">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL') }}
<input
v-model.trim="phoneNumber"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
/>
<span v-if="v$.phoneNumber.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.accountSid.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL') }}
<input
v-model.trim="accountSid"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
@blur="v$.accountSid.$touch"
/>
<span v-if="v$.accountSid.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.authToken.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL') }}
<input
v-model.trim="authToken"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
@blur="v$.authToken.$touch"
/>
<span v-if="v$.authToken.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED') }}
</span>
</label>
</div>
</div>
<!-- Add other provider configs here -->
<div class="mt-4">
<NextButton
:is-loading="uiFlags.isCreating"
:is-disabled="v$.$invalid"
:label="$t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
type="submit"
color="blue"
@click="createChannel"
/>
</div>
</form>
</div>
</template>
<script> <script>
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators'; import { required } from '@vuelidate/validators';
@@ -163,10 +72,10 @@ export default {
if (this.v$.$invalid) { if (this.v$.$invalid) {
return; return;
} }
try { try {
const providerConfig = this.getProviderConfig(); const providerConfig = this.getProviderConfig();
const channel = await this.$store.dispatch( const channel = await this.$store.dispatch(
'inboxes/createVoiceChannel', 'inboxes/createVoiceChannel',
{ {
@@ -178,7 +87,7 @@ export default {
}, },
} }
); );
router.replace({ router.replace({
name: 'settings_inboxes_add_agents', name: 'settings_inboxes_add_agents',
params: { params: {
@@ -187,9 +96,110 @@ export default {
}, },
}); });
} catch (error) { } catch (error) {
useAlert(error.response?.data?.message || this.$t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')); useAlert(
error.response?.data?.message ||
this.$t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
);
} }
}, },
}, },
}; };
</script> </script>
<template>
<div>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.VOICE.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.VOICE.DESC')"
/>
<form
class="flex flex-wrap flex-col gap-4 p-2"
@submit.prevent="createChannel"
>
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.VOICE.PROVIDER.LABEL') }}
<select
v-model="provider"
class="p-2 bg-white border border-n-blue-100 rounded"
@change="onProviderChange"
>
<option
v-for="option in providerOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
</div>
<!-- Twilio Provider Config -->
<div v-if="provider === 'twilio'" class="flex-shrink-0 flex-grow-0">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL') }}
<input
v-model.trim="phoneNumber"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
/>
<span v-if="v$.phoneNumber.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.accountSid.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL') }}
<input
v-model.trim="accountSid"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')
"
@blur="v$.accountSid.$touch"
/>
<span v-if="v$.accountSid.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.authToken.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL') }}
<input
v-model.trim="authToken"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')
"
@blur="v$.authToken.$touch"
/>
<span v-if="v$.authToken.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED') }}
</span>
</label>
</div>
</div>
<!-- Add other provider configs here -->
<div class="mt-4">
<NextButton
:is-loading="uiFlags.isCreating"
:is-disabled="v$.$invalid"
:label="$t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
type="submit"
color="blue"
@click="createChannel"
/>
</div>
</form>
</div>
</template>

View File

@@ -9,6 +9,7 @@ import auditlogs from './modules/auditlogs';
import auth from './modules/auth'; import auth from './modules/auth';
import automations from './modules/automations'; import automations from './modules/automations';
import bulkActions from './modules/bulkActions'; import bulkActions from './modules/bulkActions';
import calls from './modules/calls';
import campaigns from './modules/campaigns'; import campaigns from './modules/campaigns';
import cannedResponse from './modules/cannedResponse'; import cannedResponse from './modules/cannedResponse';
import categories from './modules/helpCenterCategories'; import categories from './modules/helpCenterCategories';
@@ -64,6 +65,7 @@ export default createStore({
auth, auth,
automations, automations,
bulkActions, bulkActions,
calls,
campaigns, campaigns,
cannedResponse, cannedResponse,
categories, categories,

View File

@@ -0,0 +1,46 @@
const state = {
activeCall: null,
};
const getters = {
getActiveCall: $state => $state.activeCall,
hasActiveCall: $state => !!$state.activeCall,
};
const actions = {
setActiveCall({ commit }, callData) {
console.log('Setting active call in store:', callData);
commit('SET_ACTIVE_CALL', callData);
// If we're in a browser environment, try to set the app state
if (typeof window !== 'undefined' && window.app && window.app.$data) {
window.app.$data.showCallWidget = true;
}
},
clearActiveCall({ commit }) {
console.log('Clearing active call in store');
commit('CLEAR_ACTIVE_CALL');
// If we're in a browser environment, try to clear the app state
if (typeof window !== 'undefined' && window.app && window.app.$data) {
window.app.$data.showCallWidget = false;
}
},
};
const mutations = {
SET_ACTIVE_CALL($state, callData) {
$state.activeCall = callData;
},
CLEAR_ACTIVE_CALL($state) {
$state.activeCall = null;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,191 @@
class CallTranscriptionJob < ApplicationJob
queue_as :low
def perform(params)
call_sid = params['CallSid']
recording_url = params['RecordingUrl']
recording_sid = params['RecordingSid']
to_number = params['To']
from_number = params['From']
direction = params['Direction']
call_status = params['CallStatus']
# Determine if this is an outbound call
is_outbound = direction == 'outbound-api'
# Find the inbox for this voice call based on the direction
# For outbound calls, the "From" is the Chatwoot number
# For inbound calls, the "To" is the Chatwoot number
channel_number = is_outbound ? from_number : to_number
inbox = Inbox.joins("INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id")
.where("channel_voice.phone_number = ?", channel_number)
.first
return if inbox.nil?
account = inbox.account
# For outbound calls, the contact is the person being called (To)
# For inbound calls, the contact is the caller (From)
contact_number = is_outbound ? to_number : from_number
# Find or create the contact
contact = account.contacts.find_or_create_by(phone_number: contact_number) do |c|
c.name = is_outbound ? "Customer at #{contact_number}" : "Caller from #{contact_number}"
end
# Find or create the contact inbox
contact_inbox = ContactInbox.find_or_initialize_by(
contact_id: contact.id,
inbox_id: inbox.id
)
# Set the source_id if it's a new record
if contact_inbox.new_record?
contact_inbox.source_id = from_number
end
contact_inbox.save!
# Find or create a conversation for this call
conversation = account.conversations.find_or_create_by(
contact_inbox_id: contact_inbox.id,
inbox_id: inbox.id
) do |conv|
conv.status = :open
end
# If the conversation was auto-resolved, reopen it
if conversation.resolved?
conversation.toggle_status
end
# If we have a transcription, add it to an existing message or create a new one
if params['TranscriptionText'].present?
# Look for a recent message from this recording
recording_message = conversation.messages
.where("additional_attributes @> ?", { recording_sid: recording_sid }.to_json)
.order(created_at: :desc)
.first
if recording_message
# Update existing message with transcription
recording_message.content = params['TranscriptionText']
recording_message.additional_attributes[:transcription] = params['TranscriptionText']
recording_message.save!
else
# Create a new message with the transcription
message_params = {
content: params['TranscriptionText'],
message_type: :incoming,
additional_attributes: {
is_transcription: true,
call_sid: call_sid,
recording_sid: recording_sid,
recording_url: recording_url
},
source_id: "transcription_#{recording_sid}"
}
message = Messages::MessageBuilder.new(contact, conversation, message_params).perform
# Attach the recording as an attachment if it's a valid Twilio recording URL
if recording_url.present? && recording_url.include?('/Recordings/') && recording_sid.present?
begin
# Get authentication details from the channel config to access the recording
config = inbox.channel.provider_config_hash
account_sid = config['account_sid']
auth_token = config['auth_token']
# Create an authenticated URL that includes auth details
# Format for MP3 playback
recording_mp3_url = "#{recording_url}.mp3"
attachment_params = {
file_type: :audio,
account_id: inbox.account_id,
external_url: recording_mp3_url,
fallback_title: 'Voice recording',
meta: {
recording_sid: recording_sid,
twilio_account_sid: account_sid,
auth_required: true,
transcription: params['TranscriptionText']
}
}
attachment = message.attachments.new(attachment_params)
attachment.save!
Rails.logger.info("Successfully attached voice recording from #{recording_url}")
rescue StandardError => e
Rails.logger.error("Error attaching voice recording: #{e.message}")
# Try with a more general file type if audio fails
begin
fallback_attachment = message.attachments.new(
file_type: :file,
account_id: inbox.account_id,
external_url: recording_mp3_url,
fallback_title: 'Voice Recording (.mp3)',
meta: {
recording_sid: recording_sid,
twilio_account_sid: account_sid,
auth_required: true,
transcription: params['TranscriptionText']
}
)
fallback_attachment.save!
rescue StandardError => e
Rails.logger.error("Failed to create fallback attachment: #{e.message}")
end
end
else
Rails.logger.error("Invalid Twilio recording URL format or missing SID: #{recording_url}")
end
end
end
# Create or update call status information
call_info_message = conversation.messages
.where("additional_attributes @> ?", { call_sid: call_sid, message_type: 'activity' }.to_json)
.first
message_attributes = {
call_sid: call_sid,
call_status: params['CallStatus'] || 'in-progress'
}
# Add recording details if available
if recording_url.present?
message_attributes[:recordings_count] = (call_info_message&.additional_attributes&.dig(:recordings_count) || 0) + 1
message_attributes[:latest_recording_url] = recording_url
message_attributes[:latest_recording_sid] = recording_sid
message_attributes[:call_duration] = params['RecordingDuration']
end
if call_info_message.present?
# Update the existing call info message
call_info_message.additional_attributes.merge!(message_attributes)
call_info_message.save!
else
# Create a new call info message
message_params = {
content: 'Voice call in progress',
message_type: :activity,
additional_attributes: message_attributes,
source_id: "voice_call_#{call_sid}"
}
Messages::MessageBuilder.new(nil, conversation, message_params).perform
end
# Notify the frontend about the update
ActionCableBroadcastJob.perform_later(
conversation.account_id,
'conversation.updated',
conversation.push_event_data.merge(
status: conversation.status
)
)
end
end

View File

@@ -0,0 +1,19 @@
class MessageListener < BaseListener
def message_created(event)
message = extract_message_and_account(event)[0]
return if message.nil?
if message.conversation.inbox.channel.class.name == 'Channel::Voice'
# Deliver the message to voice call if it's a voice channel
Voice::MessageDeliveryService.new(message).perform
end
end
private
def extract_message_and_account(event)
message = event.data[:message]
account = message.account
[message, account]
end
end

View File

@@ -9,7 +9,7 @@ class Channel::Voice < ApplicationRecord
# Provider-specific configs stored in JSON # Provider-specific configs stored in JSON
validates :provider_config, presence: true validates :provider_config, presence: true
EDITABLE_ATTRS = [:phone_number, :provider, :provider_config].freeze EDITABLE_ATTRS = [:phone_number, :provider, :provider_config].freeze
def name def name
@@ -32,9 +32,32 @@ class Channel::Voice < ApplicationRecord
def initiate_twilio_call(to) def initiate_twilio_call(to)
config = provider_config_hash config = provider_config_hash
callback_url = Rails.application.routes.url_helpers.twiml_twilio_voice_url(host: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'))
params = { from: phone_number, to: to, url: callback_url } # Generate a full URL for Twilio to request TwiML
twilio_client(config).calls.create(**params) host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
# Use the simplest possible TwiML endpoint
callback_url = "#{host}/twilio/voice/simple"
# Parameters including status callbacks for call progress tracking
params = {
from: phone_number,
to: to,
url: callback_url,
status_callback: "#{host}/twilio/voice/status_callback",
status_callback_event: %w[initiated ringing answered completed],
status_callback_method: 'POST'
}
# Create the call
call = twilio_client(config).calls.create(**params)
# Return the bare minimum
{
provider: 'twilio',
call_sid: call.sid,
status: call.status
}
end end
def twilio_client(config) def twilio_client(config)
@@ -48,4 +71,6 @@ class Channel::Voice < ApplicationRecord
JSON.parse(provider_config.to_s) JSON.parse(provider_config.to_s)
end end
end end
end
public :provider_config_hash
end

View File

@@ -0,0 +1,59 @@
module Voice
class MessageDeliveryService
# This service will handle delivering messages from agents to active voice calls
# For now we'll store the messages in Redis with the call_sid as the key
# This way the TwiML controller can retrieve and read them out to the caller
attr_reader :message, :conversation
def initialize(message)
@message = message
@conversation = message.conversation
end
def perform
return unless should_deliver_message?
call_sid = conversation.additional_attributes&.dig('call_sid')
return unless call_sid.present?
# Store the message in Redis to be read out in the next TwiML request
redis_key = "voice_message:#{call_sid}"
# Add the message to a Redis list
Redis::Alfred.lpush(redis_key, {
content: message.content,
message_id: message.id,
delivered: false
}.to_json)
# Set expiration so we don't keep messages forever
Redis::Alfred.expire(redis_key, 1.hour.to_i)
# Update the message with delivery status
update_message_delivery_status
end
private
def should_deliver_message?
# Only deliver outgoing messages (from agents)
return false unless message.outgoing?
# Only deliver text messages, not attachments etc
return false unless message.content.present?
# Only deliver to active voice calls
return false unless conversation.additional_attributes&.dig('call_sid').present?
return false unless conversation.additional_attributes&.dig('call_status') == 'in-progress'
true
end
def update_message_delivery_status
additional_attributes = message.additional_attributes || {}
additional_attributes[:voice_delivery_status] = 'queued'
message.update(additional_attributes: additional_attributes)
end
end
end

View File

@@ -55,6 +55,11 @@ en:
VOICE_CALL: 'Voice Call' VOICE_CALL: 'Voice Call'
CALL_ERROR: 'Failed to initiate voice call' CALL_ERROR: 'Failed to initiate voice call'
CALL_INITIATED: 'Voice call initiated successfully' CALL_INITIATED: 'Voice call initiated successfully'
END_CALL: 'End call'
CALL_ENDED: 'Call ended successfully'
CALL_END_ERROR: 'Failed to end call. Please try again.'
AUDIO_NOT_SUPPORTED: 'Your browser does not support audio playback'
TRANSCRIPTION: 'Transcription'
CONTACT_PANEL: CONTACT_PANEL:
NEW_MESSAGE: 'New Message' NEW_MESSAGE: 'New Message'
MERGE_CONTACT: 'Merge Contact' MERGE_CONTACT: 'Merge Contact'

View File

@@ -177,6 +177,10 @@ Rails.application.routes.draw do
post :set_agent_bot, on: :member post :set_agent_bot, on: :member
delete :avatar, on: :member delete :avatar, on: :member
end end
# Voice call management
post 'voice/end_call', to: 'voice#end_call'
get 'voice/call_status', to: 'voice#call_status'
resources :inbox_members, only: [:create, :show], param: :inbox_id do resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do collection do
delete :destroy delete :destroy
@@ -479,10 +483,16 @@ Rails.application.routes.draw do
namespace :twilio do namespace :twilio do
resources :callback, only: [:create] resources :callback, only: [:create]
resources :delivery_status, only: [:create] resources :delivery_status, only: [:create]
resource :voice, only: [] do
get :twiml # Define controller explicitly to avoid the plural/singular confusion
post :transcription_callback get 'voice/twiml', to: 'voice#twiml'
end post 'voice/twiml', to: 'voice#twiml'
get 'voice/simple', to: 'voice#simple_twiml'
post 'voice/simple', to: 'voice#simple_twiml'
post 'voice/handle_recording', to: 'voice#handle_recording'
post 'voice/handle_user_input', to: 'voice#handle_user_input'
post 'voice/transcription_callback', to: 'voice#transcription_callback'
post 'voice/status_callback', to: 'voice#status_callback'
end end
get 'microsoft/callback', to: 'microsoft/callbacks#show' get 'microsoft/callback', to: 'microsoft/callbacks#show'