Adds the server-side flow that turns Meta WhatsApp Cloud Calling webhooks into Chatwoot Calls, conversations, voice_call message bubbles, and ActionCable broadcasts. Stacked on top of #14312 (PR-2 — provider methods); intentionally does not include the HTTP controller, routes, or frontend (those land in PR-4 and PR-9). ## Closes - Part of the WhatsApp Cloud Calling rollout. Linear: TBD ## What changed **Webhook routing** - `app/jobs/webhooks/whatsapp_events_job.rb` — append `prepend_mod_with('Webhooks::WhatsappEventsJob')` so EE can extend it without forking. - `enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb` (new) — overlay that prepends `handle_message_events` to intercept `field: 'calls'` payloads (route to `Whatsapp::IncomingCallService`) and `interactive.call_permission_reply` messages (route to `Whatsapp::CallPermissionReplyService`); falls through with `super` for regular messages. **Services** - `enterprise/app/services/whatsapp/incoming_call_service.rb` (new) — gated on `provider_config['calling_enabled']`; processes `connect` (creates inbound call via `Voice::InboundCallBuilder` or transitions an existing outbound call to `in_progress`) and `terminate` events; updates conversation `additional_attributes` and broadcasts `voice_call.incoming`/`voice_call.outbound_connected`/`voice_call.ended`. - `enterprise/app/services/whatsapp/call_permission_reply_service.rb` (new) — handles WhatsApp interactive `call_permission_reply` replies; clears the conversation's `call_permission_requested_at` flag and broadcasts `voice_call.permission_granted` so the agent UI can re-enable the call button. **Builder/model adjustments** - `enterprise/app/services/voice/inbound_call_builder.rb` — provider-agnostic; accepts `provider:` and `extra_meta:` kwargs, drops `account:` (now derived from `inbox.account` to keep the param count under rubocop's ceiling without disabling cops), uses digits-only `source_id` for WhatsApp ContactInbox (validation requires `^\d{1,15}\z`), skips Twilio-only `conference_sid` for non-Twilio providers. - `enterprise/app/services/voice/call_message_builder.rb` — adds `create!`/`update_status!` API and `CALL_TO_VOICE_STATUS` map; uses direct `Message.create!` (bypasses `Messages::MessageBuilder`'s incoming-on-non-Api-inbox guard, which would otherwise reject the system bubble); content is `'WhatsApp Call'` for WhatsApp and `'Voice Call'` for Twilio. Backwards-compatible `perform!` retained for the existing Twilio call sites. - `enterprise/app/models/call.rb` — adds `default_ice_servers` (driven by `VOICE_CALL_STUN_URLS` env), `direction_label` alias for the `inbound`/`outbound` strings the FE expects, and `ringing?`/`in_progress?`/`terminal?` predicates used throughout the pipeline. **Outgoing-channel guard** - `app/services/base/send_on_channel_service.rb` — extends `invalid_message?` to skip messages with `content_type == 'voice_call'`. Without this, agent-initiated outbound calls (PR-4) would deliver \"WhatsApp Call\" as a text message to the contact every time. **Twilio call-site update** - `enterprise/app/controllers/twilio/voice_controller.rb` — drops the now-redundant `account: current_account` kwarg from the `Voice::InboundCallBuilder.perform!` call. **Tests** - New: `spec/enterprise/services/whatsapp/incoming_call_service_spec.rb` (5 examples — calling-disabled, inbound connect, outbound connect, terminate completed, terminate no-answer, unknown event). - New: `spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb` (3 examples — accept, reject, calling-disabled). - Updated: `spec/enterprise/services/voice/inbound_call_builder_spec.rb` and `spec/enterprise/controllers/twilio/voice_controller_spec.rb` to drop the `account:` kwarg from call expectations. ## How to test In `rails console` against an account with a WhatsApp inbox where `provider_config['calling_enabled']` is true: ```ruby inbox = Inbox.find(<id>) params = { calls: [{ id: 'wacid_test', from: '15550001111', event: 'connect', session: { sdp: 'v=0...', sdp_type: 'offer' } }] } Whatsapp::IncomingCallService.new(inbox: inbox, params: params).perform # => Conversation + Call (status: 'ringing', provider: 'whatsapp') + voice_call message bubble # => ActionCable broadcasts `voice_call.incoming` to the assignee or account-wide # Then terminate it: Whatsapp::IncomingCallService.new(inbox: inbox, params: { calls: [{ id: 'wacid_test', event: 'terminate', duration: 0, terminate_reason: 'no_answer' }] } ).perform # => Call status flips to 'no_answer', message bubble updates, `voice_call.ended` broadcast fires ``` End-to-end browser flow (Meta → cable → UI) requires the controller from PR-4 and the frontend from PR-9. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
4.0 KiB
Ruby
131 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,
|
|
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
|