import { ref } from 'vue'; import Cookies from 'js-cookie'; import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI'; // Module-level state lets the cable handlers and unload listeners reach the // live PeerConnection without prop-drilling refs through every composable. let pc = null; let localStream = null; let remoteStream = null; let remoteAudioEl = null; let mediaRecorder = null; let recorderChunks = []; let audioContext = null; let activeCallId = null; let intentionallyClosing = false; const ensureRemoteAudioElement = () => { if (remoteAudioEl) return remoteAudioEl; remoteAudioEl = document.createElement('audio'); remoteAudioEl.id = 'whatsapp-call-remote-audio'; remoteAudioEl.autoplay = true; remoteAudioEl.playsInline = true; remoteAudioEl.style.display = 'none'; document.body.appendChild(remoteAudioEl); return remoteAudioEl; }; const playRemoteStream = stream => { const el = ensureRemoteAudioElement(); el.srcObject = stream; el.play().catch(err => { // eslint-disable-next-line no-console console.warn('[WhatsApp Call] remote audio play() failed:', err); }); }; // 1s timeslice keeps a recent recording chunk in memory so a remote hangup // that races cleanup still has data to upload. const RECORDING_TIMESLICE_MS = 1000; const ICE_GATHER_TIMEOUT_MS = 10000; const RECORDER_MIME_CANDIDATES = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', ]; // Outbound calls have no backend-supplied ice_servers (the call doesn't exist // at offer time). Without STUN the browser only sends host candidates and // browser→Meta media silently drops through any non-trivial NAT. const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }]; 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(); if (remoteAudioEl) remoteAudioEl.srcObject = null; pc = null; localStream = null; remoteStream = null; mediaRecorder = null; recorderChunks = []; audioContext = null; activeCallId = null; intentionallyClosing = false; }; const setupRecorder = () => { if (!localStream || !remoteStream || mediaRecorder) return; // createMediaStreamSource on a stream with no audio tracks wires up to // nothing — the recorded mix would be silence. Wait until ontrack fires. if (remoteStream.getAudioTracks().length === 0) return; audioContext = new AudioContext({ sampleRate: 48000 }); // AudioContext starts suspended under most autoplay policies; without // resume() the destination stream produces silence. audioContext.resume().catch(() => {}); 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 buildPeerConnection = iceServers => { const config = iceServers && iceServers.length ? { iceServers } : {}; pc = new RTCPeerConnection(config); remoteStream = new MediaStream(); pc.ontrack = event => { // Reuse the same MediaStream object — the recorder's audioContext source // taps it once, so reassigning would orphan the recorder. const tracks = event.streams && event.streams[0] ? event.streams[0].getTracks() : [event.track]; tracks.forEach(track => { if (!remoteStream.getTracks().includes(track)) remoteStream.addTrack(track); }); playRemoteStream(remoteStream); setupRecorder(); }; return pc; }; 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 }); // Best-effort — the controller's idempotency guard handles a retry. try { await WhatsappCallsAPI.uploadRecording(callId, blob); } catch (_) { /* noop */ } }; // devise-token-auth requires access-token / client / uid headers on every // request — navigator.sendBeacon can't set custom headers, so we rehydrate // the auth payload from the cw_d_session_info cookie that the dashboard sets // at login. Used by the page-close terminate path below. const getDeviseAuthHeaders = () => { try { const raw = Cookies.get('cw_d_session_info'); if (!raw) return null; const session = JSON.parse(raw); return { 'access-token': session['access-token'] || '', client: session.client || '', uid: session.uid || '', expiry: session.expiry || '', 'token-type': session['token-type'] || 'Bearer', }; } catch (_) { return null; } }; const beaconTerminate = callId => { if (!callId) return; const accountId = window.location.pathname.split('/')[3]; if (!accountId) return; const headers = getDeviseAuthHeaders(); if (!headers) return; const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`; // fetch+keepalive (instead of navigator.sendBeacon) so we can attach auth // headers — without them devise-token-auth 401s and the call stays open on // Meta until its carrier-side timeout (~60s). try { fetch(url, { method: 'POST', keepalive: true, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...headers }, body: '{}', }).catch(() => {}); } catch (_) { /* noop */ } }; export function useWhatsappCallSession() { const isInitiating = ref(false); 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); return pc.localDescription.sdp; }; const prepareOutboundOffer = async () => { cleanup(); localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS); 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 }) => { // The store may not have sdpOffer yet (the cable broadcast can race the // click). Fall back to GET /whatsapp_calls/:id which exposes it. let offer = sdpOffer; let ice = iceServers; if (!offer && callId) { try { const fresh = await WhatsappCallsAPI.show(callId); offer = fresh?.sdp_offer || fresh?.sdpOffer; ice = ice || fresh?.ice_servers || fresh?.iceServers; } catch (e) { // eslint-disable-next-line no-console console.error( '[WhatsApp Call] failed to fetch call data for accept:', e ); } } if (!offer) { throw new Error('Missing sdp_offer for accept — call may have ended.'); } const sdpAnswer = await prepareInboundAnswer(offer, ice); 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; try { const sdpOffer = await prepareOutboundOffer(); const response = await WhatsappCallsAPI.initiate( conversationId, sdpOffer ); // The permission-request branch returns no call id; let the caller render the banner. activeCallId = response?.id || null; return response; } catch (e) { cleanup(); 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, prepareInboundAnswer, prepareOutboundOffer, acceptIncomingCall, rejectIncomingCall, initiateOutboundCall, endActiveCall, }; } // Cable handlers fire outside any composable instance, so the shared session // surface is exposed as module-level functions for them. export const applyOutboundAnswer = async (callId, sdpAnswer) => { if (!pc) return; activeCallId = callId; await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer }); }; export const cleanupWhatsappSession = () => cleanup(); export const handleWhatsappRemoteEnd = async callId => { // Snapshot before cleanup nulls activeCallId. const id = callId || activeCallId; if (!id) { cleanup(); return; } try { await stopRecorderAndUpload(id); } finally { cleanup(); } }; export const setWhatsappCallMuted = muted => { if (!localStream) return false; localStream.getAudioTracks().forEach(track => { track.enabled = !muted; }); return muted; }; export const sendWhatsappTerminateBeacon = () => { if (!activeCallId || intentionallyClosing) return; beaconTerminate(activeCallId); };