refactor(voice): split call validations into single-purpose before_actions

This commit is contained in:
Tanmay Deep Sharma
2026-04-30 18:14:29 +07:00
parent beef43f2bf
commit cb93aba55c
@@ -3,26 +3,30 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
before_action :set_call, only: %i[show accept reject terminate upload_recording]
before_action :set_conversation, only: :initiate
before_action :validate_recording, only: :upload_recording
before_action :validate_initiate, only: :initiate
before_action :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, only: :initiate
before_action :ensure_recording_present, only: :upload_recording
before_action :ensure_call_message, only: :upload_recording
rescue_from Voice::CallErrors::NotRinging,
Voice::CallErrors::AlreadyAccepted,
Voice::CallErrors::CallFailed,
with: :render_call_error
rescue_from Voice::CallErrors::NoCallPermission, with: :render_permission_request
def show; end
def accept
@call = call_service.accept
call_service.accept
end
def reject
@call = call_service.reject
call_service.reject
end
def terminate
@call = call_service.terminate
call_service.terminate
end
def upload_recording
@@ -30,11 +34,9 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
end
def initiate
@call = create_outbound_call(@conversation, params[:sdp_offer])
@call = create_outbound_call
@message = Voice::CallMessageBuilder.new(@call).perform!
@call.update!(message_id: @message.id)
rescue Voice::CallErrors::NoCallPermission
handle_no_call_permission(@conversation)
end
private
@@ -53,24 +55,36 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
authorize @conversation, :show?
end
def validate_recording
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_recording')) and return if params[:recording].blank?
# Twilio voice also exposes voice_enabled? but uses a different initiation path.
def ensure_calling_enabled
channel = @conversation.inbox.channel
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_message')) if @call.message.blank?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
end
def validate_initiate
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled')) and return unless calling_enabled?(@conversation)
render_could_not_create_error(I18n.t('errors.whatsapp.calls.sdp_offer_required')) and return if params[:sdp_offer].blank?
def ensure_sdp_offer
return if params[:sdp_offer].present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required')) if @conversation.contact&.phone_number.blank?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.sdp_offer_required'))
end
# WhatsApp Cloud Calling specifically — Twilio voice channels also expose
# voice_enabled? but use a different (Voice::OutboundCallBuilder) initiation path.
def calling_enabled?(conversation)
channel = conversation.inbox.channel
channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
def ensure_contact_phone
return if @conversation.contact&.phone_number.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
end
def ensure_recording_present
return if params[:recording].present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_recording'))
end
def ensure_call_message
return if @call.message.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_message'))
end
def attach_recording_idempotently
@@ -81,39 +95,42 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
end
# Browser-built SDP offer is forwarded to Meta; the connect webhook later delivers Meta's answer.
# validate_initiate ensures conversation.contact.phone_number is present.
def create_outbound_call(conversation, sdp_offer)
contact_phone = conversation.contact.phone_number
result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+'), sdp_offer)
def create_outbound_call
contact_phone = @conversation.contact.phone_number.delete('+')
result = @conversation.inbox.channel.provider_service.initiate_call(contact_phone, params[:sdp_offer])
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
Current.account.calls.create!(
provider: :whatsapp, inbox: conversation.inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact,
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
accepted_by_agent_id: Current.user.id,
meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
meta: { 'sdp_offer' => params[:sdp_offer], 'ice_servers' => Call.default_ice_servers }
)
end
# 138006 = no call permission yet; send opt-in template (throttled) and surface state to FE.
def handle_no_call_permission(conversation)
last_requested = conversation.additional_attributes&.dig('call_permission_requested_at')
if last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago
return render json: { status: 'permission_pending' }
end
def render_permission_request
return render json: { status: 'permission_pending' } if permission_request_throttled?
contact_phone = conversation.contact.phone_number.delete('+')
sent = conversation.inbox.channel.provider_service.send_call_permission_request(contact_phone)
sent = @conversation.inbox.channel.provider_service.send_call_permission_request(@conversation.contact.phone_number.delete('+'))
return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) unless sent
# Record the wamid so the reply webhook can match context.id back to this
# exact conversation rather than guessing by recency.
attrs = (conversation.additional_attributes || {}).merge(
record_permission_request_wamid(sent)
render json: { status: 'permission_requested' }
end
def permission_request_throttled?
last_requested = @conversation.additional_attributes&.dig('call_permission_requested_at')
last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago
end
# Record the wamid so the reply webhook can match context.id back to this conversation.
def record_permission_request_wamid(sent)
attrs = (@conversation.additional_attributes || {}).merge(
'call_permission_requested_at' => Time.current.iso8601,
'call_permission_request_message_id' => sent.dig('messages', 0, 'id')
)
conversation.update!(additional_attributes: attrs)
render json: { status: 'permission_requested' }
@conversation.update!(additional_attributes: attrs)
end
def render_call_error(error)