Files
chatwoot/enterprise/app/services/whatsapp/incoming_call_service.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

211 lines
8.7 KiB
Ruby

class Whatsapp::IncomingCallService
pattr_initialize [:inbox!, :params!]
def perform
return unless inbox.channel.voice_enabled?
Array(params[:calls]).each { |c| handle_event(c.with_indifferent_access) }
Array(params[:statuses]).each { |s| handle_status(s.with_indifferent_access) }
end
private
def handle_event(payload)
case payload[:event]
when 'connect' then handle_connect(payload)
when 'terminate' then handle_terminate(payload)
else Rails.logger.warn "[WHATSAPP CALL] Unknown call event: #{payload[:event]}"
end
end
# Meta's `connect` event for outbound calls fires when the WebRTC tunnel is
# up — empirically ~20s before the contact actually answers. The real pickup
# is reported as a separate webhook with status=ACCEPTED, and is what
# `terminate.start_time` aligns to. Treat ACCEPTED as the pickup transition.
def handle_status(payload)
return unless payload[:type] == 'call'
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
return unless call
case payload[:status]
when 'ACCEPTED' then mark_outbound_accepted(call, payload)
when 'RINGING' then nil # informational
else Rails.logger.info "[WHATSAPP CALL] Unhandled call status: #{payload[:status]} for #{payload[:id]}"
end
end
# with_lock + reload here serializes against Whatsapp::CallService (agent
# actions hold call.with_lock across the Meta API call), so a webhook racing
# with a terminate can't overwrite a freshly-finalized terminal status.
def mark_outbound_accepted(call, payload)
call.with_lock do
next unless call.outgoing?
next if call.in_progress? || call.terminal?
started_at = Time.zone.at(payload[:timestamp].to_i) if payload[:timestamp].present?
update_call!(call, 'in_progress', started_at: started_at || Time.current)
broadcast(call, 'voice_call.outbound_accepted')
end
end
def handle_connect(payload)
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
if call.nil?
# Only an `offer` payload is a real inbound caller. An `answer` with no
# local row means Meta beat our outbound `Call.create!` (tiny window
# between initiate API response and DB insert) — do not mint an inbound
# row for it; the next status webhook (or a retry) will find it.
return create_inbound_call(payload) if inbound_offer?(payload)
Rails.logger.warn "[WHATSAPP CALL] Outbound connect for unknown call #{payload[:id]}; skipping"
return
end
return accept_outbound_call(call, payload) if call.outgoing?
Rails.logger.info "[WHATSAPP CALL] Duplicate inbound connect for #{payload[:id]}; ignoring"
end
def inbound_offer?(payload)
payload.dig(:session, :sdp_type).to_s.downcase == 'offer'
end
def create_inbound_call(payload)
sdp_offer = payload.dig(:session, :sdp)
extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
name = caller_profile_name(payload)
extra_meta['contact_name'] = name if name.present?
call = Voice::InboundCallBuilder.perform!(
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
provider: :whatsapp, extra_meta: extra_meta
)
update_conversation(call)
broadcast_incoming(call, sdp_offer)
end
# Match strictly on wa_id (== calls[].from): in a batched payload missing this
# call's contact entry, borrowing another caller's name would corrupt this
# contact, so fall back to the phone number (nil here) instead of contacts.first.
def caller_profile_name(payload)
contacts = Array(params[:contacts]).map(&:with_indifferent_access)
match = contacts.find { |c| c[:wa_id].to_s == payload[:from].to_s }
match&.dig(:profile, :name).presence
end
# `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply
# Meta's SDP answer so the handshake completes during ringing; the call
# stays in `ringing` until status=ACCEPTED arrives. Don't gate on
# in_progress: an out-of-order ACCEPTED can flip status before connect is
# processed, and dropping the SDP answer would leave the browser without
# the data it needs to complete the handshake. Use the stored answer as
# the idempotency key instead.
def accept_outbound_call(call, payload)
call.with_lock do
next if call.terminal?
next if call.meta&.dig('sdp_answer').present?
# Pin setup:active so browsers don't renegotiate when Meta echoes actpass.
sdp_answer = payload.dig(:session, :sdp)&.gsub('a=setup:actpass', 'a=setup:active')
call.update!(meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer)
end
end
def handle_terminate(payload)
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
if call.nil?
# No row yet means either an out-of-order terminate (rare in practice — Meta
# delivery is FIFO) or, more dangerously, an outbound terminate landing in
# the window between the controller's Meta API call and Call.create!.
# Materialising as inbound here would collide with the unique
# (provider, provider_call_id) index. Skip; controller commits seal it.
Rails.logger.warn "[WHATSAPP CALL] Terminate for unknown call #{payload[:id]}; skipping"
return
end
call.with_lock do
# Webhook retries can re-deliver terminate after we've already finalized the
# call; don't recompute status or a duration=0 retry can flip a completed
# short call back to no_answer.
next if call.terminal?
duration = payload[:duration]&.to_i
reason = payload[:terminate_reason].to_s
status = derive_terminate_status(call, duration, reason)
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
update_call!(call, status, duration_seconds: duration, end_reason: reason, meta: meta)
broadcast(call, 'voice_call.ended', status: call.display_status, duration_seconds: call.duration_seconds)
end
end
# Provider-reported failures trump the answered/no_answer heuristic. An
# in_progress call that Meta later terminates with a failure reason would
# otherwise be recorded as 'completed' purely because it had been accepted.
FAILURE_REASONS = %w[failed error rejected busy invalid_offer cancelled].freeze
def derive_terminate_status(call, duration, reason)
return 'failed' if FAILURE_REASONS.any? { |r| reason.include?(r) }
answered?(call, duration) ? 'completed' : 'no_answer'
end
# accepted_by_agent_id is the initiating agent on outbound calls, so it only signals "answered" for inbound.
def answered?(call, duration)
call.in_progress? || duration.to_i.positive? || (call.incoming? && call.accepted_by_agent_id.present?)
end
def update_call!(call, status, **attrs)
call.update!(status: status, **attrs)
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: call.accepted_by_agent,
duration_seconds: attrs[:duration_seconds])
update_conversation(call)
end
def update_conversation(call)
call.conversation.update!(
additional_attributes: (call.conversation.additional_attributes || {}).merge(
'call_status' => call.display_status, 'call_direction' => call.direction_label
)
)
end
# Ring the assignee if any, else online inbox agents, else fall back to the
# inbox's own agents and account admins. Never the whole-account stream, which
# would ring online agents from unrelated inboxes.
def broadcast_incoming(call, sdp_offer)
contact = call.contact
token = call.conversation.assignee&.pubsub_token
streams = token ? [token] : (online_agent_streams.presence || fallback_agent_streams)
broadcast(call, 'voice_call.incoming',
streams: streams,
direction: call.direction_label, inbox_id: call.inbox_id,
sdp_offer: sdp_offer, ice_servers: Call.default_ice_servers,
caller: { name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url })
end
def online_agent_streams
inbox.available_agents.pluck('users.pubsub_token').compact
end
def fallback_agent_streams
user_ids = inbox.member_ids | inbox.account.administrators.ids
User.where(id: user_ids).pluck(:pubsub_token).compact
end
def broadcast(call, event, streams: account_streams, **extra)
payload = { event: event, data: base_payload(call).merge(extra) }
streams.each { |s| ActionCable.server.broadcast(s, payload) }
end
def account_streams
["account_#{inbox.account_id}"]
end
def base_payload(call)
{ account_id: inbox.account_id, id: call.id, call_id: call.provider_call_id,
provider: 'whatsapp', conversation_id: call.conversation_id }
end
end