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 990f5188d..cf09c33ea 100644 --- a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -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)