feat(enterprise): add voice conference API (#13064)
The backend APIs for the voice call channel ref: #11602
This commit is contained in:
@@ -48,6 +48,7 @@
|
|||||||
- Remove dead/unreachable/unused code
|
- Remove dead/unreachable/unused code
|
||||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||||
- Don't reference Claude in commit messages
|
- Don't reference Claude in commit messages
|
||||||
|
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||||
|
|
||||||
## Project-Specific
|
## Project-Specific
|
||||||
|
|
||||||
@@ -78,3 +79,4 @@ Practical checklist for any change impacting core logic or public APIs
|
|||||||
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
||||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||||
|
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
||||||
|
|||||||
@@ -203,9 +203,15 @@ Rails.application.routes.draw do
|
|||||||
delete :avatar, on: :member
|
delete :avatar, on: :member
|
||||||
post :sync_templates, on: :member
|
post :sync_templates, on: :member
|
||||||
get :health, on: :member
|
get :health, on: :member
|
||||||
|
if ChatwootApp.enterprise?
|
||||||
|
resource :conference, only: %i[create destroy], controller: 'conference' do
|
||||||
|
get :token, on: :member
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
|
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
||||||
collection do
|
collection do
|
||||||
delete :destroy
|
delete :destroy
|
||||||
@@ -550,13 +556,9 @@ Rails.application.routes.draw do
|
|||||||
resources :delivery_status, only: [:create]
|
resources :delivery_status, only: [:create]
|
||||||
|
|
||||||
if ChatwootApp.enterprise?
|
if ChatwootApp.enterprise?
|
||||||
resource :voice, only: [], controller: 'voice' do
|
post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call
|
||||||
collection do
|
post 'voice/status/:phone', to: 'voice#status', as: :voice_status
|
||||||
post 'call/:phone', action: :call_twiml
|
post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status
|
||||||
post 'status/:phone', action: :status
|
|
||||||
post 'conference_status/:phone', action: :conference_status
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -151,9 +151,8 @@ class Twilio::VoiceController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def conference_status_callback_url
|
def conference_status_callback_url
|
||||||
host = ENV.fetch('FRONTEND_URL', '')
|
|
||||||
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
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
|
end
|
||||||
|
|
||||||
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ class Channel::Voice < ApplicationRecord
|
|||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def initiate_call(to:)
|
def initiate_call(to:, conference_sid: nil, agent_id: nil)
|
||||||
case provider
|
case provider
|
||||||
when 'twilio'
|
when 'twilio'
|
||||||
Voice::Provider::TwilioAdapter.new(self).initiate_call(
|
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
|
||||||
to: to
|
to: to,
|
||||||
|
conference_sid: conference_sid,
|
||||||
|
agent_id: agent_id
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
raise "Unsupported voice provider: #{provider}"
|
raise "Unsupported voice provider: #{provider}"
|
||||||
@@ -56,12 +58,12 @@ class Channel::Voice < ApplicationRecord
|
|||||||
# Public URLs used to configure Twilio webhooks
|
# Public URLs used to configure Twilio webhooks
|
||||||
def voice_call_webhook_url
|
def voice_call_webhook_url
|
||||||
digits = phone_number.delete_prefix('+')
|
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
|
end
|
||||||
|
|
||||||
def voice_status_webhook_url
|
def voice_status_webhook_url
|
||||||
digits = phone_number.delete_prefix('+')
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -87,7 +89,6 @@ class Channel::Voice < ApplicationRecord
|
|||||||
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
# twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
|
|
||||||
|
|
||||||
def provider_config_hash
|
def provider_config_hash
|
||||||
if provider_config.is_a?(Hash)
|
if provider_config.is_a?(Hash)
|
||||||
|
|||||||
52
enterprise/app/services/voice/provider/twilio/adapter.rb
Normal file
52
enterprise/app/services/voice/provider/twilio/adapter.rb
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:voice_channel) { create(:channel_voice, account: account) }
|
||||||
|
let(:voice_inbox) { voice_channel.inbox }
|
||||||
|
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
|
||||||
|
let(:admin) { create(:user, :administrator, account: account) }
|
||||||
|
let(:agent) { create(:user, account: account, role: :agent) }
|
||||||
|
|
||||||
|
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||||
|
let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
|
||||||
|
let(:conference_service) do
|
||||||
|
instance_double(
|
||||||
|
Voice::Provider::Twilio::ConferenceService,
|
||||||
|
ensure_conference_sid: 'CF123',
|
||||||
|
mark_agent_joined: true,
|
||||||
|
end_conference: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||||
|
allow(Twilio::JWT::AccessToken::VoiceGrant).to receive(:new).and_return(voice_grant)
|
||||||
|
allow(voice_grant).to receive(:outgoing_application_sid=)
|
||||||
|
allow(voice_grant).to receive(:outgoing_application_params=)
|
||||||
|
allow(voice_grant).to receive(:incoming_allow=)
|
||||||
|
allow(Voice::Provider::Twilio::ConferenceService).to receive(:new).and_return(conference_service)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /conference/token' do
|
||||||
|
context 'when unauthenticated' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when authenticated agent with inbox access' do
|
||||||
|
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
|
||||||
|
|
||||||
|
it 'returns token payload' do
|
||||||
|
fake_token = instance_double(Twilio::JWT::AccessToken, to_jwt: 'jwt-token', add_grant: nil)
|
||||||
|
allow(Twilio::JWT::AccessToken).to receive(:new).and_return(fake_token)
|
||||||
|
|
||||||
|
get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token",
|
||||||
|
headers: agent.create_new_auth_token
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
body = response.parsed_body
|
||||||
|
expect(body['token']).to eq('jwt-token')
|
||||||
|
expect(body['account_id']).to eq(account.id)
|
||||||
|
expect(body['inbox_id']).to eq(voice_inbox.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST /conference' do
|
||||||
|
context 'when unauthenticated' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when authenticated agent with inbox access' do
|
||||||
|
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
|
||||||
|
|
||||||
|
it 'creates conference and sets identifier' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
body = response.parsed_body
|
||||||
|
expect(body['conference_sid']).to be_present
|
||||||
|
conversation.reload
|
||||||
|
expect(conversation.identifier).to eq('CALL123')
|
||||||
|
expect(conference_service).to have_received(:ensure_conference_sid)
|
||||||
|
expect(conference_service).to have_received(:mark_agent_joined)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow accessing conversations from inboxes without access' do
|
||||||
|
other_inbox = create(:inbox, account: account)
|
||||||
|
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
|
||||||
|
|
||||||
|
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: { conversation_id: other_conversation.display_id, call_sid: 'CALL123' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
other_conversation.reload
|
||||||
|
expect(other_conversation.identifier).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns conflict when call_sid missing' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: { conversation_id: conversation.display_id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'DELETE /conference' do
|
||||||
|
context 'when unauthenticated' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when authenticated agent with inbox access' do
|
||||||
|
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
|
||||||
|
|
||||||
|
it 'ends conference and returns success' do
|
||||||
|
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: { conversation_id: conversation.display_id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
||||||
|
expect(conference_service).to have_received(:end_conference)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow ending conferences for conversations from inboxes without access' do
|
||||||
|
other_inbox = create(:inbox, account: account)
|
||||||
|
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
|
||||||
|
|
||||||
|
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: { conversation_id: other_conversation.display_id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -102,8 +102,9 @@ RSpec.describe Captain::CustomTool, type: :model do
|
|||||||
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
|
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
|
||||||
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
|
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
|
||||||
|
|
||||||
expect(described_class.enabled).to include(enabled_tool)
|
enabled_ids = described_class.enabled.pluck(:id)
|
||||||
expect(described_class.enabled).not_to include(disabled_tool)
|
expect(enabled_ids).to include(enabled_tool.id)
|
||||||
|
expect(enabled_ids).not_to include(disabled_tool.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Voice::Provider::Twilio::Adapter do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:channel) { create(:channel_voice, account: account) }
|
||||||
|
let(:adapter) { described_class.new(channel) }
|
||||||
|
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||||
|
let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) }
|
||||||
|
let(:call_instance) do
|
||||||
|
instance_double(Twilio::REST::Api::V2010::AccountContext::CallInstance, sid: 'CA123', status: 'queued')
|
||||||
|
end
|
||||||
|
let(:client_double) { instance_double(Twilio::REST::Client, calls: calls_double) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'initiates an outbound call with expected params' do
|
||||||
|
allow(calls_double).to receive(:create).and_return(call_instance)
|
||||||
|
|
||||||
|
allow(Twilio::REST::Client).to receive(:new)
|
||||||
|
.with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token'])
|
||||||
|
.and_return(client_double)
|
||||||
|
|
||||||
|
result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42)
|
||||||
|
phone_digits = channel.phone_number.delete_prefix('+')
|
||||||
|
expected_url = Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
|
||||||
|
expected_status_callback = Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
|
||||||
|
|
||||||
|
expect(calls_double).to have_received(:create).with(hash_including(
|
||||||
|
from: channel.phone_number,
|
||||||
|
to: '+15550001111',
|
||||||
|
url: expected_url,
|
||||||
|
status_callback: expected_status_callback,
|
||||||
|
status_callback_event: array_including('completed', 'failed', 'busy', 'no-answer',
|
||||||
|
'canceled')
|
||||||
|
))
|
||||||
|
expect(result[:call_sid]).to eq('CA123')
|
||||||
|
expect(result[:conference_sid]).to eq('CF999')
|
||||||
|
expect(result[:agent_id]).to eq(42)
|
||||||
|
expect(result[:call_direction]).to eq('outbound')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Voice::Provider::Twilio::ConferenceService do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:channel) { create(:channel_voice, account: account) }
|
||||||
|
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
|
||||||
|
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||||
|
let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) }
|
||||||
|
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#ensure_conference_sid' do
|
||||||
|
it 'returns existing sid if present' do
|
||||||
|
conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' })
|
||||||
|
|
||||||
|
expect(service.ensure_conference_sid).to eq('CF_EXISTING')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets and returns generated sid when missing' do
|
||||||
|
allow(Voice::Conference::Name).to receive(:for).and_return('CF_GEN')
|
||||||
|
|
||||||
|
sid = service.ensure_conference_sid
|
||||||
|
|
||||||
|
expect(sid).to eq('CF_GEN')
|
||||||
|
expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#mark_agent_joined' do
|
||||||
|
it 'stores agent join metadata' do
|
||||||
|
agent = create(:user, account: account)
|
||||||
|
|
||||||
|
service.mark_agent_joined(user: agent)
|
||||||
|
|
||||||
|
attrs = conversation.reload.additional_attributes
|
||||||
|
expect(attrs['agent_joined']).to be true
|
||||||
|
expect(attrs['joined_by']['id']).to eq(agent.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#end_conference' do
|
||||||
|
it 'completes in-progress conferences' do
|
||||||
|
conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
|
||||||
|
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
|
||||||
|
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
|
||||||
|
|
||||||
|
allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
|
||||||
|
allow(conferences_proxy).to receive(:list).and_return([conf_instance])
|
||||||
|
allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
|
||||||
|
allow(conf_context).to receive(:update).with(status: 'completed')
|
||||||
|
|
||||||
|
service.end_conference
|
||||||
|
|
||||||
|
expect(conf_context).to have_received(:update).with(status: 'completed')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Voice::Provider::Twilio::TokenService do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:user) { create(:user, :administrator, account: account) }
|
||||||
|
let(:voice_channel) { create(:channel_voice, account: account) }
|
||||||
|
let(:inbox) { voice_channel.inbox }
|
||||||
|
|
||||||
|
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||||
|
let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||||
|
allow(Twilio::JWT::AccessToken::VoiceGrant).to receive(:new).and_return(voice_grant)
|
||||||
|
allow(voice_grant).to receive(:outgoing_application_sid=)
|
||||||
|
allow(voice_grant).to receive(:outgoing_application_params=)
|
||||||
|
allow(voice_grant).to receive(:incoming_allow=)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns a token payload with expected keys' do
|
||||||
|
fake_token = instance_double(Twilio::JWT::AccessToken, to_jwt: 'jwt-token', add_grant: nil)
|
||||||
|
allow(Twilio::JWT::AccessToken).to receive(:new).and_return(fake_token)
|
||||||
|
|
||||||
|
payload = described_class.new(inbox: inbox, user: user, account: account).generate
|
||||||
|
|
||||||
|
expect(payload[:token]).to eq('jwt-token')
|
||||||
|
expect(payload[:identity]).to include("agent-#{user.id}")
|
||||||
|
expect(payload[:inbox_id]).to eq(inbox.id)
|
||||||
|
expect(payload[:account_id]).to eq(account.id)
|
||||||
|
expect(payload[:voice_enabled]).to be true
|
||||||
|
expect(payload[:twiml_endpoint]).to include(voice_channel.phone_number.delete_prefix('+'))
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user