feat(voice): Incoming voice calls [EE] (#12361)

This PR delivers the first slice of the voice channel: inbound call
handling. When a customer calls a configured voice
number, Chatwoot now creates a new conversation and shows a dedicated
call bubble in the UI. As the call progresses
(ringing, answered, completed), its status updates in real time in both
the conversation list and the call bubble, so
agents can instantly see what’s happening. This focuses on the inbound
flow and is part of breaking the larger voice
feature into smaller, functional, and testable units; further
enhancements will follow in subsequent PRs.

references: #11602 , #11481  

## Testing

- Configure a Voice inbox in Chatwoot with your Twilio number.
- Place a call to that number.
- Verify a new conversation appears in the Voice inbox for the call.
- Open it and confirm a dedicated voice call message bubble is shown.
- Watch status update live (ringing/answered); hang up and see it change
to completed in both the bubble and conversation
list.
- to test missed call status, make sure to hangup the call before the
please wait while we connect you to an agent message plays


## Screens

<img width="400" alt="Screenshot 2025-09-03 at 3 11 25 PM"
src="https://github.com/user-attachments/assets/d6a1d2ff-2ded-47b7-9144-a9d898beb380"
/>

<img width="700" alt="Screenshot 2025-09-03 at 3 11 33 PM"
src="https://github.com/user-attachments/assets/c25e6a1e-a885-47f7-b3d7-c3e15eef18c7"
/>

<img width="700" alt="Screenshot 2025-09-03 at 3 11 57 PM"
src="https://github.com/user-attachments/assets/29e7366d-b1d4-4add-a062-4646d2bff435"
/>



<img width="442" height="255" alt="Screenshot 2025-09-04 at 11 55 01 PM"
src="https://github.com/user-attachments/assets/703126f6-a448-49d9-9c02-daf3092cc7f9"
/>

---------

Co-authored-by: Muhsin <muhsinkeramam@gmail.com>
This commit is contained in:
Sojan Jose
2025-09-08 22:35:23 +05:30
committed by GitHub
parent 76c110e60e
commit 6bdd4f0670
17 changed files with 648 additions and 10 deletions

View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Twilio::VoiceController', type: :request do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') }
let(:inbox) { channel.inbox }
let(:digits) { channel.phone_number.delete_prefix('+') }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
end
describe 'POST /twilio/voice/call/:phone' do
let(:call_sid) { 'CA_test_call_sid_123' }
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/>')
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
'From' => from_number,
'To' => to_number
}
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)
post '/twilio/voice/call/19998887777', params: {
'CallSid' => call_sid,
'From' => from_number,
'To' => to_number
}
expect(response).to have_http_status(:not_found)
end
end
describe 'POST /twilio/voice/status/:phone' do
let(:call_sid) { 'CA_status_sid_456' }
it 'invokes Voice::StatusUpdateService with expected params' do
service_double = instance_double(Voice::StatusUpdateService, perform: nil)
expect(Voice::StatusUpdateService).to receive(:new).with(
hash_including(
account: account,
call_sid: call_sid,
call_status: 'completed'
)
).and_return(service_double)
expect(service_double).to receive(:perform)
post "/twilio/voice/status/#{digits}", params: {
'CallSid' => call_sid,
'CallStatus' => 'completed'
}
expect(response).to have_http_status(:no_content)
end
it 'raises not found when inbox is not present' do
expect(Voice::StatusUpdateService).not_to receive(:new)
post '/twilio/voice/status/18005550101', params: {
'CallSid' => call_sid,
'CallStatus' => 'busy'
}
expect(response).to have_http_status(:not_found)
end
end
end

View File

@@ -0,0 +1,57 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Voice::InboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230001') }
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)}"))
end
def build_and_perform
described_class.new(
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')
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
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')
end
end

View File

@@ -0,0 +1,63 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Voice::StatusUpdateService do
let(:account) { create(:account) }
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
let(:contact_inbox) { ContactInbox.create!(contact: contact, inbox: inbox, source_id: from_number) }
let(:conversation) do
Conversation.create!(
account_id: account.id,
inbox_id: inbox.id,
contact_id: contact.id,
contact_inbox_id: contact_inbox.id,
identifier: call_sid,
additional_attributes: { 'call_direction' => 'inbound', 'call_status' => 'ringing' }
)
end
let(:message) do
conversation.messages.create!(
account_id: account.id,
inbox_id: inbox.id,
message_type: :incoming,
sender: contact,
content: 'Voice Call',
content_type: 'voice_call',
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
)
end
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' }
let(:call_sid) { 'CATESTSTATUS123' }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
end
it 'updates conversation and last voice message with call status' do
# Ensure records are created after stub setup
conversation
message
described_class.new(
account: account,
call_sid: call_sid,
call_status: 'completed'
).perform
conversation.reload
message.reload
expect(conversation.additional_attributes['call_status']).to eq('completed')
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
end
it 'no-ops when conversation not found' do
expect do
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
end.not_to raise_error
end
end