From da01ed142c991f307501b93c0ac14f46a73723dd Mon Sep 17 00:00:00 2001 From: Muhsin <12408980+muhsin-k@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:19:28 +0400 Subject: [PATCH] feat(voice): wire Twilio voice to unified Call model Twilio voice flows now persist Call records instead of stuffing state into conversation.additional_attributes + conversation.identifier. - InboundCallBuilder / OutboundCallBuilder create Call rows (provider, direction, status, provider_call_id, contact, meta), link call.message_id to the voice_call message, and denormalize call_status / call_direction / conference_sid onto the conversation for frontend compatibility. - Conference::Name.for(call) uses call.id, removing collisions with multiple calls per conversation. - CallMessageBuilder matches existing voice_call messages by content_attributes.data.call_sid, so each call gets its own bubble. - CallStatus::Manager, StatusUpdateService, Conference::Manager, CallSessionSyncService, ConferenceController and ConferenceService all operate on Call records and look up by provider_call_id. - InboundCallBuilder honours inbox.lock_to_single_conversation: reuses the latest non-resolved conversation when enabled, creates a new conversation otherwise. --- .../api/v1/accounts/conference_controller.rb | 31 +++++--- .../controllers/twilio/voice_controller.rb | 57 +++++++++------ .../services/voice/call_message_builder.rb | 14 ++-- .../voice/call_session_sync_service.rb | 17 ++--- .../app/services/voice/call_status/manager.rb | 41 +++++++++-- .../app/services/voice/conference/manager.rb | 20 +++--- .../app/services/voice/conference/name.rb | 4 +- .../services/voice/inbound_call_builder.rb | 70 +++++++++++++------ .../services/voice/outbound_call_builder.rb | 56 ++++++++++----- .../provider/twilio/conference_service.rb | 18 +++-- .../services/voice/status_update_service.rb | 9 +-- 11 files changed, 221 insertions(+), 116 deletions(-) diff --git a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb index 3d802fc31..9f7398a14 100644 --- a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb @@ -11,9 +11,9 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle def create conversation = fetch_conversation_by_display_id - ensure_call_sid!(conversation) + call = find_or_initialize_call!(conversation) - conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation) + conference_service = Voice::Provider::Twilio::ConferenceService.new(call: call) conference_sid = conference_service.ensure_conference_sid conference_service.mark_agent_joined(user: current_user) @@ -27,19 +27,34 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle def destroy conversation = fetch_conversation_by_display_id - Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference + call = Current.account.calls.where(conversation_id: conversation.id).order(created_at: :desc).first + return render(json: { status: 'success', id: conversation.display_id }) unless call + + Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference render json: { status: 'success', id: conversation.display_id } end private - def ensure_call_sid!(conversation) - return conversation.identifier if conversation.identifier.present? + def find_or_initialize_call!(conversation) + sid = params[:call_sid].presence + existing = Current.account.calls.where(conversation_id: conversation.id, provider: :twilio) + existing = existing.where(provider_call_id: sid) if sid + call = existing.order(created_at: :desc).first + return call if call - incoming_sid = params.require(:call_sid) + raise ActionController::ParameterMissing, :call_sid unless sid - conversation.update!(identifier: incoming_sid) - incoming_sid + Current.account.calls.create!( + inbox_id: conversation.inbox_id, + conversation: conversation, + contact_id: conversation.contact_id, + provider: :twilio, + direction: :outgoing, + status: 'ringing', + provider_call_id: sid, + accepted_by_agent_id: current_user.id + ) end def set_voice_inbox_for_conference diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb index fdadd334c..b1e4b8f34 100644 --- a/enterprise/app/controllers/twilio/voice_controller.rb +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -25,8 +25,8 @@ class Twilio::VoiceController < ApplicationController "TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}" ) - conversation = resolve_conversation - conference_sid = ensure_conference_sid!(conversation) + call = resolve_call + conference_sid = ensure_conference_sid!(call) render xml: conference_twiml(conference_sid, agent_leg?(twilio_from)) end @@ -35,15 +35,14 @@ class Twilio::VoiceController < ApplicationController event = mapped_conference_event return head :no_content unless event - conversation = find_conversation_for_conference!( + call = find_call_for_conference!( friendly_name: params[:FriendlyName], call_sid: twilio_call_sid ) Voice::Conference::Manager.new( - conversation: conversation, + call: call, event: event, - call_sid: twilio_call_sid, participant_label: participant_label ).process @@ -80,8 +79,8 @@ class Twilio::VoiceController < ApplicationController from_number.start_with?('client:') end - def resolve_conversation - return find_conversation_for_agent if agent_leg?(twilio_from) + def resolve_call + return find_call_for_agent if agent_leg?(twilio_from) case twilio_direction when 'inbound' @@ -91,6 +90,7 @@ class Twilio::VoiceController < ApplicationController from_number: twilio_from, call_sid: twilio_call_sid ) + find_call!(twilio_call_sid) when 'outbound-api', 'outbound-dial' sync_outbound_leg( call_sid: twilio_call_sid, @@ -102,36 +102,47 @@ class Twilio::VoiceController < ApplicationController end end - def find_conversation_for_agent + def find_call_for_agent if params[:conversation_id].present? - current_account.conversations.find_by!(display_id: params[:conversation_id]) + conversation = current_account.conversations.find_by!(display_id: params[:conversation_id]) + current_account.calls.active.where(conversation_id: conversation.id).order(created_at: :desc).first! else - current_account.conversations.find_by!(identifier: twilio_call_sid) + find_call!(twilio_call_sid) end end + def find_call!(sid) + current_account.calls.find_by!(provider: :twilio, provider_call_id: sid) + end + def sync_outbound_leg(call_sid:, from_number:, direction:) parent_sid = params['ParentCallSid'].presence lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid - conversation = current_account.conversations.find_by!(identifier: lookup_sid) + call = find_call!(lookup_sid) Voice::CallSessionSyncService.new( - conversation: conversation, - call_sid: call_sid, - message_call_sid: conversation.identifier, + call: call, + leg_call_sid: call_sid, leg: { from_number: from_number, to_number: twilio_to, direction: 'outbound' } ).perform + call end - def ensure_conference_sid!(conversation) + def ensure_conference_sid!(call) + name = call.meta['conference_sid'].presence || Voice::Conference::Name.for(call) + call.update!(meta: call.meta.merge('conference_sid' => name)) if call.meta['conference_sid'].blank? + + conversation = call.conversation attrs = conversation.additional_attributes || {} - attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation) - conversation.update!(additional_attributes: attrs) - attrs['conference_sid'] + if attrs['conference_sid'] != name + attrs['conference_sid'] = name + conversation.update!(additional_attributes: attrs) + end + name end def conference_twiml(conference_sid, agent_leg) @@ -155,16 +166,16 @@ class Twilio::VoiceController < ApplicationController Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits) end - def find_conversation_for_conference!(friendly_name:, call_sid:) + def find_call_for_conference!(friendly_name:, call_sid:) name = friendly_name.to_s - scope = current_account.conversations + scope = current_account.calls if name.present? - conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first - return conversation if conversation + call = scope.where("meta ->> 'conference_sid' = ?", name).first + return call if call end - scope.find_by!(identifier: call_sid) + scope.find_by!(provider: :twilio, provider_call_id: call_sid) end def set_inbox! diff --git a/enterprise/app/services/voice/call_message_builder.rb b/enterprise/app/services/voice/call_message_builder.rb index adf72f67c..82a729ed9 100644 --- a/enterprise/app/services/voice/call_message_builder.rb +++ b/enterprise/app/services/voice/call_message_builder.rb @@ -19,16 +19,21 @@ class Voice::CallMessageBuilder def perform! validate_sender! - message = latest_message - message ? update_message!(message) : create_message! + existing = existing_message + existing ? update_message!(existing) : create_message! end private attr_reader :conversation, :direction, :payload, :user, :timestamps - def latest_message - conversation.messages.voice_calls.order(created_at: :desc).first + def existing_message + sid = payload[:call_sid] || payload['call_sid'] + return if sid.blank? + + conversation.messages.voice_calls + .where("content_attributes -> 'data' ->> 'call_sid' = ?", sid) + .first end def update_message!(message) @@ -37,6 +42,7 @@ class Voice::CallMessageBuilder content_attributes: { 'data' => base_payload }, sender: sender ) + message end def create_message! diff --git a/enterprise/app/services/voice/call_session_sync_service.rb b/enterprise/app/services/voice/call_session_sync_service.rb index 8ce80ae0e..d7505eba3 100644 --- a/enterprise/app/services/voice/call_session_sync_service.rb +++ b/enterprise/app/services/voice/call_session_sync_service.rb @@ -1,10 +1,9 @@ class Voice::CallSessionSyncService - attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction + attr_reader :call, :leg_call_sid, :from_number, :to_number, :direction - def initialize(conversation:, call_sid:, leg:, message_call_sid: nil) - @conversation = conversation - @call_sid = call_sid - @message_call_sid = message_call_sid || call_sid + def initialize(call:, leg:, leg_call_sid: nil) + @call = call + @leg_call_sid = leg_call_sid || call.provider_call_id @from_number = leg[:from_number] @to_number = leg[:to_number] @direction = leg[:direction] @@ -20,16 +19,18 @@ class Voice::CallSessionSyncService sync_voice_call_message!(attrs) end - conversation + call end private + delegate :conversation, to: :call + def refreshed_attributes attrs = (conversation.additional_attributes || {}).dup attrs['call_direction'] = direction attrs['call_status'] ||= 'ringing' - attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation) + attrs['conference_sid'] ||= call.meta['conference_sid'] || Voice::Conference::Name.for(call) attrs['meta'] ||= {} attrs['meta']['initiated_at'] ||= current_timestamp attrs @@ -40,7 +41,7 @@ class Voice::CallSessionSyncService conversation: conversation, direction: direction, payload: { - call_sid: message_call_sid, + call_sid: call.provider_call_id, status: attrs['call_status'], conference_sid: attrs['conference_sid'], from_number: origin_number_for(direction), diff --git a/enterprise/app/services/voice/call_status/manager.rb b/enterprise/app/services/voice/call_status/manager.rb index 82d7efde7..9799de02a 100644 --- a/enterprise/app/services/voice/call_status/manager.rb +++ b/enterprise/app/services/voice/call_status/manager.rb @@ -1,13 +1,20 @@ class Voice::CallStatus::Manager - pattr_initialize [:conversation!, :call_sid] + pattr_initialize [:call!] ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze TERMINAL_STATUSES = %w[completed no-answer failed].freeze + # Map dashed statuses (Twilio-native / frontend) to underscored Call model statuses. + CALL_MODEL_STATUS = { + 'ringing' => 'ringing', + 'in-progress' => 'in_progress', + 'completed' => 'completed', + 'no-answer' => 'no_answer', + 'failed' => 'failed' + }.freeze + def process_status_update(status, duration: nil, timestamp: nil) return unless ALLOWED_STATUSES.include?(status) - - current_status = conversation.additional_attributes&.dig('call_status') return if current_status == status apply_status(status, duration: duration, timestamp: timestamp) @@ -16,6 +23,12 @@ class Voice::CallStatus::Manager private + delegate :conversation, to: :call + + def current_status + conversation.additional_attributes&.dig('call_status') + end + def apply_status(status, duration:, timestamp:) attrs = (conversation.additional_attributes || {}).dup attrs['call_status'] = status @@ -31,6 +44,16 @@ class Voice::CallStatus::Manager additional_attributes: attrs, last_activity_at: current_time ) + + persist_on_call!(status, attrs) + end + + def persist_on_call!(status, attrs) + updates = { status: CALL_MODEL_STATUS[status] } + updates[:started_at] = Time.zone.at(attrs['call_started_at']) if status == 'in-progress' && attrs['call_started_at'] + updates[:duration_seconds] = attrs['call_duration'] if TERMINAL_STATUSES.include?(status) && attrs['call_duration'] + + call.update!(updates) end def resolved_duration(attrs, provided_duration, timestamp) @@ -43,10 +66,7 @@ class Voice::CallStatus::Manager end def update_message(status) - message = conversation.messages - .where(content_type: 'voice_call') - .order(created_at: :desc) - .first + message = call.message || fallback_message return unless message data = (message.content_attributes || {}).dup @@ -56,6 +76,13 @@ class Voice::CallStatus::Manager message.update!(content_attributes: data) end + def fallback_message + conversation.messages.voice_calls + .where("content_attributes -> 'data' ->> 'call_sid' = ?", call.provider_call_id) + .order(created_at: :desc) + .first + end + def now_seconds current_time.to_i end diff --git a/enterprise/app/services/voice/conference/manager.rb b/enterprise/app/services/voice/conference/manager.rb index 0b54f6ae8..e468c5c2d 100644 --- a/enterprise/app/services/voice/conference/manager.rb +++ b/enterprise/app/services/voice/conference/manager.rb @@ -1,5 +1,5 @@ class Voice::Conference::Manager - pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label] + pattr_initialize [:call!, :event!, :participant_label] def process case event @@ -17,19 +17,21 @@ class Voice::Conference::Manager private + delegate :conversation, to: :call + def status_manager - @status_manager ||= Voice::CallStatus::Manager.new( - conversation: conversation, - call_sid: call_sid - ) + @status_manager ||= Voice::CallStatus::Manager.new(call: call) end def ensure_conference_sid! - attrs = conversation.additional_attributes || {} - return if attrs['conference_sid'].present? + name = Voice::Conference::Name.for(call) + call.update!(meta: call.meta.merge('conference_sid' => name)) if call.meta['conference_sid'].blank? - attrs['conference_sid'] = Voice::Conference::Name.for(conversation) - conversation.update!(additional_attributes: attrs) + conv_attrs = (conversation.additional_attributes || {}).dup + return if conv_attrs['conference_sid'].present? + + conv_attrs['conference_sid'] = name + conversation.update!(additional_attributes: conv_attrs) end def mark_ringing! diff --git a/enterprise/app/services/voice/conference/name.rb b/enterprise/app/services/voice/conference/name.rb index 027937b4c..39909bb6f 100644 --- a/enterprise/app/services/voice/conference/name.rb +++ b/enterprise/app/services/voice/conference/name.rb @@ -1,5 +1,5 @@ module Voice::Conference::Name - def self.for(conversation) - "conf_account_#{conversation.account_id}_conv_#{conversation.display_id}" + def self.for(call) + "conf_account_#{call.account_id}_call_#{call.id}" end end diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb index 03981ac58..4a14faa9d 100644 --- a/enterprise/app/services/voice/inbound_call_builder.rb +++ b/enterprise/app/services/voice/inbound_call_builder.rb @@ -13,21 +13,32 @@ class Voice::InboundCallBuilder end def perform! + existing = find_existing_call + return existing.conversation if existing + timestamp = current_timestamp ActiveRecord::Base.transaction do contact = ensure_contact! contact_inbox = ensure_contact_inbox!(contact) - conversation = find_conversation || create_conversation!(contact, contact_inbox) + conversation = find_open_conversation(contact_inbox) || create_conversation!(contact, contact_inbox) conversation.reload - update_conversation!(conversation, timestamp) - build_voice_message!(conversation, timestamp) + + call = create_call!(conversation, contact, timestamp) + message = build_voice_message!(conversation, call, timestamp) + call.update!(message_id: message.id) + + denormalize_to_conversation!(conversation, call, timestamp) conversation end end private + def find_existing_call + account.calls.find_by(provider: :twilio, provider_call_id: call_sid) + end + def ensure_contact! account.contacts.find_or_create_by!(phone_number: from_number) do |record| record.name = from_number if record.name.blank? @@ -43,10 +54,10 @@ class Voice::InboundCallBuilder end end - def find_conversation - return if call_sid.blank? + def find_open_conversation(contact_inbox) + return unless inbox.lock_to_single_conversation - account.conversations.includes(:contact).find_by(identifier: call_sid) + contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first end def create_conversation!(contact, contact_inbox) @@ -54,34 +65,33 @@ class Voice::InboundCallBuilder contact_inbox_id: contact_inbox.id, inbox_id: inbox.id, contact_id: contact.id, - status: :open, - identifier: call_sid + status: :open ) end - def update_conversation!(conversation, timestamp) - attrs = { - 'call_direction' => 'inbound', - 'call_status' => 'ringing', - 'conference_sid' => Voice::Conference::Name.for(conversation), - 'meta' => { 'initiated_at' => timestamp } - } - - conversation.update!( - identifier: call_sid, - additional_attributes: attrs, - last_activity_at: current_time + def create_call!(conversation, contact, timestamp) + call = account.calls.create!( + inbox: inbox, + conversation: conversation, + contact: contact, + provider: :twilio, + direction: :incoming, + status: 'ringing', + provider_call_id: call_sid, + meta: { 'initiated_at' => timestamp } ) + call.update!(meta: call.meta.merge('conference_sid' => Voice::Conference::Name.for(call))) + call end - def build_voice_message!(conversation, timestamp) + def build_voice_message!(conversation, call, timestamp) Voice::CallMessageBuilder.perform!( conversation: conversation, direction: 'inbound', payload: { - call_sid: call_sid, + call_sid: call.provider_call_id, status: 'ringing', - conference_sid: conversation.additional_attributes['conference_sid'], + conference_sid: call.meta['conference_sid'], from_number: from_number, to_number: inbox.channel&.phone_number }, @@ -89,6 +99,20 @@ class Voice::InboundCallBuilder ) end + def denormalize_to_conversation!(conversation, call, timestamp) + attrs = (conversation.additional_attributes || {}).merge( + 'call_direction' => 'inbound', + 'call_status' => 'ringing', + 'conference_sid' => call.meta['conference_sid'], + 'meta' => { 'initiated_at' => timestamp } + ) + + conversation.update!( + additional_attributes: attrs, + last_activity_at: current_time + ) + end + def current_timestamp @current_timestamp ||= current_time.to_i end diff --git a/enterprise/app/services/voice/outbound_call_builder.rb b/enterprise/app/services/voice/outbound_call_builder.rb index 1ebcade8a..bc39e54b0 100644 --- a/enterprise/app/services/voice/outbound_call_builder.rb +++ b/enterprise/app/services/voice/outbound_call_builder.rb @@ -22,10 +22,13 @@ class Voice::OutboundCallBuilder contact_inbox = ensure_contact_inbox! conversation = create_conversation!(contact_inbox) conversation.reload - conference_sid = Voice::Conference::Name.for(conversation) + call_sid = initiate_call! - update_conversation!(conversation, call_sid, conference_sid, timestamp) - build_voice_message!(conversation, call_sid, conference_sid, timestamp) + call = create_call!(conversation, call_sid, timestamp) + message = build_voice_message!(conversation, call, timestamp) + call.update!(message_id: message.id) + + denormalize_to_conversation!(conversation, call, timestamp) { conversation: conversation, call_sid: call_sid } end end @@ -56,30 +59,30 @@ class Voice::OutboundCallBuilder )[:call_sid] end - def update_conversation!(conversation, call_sid, conference_sid, timestamp) - attrs = { - 'call_direction' => 'outbound', - 'call_status' => 'ringing', - 'agent_id' => user.id, - 'conference_sid' => conference_sid, - 'meta' => { 'initiated_at' => timestamp } - } - - conversation.update!( - identifier: call_sid, - additional_attributes: attrs, - last_activity_at: current_time + def create_call!(conversation, call_sid, timestamp) + call = account.calls.create!( + inbox: inbox, + conversation: conversation, + contact: contact, + provider: :twilio, + direction: :outgoing, + status: 'ringing', + provider_call_id: call_sid, + accepted_by_agent_id: user.id, + meta: { 'initiated_at' => timestamp } ) + call.update!(meta: call.meta.merge('conference_sid' => Voice::Conference::Name.for(call))) + call end - def build_voice_message!(conversation, call_sid, conference_sid, timestamp) + def build_voice_message!(conversation, call, timestamp) Voice::CallMessageBuilder.perform!( conversation: conversation, direction: 'outbound', payload: { - call_sid: call_sid, + call_sid: call.provider_call_id, status: 'ringing', - conference_sid: conference_sid, + conference_sid: call.meta['conference_sid'], from_number: inbox.channel&.phone_number, to_number: contact.phone_number }, @@ -88,6 +91,21 @@ class Voice::OutboundCallBuilder ) end + def denormalize_to_conversation!(conversation, call, timestamp) + attrs = (conversation.additional_attributes || {}).merge( + 'call_direction' => 'outbound', + 'call_status' => 'ringing', + 'agent_id' => user.id, + 'conference_sid' => call.meta['conference_sid'], + 'meta' => { 'initiated_at' => timestamp } + ) + + conversation.update!( + additional_attributes: attrs, + last_activity_at: current_time + ) + end + def current_timestamp @current_timestamp ||= current_time.to_i end diff --git a/enterprise/app/services/voice/provider/twilio/conference_service.rb b/enterprise/app/services/voice/provider/twilio/conference_service.rb index 30721bfb5..40c42875e 100644 --- a/enterprise/app/services/voice/provider/twilio/conference_service.rb +++ b/enterprise/app/services/voice/provider/twilio/conference_service.rb @@ -1,17 +1,19 @@ class Voice::Provider::Twilio::ConferenceService - pattr_initialize [:conversation!, { twilio_client: nil }] + pattr_initialize [:call!, { twilio_client: nil }] def ensure_conference_sid - existing = conversation.additional_attributes&.dig('conference_sid') + existing = call.meta['conference_sid'] return existing if existing.present? - sid = Voice::Conference::Name.for(conversation) - merge_attributes('conference_sid' => sid) + sid = Voice::Conference::Name.for(call) + call.update!(meta: call.meta.merge('conference_sid' => sid)) + merge_conversation_attributes('conference_sid' => sid) sid end def mark_agent_joined(user:) - merge_attributes( + call.update!(accepted_by_agent_id: user.id) + merge_conversation_attributes( 'agent_joined' => true, 'joined_at' => Time.current.to_i, 'joined_by' => { id: user.id, name: user.name } @@ -21,13 +23,15 @@ class Voice::Provider::Twilio::ConferenceService def end_conference twilio_client .conferences - .list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress') + .list(friendly_name: Voice::Conference::Name.for(call), status: 'in-progress') .each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') } end private - def merge_attributes(attrs) + delegate :conversation, to: :call + + def merge_conversation_attributes(attrs) current = conversation.additional_attributes || {} conversation.update!(additional_attributes: current.merge(attrs)) end diff --git a/enterprise/app/services/voice/status_update_service.rb b/enterprise/app/services/voice/status_update_service.rb index 8503d8d3b..6e602c8df 100644 --- a/enterprise/app/services/voice/status_update_service.rb +++ b/enterprise/app/services/voice/status_update_service.rb @@ -19,13 +19,10 @@ class Voice::StatusUpdateService normalized_status = normalize_status(call_status) return if normalized_status.blank? - conversation = account.conversations.find_by(identifier: call_sid) - return unless conversation + call = account.calls.find_by(provider: :twilio, provider_call_id: call_sid) + return unless call - Voice::CallStatus::Manager.new( - conversation: conversation, - call_sid: call_sid - ).process_status_update( + Voice::CallStatus::Manager.new(call: call).process_status_update( normalized_status, duration: payload_duration, timestamp: payload_timestamp