Files
chatwoot/enterprise/app/services/voice/inbound_call_builder.rb
Tanmay Deep SharmaandGitHub 68e358d732 feat: voice-call UX fixes (#14579)
## Linear ticket
https://linear.app/chatwoot/issue/CW-7187/voice-calls-followup-tasks

## Description

Improvements to the WhatsApp voice-calling experience plus a cheaper,
more accurate audio-transcription model.
- First-time callers now get a real name. An inbound WhatsApp call
creates the contact from the caller's WhatsApp profile name instead of
the bare phone number.
- Clear, consistent call attribution. Call bubbles show a unified
"Handled by {agent}"
- Cleaner call widget. The dismiss (✕) button is shown only for incoming
calls
- WhatsApp calling for manual inboxes. voice_calling_supported? now
covers any whatsapp_cloud inbox
- Transcription: whisper-1 → gpt-4o-mini-transcribe.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-05-27 16:16:16 +05:30

119 lines
4.2 KiB
Ruby

class Voice::InboundCallBuilder
attr_reader :inbox, :from_number, :call_sid, :provider, :extra_meta
def self.perform!(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
new(inbox: inbox, from_number: from_number, call_sid: call_sid,
provider: provider, extra_meta: extra_meta).perform!
end
def initialize(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
@inbox = inbox
@from_number = from_number
@call_sid = call_sid
@provider = provider.to_sym
@extra_meta = extra_meta || {}
end
def perform!
existing = find_existing_call
return existing if existing
ActiveRecord::Base.transaction do
contact_inbox = ensure_contact_inbox!
contact = contact_inbox.contact
conversation = resolve_conversation!(contact, contact_inbox)
call = create_call!(contact, conversation)
message = Voice::CallMessageBuilder.new(call).perform!
call.update!(message_id: message.id)
call
end
rescue ActiveRecord::RecordNotUnique
# A concurrent provider retry won the create race; return what now exists.
find_existing_call || raise
end
private
def account
inbox.account
end
def find_existing_call
Call.where(account_id: account.id, inbox_id: inbox.id)
.find_by(provider: provider, provider_call_id: call_sid)
end
# Always look up by (inbox, source_id) first — that pair has a UNIQUE index, so
# creating with a colliding source_id under a different contact would raise
# RecordNotUnique. Reuse the existing ContactInbox (and its contact) when found.
# A concurrent message webhook for the same wa_id can win the (inbox_id, source_id)
# race; rescue and re-find so the call path doesn't drop the connect.
def ensure_contact_inbox!
sid = source_id_for_provider
existing = inbox.contact_inboxes.find_by(source_id: sid)
return existing if existing
ContactInbox.create!(contact: ensure_contact!, inbox: inbox, source_id: sid)
rescue ActiveRecord::RecordNotUnique
inbox.contact_inboxes.find_by!(source_id: sid)
end
def ensure_contact!
contact = account.contacts.find_or_create_by!(phone_number: from_number) do |record|
record.name = contact_name.presence || from_number
end
contact.update!(name: contact_name) if contact_name.present? && contact.name == from_number
contact
end
# WhatsApp inbound calls carry the caller's profile name in extra_meta; Twilio
# calls don't, so contact naming falls back to the phone number.
def contact_name
extra_meta['contact_name'].presence
end
# WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +.
# Run BR/AR-style wa_id normalization (same path messaging uses) so an inbound call
# finds the existing ContactInbox instead of forking a new contact/conversation.
def source_id_for_provider
return from_number unless provider == :whatsapp
digits = from_number.to_s.delete_prefix('+')
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(digits, :cloud)
end
# Mirror incoming-message routing: reuse the open conversation (or the last one when locked), else create new.
def resolve_conversation!(contact, contact_inbox)
reusable = if inbox.lock_to_single_conversation
contact_inbox.conversations.last
else
contact_inbox.conversations.where.not(status: :resolved).last
end
return reusable if reusable
account.conversations.create!(
contact_inbox_id: contact_inbox.id,
inbox_id: inbox.id,
contact_id: contact.id,
status: :open
)
end
def create_call!(contact, conversation)
call = Call.create!(
account: account,
inbox: inbox,
conversation: conversation,
contact: contact,
provider: provider,
direction: :incoming,
status: 'ringing',
provider_call_id: call_sid,
meta: { 'initiated_at' => Time.zone.now.to_i }.merge(extra_meta.stringify_keys)
)
# `conference_sid` is a Twilio bridging concept; WhatsApp goes browser↔Meta.
call.update!(conference_sid: call.default_conference_sid) if call.twilio?
call
end
end