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:
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user