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
@@ -2,6 +2,8 @@ import { defineStore } from 'pinia';
// Module-scoped (non-reactive) state for outbound call WebRTC objects.
// These cannot be in Pinia state because RTCPeerConnection/MediaStream are not serializable.
// Used ONLY in legacy (browser-direct) mode. In server-relay mode outbound calls
// go through the same inbound WebRTC path via handleAgentOffer.
const outboundCall = { pc: null, stream: null, audio: null, callId: null };
export function getOutboundCallState() {
@@ -12,7 +14,7 @@ export function setOutboundCallProperty(key, value) {
outboundCall[key] = value;
}
function cleanupOutboundCall() {
export function cleanupOutboundCall() {
if (outboundCall.pc) outboundCall.pc.close();
if (outboundCall.stream) {
outboundCall.stream.getTracks().forEach(t => t.stop());
@@ -35,6 +37,10 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
activeCall: null,
// Cleanup callback registered by the composable — called when a call ends externally
cleanupCallback: null,
// True while the agent is reconnecting to an active call after page reload
isReconnecting: false,
// Seconds already elapsed when reconnecting — timer resumes from this offset
callTimerOffset: 0,
}),
getters: {
@@ -43,6 +49,13 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
hasWhatsappCall: state =>
state.incomingCalls.length > 0 || state.activeCall !== null,
firstIncomingCall: state => state.incomingCalls[0] || null,
// Returns true when the active call is operating through the media server
// (server-relay mode). Detected by the absence of sdpOffer in the call data
// — in legacy mode the incoming call ActionCable event includes sdpOffer.
isMediaServerEnabled() {
return this.activeCall?.serverRelay === true;
},
},
actions: {
@@ -62,6 +75,8 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
clearActiveCall() {
this.activeCall = null;
this.callTimerOffset = 0;
this.isReconnecting = false;
},
markActiveCallConnected() {
@@ -74,6 +89,14 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
this.cleanupCallback = callback;
},
setReconnecting(value) {
this.isReconnecting = value;
},
setTimerOffset(seconds) {
this.callTimerOffset = seconds;
},
handleCallAcceptedByOther(callId) {
this.removeIncomingCall(callId);
},
@@ -81,10 +104,14 @@ export const useWhatsappCallsStore = defineStore('whatsappCalls', {
handleCallEnded(callId) {
this.removeIncomingCall(callId);
if (this.activeCall?.callId === callId) {
this.activeCall = null;
// Invoke cleanup BEFORE clearing activeCall so the callback can
// check isMediaServerEnabled (which depends on activeCall.serverRelay)
if (this.cleanupCallback) {
this.cleanupCallback();
}
this.activeCall = null;
this.callTimerOffset = 0;
this.isReconnecting = false;
}
if (outboundCall.callId === callId) {
cleanupOutboundCall();