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:
Sojan Jose
2025-11-24 17:47:00 -08:00
committed by GitHub
parent 6a712b7592
commit 48627da0f9
39 changed files with 1485 additions and 344 deletions

View File

@@ -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)