diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index c192e6ddc..053bcaeb8 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -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); diff --git a/app/javascript/dashboard/composables/useWhatsappCallSession.js b/app/javascript/dashboard/composables/useWhatsappCallSession.js index cd720d309..4fcc1e6f8 100644 --- a/app/javascript/dashboard/composables/useWhatsappCallSession.js +++ b/app/javascript/dashboard/composables/useWhatsappCallSession.js @@ -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, diff --git a/db/migrate/20260305193732_create_whatsapp_calls.rb b/db/migrate/20260305193732_create_whatsapp_calls.rb deleted file mode 100644 index 361ae18e2..000000000 --- a/db/migrate/20260305193732_create_whatsapp_calls.rb +++ /dev/null @@ -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 diff --git a/db/migrate/20260323100000_add_message_id_to_whatsapp_calls.rb b/db/migrate/20260323100000_add_message_id_to_whatsapp_calls.rb deleted file mode 100644 index 3e88ff150..000000000 --- a/db/migrate/20260323100000_add_message_id_to_whatsapp_calls.rb +++ /dev/null @@ -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 diff --git a/db/migrate/20260323110000_add_transcript_to_whatsapp_calls.rb b/db/migrate/20260323110000_add_transcript_to_whatsapp_calls.rb deleted file mode 100644 index 3e57546cf..000000000 --- a/db/migrate/20260323110000_add_transcript_to_whatsapp_calls.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddTranscriptToWhatsappCalls < ActiveRecord::Migration[7.1] - def change - add_column :whatsapp_calls, :transcript, :text - end -end diff --git a/db/schema.rb b/db/schema.rb index fbac08a87..e2d3d75df 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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). diff --git a/docs/whatsapp-calling.md b/docs/whatsapp-calling.md index 2d501c454..62fe17b66 100644 --- a/docs/whatsapp-calling.md +++ b/docs/whatsapp-calling.md @@ -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 diff --git a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb index 693deb15f..f6ac792c7 100644 --- a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -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 } diff --git a/enterprise/app/jobs/whatsapp/call_transcription_job.rb b/enterprise/app/jobs/whatsapp/call_transcription_job.rb index 5f6a1e3e5..26b9742ce 100644 --- a/enterprise/app/jobs/whatsapp/call_transcription_job.rb +++ b/enterprise/app/jobs/whatsapp/call_transcription_job.rb @@ -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 diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb index 33ae10eb1..621314128 100644 --- a/enterprise/app/models/call.rb +++ b/enterprise/app/models/call.rb @@ -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 diff --git a/enterprise/app/models/whatsapp_call.rb b/enterprise/app/models/whatsapp_call.rb deleted file mode 100644 index 27e5c1a1f..000000000 --- a/enterprise/app/models/whatsapp_call.rb +++ /dev/null @@ -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 diff --git a/enterprise/app/services/whatsapp/call_message_builder.rb b/enterprise/app/services/whatsapp/call_message_builder.rb index 32e549b6d..9b311abdb 100644 --- a/enterprise/app/services/whatsapp/call_message_builder.rb +++ b/enterprise/app/services/whatsapp/call_message_builder.rb @@ -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 diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb index fe05bd734..e6161106b 100644 --- a/enterprise/app/services/whatsapp/call_service.rb +++ b/enterprise/app/services/whatsapp/call_service.rb @@ -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 diff --git a/enterprise/app/services/whatsapp/call_transcription_service.rb b/enterprise/app/services/whatsapp/call_transcription_service.rb index 3dbef5efe..fa8918df2 100644 --- a/enterprise/app/services/whatsapp/call_transcription_service.rb +++ b/enterprise/app/services/whatsapp/call_transcription_service.rb @@ -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 diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb index fc113e458..3f072e621 100644 --- a/enterprise/app/services/whatsapp/incoming_call_service.rb +++ b/enterprise/app/services/whatsapp/incoming_call_service.rb @@ -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