Files
chatwoot/enterprise/app/models/call.rb
c4a6a19e9b feat(voice): WhatsApp Cloud Calling — UI [6] (#14346)
## Summary

Frontend for WhatsApp Cloud Calling: header / contact-panel call
buttons, ringing widget, accept/reject/hangup, mute, in-bubble audio
player + transcript, recording-on-hangup upload, mid-call reload
warning. WebRTC is browser-direct to Meta — no media server bridge.

## Closes
- https://linear.app/chatwoot/issue/PLA-150

## How to test

Requires backend support — the controller, services, model changes, and
routes ship in **#14334** (`feature/pla-150`). Merge / deploy that first
(or simultaneously); the FE alone won't function without those
endpoints.

Then on staging, for a WhatsApp Cloud + embedded-signup inbox with the
new \`Configuration → Enable voice calling\` toggle ON and webhook
registered:

1. **Outbound** — open a conversation, click the phone icon in the
conversation header (or contact panel), grant mic, your phone rings,
answer, audio both ways, hang up. Recording + transcript land in the
bubble within ~10s.
2. **Inbound** — call the business number from your phone. The
FloatingCallWidget appears bottom-right with caller name. Click accept,
audio both ways, hang up. Recording + transcript appear.
3. **Mute** — during an active WhatsApp call, click the mic icon next to
hangup. Speech stops reaching Meta until you click again.
4. **Mid-call reload guard** — try `Cmd-R` during an active call;
browser shows a confirm prompt.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-05-22 18:42:39 +05:30

132 lines
4.0 KiB
Ruby

# == Schema Information
#
# Table name: calls
#
# id :bigint not null, primary key
# direction :integer not null
# duration_seconds :integer
# end_reason :string
# meta :jsonb
# provider :integer default("twilio"), not null
# started_at :datetime
# status :string default("ringing"), not null
# transcript :text
# created_at :datetime not null
# updated_at :datetime not null
# accepted_by_agent_id :bigint
# account_id :bigint not null
# contact_id :bigint not null
# conversation_id :bigint not null
# inbox_id :bigint not null
# message_id :bigint
# provider_call_id :string not null
#
# Indexes
#
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
# index_calls_on_message_id (message_id)
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
#
class Call < ApplicationRecord
STATUSES = %w[ringing in_progress completed no_answer failed].freeze
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
# Frontend voice bubbles/stores expect inbound/outbound string values
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
DEFAULT_STUN_URL = 'stun:stun.l.google.com:19302'.freeze
enum :provider, { twilio: 0, whatsapp: 1 }
enum :direction, { incoming: 0, outgoing: 1 }
belongs_to :account
belongs_to :inbox
belongs_to :conversation
belongs_to :contact
belongs_to :message, optional: true, inverse_of: :call
belongs_to :accepted_by_agent, class_name: 'User', optional: true
has_one_attached :recording
validates :provider_call_id, presence: true
validates :provider, presence: true
validates :direction, presence: true
validates :status, presence: true, inclusion: { in: STATUSES }
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
scope :by_twilio_conference_sid, ->(sid) { where("meta->>'twilio_conference_sid' = ?", sid) }
def self.find_by_provider_call_id(provider, sid)
find_by(provider: provider, provider_call_id: sid)
end
def default_conference_sid
"conf_account_#{account_id}_call_#{id}"
end
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its public srflx candidate.
def self.default_ice_servers
urls = ENV.fetch('VOICE_CALL_STUN_URLS', DEFAULT_STUN_URL).split(',').filter_map { |u| u.strip.presence }
[{ urls: urls }]
end
def direction_label
DISPLAY_DIRECTION[direction]
end
def ringing?
status == 'ringing'
end
def in_progress?
status == 'in_progress'
end
def terminal?
TERMINAL_STATUSES.include?(status)
end
def display_status
status.to_s.tr('_', '-')
end
def from_number
incoming? ? contact.phone_number : inbox.channel&.phone_number
end
def to_number
incoming? ? inbox.channel&.phone_number : contact.phone_number
end
def recording_url
return nil unless recording.attached?
Rails.application.routes.url_helpers.rails_blob_url(recording)
end
def push_event_data
{
id: id,
provider_call_id: provider_call_id,
provider: provider,
direction: direction,
status: display_status,
duration_seconds: duration_seconds,
end_reason: end_reason,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
accepted_by_agent_name: accepted_by_agent&.available_name,
started_at: started_at&.to_i,
ended_at: ended_at,
from_number: from_number,
to_number: to_number,
recording_url: recording_url,
transcript: transcript
}
end
end