diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue index c6dbf169c..41d150c69 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue @@ -100,6 +100,21 @@ const canInitiateWhatsappCall = computed(() => { return !!inbox.value?.calling_enabled; }); +const waitForOutboundIceGathering = pc => + new Promise(resolve => { + if (pc.iceGatheringState === 'complete') { + resolve(); + return; + } + const timeout = setTimeout(() => resolve(), 10000); + pc.onicegatheringstatechange = () => { + if (pc.iceGatheringState === 'complete') { + clearTimeout(timeout); + resolve(); + } + }; + }); + const initiateWhatsappCall = async () => { if (isInitiatingCall.value || !currentChat.value?.id) return; isInitiatingCall.value = true; @@ -112,12 +127,31 @@ const initiateWhatsappCall = async () => { }); localStream.getTracks().forEach(track => pc.addTrack(track, localStream)); + // Handle remote audio from Meta + pc.ontrack = event => { + const [stream] = event.streams; + if (!stream) return; + const audio = document.createElement('audio'); + audio.srcObject = stream; + audio.autoplay = true; + document.body.appendChild(audio); + window.__outboundCallAudio = audio; + }; + + // eslint-disable-next-line no-console + pc.oniceconnectionstatechange = () => + console.log('[WhatsApp Call] Outbound ICE state:', pc.iceConnectionState); + 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; + const response = await WhatsappCallsAPI.initiate( currentChat.value.id, - offer.sdp + completeSdp ); const callStatus = response.data?.status; diff --git a/app/javascript/dashboard/composables/useWhatsappCallSession.js b/app/javascript/dashboard/composables/useWhatsappCallSession.js index ae8f9bf3f..4d9fdcc70 100644 --- a/app/javascript/dashboard/composables/useWhatsappCallSession.js +++ b/app/javascript/dashboard/composables/useWhatsappCallSession.js @@ -52,13 +52,57 @@ export function useWhatsappCallSession() { } }; + // Register cleanup callback so store can trigger WebRTC teardown on external events + callsStore.registerCleanupCallback(() => { + cleanupWebRTC(); + durationTimer.stop(); + callDuration.value = 0; + }); + + /** + * 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. + */ + const waitForIceGatheringComplete = pc => + new Promise((resolve, reject) => { + if (pc.iceGatheringState === 'complete') { + resolve(); + return; + } + + const timeout = setTimeout(() => { + // If gathering hasn't finished in 10s, send what we have + // eslint-disable-next-line no-console + console.warn( + '[WhatsApp Call] ICE gathering timed out, sending partial SDP' + ); + resolve(); + }, 10000); + + pc.onicegatheringstatechange = () => { + if (pc.iceGatheringState === 'complete') { + clearTimeout(timeout); + resolve(); + } + }; + + // Also reject if connection fails during gathering + pc.oniceconnectionstatechange = () => { + if (pc.iceConnectionState === 'failed') { + clearTimeout(timeout); + reject(new Error('ICE connection failed')); + } + }; + }); + /** * Accepts an incoming WhatsApp call: * 1. Requests mic access * 2. Creates RTCPeerConnection with ICE servers from the call payload * 3. Sets remote description (the SDP offer from Meta) * 4. Creates an SDP answer - * 5. Posts the SDP answer to Chatwoot backend → Meta API + * 5. Waits for ICE gathering to complete (Meta needs full SDP, no trickle ICE) + * 6. Posts the complete SDP answer to Chatwoot backend → Meta API */ const acceptCall = async call => { if (isAccepting.value) return; @@ -82,14 +126,18 @@ export function useWhatsappCallSession() { peerConnection.addTrack(track, localStream); }); - // 5. Handle remote audio stream → play via hidden