fix: inbound and outbound email

This commit is contained in:
Tanmay Deep Sharma
2026-03-16 16:18:59 +05:30
parent 98e4a1e6dc
commit c813505e7d
8 changed files with 263 additions and 65 deletions
@@ -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;
@@ -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 <audio> element
// 5. Handle remote audio stream → play via <audio> element
peerConnection.ontrack = event => {
const [stream] = event.streams;
if (!stream) return;
if (remoteAudio.value) {
remoteAudio.value.srcObject = stream;
remoteAudio.value.play().catch(() => {});
remoteAudio.value.play().catch(e => {
// eslint-disable-next-line no-console
console.warn('[WhatsApp Call] Audio autoplay blocked:', e);
});
} else {
// Fallback: create audio element dynamically
const audio = document.createElement('audio');
audio.srcObject = stream;
audio.autoplay = true;
@@ -98,24 +146,36 @@ export function useWhatsappCallSession() {
}
};
// 6. Set remote description from Meta's SDP offer
// 6. Monitor ICE connection state for debugging
peerConnection.oniceconnectionstatechange = () => {
// eslint-disable-next-line no-console
console.log(
'[WhatsApp Call] ICE state:',
peerConnection?.iceConnectionState
);
};
// 7. Set remote description from Meta's SDP offer
await peerConnection.setRemoteDescription({
type: 'offer',
sdp: call.sdpOffer,
});
// 7. Create SDP answer
// 8. Create SDP answer
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
// 8. Post the SDP answer to Chatwoot backend
await WhatsappCallsAPI.accept(call.id, answer.sdp);
// 9. Wait for ICE gathering to complete so SDP has all candidates
await waitForIceGatheringComplete(peerConnection);
// 9. Mark as active in store
// 10. Post the COMPLETE SDP answer (with all ICE candidates) to backend
const completeSdp = peerConnection.localDescription.sdp;
await WhatsappCallsAPI.accept(call.id, completeSdp);
// 11. Mark as active in store
callsStore.removeIncomingCall(call.callId);
callsStore.setActiveCall({
...call,
peerConnection,
});
durationTimer.start();
@@ -124,6 +184,8 @@ export function useWhatsappCallSession() {
err.name === 'NotAllowedError'
? 'Microphone access denied. Please allow mic access and try again.'
: 'Failed to accept call. Please try again.';
// eslint-disable-next-line no-console
console.error('[WhatsApp Call] acceptCall error:', err);
cleanupWebRTC();
} finally {
isAccepting.value = false;
@@ -38,6 +38,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'whatsapp_call.incoming': this.onWhatsappCallIncoming,
'whatsapp_call.accepted': this.onWhatsappCallAccepted,
'whatsapp_call.ended': this.onWhatsappCallEnded,
'whatsapp_call.outbound_connected': this.onWhatsappCallOutboundConnected,
'whatsapp_call.permission_granted': this.onWhatsappCallPermissionGranted,
};
}
@@ -237,6 +238,20 @@ class ActionCableConnector extends BaseActionCableConnector {
whatsappCallsStore.handleCallEnded(data.call_id);
};
// 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) {
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);
}
);
}
};
// eslint-disable-next-line class-methods-use-this
onWhatsappCallPermissionGranted = data => {
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
@@ -6,11 +6,15 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
incomingCalls: [],
// The single active call (accepted + audio connected)
activeCall: null,
// Cleanup callback registered by the composable — called when a call ends externally
_cleanupCallback: null,
}),
getters: {
hasIncomingCall: state => state.incomingCalls.length > 0,
hasActiveCall: state => state.activeCall !== null,
hasWhatsappCall: state =>
state.incomingCalls.length > 0 || state.activeCall !== null,
firstIncomingCall: state => state.incomingCalls[0] || null,
},
@@ -33,8 +37,11 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
this.activeCall = null;
},
registerCleanupCallback(callback) {
this._cleanupCallback = callback;
},
handleCallAcceptedByOther(callId) {
// Another agent accepted — remove from incoming list for this agent
this.removeIncomingCall(callId);
},
@@ -42,6 +49,24 @@ 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();
}
}
// 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;
}
},
},