From 1e3c6ad620dc2fab60fddd14acafd6998ec2616d Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 18 Mar 2026 14:59:56 +0530 Subject: [PATCH] fix: refactor whatsapp calling for lint compliance, outbound call UI, and SDP handling --- .../v1/accounts/whatsapp_calls_controller.rb | 52 ++++----- app/javascript/dashboard/api/whatsappCalls.js | 28 ++--- .../components/widgets/WhatsappCallWidget.vue | 19 +++- .../conversation/ConversationHeader.vue | 55 ++++++--- .../composables/useWhatsappCallSession.js | 43 ++++++-- .../dashboard/helper/actionCable.js | 16 ++- .../i18n/locale/en/whatsappCall.json | 1 + .../dashboard/stores/whatsappCalls.js | 57 +++++++--- app/jobs/webhooks/whatsapp_events_job.rb | 36 +----- .../whatsapp/call_permission_reply_service.rb | 59 ++++++++++ .../whatsapp/incoming_call_service.rb | 45 ++++---- .../providers/whatsapp_cloud_call_methods.rb | 85 ++++++++++++++ .../providers/whatsapp_cloud_service.rb | 104 +----------------- 13 files changed, 339 insertions(+), 261 deletions(-) create mode 100644 app/services/whatsapp/call_permission_reply_service.rb create mode 100644 app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb diff --git a/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb index 0be5dbe4f..05a41d085 100644 --- a/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -35,24 +35,8 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro error = validate_whatsapp_calling(conversation) return render json: { error: error }, status: :unprocessable_entity if error - contact_phone = conversation.contact&.phone_number - return render json: { error: 'Contact phone number not available' }, status: :unprocessable_entity if contact_phone.blank? - - sdp_offer = params[:sdp_offer] - return render json: { error: 'sdp_offer is required' }, status: :unprocessable_entity if sdp_offer.blank? - - 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 } + wa_call = create_outbound_call(conversation) + render json: { status: 'calling', call_id: wa_call.call_id, id: wa_call.id } rescue Whatsapp::CallErrors::NoCallPermission handle_no_call_permission(conversation) rescue ActiveRecord::RecordNotFound @@ -64,23 +48,33 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro private + def create_outbound_call(conversation) + contact_phone = conversation.contact&.phone_number + raise ArgumentError, 'Contact phone number not available' if contact_phone.blank? + 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'] + + current_account.whatsapp_calls.create!( + inbox: conversation.inbox, conversation: conversation, + call_id: call_id, direction: 'outbound', status: 'ringing', + meta: { sdp_offer: params[:sdp_offer] } + ) + end + 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 + return render json: { status: 'permission_pending' } if last_requested.present? && Time.zone.parse(last_requested) > 5.minutes.ago 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 + return render json: { error: 'Failed to send call permission request' }, status: :unprocessable_entity unless result + + attrs = (conversation.additional_attributes || {}).merge('call_permission_requested_at' => Time.current.iso8601) + conversation.update!(additional_attributes: attrs) + render json: { status: 'permission_requested' } end def validate_whatsapp_calling(conversation) diff --git a/app/javascript/dashboard/api/whatsappCalls.js b/app/javascript/dashboard/api/whatsappCalls.js index 8d0a961a5..450390897 100644 --- a/app/javascript/dashboard/api/whatsappCalls.js +++ b/app/javascript/dashboard/api/whatsappCalls.js @@ -1,39 +1,27 @@ /* global axios */ -class WhatsappCallsAPI { +import ApiClient from './ApiClient'; + +class WhatsappCallsAPI extends ApiClient { constructor() { - this.apiVersion = '/api/v1'; - } - - // eslint-disable-next-line class-methods-use-this - get accountIdFromRoute() { - const isInsideAccountScopedURLs = - window.location.pathname.includes('/app/accounts'); - if (isInsideAccountScopedURLs) { - return window.location.pathname.split('/')[3]; - } - return ''; - } - - get baseUrl() { - return `${this.apiVersion}/accounts/${this.accountIdFromRoute}/whatsapp_calls`; + super('whatsapp_calls', { accountScoped: true }); } accept(callId, sdpAnswer) { - return axios.post(`${this.baseUrl}/${callId}/accept`, { + return axios.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer, }); } reject(callId) { - return axios.post(`${this.baseUrl}/${callId}/reject`); + return axios.post(`${this.url}/${callId}/reject`); } terminate(callId) { - return axios.post(`${this.baseUrl}/${callId}/terminate`); + return axios.post(`${this.url}/${callId}/terminate`); } initiate(conversationId, sdpOffer) { - return axios.post(`${this.baseUrl}/initiate`, { + return axios.post(`${this.url}/initiate`, { conversation_id: conversationId, sdp_offer: sdpOffer, }); diff --git a/app/javascript/dashboard/components/widgets/WhatsappCallWidget.vue b/app/javascript/dashboard/components/widgets/WhatsappCallWidget.vue index 1eba314db..d96bd7f79 100644 --- a/app/javascript/dashboard/components/widgets/WhatsappCallWidget.vue +++ b/app/javascript/dashboard/components/widgets/WhatsappCallWidget.vue @@ -15,6 +15,7 @@ const { hasIncomingCall, isAccepting, isMuted, + isOutboundRinging, callError, formattedCallDuration, acceptCall, @@ -150,7 +151,10 @@ onUnmounted(() => { v-if="hasActiveCall" class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong" > -
+
{ t('WHATSAPP_CALL.UNKNOWN_CALLER') }}

