feat(whatsapp-call): add server-side WebRTC media server for call persistence

Implements a Pion Go B2BUA media server sidecar that sits between Meta's
WhatsApp Cloud API and the agent's browser, enabling call persistence
across page reloads, server-side recording, multi-participant support,
audio injection, and AI integration readiness.

Go Media Server (enterprise/media-server/):
- Pion WebRTC v4 B2BUA with Peer A (Meta) and Peer B (Agent) connections
- Real-time OGG/Opus recording with crash recovery
- Audio bridge with multi-peer fan-out and AudioConsumer plugin interface
- Audio injection from OGG files with loop support for hold music
- Session manager with graceful shutdown and orphaned recording recovery
- HTTP API with Bearer token auth, health checks, and metrics

Rails Integration:
- Whatsapp::MediaServerClient HTTP client for Go sidecar communication
- Dual-mode CallService: legacy browser-direct and server-relay paths
- Media server callback controller for agent disconnect/recording/terminate
- CallRecordingFetchJob: downloads OGG from Go server to ActiveStorage/S3
- CallCleanupJob: sweeps stale ringing and in-progress calls
- New endpoints: active, agent_answer, reconnect, join, play_audio
- DB migration: media_session_id column with indexes

Frontend:
- Dual-mode composable auto-detecting legacy vs server-relay
- handleAgentOffer() for receiving SDP from media server
- useCallReconnection composable for page reload recovery
- Removed terminateCallOnUnload in server-relay mode
- Simplified outbound call flow in ConversationHeader
- New ActionCable event: whatsapp_call.agent_offer

