## 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
119 lines
4.2 KiB
Ruby
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
|