-

- {{ formattedCallDuration }} +

+ {{ + isOutboundRinging + ? t('WHATSAPP_CALL.RINGING') + : formattedCallDuration + }}

diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue index 41d150c69..e62943d9f 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue @@ -16,6 +16,10 @@ import { useI18n } from 'vue-i18n'; import WhatsappCallsAPI from 'dashboard/api/whatsappCalls'; import { emitter } from 'shared/helpers/mitt'; import { BUS_EVENTS } from 'shared/constants/busEvents'; +import { + useWhatsappCallsStore, + setOutboundCallProperty, +} from 'dashboard/stores/whatsappCalls'; const props = defineProps({ chat: { @@ -34,6 +38,7 @@ const route = useRoute(); const conversationHeader = ref(null); const { width } = useElementSize(conversationHeader); const { isAWebWidgetInbox, isAWhatsAppCloudChannel } = useInbox(); +const whatsappCallsStore = useWhatsappCallsStore(); const isInitiatingCall = ref(false); const currentChat = computed(() => store.getters.getSelectedChat); @@ -127,7 +132,7 @@ const initiateWhatsappCall = async () => { }); localStream.getTracks().forEach(track => pc.addTrack(track, localStream)); - // Handle remote audio from Meta + // Handle remote audio from Meta — ontrack fires when the callee picks up pc.ontrack = event => { const [stream] = event.streams; if (!stream) return; @@ -135,12 +140,15 @@ const initiateWhatsappCall = async () => { audio.srcObject = stream; audio.autoplay = true; document.body.appendChild(audio); - window.__outboundCallAudio = audio; + setOutboundCallProperty('audio', audio); + // Remote audio arrived — callee picked up, transition from ringing to connected + whatsappCallsStore.markActiveCallConnected(); }; - // eslint-disable-next-line no-console - pc.oniceconnectionstatechange = () => + pc.oniceconnectionstatechange = () => { + // eslint-disable-next-line no-console console.log('[WhatsApp Call] Outbound ICE state:', pc.iceConnectionState); + }; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); @@ -155,16 +163,17 @@ const initiateWhatsappCall = async () => { ); const callStatus = response.data?.status; - if (callStatus === 'permission_requested' || callStatus === 'permission_pending') { + 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', - }); + const message = + callStatus === 'permission_requested' + ? t('WHATSAPP_CALL.PERMISSION_REQUESTED') + : t('WHATSAPP_CALL.PERMISSION_PENDING'); + emitter.emit(BUS_EVENTS.SHOW_ALERT, { message, type: 'info' }); return; } @@ -173,9 +182,25 @@ const initiateWhatsappCall = async () => { type: 'success', }); - window.__outboundCallPC = pc; - window.__outboundCallStream = localStream; - window.__outboundCallId = response.data?.call_id; + const outboundCallId = response.data?.call_id; + setOutboundCallProperty('pc', pc); + setOutboundCallProperty('stream', localStream); + setOutboundCallProperty('callId', outboundCallId); + + // Set active call in store so the WhatsappCallWidget renders + // Status starts as 'ringing' — updated to 'connected' when SDP answer arrives + whatsappCallsStore.setActiveCall({ + id: response.data?.id, + callId: outboundCallId, + direction: 'outbound', + status: 'ringing', + conversationId: currentChat.value.id, + caller: { + name: currentContact.value?.name, + phone: currentContact.value?.phone_number, + avatar: currentContact.value?.thumbnail, + }, + }); } catch (err) { if (pc) pc.close(); if (localStream) localStream.getTracks().forEach(track => track.stop()); diff --git a/app/javascript/dashboard/composables/useWhatsappCallSession.js b/app/javascript/dashboard/composables/useWhatsappCallSession.js index 4d9fdcc70..210032741 100644 --- a/app/javascript/dashboard/composables/useWhatsappCallSession.js +++ b/app/javascript/dashboard/composables/useWhatsappCallSession.js @@ -1,9 +1,14 @@ -import { ref, computed, onUnmounted } from 'vue'; -import { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls'; +import { ref, computed, watch, onUnmounted } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { + useWhatsappCallsStore, + getOutboundCallState, +} from 'dashboard/stores/whatsappCalls'; import WhatsappCallsAPI from 'dashboard/api/whatsappCalls'; import Timer from 'dashboard/helper/Timer'; export function useWhatsappCallSession() { + const { t } = useI18n(); const callsStore = useWhatsappCallsStore(); // WebRTC internals @@ -27,6 +32,12 @@ export function useWhatsappCallSession() { const hasIncomingCall = computed(() => callsStore.hasIncomingCall); const firstIncomingCall = computed(() => callsStore.firstIncomingCall); + const isOutboundRinging = computed( + () => + activeCall.value?.direction === 'outbound' && + activeCall.value?.status === 'ringing' + ); + const formattedCallDuration = computed(() => { const minutes = Math.floor(callDuration.value / 60); const seconds = callDuration.value % 60; @@ -35,7 +46,7 @@ export function useWhatsappCallSession() { const cleanupWebRTC = () => { if (localStream) { - localStream.getTracks().forEach(t => t.stop()); + localStream.getTracks().forEach(track => track.stop()); localStream = null; } if (peerConnection) { @@ -59,6 +70,17 @@ export function useWhatsappCallSession() { callDuration.value = 0; }); + // Start timer when an outbound call becomes connected (SDP answer received) + watch(activeCall, call => { + if ( + call?.direction === 'outbound' && + call?.status === 'connected' && + !durationTimer.intervalId + ) { + durationTimer.start(); + } + }); + /** * Waits for ICE candidate gathering to complete so the SDP contains all candidates. * Meta's REST API doesn't support trickle ICE — the full SDP must be sent at once. @@ -182,8 +204,8 @@ export function useWhatsappCallSession() { } catch (err) { callError.value = err.name === 'NotAllowedError' - ? 'Microphone access denied. Please allow mic access and try again.' - : 'Failed to accept call. Please try again.'; + ? t('WHATSAPP_CALL.MIC_DENIED') + : t('WHATSAPP_CALL.CALL_FAILED'); // eslint-disable-next-line no-console console.error('[WhatsApp Call] acceptCall error:', err); cleanupWebRTC(); @@ -211,7 +233,10 @@ export function useWhatsappCallSession() { } catch { // Best effort — always cleanup locally } finally { + // For inbound calls, cleanup composable-managed WebRTC cleanupWebRTC(); + // For outbound calls, cleanup module-scoped WebRTC via store + callsStore.handleCallEnded(call.callId); callsStore.clearActiveCall(); durationTimer.stop(); callDuration.value = 0; @@ -219,8 +244,11 @@ export function useWhatsappCallSession() { }; const toggleMute = () => { - if (!localStream) return; - const audioTrack = localStream.getAudioTracks()[0]; + // For inbound calls, localStream is managed by this composable. + // For outbound calls, the stream is in module-scoped outbound state. + const stream = localStream || getOutboundCallState().stream; + if (!stream) return; + const audioTrack = stream.getAudioTracks()[0]; if (!audioTrack) return; audioTrack.enabled = !audioTrack.enabled; isMuted.value = !audioTrack.enabled; @@ -242,6 +270,7 @@ export function useWhatsappCallSession() { firstIncomingCall, isAccepting, isMuted, + isOutboundRinging, callError, formattedCallDuration, acceptCall, diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index ac2e583db..f3cdbaef9 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -4,7 +4,10 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi import { BUS_EVENTS } from 'shared/constants/busEvents'; import { emitter } from 'shared/helpers/mitt'; import { useImpersonation } from 'dashboard/composables/useImpersonation'; -import { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls'; +import { + useWhatsappCallsStore, + getOutboundCallState, +} from 'dashboard/stores/whatsappCalls'; const { isImpersonating } = useImpersonation(); @@ -222,7 +225,6 @@ class ActionCableConnector extends BaseActionCableConnector { }); }; - // eslint-disable-next-line class-methods-use-this onWhatsappCallAccepted = data => { const whatsappCallsStore = useWhatsappCallsStore(); const currentUserId = this.app.$store.getters.getCurrentUserID; @@ -240,13 +242,15 @@ class ActionCableConnector extends BaseActionCableConnector { // eslint-disable-next-line class-methods-use-this onWhatsappCallOutboundConnected = data => { - // When Meta sends the SDP answer for an outbound call, set it on the peer connection - const pc = window.__outboundCallPC; - if (pc && window.__outboundCallId === data.call_id && data.sdp_answer) { + const { pc, callId } = getOutboundCallState(); + if (pc && callId === data.call_id && data.sdp_answer) { pc.setRemoteDescription({ type: 'answer', sdp: data.sdp_answer }).catch( err => { // eslint-disable-next-line no-console - console.error('[WhatsApp Call] Failed to set remote SDP answer:', err); + console.error( + '[WhatsApp Call] Failed to set remote SDP answer:', + err + ); } ); } diff --git a/app/javascript/dashboard/i18n/locale/en/whatsappCall.json b/app/javascript/dashboard/i18n/locale/en/whatsappCall.json index b5f7ea51d..1df29be07 100644 --- a/app/javascript/dashboard/i18n/locale/en/whatsappCall.json +++ b/app/javascript/dashboard/i18n/locale/en/whatsappCall.json @@ -9,6 +9,7 @@ "UNMUTE": "Unmute", "INITIATE_CALL": "Call via WhatsApp", "CALLING": "Calling…", + "RINGING": "Ringing…", "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.", diff --git a/app/javascript/dashboard/stores/whatsappCalls.js b/app/javascript/dashboard/stores/whatsappCalls.js index 6a4c98ab5..633e54977 100644 --- a/app/javascript/dashboard/stores/whatsappCalls.js +++ b/app/javascript/dashboard/stores/whatsappCalls.js @@ -1,5 +1,32 @@ import { defineStore } from 'pinia'; +// Module-scoped (non-reactive) state for outbound call WebRTC objects. +// These cannot be in Pinia state because RTCPeerConnection/MediaStream are not serializable. +const outboundCall = { pc: null, stream: null, audio: null, callId: null }; + +export function getOutboundCallState() { + return outboundCall; +} + +export function setOutboundCallProperty(key, value) { + outboundCall[key] = value; +} + +function cleanupOutboundCall() { + if (outboundCall.pc) outboundCall.pc.close(); + if (outboundCall.stream) { + outboundCall.stream.getTracks().forEach(t => t.stop()); + } + if (outboundCall.audio) { + outboundCall.audio.srcObject = null; + outboundCall.audio.remove(); + } + outboundCall.pc = null; + outboundCall.stream = null; + outboundCall.audio = null; + outboundCall.callId = null; +} + export const useWhatsappCallsStore = defineStore('whatsappCalls', { state: () => ({ // Incoming ringing calls waiting for agent action @@ -7,7 +34,7 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', { // The single active call (accepted + audio connected) activeCall: null, // Cleanup callback registered by the composable — called when a call ends externally - _cleanupCallback: null, + cleanupCallback: null, }), getters: { @@ -37,8 +64,14 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', { this.activeCall = null; }, + markActiveCallConnected() { + if (this.activeCall) { + this.activeCall = { ...this.activeCall, status: 'connected' }; + } + }, + registerCleanupCallback(callback) { - this._cleanupCallback = callback; + this.cleanupCallback = callback; }, handleCallAcceptedByOther(callId) { @@ -49,24 +82,12 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', { this.removeIncomingCall(callId); if (this.activeCall?.callId === callId) { this.activeCall = null; - // Trigger WebRTC cleanup via the registered callback - if (this._cleanupCallback) { - this._cleanupCallback(); + if (this.cleanupCallback) { + this.cleanupCallback(); } } - // Also clean up outbound call globals if they match - if (window.__outboundCallId === callId) { - if (window.__outboundCallPC) window.__outboundCallPC.close(); - if (window.__outboundCallStream) - window.__outboundCallStream.getTracks().forEach(t => t.stop()); - if (window.__outboundCallAudio) { - window.__outboundCallAudio.srcObject = null; - window.__outboundCallAudio.remove(); - } - window.__outboundCallPC = null; - window.__outboundCallStream = null; - window.__outboundCallAudio = null; - window.__outboundCallId = null; + if (outboundCall.callId === callId) { + cleanupOutboundCall(); } }, }, diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb index 9bbd7fd16..94e09e1aa 100644 --- a/app/jobs/webhooks/whatsapp_events_job.rb +++ b/app/jobs/webhooks/whatsapp_events_job.rb @@ -94,41 +94,7 @@ class Webhooks::WhatsappEventsJob < ApplicationJob 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: { - account_id: channel.inbox.account_id, - conversation_id: conversation.id, - contact_name: contact.name, - contact_phone: contact.phone_number - } - }) + Whatsapp::CallPermissionReplyService.new(inbox: channel.inbox, params: params).perform end def extract_call_params(params) diff --git a/app/services/whatsapp/call_permission_reply_service.rb b/app/services/whatsapp/call_permission_reply_service.rb new file mode 100644 index 000000000..75f5753e8 --- /dev/null +++ b/app/services/whatsapp/call_permission_reply_service.rb @@ -0,0 +1,59 @@ +class Whatsapp::CallPermissionReplyService + pattr_initialize [:inbox!, :params!] + + def perform + reply_data = extract_reply_data + return unless reply_data&.dig(:accepted) + + contact = find_contact(reply_data[:from_number]) + return unless contact + + conversation = find_active_conversation(contact) + return unless conversation + + clear_permission_flag(conversation) + broadcast_permission_granted(contact, conversation) + end + + private + + def extract_reply_data + value = params.dig(:entry, 0, :changes, 0, :value) + message = value&.dig(:messages, 0) + reply = message&.dig(:interactive, :call_permission_reply) + return unless reply + + accepted = reply[:response] == 'accept' + Rails.logger.info "[WHATSAPP CALL] call_permission_reply from=#{message[:from]} accepted=#{accepted} permanent=#{reply[:is_permanent]}" + + { from_number: message[:from], accepted: accepted } + end + + def find_contact(from_number) + inbox.contact_inboxes.joins(:contact) + .where(contacts: { phone_number: "+#{from_number}" }) + .first&.contact + end + + def find_active_conversation(contact) + inbox.conversations.where(contact: contact).where.not(status: :resolved).last + end + + def clear_permission_flag(conversation) + attrs = conversation.additional_attributes || {} + attrs.delete('call_permission_requested_at') + conversation.update!(additional_attributes: attrs) + end + + def broadcast_permission_granted(contact, conversation) + ActionCable.server.broadcast("account_#{inbox.account_id}", { + event: 'whatsapp_call.permission_granted', + data: { + account_id: inbox.account_id, + conversation_id: conversation.id, + contact_name: contact.name, + contact_phone: contact.phone_number + } + }) + end +end diff --git a/app/services/whatsapp/incoming_call_service.rb b/app/services/whatsapp/incoming_call_service.rb index 907f5bcb8..08ee4ab0b 100644 --- a/app/services/whatsapp/incoming_call_service.rb +++ b/app/services/whatsapp/incoming_call_service.rb @@ -13,15 +13,10 @@ class Whatsapp::IncomingCallService private def process_call_event(call_payload) - event = call_payload[:event] - - case event - when 'connect' - handle_call_connect(call_payload) - when 'terminate' - handle_call_terminate(call_payload) - else - Rails.logger.warn "[WHATSAPP CALL] Unknown call event: #{event}" + case call_payload[:event] + when 'connect' then handle_call_connect(call_payload) + when 'terminate' then handle_call_terminate(call_payload) + else Rails.logger.warn "[WHATSAPP CALL] Unknown call event: #{call_payload[:event]}" end end @@ -34,8 +29,9 @@ class Whatsapp::IncomingCallService existing_call = WhatsappCall.find_by(call_id: call_id) if existing_call Rails.logger.info "[WHATSAPP CALL] call_connect for existing call #{call_id} (direction=#{direction})" - existing_call.update!(meta: existing_call.meta.merge('sdp_answer' => call_payload.dig(:session, :sdp))) - broadcast_outbound_call_connected(existing_call, call_payload.dig(:session, :sdp)) + sdp_answer = fix_sdp_setup(call_payload.dig(:session, :sdp)) + existing_call.update!(meta: existing_call.meta.merge('sdp_answer' => sdp_answer)) + broadcast_outbound_call_connected(existing_call, sdp_answer) return end @@ -134,22 +130,17 @@ class Whatsapp::IncomingCallService case event when 'incoming_call' direction == 'inbound' ? 'Incoming WhatsApp call' : 'Outgoing WhatsApp call' - when 'call_ended' - formatted = format_duration(duration) - "WhatsApp call ended — #{formatted}" - when 'call_missed' - 'Missed WhatsApp call' - else - 'WhatsApp call' + when 'call_ended' then "WhatsApp call ended — #{format_duration(duration)}" + when 'call_missed' then 'Missed WhatsApp call' + else 'WhatsApp call' end end def format_duration(seconds) return '0s' if seconds.nil? || seconds.zero? - minutes = seconds / 60 - secs = seconds % 60 - minutes.positive? ? "#{minutes}m #{secs}s" : "#{secs}s" + mins, secs = seconds.divmod(60) + mins.positive? ? "#{mins}m #{secs}s" : "#{secs}s" end def broadcast_incoming_call(wa_call, contact, sdp_offer) @@ -208,14 +199,16 @@ class Whatsapp::IncomingCallService # Meta sends "USER_INITIATED" / "BUSINESS_INITIATED", map to our model values def map_direction(raw_direction) - case raw_direction&.upcase - when 'USER_INITIATED' then 'inbound' - when 'BUSINESS_INITIATED' then 'outbound' - else 'inbound' - end + return 'outbound' if raw_direction&.upcase == 'BUSINESS_INITIATED' + + 'inbound' end def default_ice_servers [{ urls: 'stun:stun.l.google.com:19302' }] end + + def fix_sdp_setup(sdp) + sdp.present? ? sdp.gsub('a=setup:actpass', 'a=setup:active') : sdp + end end diff --git a/app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb b/app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb new file mode 100644 index 000000000..07e286236 --- /dev/null +++ b/app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb @@ -0,0 +1,85 @@ +module Whatsapp::Providers::WhatsappCloudCallMethods + def pre_accept_call(call_id, sdp_answer) + call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer)) + end + + def accept_call(call_id, sdp_answer) + call_api('accept_call', call_action_body(call_id, 'accept', sdp_answer)) + end + + def reject_call(call_id) + call_api('reject_call', { messaging_product: 'whatsapp', call_id: call_id, action: 'reject' }) + end + + def terminate_call(call_id) + call_api('terminate_call', { messaging_product: 'whatsapp', call_id: call_id, action: 'terminate' }) + end + + 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: permission_request_body(to_phone_number, 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: initiate_call_body(to_phone_number, sdp_offer).to_json + ) + process_initiate_call_response(response) + end + + private + + def call_action_body(call_id, action, sdp_answer = nil) + body = { messaging_product: 'whatsapp', call_id: call_id, action: action } + body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer + body + end + + def call_api(action_name, body) + url = "#{phone_id_path}/calls" + Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}" + response = HTTParty.post(url, headers: api_headers, body: body.to_json) + Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success? + response.success? + end + + def permission_request_body(to_phone_number, body_text) + { + 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 } + } + } + end + + def initiate_call_body(to_phone_number, sdp_offer) + { + messaging_product: 'whatsapp', to: to_phone_number, type: 'audio', + session: { sdp: sdp_offer, sdp_type: 'offer' } + } + end + + def process_initiate_call_response(response) + return response.parsed_response if 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 +end diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index dd8a7423c..84491fc83 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -1,4 +1,6 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseService + include Whatsapp::Providers::WhatsappCloudCallMethods + def send_message(phone_number, message) @message = message @@ -79,110 +81,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi "#{api_base_path}/v13.0/#{media_id}" end - def pre_accept_call(call_id, sdp_answer) - body = { - messaging_product: 'whatsapp', - call_id: call_id, - action: 'pre_accept', - session: { - sdp: sdp_answer, - sdp_type: 'answer' - } - } - call_api('pre_accept_call', body) - end - - def accept_call(call_id, sdp_answer) - body = { - messaging_product: 'whatsapp', - call_id: call_id, - action: 'accept', - session: { - sdp: sdp_answer, - sdp_type: 'answer' - } - } - call_api('accept_call', body) - end - - def reject_call(call_id) - body = { - messaging_product: 'whatsapp', - call_id: call_id, - action: 'reject' - } - call_api('reject_call', body) - end - - def terminate_call(call_id) - body = { - messaging_product: 'whatsapp', - call_id: call_id, - action: 'terminate' - } - call_api('terminate_call', body) - end - - 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', - session: { - sdp: sdp_offer, - sdp_type: 'offer' - } - }.to_json - ) - 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 - def call_api(action_name, body) - url = "#{phone_id_path}/calls" - Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}" - response = HTTParty.post(url, headers: api_headers, body: body.to_json) - unless response.success? - Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" - end - response.success? - end - def csat_template_service @csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel) end