Replace the WhatsappCall model with the unified Call model introduced in feat/voice-call-model (PR #14026), enabling multi-provider support (Twilio + WhatsApp) under a single data model. Key changes: - Delete WhatsappCall model and its 3 migrations (whatsapp_calls table) - Enhance Call model with WhatsApp-specific helpers (sdp_offer, ice_servers, recording_url, direction_label, ringing?, in_progress?) - Update all backend services, controller, and jobs to use Call model - Map statuses: accepted→in_progress, rejected→failed, missed→no_answer, ended→completed - Map directions: inbound→incoming, outbound→outgoing (enums) - Rename call_id column to provider_call_id, always set provider: :whatsapp - Add authorization check on initiate action - Add race condition guard in outbound call_connect webhook handler - Consolidate direction_label helper into Call model (DRY) - Update frontend data key from waCallId to callId in VoiceCall bubble - Remove whatsapp_calls table from schema.rb, fix missing started_at Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
407 lines
11 KiB
JavaScript
407 lines
11 KiB
JavaScript
import { ref, computed, watch, onUnmounted } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import {
|
|
useWhatsappCallsStore,
|
|
getOutboundCallState,
|
|
} 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.
|
|
let inboundPc = null;
|
|
let inboundStream = null;
|
|
let inboundAudio = null;
|
|
|
|
// ── Module-level recording state ──
|
|
let mediaRecorder = null;
|
|
let recordedChunks = [];
|
|
let recordingCallId = null;
|
|
|
|
function cleanupInboundWebRTC() {
|
|
if (inboundStream) {
|
|
inboundStream.getTracks().forEach(track => track.stop());
|
|
inboundStream = null;
|
|
}
|
|
if (inboundPc) {
|
|
inboundPc.close();
|
|
inboundPc = null;
|
|
}
|
|
if (inboundAudio) {
|
|
inboundAudio.srcObject = null;
|
|
if (inboundAudio.parentNode) {
|
|
inboundAudio.parentNode.removeChild(inboundAudio);
|
|
}
|
|
inboundAudio = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start recording both local and remote audio tracks via MediaRecorder.
|
|
* Mixes them into a single stream using AudioContext.
|
|
*/
|
|
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]);
|
|
const remoteSource = ctx.createMediaStreamSource(remoteStream);
|
|
remoteSource.connect(dest);
|
|
}
|
|
});
|
|
|
|
recordedChunks = [];
|
|
recordingCallId = callId;
|
|
const recorder = new MediaRecorder(dest.stream, {
|
|
mimeType: 'audio/webm;codecs=opus',
|
|
});
|
|
|
|
recorder.ondataavailable = e => {
|
|
if (e.data.size > 0) recordedChunks.push(e.data);
|
|
};
|
|
|
|
mediaRecorder = recorder;
|
|
recorder.start(1000);
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('[WhatsApp Call] Failed to start recording:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop recording and upload the audio blob to the backend.
|
|
*/
|
|
function stopAndUploadRecording(callId) {
|
|
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
|
|
|
|
const id = callId || recordingCallId;
|
|
|
|
mediaRecorder.onstop = () => {
|
|
if (recordedChunks.length === 0 || !id) return;
|
|
|
|
const blob = new Blob(recordedChunks, { type: 'audio/webm' });
|
|
recordedChunks = [];
|
|
recordingCallId = null;
|
|
|
|
WhatsappCallsAPI.uploadRecording(id, blob).catch(err => {
|
|
// eslint-disable-next-line no-console
|
|
console.error('[WhatsApp Call] Failed to upload recording:', err);
|
|
});
|
|
};
|
|
|
|
mediaRecorder.stop();
|
|
mediaRecorder = null;
|
|
}
|
|
|
|
function waitForIceGatheringComplete(pc) {
|
|
return new Promise((resolve, reject) => {
|
|
if (pc.iceGatheringState === 'complete') {
|
|
resolve();
|
|
return;
|
|
}
|
|
const timeout = setTimeout(() => {
|
|
// 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();
|
|
}
|
|
};
|
|
pc.oniceconnectionstatechange = () => {
|
|
if (pc.iceConnectionState === 'failed') {
|
|
clearTimeout(timeout);
|
|
reject(new Error('ICE connection failed'));
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 }.
|
|
*/
|
|
async function doAcceptCall(call) {
|
|
cleanupInboundWebRTC();
|
|
|
|
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
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* Standalone function callable from VoiceCall bubble.
|
|
* Fetches call data if needed, runs WebRTC accept, updates store.
|
|
*/
|
|
export async function acceptWhatsappCallById(waCallId) {
|
|
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)
|
|
);
|
|
|
|
// 2. Not in store (page was refreshed) → fetch from API
|
|
if (!call) {
|
|
const { data } = await WhatsappCallsAPI.show(waCallId);
|
|
if (data.status !== 'ringing') {
|
|
return { success: false, error: 'not_ringing' };
|
|
}
|
|
call = {
|
|
id: data.id,
|
|
callId: data.call_id,
|
|
direction: data.direction,
|
|
inboxId: data.inbox_id,
|
|
conversationId: data.conversation_id,
|
|
sdpOffer: data.sdp_offer,
|
|
iceServers: data.ice_servers,
|
|
caller: data.caller,
|
|
};
|
|
callsStore.addIncomingCall(call);
|
|
}
|
|
|
|
// 3. Run the WebRTC accept
|
|
await doAcceptCall(call);
|
|
|
|
// 4. Move from incoming to active
|
|
callsStore.removeIncomingCall(call.callId);
|
|
callsStore.setActiveCall({ ...call });
|
|
|
|
return { success: true, call };
|
|
}
|
|
|
|
/**
|
|
* Fire-and-forget terminate request using fetch + keepalive.
|
|
* Works reliably inside beforeunload / pagehide where axios won't complete.
|
|
*/
|
|
function terminateCallOnUnload(callId) {
|
|
const authData = Auth.hasAuthCookie() ? Auth.getAuthData() : {};
|
|
const accountId =
|
|
window.location.pathname.includes('/app/accounts') &&
|
|
window.location.pathname.split('/')[3];
|
|
if (!accountId) return;
|
|
|
|
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
|
|
fetch(url, {
|
|
method: 'POST',
|
|
keepalive: true,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'access-token': authData['access-token'] || '',
|
|
'token-type': authData['token-type'] || '',
|
|
client: authData.client || '',
|
|
expiry: authData.expiry || '',
|
|
uid: authData.uid || '',
|
|
},
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// ── Composable (used by WhatsappCallWidget for floating UI + timer) ──
|
|
export function useWhatsappCallSession() {
|
|
const { t } = useI18n();
|
|
const callsStore = useWhatsappCallsStore();
|
|
|
|
const isAccepting = ref(false);
|
|
const isMuted = ref(false);
|
|
const callError = ref(null);
|
|
const callDuration = ref(0);
|
|
|
|
const durationTimer = new Timer(elapsed => {
|
|
callDuration.value = elapsed;
|
|
});
|
|
|
|
const activeCall = computed(() => callsStore.activeCall);
|
|
const incomingCalls = computed(() => callsStore.incomingCalls);
|
|
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
|
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
|
|
const firstIncomingCall = computed(() => callsStore.firstIncomingCall);
|
|
|
|
const isOutboundRinging = computed(
|
|
() =>
|
|
activeCall.value?.direction === 'outbound' &&
|
|
activeCall.value?.status === 'ringing'
|
|
);
|
|
|
|
const formattedCallDuration = computed(() => {
|
|
const minutes = Math.floor(callDuration.value / 60);
|
|
const seconds = callDuration.value % 60;
|
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
});
|
|
|
|
// Register cleanup so external call-end events can teardown WebRTC
|
|
callsStore.registerCleanupCallback(() => {
|
|
stopAndUploadRecording();
|
|
cleanupInboundWebRTC();
|
|
durationTimer.stop();
|
|
callDuration.value = 0;
|
|
});
|
|
|
|
// Terminate active call on page close / reload
|
|
const handleBeforeUnload = () => {
|
|
const call = callsStore.activeCall;
|
|
if (call?.id) {
|
|
terminateCallOnUnload(call.id);
|
|
cleanupInboundWebRTC();
|
|
}
|
|
};
|
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
|
|
// Start timer when outbound call becomes connected
|
|
watch(activeCall, call => {
|
|
if (
|
|
call?.direction === 'outbound' &&
|
|
call?.status === 'connected' &&
|
|
!durationTimer.intervalId
|
|
) {
|
|
durationTimer.start();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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;
|
|
isAccepting.value = true;
|
|
callError.value = null;
|
|
|
|
try {
|
|
await doAcceptCall(call);
|
|
callsStore.removeIncomingCall(call.callId);
|
|
callsStore.setActiveCall({ ...call });
|
|
durationTimer.start();
|
|
} catch (err) {
|
|
callError.value =
|
|
err.name === 'NotAllowedError'
|
|
? t('WHATSAPP_CALL.MIC_DENIED')
|
|
: t('WHATSAPP_CALL.CALL_FAILED');
|
|
// eslint-disable-next-line no-console
|
|
console.error('[WhatsApp Call] acceptCall error:', err);
|
|
cleanupInboundWebRTC();
|
|
} finally {
|
|
isAccepting.value = false;
|
|
}
|
|
};
|
|
|
|
const rejectCall = async call => {
|
|
try {
|
|
await WhatsappCallsAPI.reject(call.id);
|
|
} catch {
|
|
// Best effort
|
|
} finally {
|
|
callsStore.removeIncomingCall(call.callId);
|
|
}
|
|
};
|
|
|
|
const endActiveCall = async () => {
|
|
const call = activeCall.value;
|
|
if (!call) return;
|
|
|
|
stopAndUploadRecording(call.id);
|
|
|
|
try {
|
|
await WhatsappCallsAPI.terminate(call.id);
|
|
} catch {
|
|
// Best effort
|
|
} finally {
|
|
cleanupInboundWebRTC();
|
|
callsStore.handleCallEnded(call.callId);
|
|
callsStore.clearActiveCall();
|
|
durationTimer.stop();
|
|
callDuration.value = 0;
|
|
}
|
|
};
|
|
|
|
const toggleMute = () => {
|
|
const stream = inboundStream || getOutboundCallState().stream;
|
|
if (!stream) return;
|
|
const audioTrack = stream.getAudioTracks()[0];
|
|
if (!audioTrack) return;
|
|
audioTrack.enabled = !audioTrack.enabled;
|
|
isMuted.value = !audioTrack.enabled;
|
|
};
|
|
|
|
const dismissIncomingCall = call => {
|
|
callsStore.removeIncomingCall(call.callId);
|
|
};
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
durationTimer.stop();
|
|
});
|
|
|
|
return {
|
|
activeCall,
|
|
incomingCalls,
|
|
hasActiveCall,
|
|
hasIncomingCall,
|
|
firstIncomingCall,
|
|
isAccepting,
|
|
isMuted,
|
|
isOutboundRinging,
|
|
callError,
|
|
formattedCallDuration,
|
|
acceptCall,
|
|
rejectCall,
|
|
endActiveCall,
|
|
toggleMute,
|
|
dismissIncomingCall,
|
|
};
|
|
}
|