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
|
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,
|
||||||
@@ -41,21 +40,6 @@ class Twilio::VoiceController < ApplicationController
|
|||||||
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
// Use the regular contacts call endpoint for existing contacts
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`/api/v1/accounts/${accountId}/contacts/${contactId}/call`
|
`/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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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