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:
38
enterprise/app/controllers/twilio/voice_controller.rb
Normal file
38
enterprise/app/controllers/twilio/voice_controller.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
class Twilio::VoiceController < ApplicationController
|
||||
before_action :set_inbox!
|
||||
|
||||
def status
|
||||
Voice::StatusUpdateService.new(
|
||||
account: @inbox.account,
|
||||
call_sid: params[:CallSid],
|
||||
call_status: params[:CallStatus]
|
||||
).perform
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def call_twiml
|
||||
account = @inbox.account
|
||||
call_sid = params[:CallSid]
|
||||
from_number = params[:From].to_s
|
||||
to_number = params[:To].to_s
|
||||
|
||||
builder = Voice::InboundCallBuilder.new(
|
||||
account: account,
|
||||
inbox: @inbox,
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
call_sid: call_sid
|
||||
).perform
|
||||
render xml: builder.twiml_response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_inbox!
|
||||
# Resolve from the digits in the route param and look up exact E.164 match
|
||||
digits = params[:phone].to_s.gsub(/\D/, '')
|
||||
e164 = "+#{digits}"
|
||||
channel = Channel::Voice.find_by!(phone_number: e164)
|
||||
@inbox = channel.inbox
|
||||
end
|
||||
end
|
||||
@@ -44,13 +44,13 @@ class Channel::Voice < ApplicationRecord
|
||||
|
||||
# Public URLs used to configure Twilio webhooks
|
||||
def voice_call_webhook_url
|
||||
base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '')
|
||||
"#{base}/twilio/voice/call/#{phone_number}"
|
||||
digits = phone_number.delete_prefix('+')
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}"
|
||||
end
|
||||
|
||||
def voice_status_webhook_url
|
||||
base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '')
|
||||
"#{base}/twilio/voice/status/#{phone_number}"
|
||||
digits = phone_number.delete_prefix('+')
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -2,4 +2,15 @@ module Enterprise::Conversation
|
||||
def list_of_keys
|
||||
super + %w[sla_policy_id]
|
||||
end
|
||||
|
||||
# Include select additional_attributes keys (call related) for update events
|
||||
def allowed_keys?
|
||||
return true if super
|
||||
|
||||
attrs_change = previous_changes['additional_attributes']
|
||||
return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash)
|
||||
|
||||
changed_attr_keys = attrs_change[1].keys
|
||||
changed_attr_keys.intersect?(%w[call_status])
|
||||
end
|
||||
end
|
||||
|
||||
82
enterprise/app/services/voice/inbound_call_builder.rb
Normal file
82
enterprise/app/services/voice/inbound_call_builder.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
class Voice::InboundCallBuilder
|
||||
pattr_initialize [:account!, :inbox!, :from_number!, :to_number, :call_sid!]
|
||||
|
||||
attr_reader :conversation
|
||||
|
||||
def perform
|
||||
contact = find_or_create_contact!
|
||||
contact_inbox = find_or_create_contact_inbox!(contact)
|
||||
@conversation = find_or_create_conversation!(contact, contact_inbox)
|
||||
create_call_message_if_needed!
|
||||
self
|
||||
end
|
||||
|
||||
def twiml_response
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
response.say(message: 'Please wait while we connect you to an agent')
|
||||
response.to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_conversation!(contact, contact_inbox)
|
||||
account.conversations.find_or_create_by!(
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
identifier: call_sid
|
||||
) do |conv|
|
||||
conv.contact_id = contact.id
|
||||
conv.contact_inbox_id = contact_inbox.id
|
||||
conv.additional_attributes = {
|
||||
'call_direction' => 'inbound',
|
||||
'call_status' => 'ringing'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def create_call_message!
|
||||
content_attrs = call_message_content_attributes
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
message_type: :incoming,
|
||||
sender: @conversation.contact,
|
||||
content: 'Voice Call',
|
||||
content_type: 'voice_call',
|
||||
content_attributes: content_attrs
|
||||
)
|
||||
end
|
||||
|
||||
def create_call_message_if_needed!
|
||||
return if @conversation.messages.voice_calls.exists?
|
||||
|
||||
create_call_message!
|
||||
end
|
||||
|
||||
def call_message_content_attributes
|
||||
{
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: 'ringing',
|
||||
conversation_id: @conversation.display_id,
|
||||
call_direction: 'inbound',
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
meta: {
|
||||
created_at: Time.current.to_i,
|
||||
ringing_at: Time.current.to_i
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def find_or_create_contact!
|
||||
account.contacts.find_by(phone_number: from_number) ||
|
||||
account.contacts.create!(phone_number: from_number, name: 'Unknown Caller')
|
||||
end
|
||||
|
||||
def find_or_create_contact_inbox!(contact)
|
||||
ContactInbox.where(contact_id: contact.id, inbox_id: inbox.id, source_id: from_number).first_or_create!
|
||||
end
|
||||
end
|
||||
29
enterprise/app/services/voice/status_update_service.rb
Normal file
29
enterprise/app/services/voice/status_update_service.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
class Voice::StatusUpdateService
|
||||
pattr_initialize [:account!, :call_sid!, :call_status]
|
||||
|
||||
def perform
|
||||
conversation = account.conversations.find_by(identifier: call_sid)
|
||||
return unless conversation
|
||||
return if call_status.to_s.strip.empty?
|
||||
|
||||
update_conversation!(conversation)
|
||||
update_last_call_message!(conversation)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_conversation!(conversation)
|
||||
attrs = (conversation.additional_attributes || {}).merge('call_status' => call_status)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def update_last_call_message!(conversation)
|
||||
msg = conversation.messages.voice_calls.order(created_at: :desc).first
|
||||
return unless msg
|
||||
|
||||
data = msg.content_attributes.is_a?(Hash) ? msg.content_attributes : {}
|
||||
data['data'] ||= {}
|
||||
data['data']['status'] = call_status
|
||||
msg.update!(content_attributes: data)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user