feat(enterprise): add voice conference API (#13064)
The backend APIs for the voice call channel ref: #11602
This commit is contained in:
@@ -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)
|
||||
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
|
||||
|
||||
expect(described_class.enabled).to include(enabled_tool)
|
||||
expect(described_class.enabled).not_to include(disabled_tool)
|
||||
enabled_ids = described_class.enabled.pluck(:id)
|
||||
expect(enabled_ids).to include(enabled_tool.id)
|
||||
expect(enabled_ids).not_to include(disabled_tool.id)
|
||||
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