chore: commit local changes (voice cleanup, audio notifications)

This commit is contained in:
Sojan Jose
2025-08-18 13:00:08 +02:00
parent 6c6a2ec58f
commit a16e3e0352
11 changed files with 98 additions and 304 deletions

View File

@@ -32,30 +32,14 @@ class Twilio::VoiceController < ApplicationController
to_param = params[:To].to_s to_param = params[:To].to_s
agent_leg = to_param.start_with?('conf_account_') || params[:is_agent] == 'true' || params[:identity].to_s.start_with?('agent-') agent_leg = to_param.start_with?('conf_account_') || params[:is_agent] == 'true' || params[:identity].to_s.start_with?('agent-')
conversation = if agent_leg conversation = find_conversation_by_conference_to(@inbox.account, to_param) ||
find_conversation_by_conference_to(@inbox.account, to_param) || Voice::ConversationFinderService.new(
Voice::ConversationFinderService.new( account: @inbox.account,
account: @inbox.account, call_sid: @call_sid,
call_sid: @call_sid, phone_number: incoming_number,
phone_number: incoming_number, is_outbound: outbound?,
is_outbound: outbound?, inbox: @inbox
inbox: @inbox ).perform
).perform
else
Voice::ConversationFinderService.new(
account: @inbox.account,
call_sid: @call_sid,
phone_number: incoming_number,
is_outbound: outbound?,
inbox: @inbox
).perform
end
Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: @call_sid,
provider: :twilio
).process_status_update('in_progress', nil, true)
conference_sid = ensure_conference_sid(conversation, params[:conference_name]) conference_sid = ensure_conference_sid(conversation, params[:conference_name])
@@ -67,6 +51,16 @@ class Twilio::VoiceController < ApplicationController
) )
) )
# Ensure a call message exists for incoming PSTN leg (do not create for agent leg)
ensure_call_message(conversation, conference_sid) unless agent_leg
# Set initial status to ringing on first TwiML response
Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: @call_sid,
provider: :twilio
).process_status_update('ringing', nil, true)
render_twiml do |r| render_twiml do |r|
# For incoming PSTN calls, play a brief prompt # For incoming PSTN calls, play a brief prompt
r.say(message: 'Please wait while we connect you to an agent') unless agent_leg r.say(message: 'Please wait while we connect you to an agent') unless agent_leg
@@ -127,6 +121,24 @@ class Twilio::VoiceController < ApplicationController
"conf_account_#{@inbox.account_id}_conv_#{conversation.display_id}" "conf_account_#{@inbox.account_id}_conv_#{conversation.display_id}"
end end
def ensure_call_message(conversation, conference_sid)
existing = conversation.messages.voice_calls.order(created_at: :desc).first
return if existing.present?
from_number = outbound? ? @inbox.channel&.phone_number : incoming_number
to_number = outbound? ? incoming_number : @inbox.channel&.phone_number
Voice::CallMessageBuilder.new(
conversation: conversation,
direction: outbound? ? 'outbound' : 'inbound',
call_sid: @call_sid,
conference_sid: conference_sid,
from_number: from_number,
to_number: to_number,
user: nil
).perform
end
def fallback_twiml def fallback_twiml
render_twiml { |r| r.hangup } render_twiml { |r| r.hangup }
end end

View File

@@ -15,7 +15,6 @@ 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

