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:
87
spec/enterprise/controllers/twilio/voice_controller_spec.rb
Normal file
87
spec/enterprise/controllers/twilio/voice_controller_spec.rb
Normal 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
|
||||
57
spec/enterprise/services/voice/inbound_call_builder_spec.rb
Normal file
57
spec/enterprise/services/voice/inbound_call_builder_spec.rb
Normal 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
|
||||
63
spec/enterprise/services/voice/status_update_service_spec.rb
Normal file
63
spec/enterprise/services/voice/status_update_service_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user