feat: outbound voice call essentials (#12782)
- Enables outbound voice calls in voice channel . We are only caring about wiring the logic to trigger outgoing calls to the call button introduced in previous PRs. We will connect it to call component in subsequent PRs ref: #11602 ## Screens <img width="2304" height="1202" alt="image" src="https://github.com/user-attachments/assets/b91543a8-8d4e-4229-bd80-9727b42c7b0f" /> <img width="2304" height="1200" alt="image" src="https://github.com/user-attachments/assets/1a1dad2a-8cb2-4aa2-9702-c062416556a7" /> --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
This commit is contained in:
@@ -18,36 +18,94 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
let(:from_number) { '+15550003333' }
|
||||
let(:to_number) { channel.phone_number }
|
||||
|
||||
it 'invokes Voice::InboundCallBuilder with expected params and renders its TwiML' do
|
||||
builder_double = instance_double(Voice::InboundCallBuilder)
|
||||
expect(Voice::InboundCallBuilder).to receive(:new).with(
|
||||
hash_including(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
call_sid: call_sid
|
||||
)
|
||||
).and_return(builder_double)
|
||||
expect(builder_double).to receive(:perform).and_return(builder_double)
|
||||
expect(builder_double).to receive(:twiml_response).and_return('<Response/>')
|
||||
it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
|
||||
instance_double(Voice::InboundCallBuilder)
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
|
||||
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
call_sid: call_sid
|
||||
).and_return(conversation)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number
|
||||
'To' => to_number,
|
||||
'Direction' => 'inbound'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('<Response>')
|
||||
expect(response.body).to include('<Dial>')
|
||||
end
|
||||
|
||||
it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: call_sid)
|
||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||
|
||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||
hash_including(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid,
|
||||
message_call_sid: conversation.identifier,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
direction: 'outbound'
|
||||
}
|
||||
)
|
||||
).and_return(sync_double)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'Direction' => 'outbound-api'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('<Response>')
|
||||
end
|
||||
|
||||
it 'uses the parent call SID when syncing outbound-dial legs' do
|
||||
parent_sid = 'CA_parent'
|
||||
child_sid = 'CA_child'
|
||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: parent_sid)
|
||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||
|
||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||
hash_including(
|
||||
conversation: conversation,
|
||||
call_sid: child_sid,
|
||||
message_call_sid: parent_sid,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
direction: 'outbound'
|
||||
}
|
||||
)
|
||||
).and_return(sync_double)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => child_sid,
|
||||
'ParentCallSid' => parent_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'Direction' => 'outbound-dial'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to eq('<Response/>')
|
||||
end
|
||||
|
||||
it 'raises not found when inbox is not present' do
|
||||
expect(Voice::InboundCallBuilder).not_to receive(:new)
|
||||
expect(Voice::InboundCallBuilder).not_to receive(:perform!)
|
||||
post '/twilio/voice/call/19998887777', params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number
|
||||
'To' => to_number,
|
||||
'Direction' => 'inbound'
|
||||
}
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
@@ -62,7 +120,8 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
hash_including(
|
||||
account: account,
|
||||
call_sid: call_sid,
|
||||
call_status: 'completed'
|
||||
call_status: 'completed',
|
||||
payload: hash_including('CallSid' => call_sid, 'CallStatus' => 'completed')
|
||||
)
|
||||
).and_return(service_double)
|
||||
expect(service_double).to receive(:perform)
|
||||
|
||||
@@ -4,54 +4,118 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Voice::InboundCallBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230001') }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') }
|
||||
let(:inbox) { channel.inbox }
|
||||
|
||||
let(:from_number) { '+15550001111' }
|
||||
let(:to_number) { channel.phone_number }
|
||||
let(:call_sid) { 'CA1234567890abcdef' }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
|
||||
end
|
||||
|
||||
def build_and_perform
|
||||
described_class.new(
|
||||
def perform_builder
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
call_sid: call_sid
|
||||
).perform
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a new conversation with inbound ringing attributes' do
|
||||
builder = build_and_perform
|
||||
conversation = builder.conversation
|
||||
expect(conversation).to be_present
|
||||
expect(conversation.account_id).to eq(account.id)
|
||||
expect(conversation.inbox_id).to eq(inbox.id)
|
||||
expect(conversation.identifier).to eq(call_sid)
|
||||
expect(conversation.additional_attributes['call_direction']).to eq('inbound')
|
||||
expect(conversation.additional_attributes['call_status']).to eq('ringing')
|
||||
context 'when no existing conversation matches call_sid' do
|
||||
it 'creates a new inbound conversation with ringing status' do
|
||||
conversation = nil
|
||||
expect { conversation = perform_builder }.to change(account.conversations, :count).by(1)
|
||||
|
||||
attrs = conversation.additional_attributes
|
||||
expect(conversation.identifier).to eq(call_sid)
|
||||
expect(attrs['call_direction']).to eq('inbound')
|
||||
expect(attrs['call_status']).to eq('ringing')
|
||||
expect(attrs['conference_sid']).to be_present
|
||||
expect(attrs.dig('meta', 'initiated_at')).to be_present
|
||||
expect(conversation.contact.phone_number).to eq(from_number)
|
||||
end
|
||||
|
||||
it 'creates a single voice_call message marked as incoming' do
|
||||
conversation = perform_builder
|
||||
voice_message = conversation.messages.voice_calls.last
|
||||
|
||||
expect(voice_message).to be_present
|
||||
expect(voice_message.message_type).to eq('incoming')
|
||||
data = voice_message.content_attributes['data']
|
||||
expect(data).to include(
|
||||
'call_sid' => call_sid,
|
||||
'status' => 'ringing',
|
||||
'call_direction' => 'inbound',
|
||||
'conference_sid' => conversation.additional_attributes['conference_sid'],
|
||||
'from_number' => from_number,
|
||||
'to_number' => inbox.channel.phone_number
|
||||
)
|
||||
expect(data['meta']['created_at']).to be_present
|
||||
expect(data['meta']['ringing_at']).to be_present
|
||||
end
|
||||
|
||||
it 'sets the contact name to the phone number for new callers' do
|
||||
conversation = perform_builder
|
||||
|
||||
expect(conversation.contact.name).to eq(from_number)
|
||||
end
|
||||
|
||||
it 'ensures the conversation has a display_id before building the conference SID' do
|
||||
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
|
||||
expect(conversation.display_id).to be_present
|
||||
original.call(conversation)
|
||||
end
|
||||
|
||||
perform_builder
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a voice_call message with ringing status' do
|
||||
builder = build_and_perform
|
||||
conversation = builder.conversation
|
||||
msg = conversation.messages.voice_calls.last
|
||||
expect(msg).to be_present
|
||||
expect(msg.message_type).to eq('incoming')
|
||||
expect(msg.content_type).to eq('voice_call')
|
||||
expect(msg.content_attributes.dig('data', 'call_sid')).to eq(call_sid)
|
||||
expect(msg.content_attributes.dig('data', 'status')).to eq('ringing')
|
||||
end
|
||||
context 'when a conversation already exists for the call_sid' do
|
||||
let(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
||||
let!(:existing_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox,
|
||||
identifier: call_sid,
|
||||
additional_attributes: { 'call_direction' => 'outbound', 'conference_sid' => nil }
|
||||
)
|
||||
end
|
||||
let(:existing_message) do
|
||||
create(
|
||||
:message,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: existing_conversation,
|
||||
message_type: :incoming,
|
||||
content_type: :voice_call,
|
||||
sender: contact,
|
||||
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'queued' } }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns TwiML that informs the caller we are connecting' do
|
||||
builder = build_and_perform
|
||||
xml = builder.twiml_response
|
||||
expect(xml).to include('Please wait while we connect you to an agent')
|
||||
expect(xml).to include('<Say')
|
||||
it 'reuses the conversation without creating a duplicate' do
|
||||
existing_message
|
||||
expect { perform_builder }.not_to change(account.conversations, :count)
|
||||
existing_conversation.reload
|
||||
expect(existing_conversation.additional_attributes['call_direction']).to eq('inbound')
|
||||
expect(existing_conversation.additional_attributes['call_status']).to eq('ringing')
|
||||
end
|
||||
|
||||
it 'updates the existing voice call message instead of creating a new one' do
|
||||
existing_message
|
||||
expect { perform_builder }.not_to(change { existing_conversation.reload.messages.voice_calls.count })
|
||||
updated_message = existing_conversation.reload.messages.voice_calls.last
|
||||
|
||||
data = updated_message.content_attributes['data']
|
||||
expect(data['status']).to eq('ringing')
|
||||
expect(data['call_direction']).to eq('inbound')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
97
spec/enterprise/services/voice/outbound_call_builder_spec.rb
Normal file
97
spec/enterprise/services/voice/outbound_call_builder_spec.rb
Normal file
@@ -0,0 +1,97 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Voice::OutboundCallBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
|
||||
let(:call_sid) { 'CA1234567890abcdef' }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
|
||||
allow(inbox).to receive(:channel).and_return(channel)
|
||||
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
|
||||
allow(Voice::Conference::Name).to receive(:for).and_call_original
|
||||
end
|
||||
|
||||
describe '.perform!' do
|
||||
it 'creates a conversation and voice call message' do
|
||||
conversation_count = account.conversations.count
|
||||
inbox_link_count = contact.contact_inboxes.where(inbox_id: inbox.id).count
|
||||
|
||||
result = described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
|
||||
expect(account.conversations.count).to eq(conversation_count + 1)
|
||||
expect(contact.contact_inboxes.where(inbox_id: inbox.id).count).to eq(inbox_link_count + 1)
|
||||
|
||||
conversation = result[:conversation].reload
|
||||
attrs = conversation.additional_attributes
|
||||
|
||||
aggregate_failures do
|
||||
expect(result[:call_sid]).to eq(call_sid)
|
||||
expect(conversation.identifier).to eq(call_sid)
|
||||
expect(attrs).to include('call_direction' => 'outbound', 'call_status' => 'ringing')
|
||||
expect(attrs['agent_id']).to eq(user.id)
|
||||
expect(attrs['conference_sid']).to be_present
|
||||
|
||||
voice_message = conversation.messages.voice_calls.last
|
||||
expect(voice_message.message_type).to eq('outgoing')
|
||||
|
||||
message_data = voice_message.content_attributes['data']
|
||||
expect(message_data).to include(
|
||||
'call_sid' => call_sid,
|
||||
'conference_sid' => attrs['conference_sid'],
|
||||
'from_number' => channel.phone_number,
|
||||
'to_number' => contact.phone_number
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises an error when contact is missing a phone number' do
|
||||
contact.update!(phone_number: nil)
|
||||
|
||||
expect do
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
end.to raise_error(ArgumentError, 'Contact phone number required')
|
||||
end
|
||||
|
||||
it 'raises an error when user is nil' do
|
||||
expect do
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: nil,
|
||||
contact: contact
|
||||
)
|
||||
end.to raise_error(ArgumentError, 'Agent required')
|
||||
end
|
||||
|
||||
it 'ensures the conversation has a display_id before building the conference SID' do
|
||||
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
|
||||
expect(conversation.display_id).to be_present
|
||||
original.call(conversation)
|
||||
end
|
||||
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -55,6 +55,23 @@ RSpec.describe Voice::StatusUpdateService do
|
||||
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
|
||||
end
|
||||
|
||||
it 'normalizes busy to no-answer' do
|
||||
conversation
|
||||
message
|
||||
|
||||
described_class.new(
|
||||
account: account,
|
||||
call_sid: call_sid,
|
||||
call_status: 'busy'
|
||||
).perform
|
||||
|
||||
conversation.reload
|
||||
message.reload
|
||||
|
||||
expect(conversation.additional_attributes['call_status']).to eq('no-answer')
|
||||
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
|
||||
end
|
||||
|
||||
it 'no-ops when conversation not found' do
|
||||
expect do
|
||||
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
|
||||
|
||||
@@ -32,7 +32,7 @@ RSpec.describe MutexApplicationJob do
|
||||
described_class.new.send(:with_lock, lock_key) do
|
||||
# Do nothing
|
||||
end
|
||||
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
|
||||
end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
|
||||
end
|
||||
|
||||
it 'raises StandardError if it execution raises it' do
|
||||
|
||||
Reference in New Issue
Block a user