whatsapp calls fixes

This commit is contained in:
Tanmay Deep Sharma
2026-03-11 12:29:18 +05:30
parent f849cbb76a
commit 98e4a1e6dc
9 changed files with 174 additions and 11 deletions
@@ -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'
@@ -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,
});
}
}
@@ -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, {
@@ -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 {
@@ -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"
+47
View File
@@ -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
+1 -1
View File
@@ -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
)
@@ -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
+1
View File
@@ -2,4 +2,5 @@ module Whatsapp::CallErrors
class NotRinging < StandardError; end
class AlreadyAccepted < StandardError; end
class CallFailed < StandardError; end
class NoCallPermission < StandardError; end
end