Documentation:
- Server-side WebRTC architecture spec (1505 lines)
- Implementation plan with 119 trackable checklist items
- Feature spec, PR breakdown, and relay architecture docs
This commit is contained in:
tds-1
2026-04-21 04:47:53 +00:00
committed by root
parent f49032610f
commit 5e5dc21f2f
44 changed files with 8827 additions and 137 deletions
@@ -1,9 +1,11 @@
<script setup>
import { watch, onUnmounted } from 'vue';
import { watch, onUnmounted, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const { t } = useI18n();
const router = useRouter();
@@ -16,6 +18,7 @@ const {
isAccepting,
isMuted,
isOutboundRinging,
isReconnecting,
callError,
formattedCallDuration,
acceptCall,
@@ -23,8 +26,27 @@ const {
endActiveCall,
toggleMute,
dismissIncomingCall,
startDurationTimer,
} = useWhatsappCallSession();
// In server-relay mode, the timer starts when the Peer B WebRTC handshake
// completes (not when the agent clicks accept). Listen for this event.
const onAgentWebRTCConnected = () => {
startDurationTimer();
};
const onPermissionGranted = ({ contactName }) => {
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.PERMISSION_GRANTED', { contactName }),
type: 'success',
});
};
onMounted(() => {
emitter.on('whatsapp_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.on('whatsapp_call:permission_granted', onPermissionGranted);
});
// Auto-dismiss ringing calls after 30 seconds
const autoRejectTimers = new Map();
@@ -77,6 +99,8 @@ watch(
onUnmounted(() => {
autoRejectTimers.forEach(timer => clearTimeout(timer));
autoRejectTimers.clear();
emitter.off('whatsapp_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.off('whatsapp_call:permission_granted', onPermissionGranted);
});
</script>
@@ -173,14 +197,20 @@ onUnmounted(() => {
<p
class="text-sm"
:class="
isOutboundRinging ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
isOutboundRinging || isReconnecting
? 'text-n-slate-11'
: 'font-mono text-n-teal-9'
"
>
{{
isOutboundRinging
? t('WHATSAPP_CALL.RINGING')
: formattedCallDuration
}}
<template v-if="isReconnecting">
{{ t('WHATSAPP_CALL.RECONNECTING') }}
</template>
<template v-else-if="isOutboundRinging">
{{ t('WHATSAPP_CALL.RINGING') }}
</template>
<template v-else>
{{ formattedCallDuration }}
</template>
</p>
</div>
<div class="flex shrink-0 gap-2">
@@ -104,32 +104,118 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const canInitiateWhatsappCall = computed(() => {
if (!isAWhatsAppCloudChannel.value) return false;
if (!inbox.value?.calling_enabled) return false;
// Block if there's already an active or ringing WhatsApp call
if (whatsappCallsStore.hasWhatsappCall) return false;
return true;
});
// Detect if the media server is enabled for this inbox.
// When enabled, the browser should NOT create its own WebRTC offer.
const isMediaServerEnabled = computed(
() => !!inbox.value?.media_server_enabled
);
const waitForOutboundIceGathering = pc =>
new Promise(resolve => {
new Promise((resolve, reject) => {
if (pc.iceGatheringState === 'complete') {
resolve();
return;
}
const timeout = setTimeout(() => resolve(), 10000);
let timeout = null;
const cleanup = () => {
clearTimeout(timeout);
pc.onicegatheringstatechange = null;
pc.oniceconnectionstatechange = null;
};
timeout = setTimeout(() => {
cleanup();
resolve();
}, 10000);
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(timeout);
cleanup();
resolve();
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
cleanup();
reject(new Error('ICE connection failed'));
}
};
});
const initiateWhatsappCall = async () => {
/**
* Server-relay mode: POST /initiate without SDP. The media server creates
* Peer A (Meta-side) and later sends the agent Peer B offer via ActionCable
* (whatsapp_call.outbound_connected with sdp_offer).
*/
const initiateServerRelayCall = async () => {
if (isInitiatingCall.value || !currentChat.value?.id) return;
isInitiatingCall.value = true;
try {
const response = await WhatsappCallsAPI.initiate(currentChat.value.id);
const callStatus = response.data?.status;
if (
callStatus === 'permission_requested' ||
callStatus === 'permission_pending'
) {
const message =
callStatus === 'permission_requested'
? t('WHATSAPP_CALL.PERMISSION_REQUESTED')
: t('WHATSAPP_CALL.PERMISSION_PENDING');
emitter.emit(BUS_EVENTS.SHOW_ALERT, { message, type: 'info' });
return;
}
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.CALLING'),
type: 'success',
});
const outboundCallId = response.data?.call_id;
// Set active call — WebRTC setup happens when ActionCable delivers agent_offer
whatsappCallsStore.setActiveCall({
id: response.data?.id,
callId: outboundCallId,
direction: 'outbound',
status: 'ringing',
serverRelay: true,
conversationId: currentChat.value.id,
caller: {
name: currentContact.value?.name,
phone: currentContact.value?.phone_number,
avatar: currentContact.value?.thumbnail,
},
});
} catch (err) {
const errorMessage =
err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED');
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: errorMessage,
type: 'error',
});
} finally {
isInitiatingCall.value = false;
}
};
/**
* Legacy mode: Browser creates RTCPeerConnection, generates SDP offer,
* sends it to backend which forwards to Meta.
*/
const initiateLegacyCall = async () => {
if (isInitiatingCall.value || !currentChat.value?.id) return;
isInitiatingCall.value = true;
let pc = null;
let localStream = null;
let waCallId = null;
let recordCallId = null;
try {
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
pc = new RTCPeerConnection({
@@ -137,7 +223,6 @@ const initiateWhatsappCall = async () => {
});
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
// Handle remote audio from Meta — ontrack fires when the callee picks up
pc.ontrack = event => {
const [stream] = event.streams;
if (!stream) return;
@@ -146,21 +231,13 @@ const initiateWhatsappCall = async () => {
audio.autoplay = true;
document.body.appendChild(audio);
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 = () => {
// eslint-disable-next-line no-console
console.log('[WhatsApp Call] Outbound ICE state:', pc.iceConnectionState);
if (recordCallId) startCallRecording(pc, localStream, recordCallId);
};
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;
@@ -190,18 +267,17 @@ const initiateWhatsappCall = async () => {
});
const outboundCallId = response.data?.call_id;
waCallId = response.data?.id;
recordCallId = response.data?.id;
setOutboundCallProperty('pc', pc);
setOutboundCallProperty('stream', localStream);
setOutboundCallProperty('callId', outboundCallId);
// Set active call in store so the WhatsappCallWidget renders
// Status starts as 'ringing' — updated to 'connected' when SDP answer arrives
whatsappCallsStore.setActiveCall({
id: response.data?.id,
callId: outboundCallId,
direction: 'outbound',
status: 'ringing',
serverRelay: false,
conversationId: currentChat.value.id,
caller: {
name: currentContact.value?.name,
@@ -222,6 +298,13 @@ const initiateWhatsappCall = async () => {
isInitiatingCall.value = false;
}
};
const initiateWhatsappCall = () => {
if (isMediaServerEnabled.value) {
return initiateServerRelayCall();
}
return initiateLegacyCall();
};
</script>
<template>