feat(voice): wire WhatsApp call UI on top of existing Twilio session flow

This commit is contained in:
Tanmay Deep Sharma
2026-04-30 19:30:43 +07:00
parent 19d91e47f1
commit c7b6a87e77
9 changed files with 460 additions and 10 deletions
@@ -0,0 +1,45 @@
/* global axios */
import ApiClient from '../../ApiClient';
class WhatsappCallsAPI extends ApiClient {
constructor() {
super('whatsapp_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
initiate(conversationId, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
}
accept(callId, sdpAnswer) {
return axios
.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
.then(r => r.data);
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
}
uploadRecording(callId, blob, filename = 'call-recording.webm') {
const formData = new FormData();
formData.append('recording', blob, filename);
return axios
.post(`${this.url}/${callId}/upload_recording`, formData)
.then(r => r.data);
}
}
export default new WhatsappCallsAPI();
@@ -12,6 +12,13 @@ import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import {
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
const props = defineProps({
@@ -91,6 +98,45 @@ const hasMultipleInboxes = computed(
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const callsStore = useCallsStore();
const whatsappCallSession = useWhatsappCallSession();
const isWhatsappVoiceInbox = computed(
() => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP
);
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
try {
const response = await whatsappCallSession.initiateOutboundCall(
currentChat.value.id
);
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
const status = response?.status;
const messageKey =
status === 'permission_pending'
? 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING'
: 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED';
useAlert(t(messageKey));
return;
}
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId: currentChat.value.id,
inboxId: inbox.value?.id,
callDirection: 'outbound',
provider: 'whatsapp',
});
callsStore.setCallActive(response.call_id);
} catch (error) {
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
}
};
</script>
<template>
@@ -152,6 +198,16 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
:parent-width="width"
class="hidden md:flex"
/>
<button
v-if="isWhatsappVoiceInbox"
v-tooltip.bottom="$t('CONVERSATION.HEADER.WHATSAPP_CALL')"
type="button"
class="flex items-center justify-center size-7 rounded-md hover:bg-n-alpha-2 disabled:opacity-50"
:disabled="whatsappCallSession.isInitiating.value"
@click="startWhatsappCall"
>
<fluent-icon icon="call" size="16" class="text-n-slate-11" />
</button>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -2,10 +2,17 @@ import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import {
useWhatsappCallSession,
sendWhatsappTerminateBeacon,
} from 'dashboard/composables/useWhatsappCallSession';
import Timer from 'dashboard/helper/Timer';
const isWhatsappCall = call => call?.provider === 'whatsapp';
export function useCallSession() {
const callsStore = useCallsStore();
const whatsappSession = useWhatsappCallSession();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
@@ -29,20 +36,52 @@ export function useCallSession() {
{ immediate: true }
);
// Browser-native confirm prompt when reload/close happens mid-call. Reload
// tears down the WebRTC session permanently for WhatsApp (no rejoin) and
// drops the agent leg for Twilio, so warn either way.
const handleBeforeUnload = event => {
if (!hasActiveCall.value) return;
event.preventDefault();
event.returnValue = '';
};
// pagehide fires after the user confirms the prompt. Let the WhatsApp session
// best-effort sendBeacon a terminate so the server doesn't keep the call open.
const handlePageHide = () => {
sendWhatsappTerminateBeacon();
};
const handleTwilioDisconnected = () => callsStore.clearActiveCall();
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
TwilioVoiceClient.addEventListener(
'call:disconnected',
handleTwilioDisconnected
);
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
});
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
TwilioVoiceClient.removeEventListener(
'call:disconnected',
handleTwilioDisconnected
);
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
});
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
const endCall = async ({ conversationId, inboxId, callSid }) => {
if (isWhatsappCall(findCall(callSid))) {
await whatsappSession.endActiveCall();
durationTimer.stop();
callsStore.clearActiveCall();
return;
}
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
@@ -54,6 +93,18 @@ export function useCallSession() {
isJoining.value = true;
try {
const call = findCall(callSid);
if (isWhatsappCall(call)) {
await whatsappSession.acceptIncomingCall({
callId: call.callId,
sdpOffer: call.sdpOffer,
iceServers: call.iceServers,
});
callsStore.setCallActive(callSid);
durationTimer.start();
return { callId: call.callId };
}
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
@@ -83,7 +134,12 @@ export function useCallSession() {
};
const rejectIncomingCall = callSid => {
TwilioVoiceClient.endClientCall();
const call = findCall(callSid);
if (isWhatsappCall(call) && call?.callId) {
whatsappSession.rejectIncomingCall(call.callId);
} else {
TwilioVoiceClient.endClientCall();
}
callsStore.dismissCall(callSid);
};
@@ -0,0 +1,237 @@
import { ref } from 'vue';
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
// Browser ↔ Meta WebRTC is a singleton — only one PeerConnection at a time can
// hold the user's mic. Module-level state lets cable handlers and the pagehide
// listener reach the live session without prop-drilling refs through composables.
let pc = null;
let localStream = null;
let remoteStream = null;
let mediaRecorder = null;
let recorderChunks = [];
let audioContext = null;
let activeCallId = null;
let intentionallyClosing = false;
const RECORDING_TIMESLICE_MS = 5000;
const ICE_GATHER_TIMEOUT_MS = 10000;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
const waitForIceGatheringComplete = peer =>
new Promise(resolve => {
if (peer.iceGatheringState === 'complete') {
resolve();
return;
}
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
peer.addEventListener('icegatheringstatechange', () => {
if (peer.iceGatheringState === 'complete') {
clearTimeout(timer);
resolve();
}
});
});
const cleanup = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch (_) {
/* noop */
}
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(() => {});
}
if (localStream) localStream.getTracks().forEach(t => t.stop());
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
if (pc) pc.close();
pc = null;
localStream = null;
remoteStream = null;
mediaRecorder = null;
recorderChunks = [];
audioContext = null;
activeCallId = null;
intentionallyClosing = false;
};
const buildPeerConnection = iceServers => {
const config = iceServers && iceServers.length ? { iceServers } : {};
pc = new RTCPeerConnection(config);
remoteStream = new MediaStream();
pc.ontrack = event => {
event.streams.forEach(stream =>
stream.getTracks().forEach(track => remoteStream.addTrack(track))
);
};
return pc;
};
// Mix local mic + remote audio via Web Audio so the recording captures both legs.
const setupRecorder = () => {
if (!localStream || !remoteStream || mediaRecorder) return;
audioContext = new AudioContext({ sampleRate: 48000 });
const destination = audioContext.createMediaStreamDestination();
audioContext.createMediaStreamSource(localStream).connect(destination);
audioContext.createMediaStreamSource(remoteStream).connect(destination);
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
MediaRecorder.isTypeSupported(t)
);
if (!mimeType) return;
recorderChunks = [];
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
};
mediaRecorder.start(RECORDING_TIMESLICE_MS);
};
const stopRecorderAndUpload = async callId => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
await new Promise(resolve => {
mediaRecorder.addEventListener('stop', resolve, { once: true });
try {
mediaRecorder.stop();
} catch (_) {
resolve();
}
});
}
if (!recorderChunks.length || !callId) return;
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
try {
await WhatsappCallsAPI.uploadRecording(callId, blob);
} catch (_) {
/* best-effort — server-side idempotency guard handles a retry */
}
};
export function useWhatsappCallSession() {
const isInitiating = ref(false);
const error = ref(null);
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(iceServers);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
setupRecorder();
return pc.localDescription.sdp;
};
const prepareOutboundOffer = async () => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection();
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
};
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
const sdpAnswer = await prepareInboundAnswer(sdpOffer, iceServers);
activeCallId = callId;
await WhatsappCallsAPI.accept(callId, sdpAnswer);
};
const rejectIncomingCall = async callId => {
intentionallyClosing = true;
try {
await WhatsappCallsAPI.reject(callId);
} finally {
cleanup();
}
};
const initiateOutboundCall = async conversationId => {
if (isInitiating.value) return null;
isInitiating.value = true;
error.value = null;
try {
const sdpOffer = await prepareOutboundOffer();
const response = await WhatsappCallsAPI.initiate(
conversationId,
sdpOffer
);
// Permission flow returns no call id — let the caller render the banner.
activeCallId = response?.id || null;
return response;
} catch (e) {
cleanup();
error.value = e;
throw e;
} finally {
isInitiating.value = false;
}
};
const endActiveCall = async () => {
if (!activeCallId) {
cleanup();
return;
}
intentionallyClosing = true;
const callIdSnapshot = activeCallId;
try {
await stopRecorderAndUpload(callIdSnapshot);
await WhatsappCallsAPI.terminate(callIdSnapshot).catch(() => {});
} finally {
cleanup();
}
};
return {
isInitiating,
error,
prepareInboundAnswer,
prepareOutboundOffer,
acceptIncomingCall,
rejectIncomingCall,
initiateOutboundCall,
endActiveCall,
};
}
// Cable handlers fire outside any composable instance; expose the shared session
// surface so they can apply the outbound answer onto the live PeerConnection.
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
if (!pc) return;
activeCallId = callId;
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
setupRecorder();
};
export const hasActiveWhatsappCall = () => Boolean(activeCallId);
// Used by the calls store to tear down the WebRTC session when a WhatsApp call
// is removed by a cable end-event (the other side hung up).
export const cleanupWhatsappSession = () => cleanup();
// Best-effort terminate when the tab actually closes after the beforeunload prompt.
export const sendWhatsappTerminateBeacon = () => {
if (!activeCallId || intentionallyClosing) return;
const accountId = window.location.pathname.split('/')[3];
if (!accountId) return;
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${activeCallId}/terminate`;
try {
navigator.sendBeacon(url);
} catch (_) {
/* noop */
}
};
@@ -4,6 +4,8 @@ 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 { useCallsStore } from 'dashboard/stores/calls';
import { applyOutboundAnswer } from 'dashboard/composables/useWhatsappCallSession';
const { isImpersonating } = useImpersonation();
@@ -35,6 +37,11 @@ class ActionCableConnector extends BaseActionCableConnector {
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
// WhatsApp call SDP exchange happens via these events; Twilio-shaped voice_call.*
// events also flow through here but are ignored when provider !== 'whatsapp'.
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.ended': this.onVoiceCallEnded,
};
}
@@ -205,6 +212,33 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallIncoming = data => {
if (data?.provider !== 'whatsapp') return;
useCallsStore().addCall({
callSid: data.call_id,
callId: data.id,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
callDirection: 'inbound',
provider: 'whatsapp',
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
});
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = data => {
if (data?.provider !== 'whatsapp' || !data.sdp_answer) return;
applyOutboundAnswer(data.id, data.sdp_answer).catch(() => {});
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = data => {
if (data?.provider !== 'whatsapp') return;
useCallsStore().removeCall(data.call_id);
};
}
export default {
+6 -3
View File
@@ -16,6 +16,7 @@ export const INBOX_TYPES = {
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
WHATSAPP: 'whatsapp',
};
export const getVoiceCallProvider = inbox => {
@@ -25,9 +26,11 @@ export const getVoiceCallProvider = inbox => {
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
if (!voiceEnabled) return null;
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
if (channelType === INBOX_TYPES.WHATSAPP)
return VOICE_CALL_PROVIDERS.WHATSAPP;
return null;
};
@@ -97,6 +97,10 @@
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
+12 -2
View File
@@ -1,7 +1,16 @@
import { defineStore } from 'pinia';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { cleanupWhatsappSession } from 'dashboard/composables/useWhatsappCallSession';
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
const teardownByProvider = call => {
if (call?.provider === 'whatsapp') {
cleanupWhatsappSession();
} else {
TwilioVoiceClient.endClientCall();
}
};
export const useCallsStore = defineStore('calls', {
state: () => ({
calls: [],
@@ -35,7 +44,7 @@ export const useCallsStore = defineStore('calls', {
removeCall(callSid) {
const callToRemove = this.calls.find(c => c.callSid === callSid);
if (callToRemove?.isActive) {
TwilioVoiceClient.endClientCall();
teardownByProvider(callToRemove);
}
this.calls = this.calls.filter(c => c.callSid !== callSid);
},
@@ -48,7 +57,8 @@ export const useCallsStore = defineStore('calls', {
},
clearActiveCall() {
TwilioVoiceClient.endClientCall();
const active = this.calls.find(c => c.isActive);
teardownByProvider(active);
this.calls = this.calls.filter(call => !call.isActive);
},