fix: refactor whatsapp calling for lint compliance, outbound call UI, and SDP handling

This commit is contained in:
Tanmay Deep Sharma
2026-03-18 14:59:56 +05:30
parent c813505e7d
commit 1e3c6ad620
13 changed files with 339 additions and 261 deletions
@@ -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)
+8 -20
View File
@@ -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,
});
@@ -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"
>
<div class="ring-2 ring-n-teal-9 rounded-full inline-flex">
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': isOutboundRinging }"
>
<Avatar
:src="activeCall.caller?.avatar"
:name="activeCall.caller?.name || activeCall.caller?.phone"
@@ -166,8 +170,17 @@ onUnmounted(() => {
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p class="font-mono text-sm text-n-teal-9">
{{ formattedCallDuration }}
<p
class="text-sm"
:class="
isOutboundRinging ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
"
>
{{
isOutboundRinging
? t('WHATSAPP_CALL.RINGING')
: formattedCallDuration
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
@@ -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());
@@ -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,
+10 -6
View File
@@ -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
);
}
);
}
@@ -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.",
@@ -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();
}
},
},
+1 -35
View File
@@ -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)
@@ -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
+19 -26
View File
@@ -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
@@ -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
@@ -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