diff --git a/app/controllers/api/v1/accounts/contacts/calls_controller.rb b/app/controllers/api/v1/accounts/contacts/calls_controller.rb index 4fd2d358d..e97166e20 100644 --- a/app/controllers/api/v1/accounts/contacts/calls_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/calls_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController + require 'securerandom' before_action :fetch_contact def create @@ -16,12 +17,37 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont end begin - # Initiate the call using the channel's implementation - voice_inbox.channel.initiate_call(to: @contact.phone_number) - - # Create a new conversation for this call if needed + # Create a new conversation for this call 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 rescue StandardError => e 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? # 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, 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!( account_id: Current.account.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 - Messages::MessageBuilder.new( - user: Current.user, - conversation: conversation, + params = { message_type: :activity, - content: "Voice call initiated to #{@contact.phone_number}" - ).perform + content: "Voice call initiated to #{@contact.phone_number}", + source_id: "voice_call_#{SecureRandom.uuid}" # Generate a unique source_id + } + + Messages::MessageBuilder.new(Current.user, conversation, params).perform end conversation diff --git a/app/controllers/api/v1/accounts/voice_controller.rb b/app/controllers/api/v1/accounts/voice_controller.rb new file mode 100644 index 000000000..f5e5899fd --- /dev/null +++ b/app/controllers/api/v1/accounts/voice_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/twilio/voice_controller.rb b/app/controllers/twilio/voice_controller.rb new file mode 100644 index 000000000..3a78c1c24 --- /dev/null +++ b/app/controllers/twilio/voice_controller.rb @@ -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 \ No newline at end of file diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index 7416b7861..ac9cf8ce6 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -15,6 +15,7 @@ class AsyncDispatcher < BaseDispatcher CsatSurveyListener.instance, HookListener.instance, InstallationWebhookListener.instance, + MessageListener.instance, NotificationListener.instance, ParticipationListener.instance, ReportingEventListener.instance, diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index e51958e9e..8998c0df0 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -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, // Set to true for testing, false for production }; }, computed: { @@ -60,6 +65,8 @@ export default { currentUser: 'getCurrentUser', authUIFlags: 'getAuthUIFlags', accountUIFlags: 'accounts/getUIFlags', + activeCall: 'calls/getActiveCall', + hasActiveCall: 'calls/hasActiveCall', }), hasAccounts() { const { accounts = [] } = this.currentUser || {}; @@ -86,6 +93,13 @@ export default { }, }, 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.listenToThemeChanges(); this.setLocale(window.chatwootConfig.selectedLocale); @@ -106,6 +120,84 @@ export default { setLocale(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() { await this.$store.dispatch('accounts/get'); this.$store.dispatch('setActiveAccount', { @@ -153,6 +245,15 @@ export default { + + diff --git a/app/javascript/dashboard/api/channels/voice.js b/app/javascript/dashboard/api/channels/voice.js index bae9d8f9b..1d345de50 100644 --- a/app/javascript/dashboard/api/channels/voice.js +++ b/app/javascript/dashboard/api/channels/voice.js @@ -16,6 +16,63 @@ class VoiceAPI extends ApiClient { `/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(); \ No newline at end of file +export default new VoiceAPI(); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue index cd8c767b8..758b6e127 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue @@ -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() " /> diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue index 36f611775..a5996d04a 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue @@ -108,7 +108,13 @@ const onCardClick = e => { v-tooltip.left="inboxName" class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5" > + + diff --git a/app/javascript/dashboard/components-next/button/Button.vue b/app/javascript/dashboard/components-next/button/Button.vue index c54bd395a..7777cada4 100644 --- a/app/javascript/dashboard/components-next/button/Button.vue +++ b/app/javascript/dashboard/components-next/button/Button.vue @@ -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', diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index 680584048..b63f42d9a 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -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,17 @@ 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) { + 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 }); }; diff --git a/app/javascript/dashboard/components/ui/Switch.vue b/app/javascript/dashboard/components/ui/Switch.vue index b4398baf8..3caa8c28a 100644 --- a/app/javascript/dashboard/components/ui/Switch.vue +++ b/app/javascript/dashboard/components/ui/Switch.vue @@ -30,9 +30,9 @@ export default { diff --git a/app/javascript/dashboard/components/widgets/InboxName.vue b/app/javascript/dashboard/components/widgets/InboxName.vue index 693162f95..1b643be29 100644 --- a/app/javascript/dashboard/components/widgets/InboxName.vue +++ b/app/javascript/dashboard/components/widgets/InboxName.vue @@ -22,7 +22,13 @@ export default {
+ + { }; 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 => { diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index dbc6cc719..d95b8edcb 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -72,7 +72,7 @@ export const getReadableInboxByType = (type, phoneNumber) => { case INBOX_TYPES.TWILIO: return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms'; - + case INBOX_TYPES.VOICE: return 'voice'; @@ -111,7 +111,7 @@ export const getInboxClassByType = (type, phoneNumber) => { return phoneNumber?.startsWith('whatsapp') ? 'brand-whatsapp' : 'brand-sms'; - + case INBOX_TYPES.VOICE: return 'phone'; diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 7b2d8d94d..d24444784 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -382,6 +382,9 @@ "VOICE_CALL": "Call", "CALL_ERROR": "Failed to initiate call. Please try again.", "CALL_INITIATED": "Call initiated successfully.", + "END_CALL": "End call", + "AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback", + "TRANSCRIPTION": "Call transcription", "COPILOT": { "TRY_THESE_PROMPTS": "Try these prompts" }, diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/CallManager.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/CallManager.vue new file mode 100644 index 000000000..3c89db364 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/CallManager.vue @@ -0,0 +1,337 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue index cfcf197e1..4804010cb 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue @@ -13,6 +13,7 @@ import ComposeConversation from 'dashboard/components-next/NewConversation/Compo 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, @@ -30,6 +31,7 @@ export default { ComposeConversation, SocialIcons, ContactMergeModal, + CallManager, }, mixins: [inboxMixin], props: { @@ -55,6 +57,8 @@ export default { showMergeModal: false, showDeleteModal: false, isCallLoading: false, + activeCallConversation: null, + isHoveringCallButton: false, }; }, computed: { @@ -144,18 +148,255 @@ export default { }, async initiateVoiceCall() { if (!this.contact || !this.contact.id) return; - + this.isCallLoading = true; try { 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) { // Error handled with useAlert - useAlert('Failed to initiate call. Please try again.', 'error'); + useAlert('Failed to initiate voice call'); } finally { 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 }) { try { await this.$store.dispatch('contacts/delete', id); @@ -189,6 +430,16 @@ export default { openMergeModal() { 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(); + } + }, }, }; @@ -196,6 +447,14 @@ export default {