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,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

View 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