whatsapp calls fixes
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,4 +2,5 @@ module Whatsapp::CallErrors
|
||||
class NotRinging < StandardError; end
|
||||
class AlreadyAccepted < StandardError; end
|
||||
class CallFailed < StandardError; end
|
||||
class NoCallPermission < StandardError; end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user