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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user