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