feat(whatsapp-call): add server-side WebRTC media server for call persistence
Implements a Pion Go B2BUA media server sidecar that sits between Meta's WhatsApp Cloud API and the agent's browser, enabling call persistence across page reloads, server-side recording, multi-participant support, audio injection, and AI integration readiness. Go Media Server (enterprise/media-server/): - Pion WebRTC v4 B2BUA with Peer A (Meta) and Peer B (Agent) connections - Real-time OGG/Opus recording with crash recovery - Audio bridge with multi-peer fan-out and AudioConsumer plugin interface - Audio injection from OGG files with loop support for hold music - Session manager with graceful shutdown and orphaned recording recovery - HTTP API with Bearer token auth, health checks, and metrics Rails Integration: - Whatsapp::MediaServerClient HTTP client for Go sidecar communication - Dual-mode CallService: legacy browser-direct and server-relay paths - Media server callback controller for agent disconnect/recording/terminate - CallRecordingFetchJob: downloads OGG from Go server to ActiveStorage/S3 - CallCleanupJob: sweeps stale ringing and in-progress calls - New endpoints: active, agent_answer, reconnect, join, play_audio - DB migration: media_session_id column with indexes Frontend: - Dual-mode composable auto-detecting legacy vs server-relay - handleAgentOffer() for receiving SDP from media server - useCallReconnection composable for page reload recovery - Removed terminateCallOnUnload in server-relay mode - Simplified outbound call flow in ConversationHeader - New ActionCable event: whatsapp_call.agent_offer Documentation: - Server-side WebRTC architecture spec (1505 lines) - Implementation plan with 119 trackable checklist items - Feature spec, PR breakdown, and relay architecture docs
This commit is contained in:
@@ -10,10 +10,11 @@ class WhatsappCallsAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${callId}`);
|
||||
}
|
||||
|
||||
// Accept a ringing call. sdpAnswer is optional — omitted in server-relay mode
|
||||
// where the media server handles WebRTC negotiation.
|
||||
accept(callId, sdpAnswer) {
|
||||
return axios.post(`${this.url}/${callId}/accept`, {
|
||||
sdp_answer: sdpAnswer,
|
||||
});
|
||||
const body = sdpAnswer ? { sdp_answer: sdpAnswer } : {};
|
||||
return axios.post(`${this.url}/${callId}/accept`, body);
|
||||
}
|
||||
|
||||
reject(callId) {
|
||||
@@ -24,13 +25,47 @@ class WhatsappCallsAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/${callId}/terminate`);
|
||||
}
|
||||
|
||||
// Initiate an outbound call. sdpOffer is optional — omitted in server-relay
|
||||
// mode where the media server generates the SDP offer for Meta.
|
||||
initiate(conversationId, sdpOffer) {
|
||||
return axios.post(`${this.url}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
sdp_offer: sdpOffer,
|
||||
const body = { conversation_id: conversationId };
|
||||
if (sdpOffer) body.sdp_offer = sdpOffer;
|
||||
return axios.post(`${this.url}/initiate`, body);
|
||||
}
|
||||
|
||||
// Send the agent's SDP answer for the Peer B connection (server-relay mode).
|
||||
agentAnswer(callId, sdpAnswer) {
|
||||
return axios.post(`${this.url}/${callId}/agent_answer`, {
|
||||
sdp_answer: sdpAnswer,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the current agent's active call (if any). Used for reconnection on page load.
|
||||
active() {
|
||||
return axios.get(`${this.url}/active`);
|
||||
}
|
||||
|
||||
// Reconnect to an active call after page reload. Server creates a new Peer B
|
||||
// and returns a fresh SDP offer via ActionCable.
|
||||
reconnect(callId) {
|
||||
return axios.post(`${this.url}/${callId}/reconnect`);
|
||||
}
|
||||
|
||||
// Join an existing call as a supervisor (listen-only by default).
|
||||
join(callId, role = 'listen_only') {
|
||||
return axios.post(`${this.url}/${callId}/join`, { role });
|
||||
}
|
||||
|
||||
// Play an audio file to the caller via the media server.
|
||||
playAudio(callId, { filePath, mode = 'replace', loop = false }) {
|
||||
return axios.post(`${this.url}/${callId}/play_audio`, {
|
||||
file_path: filePath,
|
||||
mode,
|
||||
loop,
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy: upload browser-side recording. Deprecated when media server is enabled.
|
||||
uploadRecording(callId, blob) {
|
||||
const formData = new FormData();
|
||||
formData.append('recording', blob, `call-${callId}.webm`);
|
||||
|
||||
@@ -47,7 +47,7 @@ const isFailed = computed(() =>
|
||||
|
||||
// Call source and metadata — all camelCase due to deep transform
|
||||
const isWhatsappCall = computed(() => data.value?.callSource === 'whatsapp');
|
||||
const waCallId = computed(() => data.value?.callId);
|
||||
const callId = computed(() => data.value?.callId);
|
||||
const acceptedBy = computed(() => data.value?.acceptedBy);
|
||||
const durationSeconds = computed(() => data.value?.durationSeconds);
|
||||
const recordingUrl = computed(() => data.value?.recordingUrl);
|
||||
@@ -64,10 +64,19 @@ const formattedDuration = computed(() => {
|
||||
});
|
||||
|
||||
// Show join/accept button logic
|
||||
// WhatsApp: only ringing (peer-to-peer WebRTC — cannot rejoin after accept)
|
||||
// WhatsApp with media server: ringing + in_progress (server-relay supports rejoin)
|
||||
// WhatsApp without media server: only ringing (peer-to-peer WebRTC — cannot rejoin after accept)
|
||||
// Twilio: ringing + in-progress (conference model supports rejoin)
|
||||
const showJoinButton = computed(() => {
|
||||
if (isWhatsappCall.value) {
|
||||
// Server-relay mode enables rejoining in-progress calls
|
||||
const isMediaServerMode = data.value?.mediaServerEnabled;
|
||||
if (isMediaServerMode) {
|
||||
return [
|
||||
VOICE_CALL_STATUS.RINGING,
|
||||
VOICE_CALL_STATUS.IN_PROGRESS,
|
||||
].includes(status.value);
|
||||
}
|
||||
return status.value === VOICE_CALL_STATUS.RINGING;
|
||||
}
|
||||
return [VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes(
|
||||
@@ -133,7 +142,7 @@ const handleJoinCall = async () => {
|
||||
|
||||
try {
|
||||
if (isWhatsappCall.value) {
|
||||
const result = await acceptWhatsappCallById(waCallId.value);
|
||||
const result = await acceptWhatsappCallById(callId.value);
|
||||
if (result?.success && result.call) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { watch, onUnmounted } from 'vue';
|
||||
import { watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -16,6 +18,7 @@ const {
|
||||
isAccepting,
|
||||
isMuted,
|
||||
isOutboundRinging,
|
||||
isReconnecting,
|
||||
callError,
|
||||
formattedCallDuration,
|
||||
acceptCall,
|
||||
@@ -23,8 +26,27 @@ const {
|
||||
endActiveCall,
|
||||
toggleMute,
|
||||
dismissIncomingCall,
|
||||
startDurationTimer,
|
||||
} = useWhatsappCallSession();
|
||||
|
||||
// In server-relay mode, the timer starts when the Peer B WebRTC handshake
|
||||
// completes (not when the agent clicks accept). Listen for this event.
|
||||
const onAgentWebRTCConnected = () => {
|
||||
startDurationTimer();
|
||||
};
|
||||
|
||||
const onPermissionGranted = ({ contactName }) => {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: t('WHATSAPP_CALL.PERMISSION_GRANTED', { contactName }),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on('whatsapp_call:agent_webrtc_connected', onAgentWebRTCConnected);
|
||||
emitter.on('whatsapp_call:permission_granted', onPermissionGranted);
|
||||
});
|
||||
|
||||
// Auto-dismiss ringing calls after 30 seconds
|
||||
const autoRejectTimers = new Map();
|
||||
|
||||
@@ -77,6 +99,8 @@ watch(
|
||||
onUnmounted(() => {
|
||||
autoRejectTimers.forEach(timer => clearTimeout(timer));
|
||||
autoRejectTimers.clear();
|
||||
emitter.off('whatsapp_call:agent_webrtc_connected', onAgentWebRTCConnected);
|
||||
emitter.off('whatsapp_call:permission_granted', onPermissionGranted);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -173,14 +197,20 @@ onUnmounted(() => {
|
||||
<p
|
||||
class="text-sm"
|
||||
:class="
|
||||
isOutboundRinging ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
|
||||
isOutboundRinging || isReconnecting
|
||||
? 'text-n-slate-11'
|
||||
: 'font-mono text-n-teal-9'
|
||||
"
|
||||
>
|
||||
{{
|
||||
isOutboundRinging
|
||||
? t('WHATSAPP_CALL.RINGING')
|
||||
: formattedCallDuration
|
||||
}}
|
||||
<template v-if="isReconnecting">
|
||||
{{ t('WHATSAPP_CALL.RECONNECTING') }}
|
||||
</template>
|
||||
<template v-else-if="isOutboundRinging">
|
||||
{{ t('WHATSAPP_CALL.RINGING') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formattedCallDuration }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
|
||||
@@ -104,32 +104,118 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
const canInitiateWhatsappCall = computed(() => {
|
||||
if (!isAWhatsAppCloudChannel.value) return false;
|
||||
if (!inbox.value?.calling_enabled) return false;
|
||||
// Block if there's already an active or ringing WhatsApp call
|
||||
if (whatsappCallsStore.hasWhatsappCall) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Detect if the media server is enabled for this inbox.
|
||||
// When enabled, the browser should NOT create its own WebRTC offer.
|
||||
const isMediaServerEnabled = computed(
|
||||
() => !!inbox.value?.media_server_enabled
|
||||
);
|
||||
|
||||
const waitForOutboundIceGathering = pc =>
|
||||
new Promise(resolve => {
|
||||
new Promise((resolve, reject) => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => resolve(), 10000);
|
||||
|
||||
let timeout = null;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
pc.onicegatheringstatechange = null;
|
||||
pc.oniceconnectionstatechange = null;
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, 10000);
|
||||
|
||||
pc.onicegatheringstatechange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === 'failed') {
|
||||
cleanup();
|
||||
reject(new Error('ICE connection failed'));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const initiateWhatsappCall = async () => {
|
||||
/**
|
||||
* Server-relay mode: POST /initiate without SDP. The media server creates
|
||||
* Peer A (Meta-side) and later sends the agent Peer B offer via ActionCable
|
||||
* (whatsapp_call.outbound_connected with sdp_offer).
|
||||
*/
|
||||
const initiateServerRelayCall = async () => {
|
||||
if (isInitiatingCall.value || !currentChat.value?.id) return;
|
||||
isInitiatingCall.value = true;
|
||||
|
||||
try {
|
||||
const response = await WhatsappCallsAPI.initiate(currentChat.value.id);
|
||||
|
||||
const callStatus = response.data?.status;
|
||||
if (
|
||||
callStatus === 'permission_requested' ||
|
||||
callStatus === 'permission_pending'
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: t('WHATSAPP_CALL.CALLING'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
const outboundCallId = response.data?.call_id;
|
||||
|
||||
// Set active call — WebRTC setup happens when ActionCable delivers agent_offer
|
||||
whatsappCallsStore.setActiveCall({
|
||||
id: response.data?.id,
|
||||
callId: outboundCallId,
|
||||
direction: 'outbound',
|
||||
status: 'ringing',
|
||||
serverRelay: true,
|
||||
conversationId: currentChat.value.id,
|
||||
caller: {
|
||||
name: currentContact.value?.name,
|
||||
phone: currentContact.value?.phone_number,
|
||||
avatar: currentContact.value?.thumbnail,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED');
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: errorMessage,
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
isInitiatingCall.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy mode: Browser creates RTCPeerConnection, generates SDP offer,
|
||||
* sends it to backend which forwards to Meta.
|
||||
*/
|
||||
const initiateLegacyCall = async () => {
|
||||
if (isInitiatingCall.value || !currentChat.value?.id) return;
|
||||
isInitiatingCall.value = true;
|
||||
let pc = null;
|
||||
let localStream = null;
|
||||
let waCallId = null;
|
||||
let recordCallId = null;
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
pc = new RTCPeerConnection({
|
||||
@@ -137,7 +223,6 @@ const initiateWhatsappCall = async () => {
|
||||
});
|
||||
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
|
||||
|
||||
// Handle remote audio from Meta — ontrack fires when the callee picks up
|
||||
pc.ontrack = event => {
|
||||
const [stream] = event.streams;
|
||||
if (!stream) return;
|
||||
@@ -146,21 +231,13 @@ const initiateWhatsappCall = async () => {
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
setOutboundCallProperty('audio', audio);
|
||||
// Remote audio arrived — callee picked up, transition from ringing to connected
|
||||
whatsappCallsStore.markActiveCallConnected();
|
||||
// Start recording both local + remote audio
|
||||
if (waCallId) startCallRecording(pc, localStream, waCallId);
|
||||
};
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[WhatsApp Call] Outbound ICE state:', pc.iceConnectionState);
|
||||
if (recordCallId) startCallRecording(pc, localStream, recordCallId);
|
||||
};
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
// Wait for ICE gathering to complete before sending offer
|
||||
await waitForOutboundIceGathering(pc);
|
||||
const completeSdp = pc.localDescription.sdp;
|
||||
|
||||
@@ -190,18 +267,17 @@ const initiateWhatsappCall = async () => {
|
||||
});
|
||||
|
||||
const outboundCallId = response.data?.call_id;
|
||||
waCallId = response.data?.id;
|
||||
recordCallId = response.data?.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',
|
||||
serverRelay: false,
|
||||
conversationId: currentChat.value.id,
|
||||
caller: {
|
||||
name: currentContact.value?.name,
|
||||
@@ -222,6 +298,13 @@ const initiateWhatsappCall = async () => {
|
||||
isInitiatingCall.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const initiateWhatsappCall = () => {
|
||||
if (isMediaServerEnabled.value) {
|
||||
return initiateServerRelayCall();
|
||||
}
|
||||
return initiateLegacyCall();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { onMounted, computed } from 'vue';
|
||||
import { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls';
|
||||
import WhatsappCallsAPI from 'dashboard/api/whatsappCalls';
|
||||
|
||||
/**
|
||||
* Checks for an active WhatsApp call on page load and reconnects if found.
|
||||
* This handles the server-relay scenario where the call persists on the media
|
||||
* server even after the agent's browser reloads.
|
||||
*
|
||||
* NOTE: This composable intentionally does NOT call useWhatsappCallSession()
|
||||
* to avoid creating duplicate side effects (beforeunload handlers, cleanup
|
||||
* callbacks, timers). The WhatsappCallWidget owns the useWhatsappCallSession
|
||||
* instance. This composable only sets store state and triggers the reconnect
|
||||
* API call — the actual WebRTC setup happens when the ActionCable agent_offer
|
||||
* event arrives and is handled by handleAgentOffer.
|
||||
*
|
||||
* Usage: call `useCallReconnection()` in the app-level layout component that
|
||||
* mounts once on page load.
|
||||
*/
|
||||
export function useCallReconnection() {
|
||||
const callsStore = useWhatsappCallsStore();
|
||||
|
||||
const isReconnecting = computed(() => callsStore.isReconnecting);
|
||||
|
||||
onMounted(async () => {
|
||||
// Skip if there's already an active or incoming call in the store
|
||||
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return;
|
||||
|
||||
try {
|
||||
const { data } = await WhatsappCallsAPI.active();
|
||||
if (!data?.call) return;
|
||||
|
||||
const activeCallData = data.call;
|
||||
|
||||
callsStore.setReconnecting(true);
|
||||
callsStore.setActiveCall({
|
||||
id: activeCallData.id,
|
||||
callId: activeCallData.call_id,
|
||||
direction: activeCallData.direction,
|
||||
conversationId: activeCallData.conversation_id,
|
||||
status: 'reconnecting',
|
||||
serverRelay: true,
|
||||
caller: activeCallData.caller,
|
||||
});
|
||||
|
||||
// Set timer offset so the timer resumes from the correct elapsed time
|
||||
if (activeCallData.elapsed_seconds) {
|
||||
callsStore.setTimerOffset(activeCallData.elapsed_seconds);
|
||||
}
|
||||
|
||||
// Tell the server to create a new Peer B and send us a fresh SDP offer.
|
||||
// The server will broadcast whatsapp_call.agent_offer via ActionCable,
|
||||
// which is handled by handleAgentOffer in actionCable.js.
|
||||
await WhatsappCallsAPI.reconnect(activeCallData.id);
|
||||
} catch {
|
||||
// No active call or API/reconnect error — clear state and silent fail.
|
||||
// clearActiveCall() also resets isReconnecting and callTimerOffset.
|
||||
callsStore.clearActiveCall();
|
||||
}
|
||||
});
|
||||
|
||||
return { isReconnecting };
|
||||
}
|
||||
@@ -3,18 +3,18 @@ import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
useWhatsappCallsStore,
|
||||
getOutboundCallState,
|
||||
cleanupOutboundCall,
|
||||
} from 'dashboard/stores/whatsappCalls';
|
||||
import WhatsappCallsAPI from 'dashboard/api/whatsappCalls';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
// ── Module-level WebRTC state for inbound calls accepted from anywhere ──
|
||||
// Kept at module scope so both the composable and acceptWhatsappCallById share it.
|
||||
// ── Module-level WebRTC state (shared across legacy inbound + server-relay) ──
|
||||
let inboundPc = null;
|
||||
let inboundStream = null;
|
||||
let inboundAudio = null;
|
||||
|
||||
// ── Module-level recording state ──
|
||||
// ── Module-level recording state (legacy mode only) ──
|
||||
let mediaRecorder = null;
|
||||
let recordedChunks = [];
|
||||
let recordingCallId = null;
|
||||
@@ -40,19 +40,18 @@ function cleanupInboundWebRTC() {
|
||||
/**
|
||||
* Start recording both local and remote audio tracks via MediaRecorder.
|
||||
* Mixes them into a single stream using AudioContext.
|
||||
* Used ONLY in legacy (browser-direct) mode.
|
||||
*/
|
||||
export function startCallRecording(pc, localStream, callId) {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
|
||||
// Add local mic track
|
||||
if (localStream) {
|
||||
const localSource = ctx.createMediaStreamSource(localStream);
|
||||
localSource.connect(dest);
|
||||
}
|
||||
|
||||
// Add remote tracks from peer connection
|
||||
pc.getReceivers().forEach(receiver => {
|
||||
if (receiver.track && receiver.track.kind === 'audio') {
|
||||
const remoteStream = new MediaStream([receiver.track]);
|
||||
@@ -81,6 +80,7 @@ export function startCallRecording(pc, localStream, callId) {
|
||||
|
||||
/**
|
||||
* Stop recording and upload the audio blob to the backend.
|
||||
* Used ONLY in legacy (browser-direct) mode.
|
||||
*/
|
||||
function stopAndUploadRecording(callId) {
|
||||
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
|
||||
@@ -110,7 +110,17 @@ function waitForIceGatheringComplete(pc) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
let timeout = null;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
pc.onicegatheringstatechange = null;
|
||||
pc.oniceconnectionstatechange = null;
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[WhatsApp Call] ICE gathering timed out, sending partial SDP'
|
||||
@@ -120,85 +130,155 @@ function waitForIceGatheringComplete(pc) {
|
||||
|
||||
pc.onicegatheringstatechange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === 'failed') {
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
reject(new Error('ICE connection failed'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Server-relay mode: detect by absence of sdpOffer in incoming call data ──
|
||||
function isServerRelayCall(call) {
|
||||
return !call.sdpOffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core accept logic: creates WebRTC session and posts SDP to backend.
|
||||
* Can be called from anywhere — composable, widget, or bubble.
|
||||
* Returns { success: true } or { success: false, error }.
|
||||
* Handle an SDP offer from the media server (Peer B). Used in server-relay mode
|
||||
* for both inbound accept and outbound connect flows.
|
||||
*
|
||||
* Flow: getUserMedia -> RTCPeerConnection(iceServers) -> setRemoteDescription(offer)
|
||||
* -> createAnswer -> waitForICE -> POST /agent_answer
|
||||
*/
|
||||
async function doAcceptCall(call) {
|
||||
async function handleAgentOffer(callId, sdpOffer, iceServers) {
|
||||
cleanupInboundWebRTC();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
|
||||
const iceServers = call.iceServers?.length
|
||||
? call.iceServers
|
||||
: [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
const servers = iceServers?.length
|
||||
? iceServers
|
||||
: [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
inboundPc = pc;
|
||||
const pc = new RTCPeerConnection({ iceServers: servers });
|
||||
inboundPc = pc;
|
||||
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
|
||||
pc.ontrack = event => {
|
||||
const [remoteStream] = event.streams;
|
||||
if (!remoteStream) return;
|
||||
if (!inboundAudio) {
|
||||
const audio = document.createElement('audio');
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
inboundAudio = audio;
|
||||
}
|
||||
inboundAudio.srcObject = remoteStream;
|
||||
inboundAudio.play().catch(() => {});
|
||||
pc.ontrack = event => {
|
||||
const [remoteStream] = event.streams;
|
||||
if (!remoteStream) return;
|
||||
if (!inboundAudio) {
|
||||
const audio = document.createElement('audio');
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
inboundAudio = audio;
|
||||
}
|
||||
inboundAudio.srcObject = remoteStream;
|
||||
inboundAudio.play().catch(() => {});
|
||||
|
||||
// Start recording once remote audio is available
|
||||
startCallRecording(pc, stream, call.id);
|
||||
};
|
||||
// No client-side recording in server-relay mode — the media server records
|
||||
};
|
||||
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: call.sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
|
||||
const completeSdp = pc.localDescription.sdp;
|
||||
await WhatsappCallsAPI.accept(call.id, completeSdp);
|
||||
const completeSdp = pc.localDescription.sdp;
|
||||
await WhatsappCallsAPI.agentAnswer(callId, completeSdp);
|
||||
|
||||
return { success: true };
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
cleanupInboundWebRTC();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose handleAgentOffer so ActionCable handler can invoke it
|
||||
export { handleAgentOffer };
|
||||
|
||||
/**
|
||||
* Legacy mode: creates WebRTC session and posts SDP to backend (browser ↔ Meta).
|
||||
* Can be called from anywhere — composable, widget, or bubble.
|
||||
*/
|
||||
async function doAcceptCall(call) {
|
||||
// Server-relay mode: just POST /accept without SDP. Wait for agent_offer event.
|
||||
if (isServerRelayCall(call)) {
|
||||
await WhatsappCallsAPI.accept(call.id);
|
||||
return { success: true, awaitingAgentOffer: true };
|
||||
}
|
||||
|
||||
// Legacy mode: full browser-side WebRTC handshake
|
||||
cleanupInboundWebRTC();
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
|
||||
const iceServers = call.iceServers?.length
|
||||
? call.iceServers
|
||||
: [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
inboundPc = pc;
|
||||
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
|
||||
pc.ontrack = event => {
|
||||
const [remoteStream] = event.streams;
|
||||
if (!remoteStream) return;
|
||||
if (!inboundAudio) {
|
||||
const audio = document.createElement('audio');
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
inboundAudio = audio;
|
||||
}
|
||||
inboundAudio.srcObject = remoteStream;
|
||||
inboundAudio.play().catch(() => {});
|
||||
|
||||
// Start recording once remote audio is available (legacy mode only)
|
||||
startCallRecording(pc, stream, call.id);
|
||||
};
|
||||
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: call.sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
|
||||
const completeSdp = pc.localDescription.sdp;
|
||||
await WhatsappCallsAPI.accept(call.id, completeSdp);
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
cleanupInboundWebRTC();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone function callable from VoiceCall bubble.
|
||||
* Fetches call data if needed, runs WebRTC accept, updates store.
|
||||
*/
|
||||
export async function acceptWhatsappCallById(waCallId) {
|
||||
export async function acceptWhatsappCallById(callId) {
|
||||
const callsStore = useWhatsappCallsStore();
|
||||
|
||||
if (callsStore.hasActiveCall) {
|
||||
return { success: false, error: 'active_call_exists' };
|
||||
}
|
||||
|
||||
// 1. Check if the call is already in the incoming store
|
||||
let call = callsStore.incomingCalls.find(
|
||||
c => c.id === waCallId || c.callId === String(waCallId)
|
||||
c => c.id === callId || c.callId === String(callId)
|
||||
);
|
||||
|
||||
// 2. Not in store (page was refreshed) → fetch from API
|
||||
if (!call) {
|
||||
const { data } = await WhatsappCallsAPI.show(waCallId);
|
||||
const { data } = await WhatsappCallsAPI.show(callId);
|
||||
if (data.status !== 'ringing') {
|
||||
return { success: false, error: 'not_ringing' };
|
||||
}
|
||||
@@ -215,19 +295,30 @@ export async function acceptWhatsappCallById(waCallId) {
|
||||
callsStore.addIncomingCall(call);
|
||||
}
|
||||
|
||||
// 3. Run the WebRTC accept
|
||||
await doAcceptCall(call);
|
||||
try {
|
||||
const result = await doAcceptCall(call);
|
||||
|
||||
// 4. Move from incoming to active
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.setActiveCall({ ...call });
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
|
||||
return { success: true, call };
|
||||
// In server-relay mode the call becomes active but awaits the agent_offer
|
||||
// ActionCable event to complete WebRTC setup. Mark it with serverRelay flag.
|
||||
const activeCallData = {
|
||||
...call,
|
||||
serverRelay: isServerRelayCall(call),
|
||||
};
|
||||
callsStore.setActiveCall(activeCallData);
|
||||
|
||||
return { success: true, call: activeCallData, ...result };
|
||||
} catch (err) {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget terminate request using fetch + keepalive.
|
||||
* Works reliably inside beforeunload / pagehide where axios won't complete.
|
||||
* Used ONLY in legacy mode. Server-relay mode does NOT terminate on unload.
|
||||
*/
|
||||
function terminateCallOnUnload(callId) {
|
||||
const authData = Auth.hasAuthCookie() ? Auth.getAuthData() : {};
|
||||
@@ -260,9 +351,10 @@ export function useWhatsappCallSession() {
|
||||
const isMuted = ref(false);
|
||||
const callError = ref(null);
|
||||
const callDuration = ref(0);
|
||||
const isReconnecting = computed(() => callsStore.isReconnecting);
|
||||
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
callDuration.value = elapsed;
|
||||
callDuration.value = callsStore.callTimerOffset + elapsed;
|
||||
});
|
||||
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
@@ -285,16 +377,27 @@ export function useWhatsappCallSession() {
|
||||
|
||||
// Register cleanup so external call-end events can teardown WebRTC
|
||||
callsStore.registerCleanupCallback(() => {
|
||||
stopAndUploadRecording();
|
||||
// Only do recording cleanup in legacy mode
|
||||
if (!callsStore.isMediaServerEnabled) {
|
||||
stopAndUploadRecording();
|
||||
}
|
||||
cleanupInboundWebRTC();
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
});
|
||||
|
||||
// Terminate active call on page close / reload
|
||||
// On page close / reload:
|
||||
// - Legacy mode: terminate call (current behavior)
|
||||
// - Server-relay mode: just clean up local WebRTC resources, call persists
|
||||
const handleBeforeUnload = () => {
|
||||
const call = callsStore.activeCall;
|
||||
if (call?.id) {
|
||||
if (!call?.id) return;
|
||||
|
||||
if (call.serverRelay) {
|
||||
// Server-relay: only clean up local resources, do NOT terminate
|
||||
cleanupInboundWebRTC();
|
||||
} else {
|
||||
// Legacy: terminate and clean up
|
||||
terminateCallOnUnload(call.id);
|
||||
cleanupInboundWebRTC();
|
||||
}
|
||||
@@ -314,7 +417,6 @@ export function useWhatsappCallSession() {
|
||||
|
||||
/**
|
||||
* Accept an incoming call — used by the floating widget buttons.
|
||||
* Uses the same doAcceptCall core + starts the timer.
|
||||
*/
|
||||
const acceptCall = async call => {
|
||||
if (isAccepting.value) return;
|
||||
@@ -322,10 +424,21 @@ export function useWhatsappCallSession() {
|
||||
callError.value = null;
|
||||
|
||||
try {
|
||||
await doAcceptCall(call);
|
||||
const result = await doAcceptCall(call);
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.setActiveCall({ ...call });
|
||||
durationTimer.start();
|
||||
|
||||
const activeCallData = {
|
||||
...call,
|
||||
serverRelay: isServerRelayCall(call),
|
||||
};
|
||||
callsStore.setActiveCall(activeCallData);
|
||||
|
||||
// In legacy mode, WebRTC is already established so start timer now.
|
||||
// In server-relay mode, timer starts when handleAgentOffer completes
|
||||
// (triggered by the whatsapp_call.agent_offer ActionCable event).
|
||||
if (!result.awaitingAgentOffer) {
|
||||
durationTimer.start();
|
||||
}
|
||||
} catch (err) {
|
||||
callError.value =
|
||||
err.name === 'NotAllowedError'
|
||||
@@ -333,7 +446,7 @@ export function useWhatsappCallSession() {
|
||||
: t('WHATSAPP_CALL.CALL_FAILED');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[WhatsApp Call] acceptCall error:', err);
|
||||
cleanupInboundWebRTC();
|
||||
// Note: doAcceptCall already cleans up WebRTC resources on error
|
||||
} finally {
|
||||
isAccepting.value = false;
|
||||
}
|
||||
@@ -353,7 +466,10 @@ export function useWhatsappCallSession() {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
|
||||
stopAndUploadRecording(call.id);
|
||||
// Only upload recording in legacy mode
|
||||
if (!call.serverRelay) {
|
||||
stopAndUploadRecording(call.id);
|
||||
}
|
||||
|
||||
try {
|
||||
await WhatsappCallsAPI.terminate(call.id);
|
||||
@@ -361,7 +477,10 @@ export function useWhatsappCallSession() {
|
||||
// Best effort
|
||||
} finally {
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.handleCallEnded(call.callId);
|
||||
cleanupOutboundCall();
|
||||
// Clear state directly — do NOT use handleCallEnded here since that is
|
||||
// meant for external events (ActionCable) and would invoke cleanupCallback
|
||||
// which would duplicate the cleanup we just performed.
|
||||
callsStore.clearActiveCall();
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
@@ -381,6 +500,14 @@ export function useWhatsappCallSession() {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the duration timer. Called externally after server-relay WebRTC
|
||||
* setup completes (handleAgentOffer).
|
||||
*/
|
||||
const startDurationTimer = () => {
|
||||
durationTimer.start();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
durationTimer.stop();
|
||||
@@ -395,6 +522,7 @@ export function useWhatsappCallSession() {
|
||||
isAccepting,
|
||||
isMuted,
|
||||
isOutboundRinging,
|
||||
isReconnecting,
|
||||
callError,
|
||||
formattedCallDuration,
|
||||
acceptCall,
|
||||
@@ -402,5 +530,6 @@ export function useWhatsappCallSession() {
|
||||
endActiveCall,
|
||||
toggleMute,
|
||||
dismissIncomingCall,
|
||||
startDurationTimer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useWhatsappCallsStore,
|
||||
getOutboundCallState,
|
||||
} from 'dashboard/stores/whatsappCalls';
|
||||
import { handleAgentOffer } from 'dashboard/composables/useWhatsappCallSession';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
@@ -43,6 +44,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'whatsapp_call.ended': this.onWhatsappCallEnded,
|
||||
'whatsapp_call.outbound_connected': this.onWhatsappCallOutboundConnected,
|
||||
'whatsapp_call.permission_granted': this.onWhatsappCallPermissionGranted,
|
||||
'whatsapp_call.agent_offer': this.onWhatsappCallAgentOffer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,6 +215,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onWhatsappCallIncoming = data => {
|
||||
const whatsappCallsStore = useWhatsappCallsStore();
|
||||
// In server-relay mode, sdp_offer and ice_servers are absent — the media
|
||||
// server handles WebRTC with Meta, and the browser only needs call metadata.
|
||||
whatsappCallsStore.addIncomingCall({
|
||||
id: data.id,
|
||||
callId: data.call_id,
|
||||
@@ -220,8 +224,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
inboxId: data.inbox_id,
|
||||
conversationId: data.conversation_id,
|
||||
caller: data.caller,
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
sdpOffer: data.sdp_offer || null,
|
||||
iceServers: data.ice_servers || null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -242,6 +246,31 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onWhatsappCallOutboundConnected = data => {
|
||||
const whatsappCallsStore = useWhatsappCallsStore();
|
||||
|
||||
// Server-relay mode: data contains sdp_offer (media server generated offer
|
||||
// for Peer B) instead of sdp_answer.
|
||||
if (data.sdp_offer) {
|
||||
const activeCall = whatsappCallsStore.activeCall;
|
||||
if (activeCall && activeCall.callId === data.call_id) {
|
||||
handleAgentOffer(activeCall.id, data.sdp_offer, data.ice_servers)
|
||||
.then(() => {
|
||||
whatsappCallsStore.markActiveCallConnected();
|
||||
// Emit event so the composable can start the timer
|
||||
emitter.emit('whatsapp_call:agent_webrtc_connected');
|
||||
})
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'[WhatsApp Call] Failed to handle outbound agent offer:',
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy mode: data contains sdp_answer (Meta's answer to browser's offer)
|
||||
const { pc, callId } = getOutboundCallState();
|
||||
if (pc && callId === data.call_id && data.sdp_answer) {
|
||||
pc.setRemoteDescription({ type: 'answer', sdp: data.sdp_answer }).catch(
|
||||
@@ -258,11 +287,34 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
// 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',
|
||||
emitter.emit('whatsapp_call:permission_granted', {
|
||||
contactName: data.contact_name,
|
||||
});
|
||||
};
|
||||
|
||||
// Server-relay mode: the media server created Peer B and sent an SDP offer
|
||||
// for the agent's browser. This fires after POST /accept or POST /reconnect.
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onWhatsappCallAgentOffer = data => {
|
||||
const whatsappCallsStore = useWhatsappCallsStore();
|
||||
const activeCall = whatsappCallsStore.activeCall;
|
||||
|
||||
if (!activeCall) return;
|
||||
// Verify this offer is for the current active call
|
||||
if (activeCall.callId !== data.call_id && activeCall.id !== data.id) return;
|
||||
|
||||
handleAgentOffer(activeCall.id, data.sdp_offer, data.ice_servers)
|
||||
.then(() => {
|
||||
whatsappCallsStore.markActiveCallConnected();
|
||||
whatsappCallsStore.setReconnecting(false);
|
||||
// Emit event so the composable can start the timer
|
||||
emitter.emit('whatsapp_call:agent_webrtc_connected');
|
||||
})
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[WhatsApp Call] Failed to handle agent offer:', err);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"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"
|
||||
"CALL_TAKEN": "Call accepted by another agent",
|
||||
"RECONNECTING": "Reconnecting…",
|
||||
"PERMISSION_GRANTED": "{contactName} approved the call permission request. You can now call them."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ 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.
|
||||
// Used ONLY in legacy (browser-direct) mode. In server-relay mode outbound calls
|
||||
// go through the same inbound WebRTC path via handleAgentOffer.
|
||||
const outboundCall = { pc: null, stream: null, audio: null, callId: null };
|
||||
|
||||
export function getOutboundCallState() {
|
||||
@@ -12,7 +14,7 @@ export function setOutboundCallProperty(key, value) {
|
||||
outboundCall[key] = value;
|
||||
}
|
||||
|
||||
function cleanupOutboundCall() {
|
||||
export function cleanupOutboundCall() {
|
||||
if (outboundCall.pc) outboundCall.pc.close();
|
||||
if (outboundCall.stream) {
|
||||
outboundCall.stream.getTracks().forEach(t => t.stop());
|
||||
@@ -35,6 +37,10 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
|
||||
activeCall: null,
|
||||
// Cleanup callback registered by the composable — called when a call ends externally
|
||||
cleanupCallback: null,
|
||||
// True while the agent is reconnecting to an active call after page reload
|
||||
isReconnecting: false,
|
||||
// Seconds already elapsed when reconnecting — timer resumes from this offset
|
||||
callTimerOffset: 0,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -43,6 +49,13 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
|
||||
hasWhatsappCall: state =>
|
||||
state.incomingCalls.length > 0 || state.activeCall !== null,
|
||||
firstIncomingCall: state => state.incomingCalls[0] || null,
|
||||
|
||||
// Returns true when the active call is operating through the media server
|
||||
// (server-relay mode). Detected by the absence of sdpOffer in the call data
|
||||
// — in legacy mode the incoming call ActionCable event includes sdpOffer.
|
||||
isMediaServerEnabled() {
|
||||
return this.activeCall?.serverRelay === true;
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
@@ -62,6 +75,8 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
|
||||
|
||||
clearActiveCall() {
|
||||
this.activeCall = null;
|
||||
this.callTimerOffset = 0;
|
||||
this.isReconnecting = false;
|
||||
},
|
||||
|
||||
markActiveCallConnected() {
|
||||
@@ -74,6 +89,14 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
|
||||
this.cleanupCallback = callback;
|
||||
},
|
||||
|
||||
setReconnecting(value) {
|
||||
this.isReconnecting = value;
|
||||
},
|
||||
|
||||
setTimerOffset(seconds) {
|
||||
this.callTimerOffset = seconds;
|
||||
},
|
||||
|
||||
handleCallAcceptedByOther(callId) {
|
||||
this.removeIncomingCall(callId);
|
||||
},
|
||||
@@ -81,10 +104,14 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
|
||||
handleCallEnded(callId) {
|
||||
this.removeIncomingCall(callId);
|
||||
if (this.activeCall?.callId === callId) {
|
||||
this.activeCall = null;
|
||||
// Invoke cleanup BEFORE clearing activeCall so the callback can
|
||||
// check isMediaServerEnabled (which depends on activeCall.serverRelay)
|
||||
if (this.cleanupCallback) {
|
||||
this.cleanupCallback();
|
||||
}
|
||||
this.activeCall = null;
|
||||
this.callTimerOffset = 0;
|
||||
this.isReconnecting = false;
|
||||
}
|
||||
if (outboundCall.callId === callId) {
|
||||
cleanupOutboundCall();
|
||||
|
||||
Reference in New Issue
Block a user