mirror of
https://github.com/lingble/chatwoot.git
synced 2025-12-01 10:33:52 +00:00
chore: commit local changes (voice cleanup, audio notifications)
This commit is contained in:
@@ -32,8 +32,7 @@ class Twilio::VoiceController < ApplicationController
|
||||
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-')
|
||||
|
||||
conversation = if agent_leg
|
||||
find_conversation_by_conference_to(@inbox.account, to_param) ||
|
||||
conversation = find_conversation_by_conference_to(@inbox.account, to_param) ||
|
||||
Voice::ConversationFinderService.new(
|
||||
account: @inbox.account,
|
||||
call_sid: @call_sid,
|
||||
@@ -41,21 +40,6 @@ class Twilio::VoiceController < ApplicationController
|
||||
is_outbound: outbound?,
|
||||
inbox: @inbox
|
||||
).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])
|
||||
|
||||
@@ -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|
|
||||
# For incoming PSTN calls, play a brief prompt
|
||||
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}"
|
||||
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
|
||||
render_twiml { |r| r.hangup }
|
||||
end
|
||||
|
||||
@@ -15,7 +15,6 @@ class AsyncDispatcher < BaseDispatcher
|
||||
CsatSurveyListener.instance,
|
||||
HookListener.instance,
|
||||
InstallationWebhookListener.instance,
|
||||
MessageListener.instance,
|
||||
NotificationListener.instance,
|
||||
ParticipationListener.instance,
|
||||
ReportingEventListener.instance,
|
||||
|
||||
@@ -7,6 +7,13 @@ export function useRingtone(intervalMs = 2500) {
|
||||
clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
try {
|
||||
if (typeof window.stopAudioAlert === 'function') {
|
||||
window.stopAudioAlert();
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore stop errors
|
||||
}
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
|
||||
@@ -160,6 +160,16 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
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
|
||||
if (
|
||||
['ringing', 'in_progress'].includes(
|
||||
@@ -168,7 +178,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
) {
|
||||
const hasIncoming = this.app.$store.getters['calls/hasIncomingCall'];
|
||||
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 (
|
||||
!hasIncoming &&
|
||||
!hasActive &&
|
||||
|
||||
@@ -102,23 +102,11 @@ export const actions = {
|
||||
|
||||
if (isVoiceCall) {
|
||||
const accountId = window.store.getters['accounts/getCurrentAccountId'];
|
||||
|
||||
if (contactId) {
|
||||
// Use the regular contacts call endpoint for existing contacts
|
||||
// MVP: Only support calls to existing contacts via the contacts endpoint
|
||||
const response = await axios.post(
|
||||
`/api/v1/accounts/${accountId}/contacts/${contactId}/call`
|
||||
);
|
||||
data = response.data;
|
||||
} else {
|
||||
// For direct phone calls without a contact, use a special endpoint
|
||||
// Add phoneNumber to the payload for voice call
|
||||
payload.phone_number = params.phoneNumber || '';
|
||||
const response = await axios.post(
|
||||
`/api/v1/accounts/${accountId}/conversations/trigger_voice`,
|
||||
payload
|
||||
);
|
||||
data = response.data;
|
||||
}
|
||||
} else {
|
||||
// Regular conversation creation
|
||||
const response = await ConversationApi.create(payload);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import types from '../../mutation-types';
|
||||
import { normalizeStatus } from 'dashboard/helper/voice';
|
||||
import getters, { getSelectedChatConversation } from './getters';
|
||||
import actions from './actions';
|
||||
import { findPendingMessageIndex } from './helpers';
|
||||
@@ -286,6 +287,24 @@ export const mutations = {
|
||||
chat.additional_attributes = {};
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -13,16 +13,35 @@ export const getAudioContext = () => {
|
||||
// eslint-disable-next-line default-param-last
|
||||
export const getAlertAudio = async (baseUrl = '', requestContext) => {
|
||||
const audioCtx = getAudioContext();
|
||||
let lastSource;
|
||||
const stopLast = () => {
|
||||
try {
|
||||
if (lastSource) {
|
||||
lastSource.stop();
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore stop errors
|
||||
} finally {
|
||||
lastSource = null;
|
||||
}
|
||||
};
|
||||
|
||||
const playSound = audioBuffer => {
|
||||
window.playAudioAlert = () => {
|
||||
if (audioCtx) {
|
||||
stopLast();
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioCtx.destination);
|
||||
source.loop = false;
|
||||
source.start();
|
||||
lastSource = source;
|
||||
source.onended = () => {
|
||||
if (lastSource === source) lastSource = null;
|
||||
};
|
||||
}
|
||||
};
|
||||
window.stopAudioAlert = stopLast;
|
||||
};
|
||||
|
||||
if (audioCtx) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user