mirror of
https://github.com/lingble/chatwoot.git
synced 2025-12-12 15:55:24 +00:00
230 lines
7.9 KiB
Ruby
230 lines
7.9 KiB
Ruby
require 'twilio-ruby'
|
||
|
||
class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||
before_action :fetch_conversation, only: %i[end_call join_call reject_call]
|
||
skip_before_action :authenticate_user!, only: :twiml_for_client
|
||
protect_from_forgery with: :null_session, only: :twiml_for_client
|
||
|
||
before_action :render_options, if: -> { request.options? }
|
||
after_action :set_cors_headers, if: -> { action_name == 'twiml_for_client' }
|
||
|
||
# ---------- PUBLIC ACTIONS --------------------------------------------------
|
||
|
||
def end_call
|
||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||
return render_not_found('active call') unless call_sid
|
||
|
||
twilio_client.calls(call_sid).update(status: 'completed') if in_progress?(call_sid)
|
||
|
||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||
call_sid: call_sid,
|
||
provider: :twilio)
|
||
.process_status_update('completed', nil, false, "Call ended by #{current_user.name}")
|
||
|
||
broadcast_status(call_sid, 'completed')
|
||
render_success('Call successfully ended')
|
||
rescue StandardError => e
|
||
render_error("Failed to end call: #{e.message}")
|
||
end
|
||
|
||
def join_call
|
||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||
outbound = convo_attr('requires_agent_join') == true
|
||
|
||
return render_not_found('active call') unless call_sid || outbound
|
||
|
||
conference_sid = convo_attr('conference_sid') || create_conference_sid!
|
||
update_join_metadata!(call_sid)
|
||
broadcast_status(call_sid, 'in-progress')
|
||
|
||
render json: {
|
||
status: 'success',
|
||
message: 'Agent joining call via WebRTC',
|
||
conference_sid: conference_sid,
|
||
using_webrtc: true,
|
||
conversation_id: @conversation.display_id,
|
||
account_id: Current.account.id
|
||
}
|
||
rescue StandardError => e
|
||
render_error("Failed to join call: #{e.message}")
|
||
end
|
||
|
||
def reject_call
|
||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||
return render_not_found('active call') unless call_sid
|
||
|
||
@conversation.update!(additional_attributes: convo_attrs.merge(
|
||
'agent_rejected' => true,
|
||
'rejected_at' => Time.current.to_i,
|
||
'rejected_by' => user_meta
|
||
))
|
||
|
||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||
call_sid: call_sid,
|
||
provider: :twilio)
|
||
.create_activity_message("#{current_user.name} declined to answer",
|
||
rejected_by: current_user.name,
|
||
rejected_at: Time.current.to_i)
|
||
|
||
render_success('Call rejected by agent')
|
||
end
|
||
|
||
def call_status
|
||
call_sid = params[:call_sid]
|
||
return render_not_found('active call') unless call_sid
|
||
|
||
call = twilio_client.calls(call_sid).fetch
|
||
render json: call.slice(:status, :duration, :direction, :from, :to, :start_time, :end_time)
|
||
rescue StandardError => e
|
||
render_error("Failed to fetch call status: #{e.message}")
|
||
end
|
||
|
||
# TwiML for agent WebRTC dial‑in
|
||
def twiml_for_client
|
||
to = params[:To] || params[:to]
|
||
return render_twiml_error('Missing conference ID parameter') if to.blank?
|
||
|
||
render xml: build_twiml(to), content_type: 'text/xml'
|
||
rescue StandardError => e
|
||
render_twiml_error(e.message)
|
||
end
|
||
|
||
# ---------- PRIVATE ---------------------------------------------------------
|
||
|
||
private
|
||
|
||
# ---- Helpers ---------------------------------------------------------------
|
||
|
||
def render_options
|
||
head :ok
|
||
end
|
||
|
||
def set_cors_headers
|
||
headers['Content-Type'] ||= 'text/xml; charset=utf-8'
|
||
headers['Access-Control-Allow-Origin'] = '*'
|
||
headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
|
||
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
|
||
headers['Access-Control-Max-Age'] = '86400'
|
||
end
|
||
|
||
def render_success(msg) = render json: { status: 'success', message: msg }
|
||
def render_not_found(resource) = render json: { error: "No #{resource} found" }, status: :not_found
|
||
def render_error(msg) = render json: { error: msg }, status: :internal_server_error
|
||
|
||
def fetch_conversation
|
||
@conversation = Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||
end
|
||
|
||
def twilio_client
|
||
@twilio_client ||= begin
|
||
cfg = @conversation.inbox.channel.provider_config_hash
|
||
Twilio::REST::Client.new(cfg['account_sid'], cfg['auth_token'])
|
||
end
|
||
end
|
||
|
||
def in_progress?(call_sid)
|
||
%w[in-progress ringing].include?(twilio_client.calls(call_sid).fetch.status)
|
||
end
|
||
|
||
def convo_attrs
|
||
@conversation.additional_attributes || {}
|
||
end
|
||
|
||
def convo_attr(key)
|
||
convo_attrs[key]
|
||
end
|
||
|
||
def user_meta
|
||
{ id: current_user.id, name: current_user.name }
|
||
end
|
||
|
||
def create_conference_sid!
|
||
sid = "conf_account_#{Current.account.id}_conv_#{@conversation.display_id}"
|
||
@conversation.update!(additional_attributes: convo_attrs.merge('conference_sid' => sid))
|
||
sid
|
||
end
|
||
|
||
def update_join_metadata!(call_sid)
|
||
@conversation.update!(additional_attributes: convo_attrs.merge(
|
||
'agent_joined' => true,
|
||
'joined_at' => Time.current.to_i,
|
||
'joined_by' => user_meta,
|
||
'call_status' => 'in-progress'
|
||
))
|
||
|
||
Voice::CallStatus::Manager.new(conversation: @conversation,
|
||
call_sid: call_sid,
|
||
provider: :twilio)
|
||
.process_status_update('in-progress', nil, false, "#{current_user.name} joined the call")
|
||
end
|
||
|
||
def broadcast_status(call_sid, status)
|
||
ActionCable.server.broadcast "account_#{@conversation.account_id}", {
|
||
event_name: 'call_status_changed',
|
||
data: {
|
||
call_sid: call_sid,
|
||
status: status,
|
||
conversation_id: @conversation.display_id,
|
||
inbox_id: @conversation.inbox_id,
|
||
timestamp: Time.current.to_i
|
||
}
|
||
}
|
||
end
|
||
|
||
# ---- TwiML -----------------------------------------------------------------
|
||
|
||
def build_twiml(conference_name)
|
||
# For agent legs, we need to add transcription too
|
||
account_id = params[:account_id] || Current.account&.id
|
||
agent_id = params[:agent_id] || current_user&.id
|
||
transcription_url = "#{base_url}/twilio/transcription_callback?account_id=#{account_id}&conference_sid=#{conference_name}&speaker_type=agent&agent_id=#{agent_id}"
|
||
|
||
Twilio::TwiML::VoiceResponse.new do |r|
|
||
# Add transcription for the agent leg too
|
||
r.start do |start|
|
||
start.transcription(
|
||
status_callback_url: transcription_url,
|
||
status_callback_method: 'POST',
|
||
track: 'inbound_track', # Use inbound_track consistently for conference calls
|
||
language_code: 'en-US'
|
||
)
|
||
end
|
||
|
||
r.dial do |dial|
|
||
dial.conference(
|
||
conference_name,
|
||
startConferenceOnEnter: true,
|
||
endConferenceOnExit: true,
|
||
muted: false,
|
||
beep: false,
|
||
waitUrl: '',
|
||
earlyMedia: true,
|
||
statusCallback: conference_callback_url,
|
||
statusCallbackEvent: 'start end join leave',
|
||
statusCallbackMethod: 'POST',
|
||
participantLabel: "agent-#{params[:agent_id] || current_user&.id}"
|
||
)
|
||
end
|
||
end.to_s
|
||
end
|
||
|
||
def conference_callback_url
|
||
account_id = params[:account_id] || Current.account&.id
|
||
"#{base_url.chomp('/')}/api/v1/accounts/#{account_id}/channels/voice/webhooks/conference_status"
|
||
end
|
||
|
||
def base_url
|
||
ENV.fetch('FRONTEND_URL', '')
|
||
end
|
||
|
||
# ---- TwiML Error -----------------------------------------------------------
|
||
|
||
def render_twiml_error(message)
|
||
response = Twilio::TwiML::VoiceResponse.new do |r|
|
||
r.say(message: "Error: #{message}")
|
||
r.hangup
|
||
end
|
||
set_cors_headers
|
||
render xml: response.to_s, content_type: 'text/xml'
|
||
end
|
||
end |