feat(whatsapp-call): add call recording, transcription, beforeunload termination and documentation
This commit is contained in:
@@ -6,6 +6,10 @@ class WhatsappCallsAPI extends ApiClient {
|
||||
super('whatsapp_calls', { accountScoped: true });
|
||||
}
|
||||
|
||||
show(callId) {
|
||||
return axios.get(`${this.url}/${callId}`);
|
||||
}
|
||||
|
||||
accept(callId, sdpAnswer) {
|
||||
return axios.post(`${this.url}/${callId}/accept`, {
|
||||
sdp_answer: sdpAnswer,
|
||||
@@ -26,6 +30,14 @@ class WhatsappCallsAPI extends ApiClient {
|
||||
sdp_offer: sdpOffer,
|
||||
});
|
||||
}
|
||||
|
||||
uploadRecording(callId, blob) {
|
||||
const formData = new FormData();
|
||||
formData.append('recording', blob, `call-${callId}.webm`);
|
||||
return axios.post(`${this.url}/${callId}/upload_recording`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new WhatsappCallsAPI();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
import { acceptWhatsappCallById } from 'dashboard/composables/useWhatsappCallSession';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -30,8 +32,11 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const { contentAttributes, messageType } = useMessageContext();
|
||||
|
||||
// NOTE: contentAttributes.data keys are camelCase because MessageList.vue
|
||||
// applies useCamelCase(messages, { deep: true }) before rendering.
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
|
||||
@@ -40,6 +45,43 @@ const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
// Call source and metadata — all camelCase due to deep transform
|
||||
const isWhatsappCall = computed(() => data.value?.callSource === 'whatsapp');
|
||||
const waCallId = computed(() => data.value?.waCallId);
|
||||
const acceptedBy = computed(() => data.value?.acceptedBy);
|
||||
const durationSeconds = computed(() => data.value?.durationSeconds);
|
||||
const recordingUrl = computed(() => data.value?.recordingUrl);
|
||||
const transcript = computed(() => data.value?.transcript);
|
||||
const isJoining = ref(false);
|
||||
const showTranscript = ref(false);
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
const seconds = durationSeconds.value;
|
||||
if (!seconds || seconds <= 0) return '';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
||||
});
|
||||
|
||||
// Show join/accept button logic
|
||||
// WhatsApp: only ringing (peer-to-peer WebRTC — cannot rejoin after accept)
|
||||
// Twilio: ringing + in-progress (conference model supports rejoin)
|
||||
const showJoinButton = computed(() => {
|
||||
if (isWhatsappCall.value) {
|
||||
return status.value === VOICE_CALL_STATUS.RINGING;
|
||||
}
|
||||
return [VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes(
|
||||
status.value
|
||||
);
|
||||
});
|
||||
|
||||
const joinButtonLabel = computed(() => {
|
||||
if (isWhatsappCall.value && status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return 'CONVERSATION.VOICE_CALL.ACCEPT_CALL';
|
||||
}
|
||||
return 'CONVERSATION.VOICE_CALL.JOIN_CALL';
|
||||
});
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
@@ -53,6 +95,15 @@ const labelKey = computed(() => {
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
if (
|
||||
acceptedBy.value?.name &&
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS, VOICE_CALL_STATUS.COMPLETED].includes(
|
||||
status.value
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
@@ -64,12 +115,39 @@ const subtextKey = computed(() => {
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const answeredByText = computed(() => {
|
||||
if (!acceptedBy.value?.name) return '';
|
||||
return acceptedBy.value.name;
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
const handleJoinCall = async () => {
|
||||
if (isJoining.value) return;
|
||||
isJoining.value = true;
|
||||
|
||||
try {
|
||||
if (isWhatsappCall.value) {
|
||||
const result = await acceptWhatsappCallById(waCallId.value);
|
||||
if (result?.success && result.call) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: result.call.conversationId },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[WhatsApp Call] Accept from bubble failed:', err);
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -90,14 +168,75 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<div class="flex overflow-hidden flex-col flex-grow gap-0.5">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
<span v-if="answeredByText" class="text-xs text-n-slate-11">
|
||||
{{
|
||||
$t('CONVERSATION.VOICE_CALL.ANSWERED_BY', {
|
||||
name: answeredByText,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="subtextKey" class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="formattedDuration && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="text-xs text-n-slate-10"
|
||||
>
|
||||
{{ formattedDuration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showJoinButton"
|
||||
:disabled="isJoining"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-n-teal-9 hover:bg-n-teal-10 rounded-lg transition-colors shrink-0"
|
||||
:class="{ 'opacity-75 cursor-wait': isJoining }"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
<i
|
||||
v-if="isJoining"
|
||||
class="i-ph-circle-notch-bold text-sm animate-spin"
|
||||
/>
|
||||
<i v-else class="i-ph-phone-bold text-sm" />
|
||||
{{ $t(joinButtonLabel) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="recordingUrl && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="px-3 pb-2"
|
||||
>
|
||||
<audio controls class="w-full h-8" :src="recordingUrl">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.AUDIO_NOT_SUPPORTED') }}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="transcript && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="px-3 pb-3"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 text-xs text-n-slate-11 hover:text-n-slate-12 transition-colors"
|
||||
@click="showTranscript = !showTranscript"
|
||||
>
|
||||
<i
|
||||
class="text-sm"
|
||||
:class="
|
||||
showTranscript ? 'i-ph-caret-up-bold' : 'i-ph-caret-down-bold'
|
||||
"
|
||||
/>
|
||||
{{ $t('CONVERSATION.VOICE_CALL.TRANSCRIPT') }}
|
||||
</button>
|
||||
<p
|
||||
v-if="showTranscript"
|
||||
class="mt-1 text-xs leading-relaxed text-n-slate-11 whitespace-pre-wrap"
|
||||
>
|
||||
{{ transcript }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useWhatsappCallsStore,
|
||||
setOutboundCallProperty,
|
||||
} from 'dashboard/stores/whatsappCalls';
|
||||
import { startCallRecording } from 'dashboard/composables/useWhatsappCallSession';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
@@ -128,6 +129,7 @@ const initiateWhatsappCall = async () => {
|
||||
isInitiatingCall.value = true;
|
||||
let pc = null;
|
||||
let localStream = null;
|
||||
let waCallId = null;
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
pc = new RTCPeerConnection({
|
||||
@@ -146,6 +148,8 @@ const initiateWhatsappCall = async () => {
|
||||
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 = () => {
|
||||
@@ -186,6 +190,7 @@ const initiateWhatsappCall = async () => {
|
||||
});
|
||||
|
||||
const outboundCallId = response.data?.call_id;
|
||||
waCallId = response.data?.id;
|
||||
setOutboundCallProperty('pc', pc);
|
||||
setOutboundCallProperty('stream', localStream);
|
||||
setOutboundCallProperty('callId', outboundCallId);
|
||||
|
||||
@@ -5,18 +5,258 @@ import {
|
||||
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.waCallId === 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,
|
||||
waCallId: data.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();
|
||||
|
||||
// WebRTC internals
|
||||
let peerConnection = null;
|
||||
let localStream = null;
|
||||
const remoteAudio = ref(null);
|
||||
|
||||
// UI state
|
||||
const isAccepting = ref(false);
|
||||
const isMuted = ref(false);
|
||||
const callError = ref(null);
|
||||
@@ -44,33 +284,25 @@ export function useWhatsappCallSession() {
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
const cleanupWebRTC = () => {
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(track => track.stop());
|
||||
localStream = null;
|
||||
}
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
peerConnection = null;
|
||||
}
|
||||
if (remoteAudio.value) {
|
||||
remoteAudio.value.srcObject = null;
|
||||
// Remove dynamically created audio element from DOM
|
||||
if (remoteAudio.value.parentNode) {
|
||||
remoteAudio.value.parentNode.removeChild(remoteAudio.value);
|
||||
}
|
||||
remoteAudio.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Register cleanup callback so store can trigger WebRTC teardown on external events
|
||||
// Register cleanup so external call-end events can teardown WebRTC
|
||||
callsStore.registerCleanupCallback(() => {
|
||||
cleanupWebRTC();
|
||||
stopAndUploadRecording();
|
||||
cleanupInboundWebRTC();
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
});
|
||||
|
||||
// Start timer when an outbound call becomes connected (SDP answer received)
|
||||
// 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' &&
|
||||
@@ -82,49 +314,8 @@ export function useWhatsappCallSession() {
|
||||
});
|
||||
|
||||
/**
|
||||
* 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. Waits for ICE gathering to complete (Meta needs full SDP, no trickle ICE)
|
||||
* 6. Posts the complete SDP answer to Chatwoot backend → Meta API
|
||||
* 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;
|
||||
@@ -132,74 +323,9 @@ export function useWhatsappCallSession() {
|
||||
callError.value = null;
|
||||
|
||||
try {
|
||||
// 1. Get microphone access
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
// 2. Build ICE config
|
||||
const iceServers = call.iceServers?.length
|
||||
? call.iceServers
|
||||
: [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
// 3. Create RTCPeerConnection
|
||||
peerConnection = new RTCPeerConnection({ iceServers });
|
||||
|
||||
// 4. Add local audio tracks
|
||||
localStream.getTracks().forEach(track => {
|
||||
peerConnection.addTrack(track, localStream);
|
||||
});
|
||||
|
||||
// 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(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[WhatsApp Call] Audio autoplay blocked:', e);
|
||||
});
|
||||
} else {
|
||||
const audio = document.createElement('audio');
|
||||
audio.srcObject = stream;
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
remoteAudio.value = audio;
|
||||
}
|
||||
};
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// 8. Create SDP answer
|
||||
const answer = await peerConnection.createAnswer();
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
|
||||
// 9. Wait for ICE gathering to complete so SDP has all candidates
|
||||
await waitForIceGatheringComplete(peerConnection);
|
||||
|
||||
// 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
|
||||
await doAcceptCall(call);
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.setActiveCall({
|
||||
...call,
|
||||
});
|
||||
|
||||
callsStore.setActiveCall({ ...call });
|
||||
durationTimer.start();
|
||||
} catch (err) {
|
||||
callError.value =
|
||||
@@ -208,7 +334,7 @@ export function useWhatsappCallSession() {
|
||||
: t('WHATSAPP_CALL.CALL_FAILED');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[WhatsApp Call] acceptCall error:', err);
|
||||
cleanupWebRTC();
|
||||
cleanupInboundWebRTC();
|
||||
} finally {
|
||||
isAccepting.value = false;
|
||||
}
|
||||
@@ -228,14 +354,14 @@ export function useWhatsappCallSession() {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
|
||||
stopAndUploadRecording(call.id);
|
||||
|
||||
try {
|
||||
await WhatsappCallsAPI.terminate(call.id);
|
||||
} catch {
|
||||
// Best effort — always cleanup locally
|
||||
// Best effort
|
||||
} finally {
|
||||
// For inbound calls, cleanup composable-managed WebRTC
|
||||
cleanupWebRTC();
|
||||
// For outbound calls, cleanup module-scoped WebRTC via store
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.handleCallEnded(call.callId);
|
||||
callsStore.clearActiveCall();
|
||||
durationTimer.stop();
|
||||
@@ -244,9 +370,7 @@ export function useWhatsappCallSession() {
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
// For inbound calls, localStream is managed by this composable.
|
||||
// For outbound calls, the stream is in module-scoped outbound state.
|
||||
const stream = localStream || getOutboundCallState().stream;
|
||||
const stream = inboundStream || getOutboundCallState().stream;
|
||||
if (!stream) return;
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
if (!audioTrack) return;
|
||||
@@ -259,6 +383,7 @@ export function useWhatsappCallSession() {
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
durationTimer.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ const isVoiceCallMessage = message => {
|
||||
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
|
||||
};
|
||||
|
||||
const isWhatsappCall = message => {
|
||||
return message?.content_attributes?.data?.call_source === 'whatsapp';
|
||||
};
|
||||
|
||||
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||
};
|
||||
@@ -36,6 +40,10 @@ function extractCallData(message) {
|
||||
export function handleVoiceCallCreated(message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
// WhatsApp calls are managed by their own store (whatsappCalls),
|
||||
// don't add them to the Twilio calls store.
|
||||
if (isWhatsappCall(message)) return;
|
||||
|
||||
const { callSid, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
@@ -56,14 +64,18 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
const { callSid, status, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||
|
||||
// Vuex message/conversation status updates apply to all call sources
|
||||
const callInfo = { conversationId, callStatus: status };
|
||||
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||
|
||||
// Twilio-specific store interactions — skip for WhatsApp calls
|
||||
if (isWhatsappCall(message)) return;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||
|
||||
const isNewCall =
|
||||
status === 'ringing' &&
|
||||
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||
|
||||
@@ -82,7 +82,13 @@
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered"
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"ANSWERED_BY": "Answered by {name}",
|
||||
"DURATION": "{duration}",
|
||||
"ACCEPT_CALL": "Accept",
|
||||
"JOIN_CALL": "Join",
|
||||
"TRANSCRIPT": "Transcript",
|
||||
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback."
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
|
||||
+2
-1
@@ -297,11 +297,12 @@ Rails.application.routes.draw do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
resources :whatsapp_calls, only: [] do
|
||||
resources :whatsapp_calls, only: [:show] do
|
||||
member do
|
||||
post :accept
|
||||
post :reject
|
||||
post :terminate
|
||||
post :upload_recording
|
||||
end
|
||||
collection do
|
||||
post :initiate
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddMessageIdToWhatsappCalls < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_reference :whatsapp_calls, :message, null: true, foreign_key: true, index: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddTranscriptToWhatsappCalls < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :whatsapp_calls, :transcript, :text
|
||||
end
|
||||
end
|
||||
+5
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_03_20_074636) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_03_23_110000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -1289,9 +1289,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_20_074636) do
|
||||
t.jsonb "meta", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "message_id"
|
||||
t.text "transcript"
|
||||
t.index ["account_id", "conversation_id"], name: "index_whatsapp_calls_on_account_id_and_conversation_id"
|
||||
t.index ["call_id"], name: "index_whatsapp_calls_on_call_id", unique: true
|
||||
t.index ["inbox_id", "status"], name: "index_whatsapp_calls_on_inbox_id_and_status"
|
||||
t.index ["message_id"], name: "index_whatsapp_calls_on_message_id"
|
||||
end
|
||||
|
||||
create_table "working_hours", force: :cascade do |t|
|
||||
@@ -1313,6 +1316,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_20_074636) do
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "inboxes", "portals"
|
||||
add_foreign_key "whatsapp_calls", "messages"
|
||||
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
|
||||
on("accounts").
|
||||
after(:insert).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
||||
module Enterprise::Messages::MessageBuilder
|
||||
private
|
||||
|
||||
INCOMING_ALLOWED_CHANNEL_TYPES = %w[Channel::Voice Channel::Whatsapp].freeze
|
||||
|
||||
def message_type
|
||||
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
|
||||
return @message_type if @message_type == 'incoming' && INCOMING_ALLOWED_CHANNEL_TYPES.include?(@conversation.inbox.channel_type)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_whatsapp_call_enabled
|
||||
before_action :set_whatsapp_call, only: [:accept, :reject, :terminate]
|
||||
before_action :set_whatsapp_call, only: [:show, :accept, :reject, :terminate, :upload_recording]
|
||||
|
||||
def show
|
||||
render json: {
|
||||
id: @whatsapp_call.id,
|
||||
call_id: @whatsapp_call.call_id,
|
||||
status: @whatsapp_call.status,
|
||||
direction: @whatsapp_call.direction,
|
||||
conversation_id: @whatsapp_call.conversation_id,
|
||||
inbox_id: @whatsapp_call.inbox_id,
|
||||
message_id: @whatsapp_call.message_id,
|
||||
sdp_offer: @whatsapp_call.ringing? ? @whatsapp_call.sdp_offer : nil,
|
||||
ice_servers: @whatsapp_call.ice_servers,
|
||||
caller: caller_info
|
||||
}
|
||||
end
|
||||
|
||||
def accept
|
||||
sdp_answer = params[:sdp_answer]
|
||||
return render json: { error: 'sdp_answer is required' }, status: :unprocessable_entity if sdp_answer.blank?
|
||||
|
||||
wa_call = Whatsapp::CallService.new(wa_call: @whatsapp_call, agent: current_user).pre_accept_and_accept(sdp_answer)
|
||||
render json: { id: wa_call.id, status: wa_call.status }
|
||||
render json: { id: wa_call.id, status: wa_call.status, message_id: wa_call.message_id }
|
||||
rescue Whatsapp::CallErrors::NotRinging, Whatsapp::CallErrors::AlreadyAccepted => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
@@ -31,13 +46,26 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
render json: { error: 'Failed to terminate call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def upload_recording
|
||||
return render json: { error: 'No recording file provided' }, status: :unprocessable_entity if params[:recording].blank?
|
||||
return render json: { error: 'Call is not ended' }, status: :unprocessable_entity unless @whatsapp_call.terminal?
|
||||
|
||||
attach_recording_and_enqueue_transcription
|
||||
render json: { id: @whatsapp_call.id, status: 'uploaded' }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP CALL] upload_recording failed: #{e.message}"
|
||||
render json: { error: 'Failed to upload recording' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def initiate
|
||||
conversation = current_account.conversations.find(params[:conversation_id])
|
||||
error = validate_whatsapp_calling(conversation)
|
||||
return render json: { error: error }, status: :unprocessable_entity if error
|
||||
|
||||
wa_call = create_outbound_call(conversation)
|
||||
render json: { status: 'calling', call_id: wa_call.call_id, id: wa_call.id }
|
||||
message = Whatsapp::CallMessageBuilder.create!(conversation: conversation, wa_call: wa_call, user: current_user)
|
||||
wa_call.update!(message_id: message.id)
|
||||
render json: { status: 'calling', call_id: wa_call.call_id, id: wa_call.id, message_id: message.id }
|
||||
rescue Whatsapp::CallErrors::NoCallPermission
|
||||
handle_no_call_permission(conversation)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
@@ -96,4 +124,17 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Call not found' }, status: :not_found
|
||||
end
|
||||
|
||||
def attach_recording_and_enqueue_transcription
|
||||
@whatsapp_call.recording.attach(params[:recording])
|
||||
Whatsapp::CallMessageBuilder.update_recording_url!(wa_call: @whatsapp_call)
|
||||
Whatsapp::CallTranscriptionJob.perform_later(@whatsapp_call.id)
|
||||
end
|
||||
|
||||
def caller_info
|
||||
contact = @whatsapp_call.conversation&.contact
|
||||
return {} unless contact
|
||||
|
||||
{ name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class Whatsapp::CallTranscriptionJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3
|
||||
discard_on Faraday::BadRequestError do |job, error|
|
||||
Rails.logger.warn("[WHATSAPP CALL] Discarding transcription job: call_id=#{job.arguments.first}, status=#{error.response&.dig(:status)}")
|
||||
end
|
||||
|
||||
def perform(whatsapp_call_id)
|
||||
wa_call = WhatsappCall.find_by(id: whatsapp_call_id)
|
||||
return if wa_call.blank? || !wa_call.recording.attached?
|
||||
|
||||
Whatsapp::CallTranscriptionService.new(wa_call).perform
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,9 @@ class WhatsappCall < ApplicationRecord
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation
|
||||
belongs_to :accepted_by_agent, class_name: 'User', optional: true
|
||||
belongs_to :message, optional: true
|
||||
|
||||
has_one_attached :recording
|
||||
|
||||
validates :call_id, presence: true, uniqueness: true
|
||||
validates :direction, inclusion: { in: DIRECTIONS }
|
||||
@@ -33,4 +36,10 @@ class WhatsappCall < ApplicationRecord
|
||||
def ice_servers
|
||||
meta['ice_servers'] || []
|
||||
end
|
||||
|
||||
def recording_url
|
||||
return unless recording.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_blob_path(recording, only_path: true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
class Whatsapp::CallMessageBuilder
|
||||
WHATSAPP_TO_VOICE_STATUS = {
|
||||
'ringing' => 'ringing',
|
||||
'accepted' => 'in-progress',
|
||||
'rejected' => 'failed',
|
||||
'missed' => 'no-answer',
|
||||
'ended' => 'completed',
|
||||
'failed' => 'failed'
|
||||
}.freeze
|
||||
|
||||
def self.create!(conversation:, wa_call:, user: nil)
|
||||
new(conversation: conversation, wa_call: wa_call, user: user).create!
|
||||
end
|
||||
|
||||
def self.update_status!(wa_call:, status: nil, agent: nil, duration_seconds: nil)
|
||||
new(conversation: wa_call.conversation, wa_call: wa_call).update_status!(
|
||||
status: status, agent: agent, duration_seconds: duration_seconds
|
||||
)
|
||||
end
|
||||
|
||||
def self.update_recording_url!(wa_call:)
|
||||
message = wa_call.message
|
||||
return unless message
|
||||
|
||||
data = (message.content_attributes || {}).dup
|
||||
data['data'] ||= {}
|
||||
data['data']['recording_url'] = wa_call.recording_url
|
||||
message.update!(content_attributes: data)
|
||||
end
|
||||
|
||||
def initialize(conversation:, wa_call:, user: nil)
|
||||
@conversation = conversation
|
||||
@wa_call = wa_call
|
||||
@user = user
|
||||
end
|
||||
|
||||
def create!
|
||||
params = {
|
||||
content: 'WhatsApp Call',
|
||||
message_type: message_type,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { 'data' => build_data_payload }
|
||||
}
|
||||
|
||||
Messages::MessageBuilder.new(sender, conversation, params).perform
|
||||
end
|
||||
|
||||
def update_status!(status:, agent: nil, duration_seconds: nil)
|
||||
message = wa_call.message
|
||||
return unless message
|
||||
|
||||
data = (message.content_attributes || {}).dup
|
||||
data['data'] ||= {}
|
||||
data['data']['status'] = map_status(status) if status
|
||||
data['data']['accepted_by'] = { 'id' => agent.id, 'name' => agent.name } if agent
|
||||
data['data']['duration_seconds'] = duration_seconds if duration_seconds
|
||||
|
||||
message.update!(content_attributes: data)
|
||||
message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversation, :wa_call, :user
|
||||
|
||||
def build_data_payload
|
||||
{
|
||||
'call_sid' => wa_call.call_id,
|
||||
'status' => map_status(wa_call.status),
|
||||
'call_direction' => wa_call.direction,
|
||||
'call_source' => 'whatsapp',
|
||||
'wa_call_id' => wa_call.id,
|
||||
'from_number' => from_number,
|
||||
'to_number' => to_number,
|
||||
'meta' => { 'created_at' => Time.zone.now.to_i }
|
||||
}
|
||||
end
|
||||
|
||||
def message_type
|
||||
wa_call.direction == 'outbound' ? 'outgoing' : 'incoming'
|
||||
end
|
||||
|
||||
def sender
|
||||
return user if wa_call.direction == 'outbound' && user
|
||||
|
||||
conversation.contact
|
||||
end
|
||||
|
||||
def from_number
|
||||
if wa_call.direction == 'inbound'
|
||||
conversation.contact&.phone_number
|
||||
else
|
||||
conversation.inbox.channel&.phone_number
|
||||
end
|
||||
end
|
||||
|
||||
def to_number
|
||||
if wa_call.direction == 'inbound'
|
||||
conversation.inbox.channel&.phone_number
|
||||
else
|
||||
conversation.contact&.phone_number
|
||||
end
|
||||
end
|
||||
|
||||
def map_status(status)
|
||||
WHATSAPP_TO_VOICE_STATUS[status] || status
|
||||
end
|
||||
end
|
||||
@@ -24,6 +24,8 @@ class Whatsapp::CallService
|
||||
)
|
||||
end
|
||||
|
||||
Whatsapp::CallMessageBuilder.update_status!(wa_call: wa_call, status: 'accepted', agent: agent)
|
||||
update_conversation_call_status('in-progress')
|
||||
broadcast_accepted
|
||||
wa_call
|
||||
end
|
||||
@@ -37,6 +39,8 @@ class Whatsapp::CallService
|
||||
Rails.logger.error "[WHATSAPP CALL] reject_call API returned false for call #{wa_call.call_id}" unless success
|
||||
|
||||
wa_call.update!(status: 'rejected')
|
||||
Whatsapp::CallMessageBuilder.update_status!(wa_call: wa_call, status: 'rejected')
|
||||
update_conversation_call_status('failed')
|
||||
broadcast_call_ended
|
||||
wa_call
|
||||
end
|
||||
@@ -49,6 +53,8 @@ class Whatsapp::CallService
|
||||
Rails.logger.error "[WHATSAPP CALL] terminate_call API returned false for call #{wa_call.call_id}" unless success
|
||||
|
||||
wa_call.update!(status: 'ended')
|
||||
Whatsapp::CallMessageBuilder.update_status!(wa_call: wa_call, status: 'ended')
|
||||
update_conversation_call_status('completed')
|
||||
broadcast_call_ended
|
||||
wa_call
|
||||
end
|
||||
@@ -67,6 +73,12 @@ class Whatsapp::CallService
|
||||
sdp.gsub('a=setup:actpass', 'a=setup:active')
|
||||
end
|
||||
|
||||
def update_conversation_call_status(mapped_status)
|
||||
conversation = wa_call.conversation
|
||||
attrs = (conversation.additional_attributes || {}).merge('call_status' => mapped_status)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def broadcast_accepted
|
||||
payload = {
|
||||
event: 'whatsapp_call.accepted',
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
class Whatsapp::CallTranscriptionService < Llm::LegacyBaseOpenAiService
|
||||
WHISPER_MODEL = 'whisper-1'.freeze
|
||||
|
||||
attr_reader :wa_call, :account
|
||||
|
||||
def initialize(wa_call)
|
||||
super()
|
||||
@wa_call = wa_call
|
||||
@account = wa_call.account
|
||||
end
|
||||
|
||||
def perform
|
||||
return { error: 'Transcription not available' } unless can_transcribe?
|
||||
return { error: 'No recording attached' } unless wa_call.recording.attached?
|
||||
|
||||
transcribed_text = transcribe_audio
|
||||
update_call_and_message(transcribed_text)
|
||||
{ success: true, transcript: transcribed_text }
|
||||
rescue Faraday::UnauthorizedError
|
||||
Rails.logger.warn('[WHATSAPP CALL] Skipping transcription: OpenAI configuration is invalid (401)')
|
||||
{ error: 'OpenAI configuration is invalid' }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_transcribe?
|
||||
account.feature_enabled?('captain_integration') &&
|
||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||
end
|
||||
|
||||
def transcribe_audio
|
||||
temp_file_path = fetch_audio_file
|
||||
transcribed_text = nil
|
||||
|
||||
File.open(temp_file_path, 'rb') do |file|
|
||||
response = @client.audio.transcribe(
|
||||
parameters: { model: WHISPER_MODEL, file: file, temperature: 0.4 }
|
||||
)
|
||||
transcribed_text = response['text']
|
||||
end
|
||||
|
||||
transcribed_text
|
||||
ensure
|
||||
FileUtils.rm_f(temp_file_path) if temp_file_path.present?
|
||||
end
|
||||
|
||||
def fetch_audio_file
|
||||
blob = wa_call.recording.blob
|
||||
temp_dir = Rails.root.join('tmp/uploads/call-transcriptions')
|
||||
FileUtils.mkdir_p(temp_dir)
|
||||
|
||||
extension = extension_from_content_type(blob.content_type)
|
||||
temp_file_path = File.join(temp_dir, "#{blob.key}.#{extension}")
|
||||
|
||||
File.open(temp_file_path, 'wb') do |file|
|
||||
blob.open { |blob_file| IO.copy_stream(blob_file, file) }
|
||||
end
|
||||
|
||||
temp_file_path
|
||||
end
|
||||
|
||||
def update_call_and_message(transcribed_text)
|
||||
return if transcribed_text.blank?
|
||||
|
||||
wa_call.update!(transcript: transcribed_text)
|
||||
account.increment_response_usage
|
||||
|
||||
message = wa_call.message
|
||||
return unless message
|
||||
|
||||
data = (message.content_attributes || {}).dup
|
||||
data['data'] ||= {}
|
||||
data['data']['transcript'] = transcribed_text
|
||||
data['data']['recording_url'] = wa_call.recording_url
|
||||
message.update!(content_attributes: data)
|
||||
end
|
||||
|
||||
def extension_from_content_type(content_type)
|
||||
subtype = content_type.to_s.downcase.split(';').first.to_s.split('/').last.to_s
|
||||
{ 'webm' => 'webm', 'ogg' => 'ogg', 'x-m4a' => 'm4a', 'x-wav' => 'wav', 'mpeg' => 'mp3' }.fetch(subtype, 'webm')
|
||||
end
|
||||
end
|
||||
@@ -33,6 +33,8 @@ class Whatsapp::IncomingCallService
|
||||
Rails.logger.info "[WHATSAPP CALL] call_connect for existing call #{call_id} (direction=#{direction})"
|
||||
sdp_answer = fix_sdp_setup(call_payload.dig(:session, :sdp))
|
||||
existing_call.update!(status: 'accepted', meta: existing_call.meta.merge('sdp_answer' => sdp_answer))
|
||||
Whatsapp::CallMessageBuilder.update_status!(wa_call: existing_call, status: 'accepted')
|
||||
update_conversation_call_status(existing_call.conversation, 'in-progress', direction)
|
||||
broadcast_outbound_call_connected(existing_call, sdp_answer)
|
||||
return
|
||||
end
|
||||
@@ -44,12 +46,20 @@ class Whatsapp::IncomingCallService
|
||||
return unless conversation
|
||||
|
||||
wa_call = create_call_record(call_payload, conversation, direction)
|
||||
create_call_activity_message(conversation, 'incoming_call', direction)
|
||||
create_voice_call_message(conversation, wa_call)
|
||||
update_conversation_call_status(conversation, 'ringing', direction)
|
||||
broadcast_incoming_call(wa_call, contact, call_payload.dig(:session, :sdp))
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
Rails.logger.warn "[WHATSAPP CALL] Duplicate call_id received: #{call_id}"
|
||||
end
|
||||
|
||||
def create_voice_call_message(conversation, wa_call, user: nil)
|
||||
message = Whatsapp::CallMessageBuilder.create!(conversation: conversation, wa_call: wa_call, user: user)
|
||||
wa_call.update!(message_id: message.id)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP CALL] Failed to create voice_call message: #{e.message}"
|
||||
end
|
||||
|
||||
def create_call_record(call_payload, conversation, direction)
|
||||
WhatsappCall.create!(
|
||||
account: inbox.account,
|
||||
@@ -70,15 +80,20 @@ class Whatsapp::IncomingCallService
|
||||
wa_call = WhatsappCall.find_by(call_id: call_id)
|
||||
return unless wa_call
|
||||
|
||||
final_status = wa_call.accepted? ? 'ended' : 'missed'
|
||||
# Determine if the call was answered: check accepted status, duration > 0,
|
||||
# or accepted_by_agent_id presence (handles webhook race conditions)
|
||||
was_answered = wa_call.accepted? || duration.to_i.positive? || wa_call.accepted_by_agent_id.present?
|
||||
final_status = was_answered ? 'ended' : 'missed'
|
||||
wa_call.update!(
|
||||
status: final_status,
|
||||
duration_seconds: duration,
|
||||
end_reason: end_reason
|
||||
)
|
||||
|
||||
call_event = duration.to_i.positive? ? 'call_ended' : 'call_missed'
|
||||
create_call_activity_message(wa_call.conversation, call_event, wa_call.direction, duration: duration)
|
||||
agent = wa_call.accepted_by_agent if wa_call.accepted_by_agent_id.present?
|
||||
Whatsapp::CallMessageBuilder.update_status!(wa_call: wa_call, status: final_status, agent: agent, duration_seconds: duration)
|
||||
mapped = Whatsapp::CallMessageBuilder::WHATSAPP_TO_VOICE_STATUS[final_status] || final_status
|
||||
update_conversation_call_status(wa_call.conversation, mapped, wa_call.direction)
|
||||
broadcast_call_ended(wa_call)
|
||||
end
|
||||
|
||||
@@ -113,36 +128,12 @@ class Whatsapp::IncomingCallService
|
||||
)
|
||||
end
|
||||
|
||||
def create_call_activity_message(conversation, event, direction, duration: nil)
|
||||
content = call_activity_content(event, direction, duration)
|
||||
conversation.messages.create!(
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: content,
|
||||
content_attributes: {
|
||||
call_event: event,
|
||||
call_direction: direction,
|
||||
call_duration_seconds: duration
|
||||
}
|
||||
def update_conversation_call_status(conversation, call_status, direction)
|
||||
attrs = (conversation.additional_attributes || {}).merge(
|
||||
'call_status' => call_status,
|
||||
'call_direction' => direction
|
||||
)
|
||||
end
|
||||
|
||||
def call_activity_content(event, direction, duration)
|
||||
case event
|
||||
when 'incoming_call'
|
||||
direction == 'inbound' ? 'Incoming WhatsApp call' : 'Outgoing WhatsApp call'
|
||||
when 'call_ended' then "WhatsApp call ended — #{format_duration(duration)}"
|
||||
when 'call_missed' then 'Missed WhatsApp call'
|
||||
else 'WhatsApp call'
|
||||
end
|
||||
end
|
||||
|
||||
def format_duration(seconds)
|
||||
return '0s' if seconds.nil? || seconds.zero?
|
||||
|
||||
mins, secs = seconds.divmod(60)
|
||||
mins.positive? ? "#{mins}m #{secs}s" : "#{secs}s"
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def broadcast_incoming_call(wa_call, contact, sdp_offer)
|
||||
|
||||
Reference in New Issue
Block a user