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:
@@ -8,6 +8,7 @@ import {
|
||||
useWhatsappCallsStore,
|
||||
getOutboundCallState,
|
||||
} from 'dashboard/stores/whatsappCalls';
|
||||
import { handleAgentOffer } from 'dashboard/composables/useWhatsappCallSession';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
@@ -43,6 +44,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'whatsapp_call.ended': this.onWhatsappCallEnded,
|
||||
'whatsapp_call.outbound_connected': this.onWhatsappCallOutboundConnected,
|
||||
'whatsapp_call.permission_granted': this.onWhatsappCallPermissionGranted,
|
||||
'whatsapp_call.agent_offer': this.onWhatsappCallAgentOffer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,6 +215,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onWhatsappCallIncoming = data => {
|
||||
const whatsappCallsStore = useWhatsappCallsStore();
|
||||
// In server-relay mode, sdp_offer and ice_servers are absent — the media
|
||||
// server handles WebRTC with Meta, and the browser only needs call metadata.
|
||||
whatsappCallsStore.addIncomingCall({
|
||||
id: data.id,
|
||||
callId: data.call_id,
|
||||
@@ -220,8 +224,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
inboxId: data.inbox_id,
|
||||
conversationId: data.conversation_id,
|
||||
caller: data.caller,
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
sdpOffer: data.sdp_offer || null,
|
||||
iceServers: data.ice_servers || null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -242,6 +246,31 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onWhatsappCallOutboundConnected = data => {
|
||||
const whatsappCallsStore = useWhatsappCallsStore();
|
||||
|
||||
// Server-relay mode: data contains sdp_offer (media server generated offer
|
||||
// for Peer B) instead of sdp_answer.
|
||||
if (data.sdp_offer) {
|
||||
const activeCall = whatsappCallsStore.activeCall;
|
||||
if (activeCall && activeCall.callId === data.call_id) {
|
||||
handleAgentOffer(activeCall.id, data.sdp_offer, data.ice_servers)
|
||||
.then(() => {
|
||||
whatsappCallsStore.markActiveCallConnected();
|
||||
// Emit event so the composable can start the timer
|
||||
emitter.emit('whatsapp_call:agent_webrtc_connected');
|
||||
})
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'[WhatsApp Call] Failed to handle outbound agent offer:',
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy mode: data contains sdp_answer (Meta's answer to browser's offer)
|
||||
const { pc, callId } = getOutboundCallState();
|
||||
if (pc && callId === data.call_id && data.sdp_answer) {
|
||||
pc.setRemoteDescription({ type: 'answer', sdp: data.sdp_answer }).catch(
|
||||
@@ -258,11 +287,34 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onWhatsappCallPermissionGranted = data => {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: `${data.contact_name} approved the call permission request. You can now call them.`,
|
||||
type: 'success',
|
||||
emitter.emit('whatsapp_call:permission_granted', {
|
||||
contactName: data.contact_name,
|
||||
});
|
||||
};
|
||||
|
||||
// Server-relay mode: the media server created Peer B and sent an SDP offer
|
||||
// for the agent's browser. This fires after POST /accept or POST /reconnect.
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onWhatsappCallAgentOffer = data => {
|
||||
const whatsappCallsStore = useWhatsappCallsStore();
|
||||
const activeCall = whatsappCallsStore.activeCall;
|
||||
|
||||
if (!activeCall) return;
|
||||
// Verify this offer is for the current active call
|
||||
if (activeCall.callId !== data.call_id && activeCall.id !== data.id) return;
|
||||
|
||||
handleAgentOffer(activeCall.id, data.sdp_offer, data.ice_servers)
|
||||
.then(() => {
|
||||
whatsappCallsStore.markActiveCallConnected();
|
||||
whatsappCallsStore.setReconnecting(false);
|
||||
// Emit event so the composable can start the timer
|
||||
emitter.emit('whatsapp_call:agent_webrtc_connected');
|
||||
})
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[WhatsApp Call] Failed to handle agent offer:', err);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
Reference in New Issue
Block a user