@@ -7,6 +7,13 @@ export function useRingtone(intervalMs = 2500) {
clearInterval(timer.value); clearInterval(timer.value);
timer.value = null; timer.value = null;
} }
try {
if (typeof window.stopAudioAlert === 'function') {
window.stopAudioAlert();
}
} catch (_) {
// ignore stop errors
}
}; };
const play = () => { const play = () => {

View File

@@ -160,6 +160,16 @@ class ActionCableConnector extends BaseActionCableConnector {
inboxId: data.inbox_id, inboxId: data.inbox_id,
}); });
// Reflect call status onto the latest voice call message in the store
try {
this.app.$store.commit('UPDATE_CONVERSATION_CALL_STATUS', {
conversationId: data.display_id,
callStatus: data.additional_attributes.call_status,
});
} catch (_) {
// ignore store commit failures
}
// Backfill: if status indicates a live call and we missed creation // Backfill: if status indicates a live call and we missed creation
if ( if (
['ringing', 'in_progress'].includes( ['ringing', 'in_progress'].includes(
@@ -168,7 +178,8 @@ class ActionCableConnector extends BaseActionCableConnector {
) { ) {
const hasIncoming = this.app.$store.getters['calls/hasIncomingCall']; const hasIncoming = this.app.$store.getters['calls/hasIncomingCall'];
const hasActive = this.app.$store.getters['calls/hasActiveCall']; const hasActive = this.app.$store.getters['calls/hasActiveCall'];
const currentIncoming = this.app.$store.getters['calls/getIncomingCall']; const currentIncoming =
this.app.$store.getters['calls/getIncomingCall'];
if ( if (
!hasIncoming && !hasIncoming &&
!hasActive && !hasActive &&

View File

@@ -102,23 +102,11 @@ export const actions = {
if (isVoiceCall) { if (isVoiceCall) {
const accountId = window.store.getters['accounts/getCurrentAccountId']; const accountId = window.store.getters['accounts/getCurrentAccountId'];
// MVP: Only support calls to existing contacts via the contacts endpoint
if (contactId) { const response = await axios.post(
// Use the regular contacts call endpoint for existing contacts `/api/v1/accounts/${accountId}/contacts/${contactId}/call`
const response = await axios.post( );
`/api/v1/accounts/${accountId}/contacts/${contactId}/call` data = response.data;
);
data = response.data;
} else {
// For direct phone calls without a contact, use a special endpoint
// Add phoneNumber to the payload for voice call
payload.phone_number = params.phoneNumber || '';
const response = await axios.post(
`/api/v1/accounts/${accountId}/conversations/trigger_voice`,
payload
);
data = response.data;
}
} else { } else {
// Regular conversation creation // Regular conversation creation
const response = await ConversationApi.create(payload); const response = await ConversationApi.create(payload);

View File

@@ -1,4 +1,5 @@
import types from '../../mutation-types'; import types from '../../mutation-types';
import { normalizeStatus } from 'dashboard/helper/voice';
import getters, { getSelectedChatConversation } from './getters'; import getters, { getSelectedChatConversation } from './getters';
import actions from './actions'; import actions from './actions';
import { findPendingMessageIndex } from './helpers'; import { findPendingMessageIndex } from './helpers';
@@ -286,6 +287,24 @@ export const mutations = {
chat.additional_attributes = {}; chat.additional_attributes = {};
} }
chat.additional_attributes.call_status = callStatus; chat.additional_attributes.call_status = callStatus;
// Also update the latest voice call message status if present
const messages = chat.messages || [];
const lastCallIndex = [...messages].reverse().findIndex(m => {
const ct = m.content_type || m.contentType;
return ct === 'voice_call' || ct === 12; // enum fallback
});
if (lastCallIndex !== -1) {
const idx = messages.length - 1 - lastCallIndex;
const msg = messages[idx];
const key = msg.content_attributes
? 'content_attributes'
: 'contentAttributes';
const container = msg[key] || {};
container.data = container.data || {};
container.data.status = normalizeStatus(callStatus);
msg[key] = container;
}
} }
}, },

View File

@@ -13,16 +13,35 @@ export const getAudioContext = () => {
// eslint-disable-next-line default-param-last // eslint-disable-next-line default-param-last
export const getAlertAudio = async (baseUrl = '', requestContext) => { export const getAlertAudio = async (baseUrl = '', requestContext) => {
const audioCtx = getAudioContext(); const audioCtx = getAudioContext();
let lastSource;
const stopLast = () => {
try {
if (lastSource) {
lastSource.stop();
}
} catch (_) {
// ignore stop errors
} finally {
lastSource = null;
}
};
const playSound = audioBuffer => { const playSound = audioBuffer => {
window.playAudioAlert = () => { window.playAudioAlert = () => {
if (audioCtx) { if (audioCtx) {
stopLast();
const source = audioCtx.createBufferSource(); const source = audioCtx.createBufferSource();
source.buffer = audioBuffer; source.buffer = audioBuffer;
source.connect(audioCtx.destination); source.connect(audioCtx.destination);
source.loop = false; source.loop = false;
source.start(); source.start();
lastSource = source;
source.onended = () => {
if (lastSource === source) lastSource = null;
};
} }
}; };
window.stopAudioAlert = stopLast;
}; };
if (audioCtx) { if (audioCtx) {

View File

@@ -1,17 +0,0 @@
class MessageListener < BaseListener
def message_created(event)
message = extract_message_and_account(event)[0]
return if message.nil?
# Voice message delivery functionality has been removed for now
# This would be where we'd handle delivering agent messages to voice calls
end
private
def extract_message_and_account(event)
message = event.data[:message]
account = message.account
[message, account]
end
end

View File

@@ -1,59 +0,0 @@
module Voice
class ConferenceStatusService
pattr_initialize [:account!, :params!]
def process
info = status_info
conversation = find_conversation(info)
return unless conversation
Voice::ConferenceManagerService.new(
conversation: conversation,
event: info[:event],
call_sid: info[:call_sid],
participant_label: info[:participant_label]
).process
end
def status_info
{
call_sid: params['CallSid'],
conference_sid: params['ConferenceSid'],
event: params['StatusCallbackEvent'],
participant_sid: params['ParticipantSid'],
participant_label: params['ParticipantLabel']
}
end
private
def find_conversation(info)
# Try by conference_sid first
if info[:conference_sid].present?
conversation = account.conversations.where("additional_attributes->>'conference_sid' = ?", info[:conference_sid]).first
return conversation if conversation
# Try pattern matching for our format
if info[:conference_sid].start_with?('conf_account_')
match = info[:conference_sid].match(/conf_account_\d+_conv_(\d+)/)
return account.conversations.find_by(display_id: match[1]) if match && match[1].present?
end
end
# Try by call_sid
if info[:call_sid].present?
finder_service = Voice::ConversationFinderService.new(
account: account,
call_sid: info[:call_sid]
)
return finder_service.find_by_call_sid
end
nil
end
end
end

View File

@@ -1,113 +0,0 @@
module Voice
class IncomingCallService
pattr_initialize [:account!, :params!]
def process
find_inbox
ActiveRecord::Base.transaction do
find_or_create_conversation
# Delegate creation of voice message and initial status to the orchestrator
Voice::CallOrchestratorService.new(
account: account,
inbox: @inbox,
direction: :inbound,
phone_number: caller_info[:from_number],
call_sid: caller_info[:call_sid]
).inbound!
end
generate_twiml_response
rescue StandardError => e
# Log the error
Rails.logger.error("Error processing incoming call: #{e.message}")
# Return a simple error TwiML
error_twiml(e.message)
end
def caller_info
{
call_sid: params['CallSid'],
from_number: params['From'],
to_number: params['To']
}
end
private
def find_inbox
# Find the inbox for this phone number
@inbox = account.inboxes
.where(channel_type: 'Channel::Voice')
.joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
.where('channel_voice.phone_number = ?', caller_info[:to_number])
.first
raise "Inbox not found for phone number #{caller_info[:to_number]}" unless @inbox.present?
end
def find_or_create_conversation
# Delegate conversation creation/lookup to shared service to keep logic in one place
@conversation = Voice::ConversationFinderService.new(
account: account,
phone_number: caller_info[:from_number],
is_outbound: false,
inbox: @inbox,
call_sid: caller_info[:call_sid]
).perform
# Ensure we have a valid conference SID (ConversationFinderService sets it, but be defensive)
@conversation.reload
@conference_sid = @conversation.additional_attributes['conference_sid']
if @conference_sid.blank? || !@conference_sid.match?(/^conf_account_\d+_conv_\d+$/)
@conference_sid = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
@conversation.additional_attributes['conference_sid'] = @conference_sid
@conversation.save!
end
end
def generate_twiml_response
conference_sid = @conversation.additional_attributes['conference_sid']
response = Twilio::TwiML::VoiceResponse.new
response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.')
# Setup callback URLs
conference_callback_url = "#{base_url}/api/v1/accounts/#{account.id}/channels/voice/webhooks/conference_status"
# Now add the caller to the conference
response.dial do |dial|
dial.conference(
conference_sid,
startConferenceOnEnter: false,
endConferenceOnExit: true,
beep: false,
muted: false,
waitUrl: '',
statusCallback: conference_callback_url,
statusCallbackMethod: 'POST',
statusCallbackEvent: 'start end join leave',
participantLabel: "caller-#{caller_info[:call_sid].last(8)}"
)
end
response.to_s
end
def error_twiml(_message)
response = Twilio::TwiML::VoiceResponse.new
response.say(message: 'We are experiencing technical difficulties with our phone system. Please try again later.')
response.hangup
response.to_s
end
def base_url
url = ENV.fetch('FRONTEND_URL', "https://#{params['host_with_port']}")
url.gsub(%r{/$}, '') # Remove trailing slash if present
end
end
end

View File

@@ -1,72 +0,0 @@
module Voice
# Validates incoming Twilio webhooks to ensure they are legitimate requests
class TwilioValidatorService
pattr_initialize [:account!, :params!, :request!]
def valid?
# Skip validation for these cases:
return true if skip_validation?
begin
# Find the inbox and get the auth token
to_number = params['To']
inbox = find_voice_inbox(to_number)
# Allow callbacks if we can't find the inbox or auth token
return true unless inbox
return true unless (auth_token = get_auth_token(inbox))
# Check if we have a signature to validate
signature = request.headers['X-Twilio-Signature']
return true unless signature.present?
# Validate the signature
validator = Twilio::Security::RequestValidator.new(auth_token)
url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}"
is_valid = validator.validate(url, params.to_unsafe_h, signature)
if is_valid
# Valid signature
else
Rails.logger.error("Invalid Twilio signature for URL: #{url}")
return false
end
rescue StandardError => e
Rails.logger.error("Twilio validation error: #{e.message}")
return true # Allow on errors for robustness
end
true
end
private
def skip_validation?
# Skip for OPTIONS requests and in development
return true if request.method == 'OPTIONS'
return true if Rails.env.development?
return true if account.blank?
false
end
def get_auth_token(inbox)
channel = inbox.channel
return nil unless channel.is_a?(Channel::Voice)
provider_config = channel.provider_config_hash
provider_config['auth_token'] if provider_config.present?
end
def find_voice_inbox(to_number)
return nil if to_number.blank?
account.inboxes
.where(channel_type: 'Channel::Voice')
.joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
.where('channel_voice.phone_number = ?', to_number)
.first
end
end
end