feat(enterprise): add voice conference API (#13064)

The backend APIs for the voice call channel 
ref: #11602
This commit is contained in:
Sojan Jose
2025-12-15 15:11:59 -08:00
committed by GitHub
parent 3fce56c98f
commit d2ba9a2ad3
14 changed files with 518 additions and 49 deletions

View File

@@ -0,0 +1,58 @@
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox_for_conference
def token
render json: Voice::Provider::Twilio::TokenService.new(
inbox: @voice_inbox,
user: Current.user,
account: Current.account
).generate
end
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user)
render json: {
status: 'success',
id: conversation.display_id,
conference_sid: conference_sid,
using_webrtc: true
}
end
def destroy
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
render json: { status: 'success', id: conversation.display_id }
end
private
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
incoming_sid = params.require(:call_sid)
conversation.update!(identifier: incoming_sid)
incoming_sid
end
def set_voice_inbox_for_conference
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @voice_inbox, :show?
end
def fetch_conversation_by_display_id
cid = params[:conversation_id]
raise ActiveRecord::RecordNotFound, 'conversation_id required' if cid.blank?
conversation = @voice_inbox.conversations.find_by!(display_id: cid)
authorize conversation, :show?
conversation
end
end

View File

@@ -151,9 +151,8 @@ class Twilio::VoiceController < ApplicationController
end
def conference_status_callback_url
host = ENV.fetch('FRONTEND_URL', '')
phone_digits = inbox_channel.phone_number.delete_prefix('+')
"#{host}/twilio/voice/conference_status/#{phone_digits}"
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def find_conversation_for_conference!(friendly_name:, call_sid:)

View File

@@ -42,11 +42,13 @@ class Channel::Voice < ApplicationRecord
false
end
def initiate_call(to:)
def initiate_call(to:, conference_sid: nil, agent_id: nil)
case provider
when 'twilio'
Voice::Provider::TwilioAdapter.new(self).initiate_call(
to: to
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
)
else
raise "Unsupported voice provider: #{provider}"
@@ -56,12 +58,12 @@ class Channel::Voice < ApplicationRecord
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}"
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}"
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
private
@@ -87,7 +89,6 @@ class Channel::Voice < ApplicationRecord
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
# twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
def provider_config_hash
if provider_config.is_a?(Hash)

View File

@@ -0,0 +1,52 @@
class Voice::Provider::Twilio::Adapter
def initialize(channel)
@channel = channel
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
call = twilio_client.calls.create(**call_params(to))
{
provider: 'twilio',
call_sid: call.sid,
status: call.status,
call_direction: 'outbound',
requires_agent_join: true,
agent_id: agent_id,
conference_sid: conference_sid
}
end
private
def call_params(to)
phone_digits = @channel.phone_number.delete_prefix('+')
{
from: @channel.phone_number,
to: to,
url: twilio_call_twiml_url(phone_digits),
status_callback: twilio_call_status_url(phone_digits),
status_callback_event: %w[
initiated ringing answered completed failed busy no-answer canceled
],
status_callback_method: 'POST'
}
end
def twilio_call_twiml_url(phone_digits)
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
end
def twilio_call_status_url(phone_digits)
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
end
def twilio_client
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
def config
@config ||= @channel.provider_config_hash
end
end

View File

@@ -0,0 +1,46 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!, { twilio_client: nil }]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
return existing if existing.present?
sid = Voice::Conference::Name.for(conversation)
merge_attributes('conference_sid' => sid)
sid
end
def mark_agent_joined(user:)
merge_attributes(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => { id: user.id, name: user.name }
)
end
def end_conference
twilio_client
.conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
end
private
def merge_attributes(attrs)
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
def twilio_client
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
end
def account_sid
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
end
def auth_token
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
end
end

View File

@@ -0,0 +1,62 @@
class Voice::Provider::Twilio::TokenService
pattr_initialize [:inbox!, :user!, :account!]
def generate
{
token: access_token.to_jwt,
identity: identity,
voice_enabled: true,
account_sid: config['account_sid'],
agent_id: user.id,
account_id: account.id,
inbox_id: inbox.id,
phone_number: inbox.channel.phone_number,
twiml_endpoint: twiml_url,
has_twiml_app: config['twiml_app_sid'].present?
}
end
private
def config
@config ||= inbox.channel.provider_config_hash || {}
end
def identity
@identity ||= "agent-#{user.id}-account-#{account.id}"
end
def access_token
Twilio::JWT::AccessToken.new(
config['account_sid'],
config['api_key_sid'],
config['api_key_secret'],
identity: identity,
ttl: 1.hour.to_i
).tap { |token| token.add_grant(voice_grant) }
end
def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true
grant.outgoing_application_sid = config['twiml_app_sid']
grant.outgoing_application_params = outgoing_params
end
end
def outgoing_params
{
account_id: account.id,
agent_id: user.id,
identity: identity,
client_name: identity,
accountSid: config['account_sid'],
is_agent: 'true'
}
end
def twiml_url
digits = inbox.channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
end

View File

@@ -1,32 +0,0 @@
class Voice::Provider::TwilioAdapter
def initialize(channel)
@channel = channel
end
def initiate_call(to:, _conference_sid: nil, _agent_id: nil)
cfg = @channel.provider_config_hash
host = ENV.fetch('FRONTEND_URL')
phone_digits = @channel.phone_number.delete_prefix('+')
callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
params = {
from: @channel.phone_number,
to: to,
url: callback_url,
status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
status_callback_event: %w[initiated ringing answered completed],
status_callback_method: 'POST'
}
call = twilio_client(cfg).calls.create(**params)
{ call_sid: call.sid }
end
private
def twilio_client(config)
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
end