refactor(whatsapp-call): migrate to unified Call model from PR #14026

Replace the WhatsappCall model with the unified Call model introduced
in feat/voice-call-model (PR #14026), enabling multi-provider support
(Twilio + WhatsApp) under a single data model.

Key changes:
- Delete WhatsappCall model and its 3 migrations (whatsapp_calls table)
- Enhance Call model with WhatsApp-specific helpers (sdp_offer,
  ice_servers, recording_url, direction_label, ringing?, in_progress?)
- Update all backend services, controller, and jobs to use Call model
- Map statuses: accepted→in_progress, rejected→failed, missed→no_answer,
  ended→completed
- Map directions: inbound→incoming, outbound→outgoing (enums)
- Rename call_id column to provider_call_id, always set provider: :whatsapp
- Add authorization check on initiate action
- Add race condition guard in outbound call_connect webhook handler
- Consolidate direction_label helper into Call model (DRY)
- Update frontend data key from waCallId to callId in VoiceCall bubble
- Remove whatsapp_calls table from schema.rb, fix missing started_at

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-12 17:17:30 +00:00
co-authored by Claude Opus 4.6
parent 0a0915ace8
commit f49032610f
15 changed files with 210 additions and 266 deletions
@@ -47,7 +47,7 @@ const isFailed = computed(() =>
// Call source and metadata — all camelCase due to deep transform
const isWhatsappCall = computed(() => data.value?.callSource === 'whatsapp');
const waCallId = computed(() => data.value?.waCallId);
const waCallId = computed(() => data.value?.callId);
const acceptedBy = computed(() => data.value?.acceptedBy);
const durationSeconds = computed(() => data.value?.durationSeconds);
const recordingUrl = computed(() => data.value?.recordingUrl);
@@ -193,7 +193,7 @@ export async function acceptWhatsappCallById(waCallId) {
// 1. Check if the call is already in the incoming store
let call = callsStore.incomingCalls.find(
c => c.id === waCallId || c.waCallId === waCallId
c => c.id === waCallId || c.callId === String(waCallId)
);
// 2. Not in store (page was refreshed) → fetch from API
@@ -205,7 +205,6 @@ export async function acceptWhatsappCallById(waCallId) {
call = {
id: data.id,
callId: data.call_id,
waCallId: data.id,
direction: data.direction,
inboxId: data.inbox_id,
conversationId: data.conversation_id,
@@ -1,21 +0,0 @@
class CreateWhatsappCalls < ActiveRecord::Migration[7.1]
def change
create_table :whatsapp_calls do |t|
t.bigint :account_id, null: false
t.bigint :inbox_id, null: false
t.bigint :conversation_id, null: false
t.bigint :accepted_by_agent_id
t.string :call_id, null: false
t.string :direction, null: false
t.string :status, null: false, default: 'ringing'
t.integer :duration_seconds
t.string :end_reason
t.jsonb :meta, null: false, default: {}
t.timestamps
end
add_index :whatsapp_calls, :call_id, unique: true
add_index :whatsapp_calls, [:account_id, :conversation_id]
add_index :whatsapp_calls, [:inbox_id, :status]
end
end
@@ -1,5 +0,0 @@
class AddMessageIdToWhatsappCalls < ActiveRecord::Migration[7.1]
def change
add_reference :whatsapp_calls, :message, null: true, foreign_key: true, index: true
end
end
@@ -1,5 +0,0 @@
class AddTranscriptToWhatsappCalls < ActiveRecord::Migration[7.1]
def change
add_column :whatsapp_calls, :transcript, :text
end
end
+1 -22
View File
@@ -275,6 +275,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_09_091202) do
t.integer "provider", default: 0, null: false
t.integer "direction", null: false
t.string "status", default: "ringing", null: false
t.datetime "started_at"
t.integer "duration_seconds"
t.string "end_reason"
t.jsonb "meta", default: {}
@@ -1300,27 +1301,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_09_091202) do
t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true
end
create_table "whatsapp_calls", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "inbox_id", null: false
t.bigint "conversation_id", null: false
t.bigint "accepted_by_agent_id"
t.string "call_id", null: false
t.string "direction", null: false
t.string "status", default: "ringing", null: false
t.integer "duration_seconds"
t.string "end_reason"
t.jsonb "meta", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "message_id"
t.text "transcript"
t.index ["account_id", "conversation_id"], name: "index_whatsapp_calls_on_account_id_and_conversation_id"
t.index ["call_id"], name: "index_whatsapp_calls_on_call_id", unique: true
t.index ["inbox_id", "status"], name: "index_whatsapp_calls_on_inbox_id_and_status"
t.index ["message_id"], name: "index_whatsapp_calls_on_message_id"
end
create_table "working_hours", force: :cascade do |t|
t.bigint "inbox_id"
t.bigint "account_id"
@@ -1340,7 +1320,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_09_091202) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "inboxes", "portals"
add_foreign_key "whatsapp_calls", "messages"
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
on("accounts").
after(:insert).
+9 -5
View File
@@ -908,7 +908,9 @@ If any prerequisite is missing, the recording is still saved — only transcript
## 7. Data Model
### `whatsapp_calls` table
### `calls` table (unified Call model)
WhatsApp calls use the unified `Call` model (from PR #14026) with `provider: :whatsapp`.
| Column | Type | Description |
|---|---|---|
@@ -918,9 +920,11 @@ If any prerequisite is missing, the recording is still saved — only transcript
| `conversation_id` | bigint | FK → conversations |
| `accepted_by_agent_id` | bigint | FK → users (nullable) |
| `message_id` | bigint | FK → messages (nullable) — the voice_call message |
| `call_id` | string | Unique Meta call ID |
| `direction` | string | `inbound` or `outbound` |
| `status` | string | `ringing`, `accepted`, `rejected`, `missed`, `ended`, `failed` |
| `provider_call_id` | string | Unique provider call ID (Meta call ID for WhatsApp) |
| `provider` | integer | Enum: `twilio` (0), `whatsapp` (1) |
| `direction` | integer | Enum: `incoming` (0), `outgoing` (1) |
| `status` | string | `ringing`, `in_progress`, `completed`, `no_answer`, `failed` |
| `started_at` | datetime | When the call was answered |
| `duration_seconds` | integer | Call duration (set on termination) |
| `end_reason` | string | Reason from Meta (e.g., `caller_hangup`) |
| `meta` | jsonb | Stores `sdp_offer`, `sdp_answer`, `ice_servers` |
@@ -930,7 +934,7 @@ If any prerequisite is missing, the recording is still saved — only transcript
**ActiveStorage attachment:** `has_one_attached :recording` (audio/webm)
**Indexes:** `call_id` (unique), `[account_id, conversation_id]`, `[inbox_id, status]`, `message_id`
**Indexes:** `[provider, provider_call_id]` (unique), `[account_id, conversation_id]`, `message_id`
### Voice call message structure
@@ -1,18 +1,18 @@
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
before_action :ensure_whatsapp_call_enabled
before_action :set_whatsapp_call, only: [:show, :accept, :reject, :terminate, :upload_recording]
before_action :set_call, only: [:show, :accept, :reject, :terminate, :upload_recording]
def show
render json: {
id: @whatsapp_call.id,
call_id: @whatsapp_call.call_id,
status: @whatsapp_call.status,
direction: @whatsapp_call.direction,
conversation_id: @whatsapp_call.conversation_id,
inbox_id: @whatsapp_call.inbox_id,
message_id: @whatsapp_call.message_id,
sdp_offer: @whatsapp_call.ringing? ? @whatsapp_call.sdp_offer : nil,
ice_servers: @whatsapp_call.ice_servers,
id: @call.id,
call_id: @call.provider_call_id,
status: @call.status,
direction: @call.direction_label,
conversation_id: @call.conversation_id,
inbox_id: @call.inbox_id,
message_id: @call.message_id,
sdp_offer: @call.ringing? ? @call.sdp_offer : nil,
ice_servers: @call.ice_servers,
caller: caller_info
}
end
@@ -21,8 +21,8 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
sdp_answer = params[:sdp_answer]
return render json: { error: 'sdp_answer is required' }, status: :unprocessable_entity if sdp_answer.blank?
wa_call = Whatsapp::CallService.new(wa_call: @whatsapp_call, agent: current_user).pre_accept_and_accept(sdp_answer)
render json: { id: wa_call.id, status: wa_call.status, message_id: wa_call.message_id }
call = Whatsapp::CallService.new(call: @call, agent: current_user).pre_accept_and_accept(sdp_answer)
render json: { id: call.id, status: call.status, message_id: call.message_id }
rescue Whatsapp::CallErrors::NotRinging, Whatsapp::CallErrors::AlreadyAccepted => e
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
@@ -31,16 +31,16 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
end
def reject
wa_call = Whatsapp::CallService.new(wa_call: @whatsapp_call, agent: current_user).reject
render json: { id: wa_call.id, status: wa_call.status }
call = Whatsapp::CallService.new(call: @call, agent: current_user).reject
render json: { id: call.id, status: call.status }
rescue StandardError => e
Rails.logger.error "[WHATSAPP CALL] reject failed: #{e.message}"
render json: { error: 'Failed to reject call' }, status: :internal_server_error
end
def terminate
wa_call = Whatsapp::CallService.new(wa_call: @whatsapp_call, agent: current_user).terminate
render json: { id: wa_call.id, status: wa_call.status }
call = Whatsapp::CallService.new(call: @call, agent: current_user).terminate
render json: { id: call.id, status: call.status }
rescue StandardError => e
Rails.logger.error "[WHATSAPP CALL] terminate failed: #{e.message}"
render json: { error: 'Failed to terminate call' }, status: :internal_server_error
@@ -48,10 +48,10 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
def upload_recording
return render json: { error: 'No recording file provided' }, status: :unprocessable_entity if params[:recording].blank?
return render json: { error: 'Call is not ended' }, status: :unprocessable_entity unless @whatsapp_call.terminal?
return render json: { error: 'Call is not ended' }, status: :unprocessable_entity unless @call.terminal?
attach_recording_and_enqueue_transcription
render json: { id: @whatsapp_call.id, status: 'uploaded' }
render json: { id: @call.id, status: 'uploaded' }
rescue StandardError => e
Rails.logger.error "[WHATSAPP CALL] upload_recording failed: #{e.message}"
render json: { error: 'Failed to upload recording' }, status: :internal_server_error
@@ -59,13 +59,14 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
def initiate
conversation = current_account.conversations.find(params[:conversation_id])
authorize conversation, :show?
error = validate_whatsapp_calling(conversation)
return render json: { error: error }, status: :unprocessable_entity if error
wa_call = create_outbound_call(conversation)
message = Whatsapp::CallMessageBuilder.create!(conversation: conversation, wa_call: wa_call, user: current_user)
wa_call.update!(message_id: message.id)
render json: { status: 'calling', call_id: wa_call.call_id, id: wa_call.id, message_id: message.id }
call = create_outbound_call(conversation)
message = Whatsapp::CallMessageBuilder.create!(conversation: conversation, call: call, user: current_user)
call.update!(message_id: message.id)
render json: { status: 'calling', call_id: call.provider_call_id, id: call.id, message_id: message.id }
rescue Whatsapp::CallErrors::NoCallPermission
handle_no_call_permission(conversation)
rescue ActiveRecord::RecordNotFound
@@ -83,11 +84,12 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
raise ArgumentError, 'sdp_offer is required' if params[:sdp_offer].blank?
result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+'), params[:sdp_offer])
call_id = result.dig('calls', 0, 'id') || result['call_id']
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
current_account.whatsapp_calls.create!(
current_account.calls.create!(
provider: :whatsapp,
inbox: conversation.inbox, conversation: conversation,
call_id: call_id, direction: 'outbound', status: 'ringing',
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
meta: { sdp_offer: params[:sdp_offer] }
)
end
@@ -118,21 +120,21 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
render_payment_required('WhatsApp calling is not enabled for this account') unless current_account.feature_enabled?('whatsapp_call')
end
def set_whatsapp_call
@whatsapp_call = current_account.whatsapp_calls.find(params[:id])
authorize @whatsapp_call.conversation, :show?
def set_call
@call = current_account.calls.whatsapp.find(params[:id])
authorize @call.conversation, :show?
rescue ActiveRecord::RecordNotFound
render json: { error: 'Call not found' }, status: :not_found
end
def attach_recording_and_enqueue_transcription
@whatsapp_call.recording.attach(params[:recording])
Whatsapp::CallMessageBuilder.update_recording_url!(wa_call: @whatsapp_call)
Whatsapp::CallTranscriptionJob.perform_later(@whatsapp_call.id)
@call.recording.attach(params[:recording])
Whatsapp::CallMessageBuilder.update_recording_url!(call: @call)
Whatsapp::CallTranscriptionJob.perform_later(@call.id)
end
def caller_info
contact = @whatsapp_call.conversation&.contact
contact = @call.conversation&.contact
return {} unless contact
{ name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url }
@@ -6,10 +6,10 @@ class Whatsapp::CallTranscriptionJob < ApplicationJob
Rails.logger.warn("[WHATSAPP CALL] Discarding transcription job: call_id=#{job.arguments.first}, status=#{error.response&.dig(:status)}")
end
def perform(whatsapp_call_id)
wa_call = WhatsappCall.find_by(id: whatsapp_call_id)
return if wa_call.blank? || !wa_call.recording.attached?
def perform(call_id)
call = Call.whatsapp.find_by(id: call_id)
return if call.blank? || !call.recording.attached?
Whatsapp::CallTranscriptionService.new(wa_call).perform
Whatsapp::CallTranscriptionService.new(call).perform
end
end
+32
View File
@@ -21,4 +21,36 @@ class Call < ApplicationRecord
validates :status, presence: true, inclusion: { in: STATUSES }
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
scope :ringing, -> { where(status: 'ringing') }
def ringing?
status == 'ringing'
end
def in_progress?
status == 'in_progress'
end
def terminal?
TERMINAL_STATUSES.include?(status)
end
# Frontend-facing direction label: incoming→inbound, outgoing→outbound
def direction_label
incoming? ? 'inbound' : 'outbound'
end
def sdp_offer
meta&.dig('sdp_offer')
end
def ice_servers
meta&.dig('ice_servers') || []
end
def recording_url
return unless recording.attached?
Rails.application.routes.url_helpers.rails_blob_path(recording, only_path: true)
end
end
-45
View File
@@ -1,45 +0,0 @@
class WhatsappCall < ApplicationRecord
STATUSES = %w[ringing accepted rejected missed ended failed].freeze
DIRECTIONS = %w[inbound outbound].freeze
belongs_to :account
belongs_to :inbox
belongs_to :conversation
belongs_to :accepted_by_agent, class_name: 'User', optional: true
belongs_to :message, optional: true
has_one_attached :recording
validates :call_id, presence: true, uniqueness: true
validates :direction, inclusion: { in: DIRECTIONS }
validates :status, inclusion: { in: STATUSES }
scope :active, -> { where(status: %w[ringing accepted]) }
scope :ringing, -> { where(status: 'ringing') }
def accepted?
status == 'accepted'
end
def ringing?
status == 'ringing'
end
def terminal?
%w[rejected missed ended failed].include?(status)
end
def sdp_offer
meta['sdp_offer']
end
def ice_servers
meta['ice_servers'] || []
end
def recording_url
return unless recording.attached?
Rails.application.routes.url_helpers.rails_blob_path(recording, only_path: true)
end
end
@@ -1,36 +1,36 @@
class Whatsapp::CallMessageBuilder
WHATSAPP_TO_VOICE_STATUS = {
# Maps Call model statuses to voice_call display statuses (hyphenated for frontend)
CALL_TO_VOICE_STATUS = {
'ringing' => 'ringing',
'accepted' => 'in-progress',
'rejected' => 'failed',
'missed' => 'no-answer',
'ended' => 'completed',
'failed' => 'failed'
'in_progress' => 'in-progress',
'failed' => 'failed',
'no_answer' => 'no-answer',
'completed' => 'completed'
}.freeze
def self.create!(conversation:, wa_call:, user: nil)
new(conversation: conversation, wa_call: wa_call, user: user).create!
def self.create!(conversation:, call:, user: nil)
new(conversation: conversation, call: call, user: user).create!
end
def self.update_status!(wa_call:, status: nil, agent: nil, duration_seconds: nil)
new(conversation: wa_call.conversation, wa_call: wa_call).update_status!(
def self.update_status!(call:, status: nil, agent: nil, duration_seconds: nil)
new(conversation: call.conversation, call: call).update_status!(
status: status, agent: agent, duration_seconds: duration_seconds
)
end
def self.update_recording_url!(wa_call:)
message = wa_call.message
def self.update_recording_url!(call:)
message = call.message
return unless message
data = (message.content_attributes || {}).dup
data['data'] ||= {}
data['data']['recording_url'] = wa_call.recording_url
data['data']['recording_url'] = call.recording_url
message.update!(content_attributes: data)
end
def initialize(conversation:, wa_call:, user: nil)
def initialize(conversation:, call:, user: nil)
@conversation = conversation
@wa_call = wa_call
@call = call
@user = user
end
@@ -46,7 +46,7 @@ class Whatsapp::CallMessageBuilder
end
def update_status!(status:, agent: nil, duration_seconds: nil)
message = wa_call.message
message = call.message
return unless message
data = (message.content_attributes || {}).dup
@@ -61,15 +61,15 @@ class Whatsapp::CallMessageBuilder
private
attr_reader :conversation, :wa_call, :user
attr_reader :conversation, :call, :user
def build_data_payload
{
'call_sid' => wa_call.call_id,
'status' => map_status(wa_call.status),
'call_direction' => wa_call.direction,
'call_sid' => call.provider_call_id,
'status' => map_status(call.status),
'call_direction' => call.direction_label,
'call_source' => 'whatsapp',
'wa_call_id' => wa_call.id,
'call_id' => call.id,
'from_number' => from_number,
'to_number' => to_number,
'meta' => { 'created_at' => Time.zone.now.to_i }
@@ -77,17 +77,17 @@ class Whatsapp::CallMessageBuilder
end
def message_type
wa_call.direction == 'outbound' ? 'outgoing' : 'incoming'
call.outgoing? ? 'outgoing' : 'incoming'
end
def sender
return user if wa_call.direction == 'outbound' && user
return user if call.outgoing? && user
conversation.contact
end
def from_number
if wa_call.direction == 'inbound'
if call.incoming?
conversation.contact&.phone_number
else
conversation.inbox.channel&.phone_number
@@ -95,7 +95,7 @@ class Whatsapp::CallMessageBuilder
end
def to_number
if wa_call.direction == 'inbound'
if call.incoming?
conversation.inbox.channel&.phone_number
else
conversation.contact&.phone_number
@@ -103,6 +103,6 @@ class Whatsapp::CallMessageBuilder
end
def map_status(status)
WHATSAPP_TO_VOICE_STATUS[status] || status
CALL_TO_VOICE_STATUS[status] || status
end
end
@@ -1,72 +1,72 @@
class Whatsapp::CallService
pattr_initialize [:wa_call!, :agent!]
pattr_initialize [:call!, :agent!]
def pre_accept_and_accept(sdp_answer)
wa_call.with_lock do
call.with_lock do
ensure_ringing!
ensure_not_already_taken!
provider = wa_call.inbox.channel.provider_service
call_id = wa_call.call_id
provider = call.inbox.channel.provider_service
fixed_sdp = fix_sdp_setup(sdp_answer)
# Step 1: pre_accept (with SDP answer required by Meta)
pre_response = provider.pre_accept_call(call_id, fixed_sdp)
# Step 1: pre_accept (with SDP answer - required by Meta)
pre_response = provider.pre_accept_call(call.provider_call_id, fixed_sdp)
raise Whatsapp::CallErrors::NotRinging, 'Meta pre_accept failed' unless pre_response
# Step 2: accept with same SDP answer
accept_response = provider.accept_call(call_id, fixed_sdp)
accept_response = provider.accept_call(call.provider_call_id, fixed_sdp)
raise Whatsapp::CallErrors::NotRinging, 'Meta accept failed' unless accept_response
wa_call.update!(
status: 'accepted',
accepted_by_agent_id: agent.id
call.update!(
status: 'in_progress',
accepted_by_agent_id: agent.id,
started_at: Time.current
)
end
Whatsapp::CallMessageBuilder.update_status!(wa_call: wa_call, status: 'accepted', agent: agent)
Whatsapp::CallMessageBuilder.update_status!(call: call, status: 'in_progress', agent: agent)
update_conversation_call_status('in-progress')
broadcast_accepted
wa_call
call
end
def reject
wa_call.reload
return wa_call if wa_call.terminal? || wa_call.accepted?
call.reload
return call if call.terminal? || call.in_progress?
provider = wa_call.inbox.channel.provider_service
success = provider.reject_call(wa_call.call_id)
Rails.logger.error "[WHATSAPP CALL] reject_call API returned false for call #{wa_call.call_id}" unless success
provider = call.inbox.channel.provider_service
success = provider.reject_call(call.provider_call_id)
Rails.logger.error "[WHATSAPP CALL] reject_call API returned false for call #{call.provider_call_id}" unless success
wa_call.update!(status: 'rejected')
Whatsapp::CallMessageBuilder.update_status!(wa_call: wa_call, status: 'rejected')
call.update!(status: 'failed')
Whatsapp::CallMessageBuilder.update_status!(call: call, status: 'failed')
update_conversation_call_status('failed')
broadcast_call_ended
wa_call
call
end
def terminate
return wa_call if wa_call.terminal?
return call if call.terminal?
provider = wa_call.inbox.channel.provider_service
success = provider.terminate_call(wa_call.call_id)
Rails.logger.error "[WHATSAPP CALL] terminate_call API returned false for call #{wa_call.call_id}" unless success
provider = call.inbox.channel.provider_service
success = provider.terminate_call(call.provider_call_id)
Rails.logger.error "[WHATSAPP CALL] terminate_call API returned false for call #{call.provider_call_id}" unless success
wa_call.update!(status: 'ended')
Whatsapp::CallMessageBuilder.update_status!(wa_call: wa_call, status: 'ended')
call.update!(status: 'completed')
Whatsapp::CallMessageBuilder.update_status!(call: call, status: 'completed')
update_conversation_call_status('completed')
broadcast_call_ended
wa_call
call
end
private
def ensure_ringing!
raise Whatsapp::CallErrors::NotRinging, 'Call is not in ringing state' unless wa_call.ringing?
raise Whatsapp::CallErrors::NotRinging, 'Call is not in ringing state' unless call.ringing?
end
def ensure_not_already_taken!
raise Whatsapp::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if wa_call.accepted?
raise Whatsapp::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if call.in_progress?
end
def fix_sdp_setup(sdp)
@@ -74,7 +74,7 @@ class Whatsapp::CallService
end
def update_conversation_call_status(mapped_status)
conversation = wa_call.conversation
conversation = call.conversation
attrs = (conversation.additional_attributes || {}).merge('call_status' => mapped_status)
conversation.update!(additional_attributes: attrs)
end
@@ -83,27 +83,27 @@ class Whatsapp::CallService
payload = {
event: 'whatsapp_call.accepted',
data: {
account_id: wa_call.account_id,
id: wa_call.id,
call_id: wa_call.call_id,
account_id: call.account_id,
id: call.id,
call_id: call.provider_call_id,
accepted_by_agent_id: agent.id,
conversation_id: wa_call.conversation_id
conversation_id: call.conversation_id
}
}
ActionCable.server.broadcast("account_#{wa_call.account_id}", payload)
ActionCable.server.broadcast("account_#{call.account_id}", payload)
end
def broadcast_call_ended
payload = {
event: 'whatsapp_call.ended',
data: {
account_id: wa_call.account_id,
id: wa_call.id,
call_id: wa_call.call_id,
status: wa_call.status,
conversation_id: wa_call.conversation_id
account_id: call.account_id,
id: call.id,
call_id: call.provider_call_id,
status: call.status,
conversation_id: call.conversation_id
}
}
ActionCable.server.broadcast("account_#{wa_call.account_id}", payload)
ActionCable.server.broadcast("account_#{call.account_id}", payload)
end
end
@@ -1,17 +1,17 @@
class Whatsapp::CallTranscriptionService < Llm::LegacyBaseOpenAiService
WHISPER_MODEL = 'whisper-1'.freeze
attr_reader :wa_call, :account
attr_reader :call, :account
def initialize(wa_call)
def initialize(call)
super()
@wa_call = wa_call
@account = wa_call.account
@call = call
@account = call.account
end
def perform
return { error: 'Transcription not available' } unless can_transcribe?
return { error: 'No recording attached' } unless wa_call.recording.attached?
return { error: 'No recording attached' } unless call.recording.attached?
transcribed_text = transcribe_audio
update_call_and_message(transcribed_text)
@@ -45,7 +45,7 @@ class Whatsapp::CallTranscriptionService < Llm::LegacyBaseOpenAiService
end
def fetch_audio_file
blob = wa_call.recording.blob
blob = call.recording.blob
temp_dir = Rails.root.join('tmp/uploads/call-transcriptions')
FileUtils.mkdir_p(temp_dir)
@@ -62,16 +62,16 @@ class Whatsapp::CallTranscriptionService < Llm::LegacyBaseOpenAiService
def update_call_and_message(transcribed_text)
return if transcribed_text.blank?
wa_call.update!(transcript: transcribed_text)
call.update!(transcript: transcribed_text)
account.increment_response_usage
message = wa_call.message
message = call.message
return unless message
data = (message.content_attributes || {}).dup
data['data'] ||= {}
data['data']['transcript'] = transcribed_text
data['data']['recording_url'] = wa_call.recording_url
data['data']['recording_url'] = call.recording_url
message.update!(content_attributes: data)
end
@@ -23,18 +23,21 @@ class Whatsapp::IncomingCallService
end
def handle_call_connect(call_payload)
call_id = call_payload[:id]
provider_call_id = call_payload[:id]
direction = map_direction(call_payload[:direction])
# For outbound calls, a WhatsappCall record already exists from initiate.
# For outbound calls, a Call record already exists from initiate.
# Update it instead of creating a duplicate.
existing_call = WhatsappCall.find_by(call_id: call_id)
existing_call = Call.whatsapp.find_by(provider_call_id: provider_call_id)
if existing_call
Rails.logger.info "[WHATSAPP CALL] call_connect for existing call #{call_id} (direction=#{direction})"
Rails.logger.info "[WHATSAPP CALL] call_connect for existing call #{provider_call_id} (direction=#{direction})"
# Guard against race condition: skip if already in_progress (agent accepted via CallService)
return if existing_call.in_progress?
sdp_answer = fix_sdp_setup(call_payload.dig(:session, :sdp))
existing_call.update!(status: 'accepted', meta: existing_call.meta.merge('sdp_answer' => sdp_answer))
Whatsapp::CallMessageBuilder.update_status!(wa_call: existing_call, status: 'accepted')
update_conversation_call_status(existing_call.conversation, 'in-progress', direction)
existing_call.update!(status: 'in_progress', started_at: Time.current, meta: (existing_call.meta || {}).merge('sdp_answer' => sdp_answer))
Whatsapp::CallMessageBuilder.update_status!(call: existing_call, status: 'in_progress')
update_conversation_call_status(existing_call.conversation, 'in-progress', existing_call.direction_label)
broadcast_outbound_call_connected(existing_call, sdp_answer)
return
end
@@ -45,27 +48,28 @@ class Whatsapp::IncomingCallService
conversation = find_or_create_conversation(contact)
return unless conversation
wa_call = create_call_record(call_payload, conversation, direction)
create_voice_call_message(conversation, wa_call)
update_conversation_call_status(conversation, 'ringing', direction)
broadcast_incoming_call(wa_call, contact, call_payload.dig(:session, :sdp))
call = create_call_record(call_payload, conversation, direction)
create_voice_call_message(conversation, call)
update_conversation_call_status(conversation, 'ringing', call.direction_label)
broadcast_incoming_call(call, contact, call_payload.dig(:session, :sdp))
rescue ActiveRecord::RecordNotUnique
Rails.logger.warn "[WHATSAPP CALL] Duplicate call_id received: #{call_id}"
Rails.logger.warn "[WHATSAPP CALL] Duplicate provider_call_id received: #{provider_call_id}"
end
def create_voice_call_message(conversation, wa_call, user: nil)
message = Whatsapp::CallMessageBuilder.create!(conversation: conversation, wa_call: wa_call, user: user)
wa_call.update!(message_id: message.id)
def create_voice_call_message(conversation, call, user: nil)
message = Whatsapp::CallMessageBuilder.create!(conversation: conversation, call: call, user: user)
call.update!(message_id: message.id)
rescue StandardError => e
Rails.logger.error "[WHATSAPP CALL] Failed to create voice_call message: #{e.message}"
end
def create_call_record(call_payload, conversation, direction)
WhatsappCall.create!(
Call.create!(
provider: :whatsapp,
account: inbox.account,
inbox: inbox,
conversation: conversation,
call_id: call_payload[:id],
provider_call_id: call_payload[:id],
direction: direction,
status: 'ringing',
meta: { sdp_offer: call_payload.dig(:session, :sdp), ice_servers: default_ice_servers }
@@ -73,28 +77,28 @@ class Whatsapp::IncomingCallService
end
def handle_call_terminate(call_payload)
call_id = call_payload[:id]
provider_call_id = call_payload[:id]
duration = call_payload[:duration]&.to_i
end_reason = call_payload[:terminate_reason]
wa_call = WhatsappCall.find_by(call_id: call_id)
return unless wa_call
call = Call.whatsapp.find_by(provider_call_id: provider_call_id)
return unless call
# Determine if the call was answered: check accepted status, duration > 0,
# Determine if the call was answered: check in_progress status, duration > 0,
# or accepted_by_agent_id presence (handles webhook race conditions)
was_answered = wa_call.accepted? || duration.to_i.positive? || wa_call.accepted_by_agent_id.present?
final_status = was_answered ? 'ended' : 'missed'
wa_call.update!(
was_answered = call.in_progress? || duration.to_i.positive? || call.accepted_by_agent_id.present?
final_status = was_answered ? 'completed' : 'no_answer'
call.update!(
status: final_status,
duration_seconds: duration,
end_reason: end_reason
)
agent = wa_call.accepted_by_agent if wa_call.accepted_by_agent_id.present?
Whatsapp::CallMessageBuilder.update_status!(wa_call: wa_call, status: final_status, agent: agent, duration_seconds: duration)
mapped = Whatsapp::CallMessageBuilder::WHATSAPP_TO_VOICE_STATUS[final_status] || final_status
update_conversation_call_status(wa_call.conversation, mapped, wa_call.direction)
broadcast_call_ended(wa_call)
agent = call.accepted_by_agent if call.accepted_by_agent_id.present?
Whatsapp::CallMessageBuilder.update_status!(call: call, status: final_status, agent: agent, duration_seconds: duration)
mapped = Whatsapp::CallMessageBuilder::CALL_TO_VOICE_STATUS[final_status] || final_status
update_conversation_call_status(call.conversation, mapped, call.direction_label)
broadcast_call_ended(call)
end
def find_or_create_contact(phone_number)
@@ -136,16 +140,16 @@ class Whatsapp::IncomingCallService
conversation.update!(additional_attributes: attrs)
end
def broadcast_incoming_call(wa_call, contact, sdp_offer)
def broadcast_incoming_call(call, contact, sdp_offer)
payload = {
event: 'whatsapp_call.incoming',
data: {
account_id: inbox.account_id,
id: wa_call.id,
call_id: wa_call.call_id,
direction: wa_call.direction,
inbox_id: wa_call.inbox_id,
conversation_id: wa_call.conversation_id,
id: call.id,
call_id: call.provider_call_id,
direction: call.direction_label,
inbox_id: call.inbox_id,
conversation_id: call.conversation_id,
caller: {
name: contact.name,
phone: contact.phone_number,
@@ -159,30 +163,30 @@ class Whatsapp::IncomingCallService
ActionCable.server.broadcast("account_#{inbox.account_id}", payload)
end
def broadcast_call_ended(wa_call)
def broadcast_call_ended(call)
payload = {
event: 'whatsapp_call.ended',
data: {
account_id: inbox.account_id,
id: wa_call.id,
call_id: wa_call.call_id,
status: wa_call.status,
duration_seconds: wa_call.duration_seconds,
conversation_id: wa_call.conversation_id
id: call.id,
call_id: call.provider_call_id,
status: call.status,
duration_seconds: call.duration_seconds,
conversation_id: call.conversation_id
}
}
ActionCable.server.broadcast("account_#{inbox.account_id}", payload)
end
def broadcast_outbound_call_connected(wa_call, sdp_answer)
def broadcast_outbound_call_connected(call, sdp_answer)
payload = {
event: 'whatsapp_call.outbound_connected',
data: {
account_id: inbox.account_id,
id: wa_call.id,
call_id: wa_call.call_id,
conversation_id: wa_call.conversation_id,
id: call.id,
call_id: call.provider_call_id,
conversation_id: call.conversation_id,
sdp_answer: sdp_answer
}
}
@@ -190,11 +194,11 @@ class Whatsapp::IncomingCallService
ActionCable.server.broadcast("account_#{inbox.account_id}", payload)
end
# Meta sends "USER_INITIATED" / "BUSINESS_INITIATED", map to our model values
# Meta sends "USER_INITIATED" / "BUSINESS_INITIATED", map to Call enum values
def map_direction(raw_direction)
return 'outbound' if raw_direction&.upcase == 'BUSINESS_INITIATED'
return :outgoing if raw_direction&.upcase == 'BUSINESS_INITIATED'
'inbound'
:incoming
end
def default_ice_servers