From 98e4a1e6dca05f84eb0710783fa091af1b07b162 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 11 Mar 2026 12:29:18 +0530 Subject: [PATCH] whatsapp calls fixes --- .../v1/accounts/whatsapp_calls_controller.rb | 40 ++++++++++++++-- app/javascript/dashboard/api/whatsappCalls.js | 3 +- .../conversation/ConversationHeader.vue | 39 ++++++++++++++- .../dashboard/helper/actionCable.js | 9 ++++ .../i18n/locale/en/whatsappCall.json | 2 + app/jobs/webhooks/whatsapp_events_job.rb | 47 +++++++++++++++++++ app/services/whatsapp/facebook_api_client.rb | 2 +- .../providers/whatsapp_cloud_service.rb | 42 +++++++++++++++-- lib/whatsapp/call_errors.rb | 1 + 9 files changed, 174 insertions(+), 11 deletions(-) diff --git a/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb index 02c2d67d0..0be5dbe4f 100644 --- a/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -38,19 +38,51 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro contact_phone = conversation.contact&.phone_number return render json: { error: 'Contact phone number not available' }, status: :unprocessable_entity if contact_phone.blank? - result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+')) - return render json: { error: 'Failed to initiate call' }, status: :internal_server_error unless result + sdp_offer = params[:sdp_offer] + return render json: { error: 'sdp_offer is required' }, status: :unprocessable_entity if sdp_offer.blank? - render json: { status: 'calling', call_id: result['call_id'] } + result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+'), sdp_offer) + call_id = result.dig('calls', 0, 'id') || result['call_id'] + wa_call = current_account.whatsapp_calls.create!( + inbox: conversation.inbox, + conversation: conversation, + call_id: call_id, + direction: 'outbound', + status: 'ringing', + meta: { sdp_offer: sdp_offer } + ) + + render json: { status: 'calling', call_id: call_id, id: wa_call.id } + rescue Whatsapp::CallErrors::NoCallPermission + handle_no_call_permission(conversation) rescue ActiveRecord::RecordNotFound render json: { error: 'Conversation not found' }, status: :not_found rescue StandardError => e Rails.logger.error "[WHATSAPP CALL] initiate failed: #{e.message}" - render json: { error: 'Failed to initiate call' }, status: :internal_server_error + render json: { error: e.message }, status: :unprocessable_entity end private + def handle_no_call_permission(conversation) + last_requested = conversation.additional_attributes&.dig('call_permission_requested_at') + + # Don't re-send if permission was requested within the last 5 minutes + if last_requested.present? && Time.zone.parse(last_requested) > 5.minutes.ago + render json: { status: 'permission_pending' } + return + end + + contact_phone = conversation.contact.phone_number.delete('+') + result = conversation.inbox.channel.provider_service.send_call_permission_request(contact_phone) + if result + conversation.update!(additional_attributes: (conversation.additional_attributes || {}).merge('call_permission_requested_at' => Time.current.iso8601)) + render json: { status: 'permission_requested' } + else + render json: { error: 'Failed to send call permission request' }, status: :unprocessable_entity + end + end + def validate_whatsapp_calling(conversation) channel = conversation.inbox.channel return 'Calling is only supported on WhatsApp Cloud inboxes' unless channel.is_a?(Channel::Whatsapp) && channel.provider == 'whatsapp_cloud' diff --git a/app/javascript/dashboard/api/whatsappCalls.js b/app/javascript/dashboard/api/whatsappCalls.js index cadb16325..8d0a961a5 100644 --- a/app/javascript/dashboard/api/whatsappCalls.js +++ b/app/javascript/dashboard/api/whatsappCalls.js @@ -32,9 +32,10 @@ class WhatsappCallsAPI { return axios.post(`${this.baseUrl}/${callId}/terminate`); } - initiate(conversationId) { + initiate(conversationId, sdpOffer) { return axios.post(`${this.baseUrl}/initiate`, { conversation_id: conversationId, + sdp_offer: sdpOffer, }); } } diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue index 15ffe62d6..c6dbf169c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue @@ -97,19 +97,54 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); const canInitiateWhatsappCall = computed(() => { if (!isAWhatsAppCloudChannel.value) return false; - return !!inbox.value?.callingEnabled; + return !!inbox.value?.calling_enabled; }); const initiateWhatsappCall = async () => { if (isInitiatingCall.value || !currentChat.value?.id) return; isInitiatingCall.value = true; + let pc = null; + let localStream = null; try { - await WhatsappCallsAPI.initiate(currentChat.value.id); + localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + pc = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + }); + localStream.getTracks().forEach(track => pc.addTrack(track, localStream)); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + const response = await WhatsappCallsAPI.initiate( + currentChat.value.id, + offer.sdp + ); + + const callStatus = response.data?.status; + if (callStatus === 'permission_requested' || callStatus === 'permission_pending') { + pc.close(); + localStream.getTracks().forEach(track => track.stop()); + const messageKey = callStatus === 'permission_requested' + ? 'WHATSAPP_CALL.PERMISSION_REQUESTED' + : 'WHATSAPP_CALL.PERMISSION_PENDING'; + emitter.emit(BUS_EVENTS.SHOW_ALERT, { + message: t(messageKey), + type: 'info', + }); + return; + } + emitter.emit(BUS_EVENTS.SHOW_ALERT, { message: t('WHATSAPP_CALL.CALLING'), type: 'success', }); + + window.__outboundCallPC = pc; + window.__outboundCallStream = localStream; + window.__outboundCallId = response.data?.call_id; } catch (err) { + if (pc) pc.close(); + if (localStream) localStream.getTracks().forEach(track => track.stop()); const errorMessage = err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED'); emitter.emit(BUS_EVENTS.SHOW_ALERT, { diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 0c11d7208..eb641db69 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -38,6 +38,7 @@ class ActionCableConnector extends BaseActionCableConnector { 'whatsapp_call.incoming': this.onWhatsappCallIncoming, 'whatsapp_call.accepted': this.onWhatsappCallAccepted, 'whatsapp_call.ended': this.onWhatsappCallEnded, + 'whatsapp_call.permission_granted': this.onWhatsappCallPermissionGranted, }; } @@ -235,6 +236,14 @@ class ActionCableConnector extends BaseActionCableConnector { const whatsappCallsStore = useWhatsappCallsStore(); whatsappCallsStore.handleCallEnded(data.call_id); }; + + // eslint-disable-next-line class-methods-use-this + onWhatsappCallPermissionGranted = data => { + emitter.emit(BUS_EVENTS.SHOW_ALERT, { + message: `${data.contact_name} approved the call permission request. You can now call them.`, + type: 'success', + }); + }; } export default { diff --git a/app/javascript/dashboard/i18n/locale/en/whatsappCall.json b/app/javascript/dashboard/i18n/locale/en/whatsappCall.json index d2fb19d5c..b5f7ea51d 100644 --- a/app/javascript/dashboard/i18n/locale/en/whatsappCall.json +++ b/app/javascript/dashboard/i18n/locale/en/whatsappCall.json @@ -10,6 +10,8 @@ "INITIATE_CALL": "Call via WhatsApp", "CALLING": "Calling…", "CALL_FAILED": "Call failed. Please try again.", + "PERMISSION_REQUESTED": "Call permission request sent to the contact. You can call once they approve.", + "PERMISSION_PENDING": "Waiting for the contact to approve the call permission request. Please try again shortly.", "UNKNOWN_CALLER": "Unknown caller", "MIC_DENIED": "Microphone access denied. Please allow mic access and try again.", "CALL_TAKEN": "Call accepted by another agent" diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb index 25ba1d996..9dbb61447 100644 --- a/app/jobs/webhooks/whatsapp_events_job.rb +++ b/app/jobs/webhooks/whatsapp_events_job.rb @@ -64,6 +64,11 @@ class Webhooks::WhatsappEventsJob < ApplicationJob return end + if call_permission_reply?(params) + handle_call_permission_reply(channel, params) + return + end + case channel.provider when 'whatsapp_cloud' Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform @@ -83,6 +88,48 @@ class Webhooks::WhatsappEventsJob < ApplicationJob params.dig(:entry, 0, :changes, 0, :field) == 'calls' end + def call_permission_reply?(params) + message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0) + message&.dig(:type) == 'interactive' && message&.dig(:interactive, :type) == 'call_permission_reply' + end + + def handle_call_permission_reply(channel, params) + value = params.dig(:entry, 0, :changes, 0, :value) + message = value&.dig(:messages, 0) + reply = message&.dig(:interactive, :call_permission_reply) + return unless reply + + from_number = message[:from] + accepted = reply[:response] == 'accept' + + Rails.logger.info "[WHATSAPP CALL] call_permission_reply from=#{from_number} accepted=#{accepted} permanent=#{reply[:is_permanent]}" + + return unless accepted + + contact = channel.inbox.contact_inboxes.joins(:contact) + .where(contacts: { phone_number: "+#{from_number}" }) + .first&.contact + return unless contact + + conversation = channel.inbox.conversations.where(contact: contact).where.not(status: :resolved).last + return unless conversation + + # Clear the permission requested flag so next call attempt goes through + attrs = conversation.additional_attributes || {} + attrs.delete('call_permission_requested_at') + conversation.update!(additional_attributes: attrs) + + # Notify agents that call permission was granted + ActionCable.server.broadcast("account_#{channel.inbox.account_id}", { + event: 'whatsapp_call.permission_granted', + data: { + conversation_id: conversation.id, + contact_name: contact.name, + contact_phone: contact.phone_number + } + }) + end + def extract_call_params(params) params.dig(:entry, 0, :changes, 0, :value) || {} end diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb index fa09a4b44..94e46dabd 100644 --- a/app/services/whatsapp/facebook_api_client.rb +++ b/app/services/whatsapp/facebook_api_client.rb @@ -86,7 +86,7 @@ class Whatsapp::FacebookApiClient body: { override_callback_uri: callback_url, verify_token: verify_token, - subscribed_fields: %w[messages smb_message_echoes] + subscribed_fields: %w[messages smb_message_echoes calls] }.to_json ) diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 3ac764d43..82806d9a7 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -119,16 +119,52 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi response.success? end - def initiate_call(to_phone_number) + def send_call_permission_request(to_phone_number, body_text = 'We would like to call you regarding your conversation.') + response = HTTParty.post( + "#{phone_id_path}/messages", + headers: api_headers, + body: { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: to_phone_number, + type: 'interactive', + interactive: { + type: 'call_permission_request', + action: { name: 'call_permission_request' }, + body: { text: body_text } + } + }.to_json + ) + unless response.success? + Rails.logger.error "[WHATSAPP CALL] send_call_permission_request failed: status=#{response.code} body=#{response.body}" + return nil + end + response.parsed_response + end + + def initiate_call(to_phone_number, sdp_offer) response = HTTParty.post( "#{phone_id_path}/calls", headers: api_headers, body: { + messaging_product: 'whatsapp', to: to_phone_number, - type: 'audio' + type: 'audio', + session: { + sdp: sdp_offer, + sdp_type: 'offer' + } }.to_json ) - response.parsed_response if response.success? + unless response.success? + parsed = response.parsed_response + error_code = parsed&.dig('error', 'code') + error_msg = parsed&.dig('error', 'error_user_msg') || 'Failed to initiate call' + Rails.logger.error "[WHATSAPP CALL] initiate_call failed: status=#{response.code} body=#{response.body}" + raise Whatsapp::CallErrors::NoCallPermission, error_msg if error_code == 138_006 + raise StandardError, error_msg + end + response.parsed_response end private diff --git a/lib/whatsapp/call_errors.rb b/lib/whatsapp/call_errors.rb index 077d9e1d0..c958cef4c 100644 --- a/lib/whatsapp/call_errors.rb +++ b/lib/whatsapp/call_errors.rb @@ -2,4 +2,5 @@ module Whatsapp::CallErrors class NotRinging < StandardError; end class AlreadyAccepted < StandardError; end class CallFailed < StandardError; end + class NoCallPermission < StandardError; end end