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
@@ -47,7 +47,7 @@ const isFailed = computed(() =>
// Call source and metadata — all camelCase due to deep transform
const isWhatsappCall = computed(() => data.value?.callSource === 'whatsapp');
const waCallId = computed(() => data.value?.callId);
const callId = computed(() => data.value?.callId);
const acceptedBy = computed(() => data.value?.acceptedBy);
const durationSeconds = computed(() => data.value?.durationSeconds);
const recordingUrl = computed(() => data.value?.recordingUrl);
@@ -64,10 +64,19 @@ const formattedDuration = computed(() => {
});
// Show join/accept button logic
// WhatsApp: only ringing (peer-to-peer WebRTC — cannot rejoin after accept)
// WhatsApp with media server: ringing + in_progress (server-relay supports rejoin)
// WhatsApp without media server: only ringing (peer-to-peer WebRTC — cannot rejoin after accept)
// Twilio: ringing + in-progress (conference model supports rejoin)
const showJoinButton = computed(() => {
if (isWhatsappCall.value) {
// Server-relay mode enables rejoining in-progress calls
const isMediaServerMode = data.value?.mediaServerEnabled;
if (isMediaServerMode) {
return [
VOICE_CALL_STATUS.RINGING,
VOICE_CALL_STATUS.IN_PROGRESS,
].includes(status.value);
}
return status.value === VOICE_CALL_STATUS.RINGING;
}
return [VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes(
@@ -133,7 +142,7 @@ const handleJoinCall = async () => {
try {
if (isWhatsappCall.value) {
const result = await acceptWhatsappCallById(waCallId.value);
const result = await acceptWhatsappCallById(callId.value);
if (result?.success && result.call) {
router.push({
name: 'inbox_conversation',